Open RF Prototyping

pn.py

Note that this page is automatically generated from an org-mode source file. The Python code for this reference design is also automatically generated from this file using org-babel-tangle.

The Script

#
# Generated by pn.org
#
<<imports>>

<<pn-data>>

<<pn-measure>>

<<app-class>>

<<main-func>>

if __name__ == '__main__':
    main()

The main Function

The asyncio framework is used to coordinate communications over GPIB without blocking user interface events. For this purpose we make use of QEventLoop and QThreadExecutor from the qasync package.

def main():

    parser = ArgumentParser(description=
                            '''Measure phase noise.''')

    parser.add_argument("--sg", choices=['SMHU58', 'DSG815'], default="SMHU58",
                        help="Reference signal generator in use. Default=SMHU58")
    parser.add_argument("-H", "--harmonics", action='store_true',
                        help="Don't suppress power supply harmonics at 150 and 250Hz")

    args = parser.parse_args()

    app = QApplication(sys.argv)
    loop = QEventLoop(app)
    loop.set_default_executor(QThreadExecutor(1))
    asyncio.set_event_loop(loop)

    appWindow = PhaseNoiseApp(sg_model=args.sg)
    appWindow.show()

    with loop:
        loop.set_debug(True)
        sys.exit(loop.run_forever())

Phase Noise App Class

Measuring the phase noise of a given device (DUT) proceeds in the following steps:

  • Measure the approximate frequency and power of the DUT signal: Measure DUT signal

  • Calibrate the test setup using a known FM modulated signal source: Calibration

  • Ensure that the DUT signal is locked to the reference signal: Capture

  • Acquire phase noise measurements: Measure and display

class PhaseNoiseApp(QMainWindow):

    GPIB_CONTROLLER_LIST = [PROLOGIX_IPADDR]

    FREQ_SUFFIX = ' MHz'
    MIN_DUT_FREQUENCY = 5.0
    MAX_DUT_FREQUENCY = 3200.0
    DEFAULT_DUT_FREQUENCY = 1000.0

    OFFSET_START = {'100': 1e2, '1K': 1e3, '10K': 1e4}
    OFFSET_END = {'100K': 1e5, '1M': 1e6, '10M': 1e7}
    SA_SMOOTHING = {"0.003": .003, "0.01": .01, "0.03": .03,
                    "0.1": .1, "0.3": .3, "1.0": 1.0, "3.0": 3.0}

    # FILTER_BANDS = {1: [5.0, 1280.0], 2: [1280.0, 3200.0], 3: [3200.0, 5760.0],
    #                4: [5760, 8320], 5: [8320, 10880], 6: [10880, 13440],
    #                7: [13440, 16000], 8: [16000, 18000]}
    FILTER_BANDS = {1: [5.0, 1280.0], 2: [1280.0, 3200.0]}
    LOCK_BANDWIDTHS = {"1": 1, "10": 2, "100": 3, "1K": 4, "10K": 5}
    FMDEV_RANGE = [10.0, 50000.0]

    CAL_ATTEN = -40.0
    CAL_FREQ_OFFSET = 0.05
    DEFAULT_FMDEV = 5000

    # Tuple format: (start_freq, stop_freq, rbw, wn)
    # An rbw entry of 0 implies 'AUTO'
    # wn is the
    PN_BANDS = [ (100, 2e3, 10, 15/300), (2e3, 1e4, 0, 20/300),
                 (1e4, 1e5, 0, 25/300), (1e5, 1e6, 0, 25/300),
                 (1e6, 1e7, 0, 25/300)]

    Lf_CONV_FACTOR = 6.0
    LOG_CONV_FACTOR = 2.5


    def __init__(self, sg_model='SMHU58'):
        super().__init__()
        self.gpib = None
        self.sa = None
        self.sg = None
        self.pn = None
        self._gpib_ctl_addr = PROLOGIX_IPADDR
        self._band = 1
        self._if_freq = PhaseNoiseApp.DEFAULT_DUT_FREQUENCY
        self._dut_freq = PhaseNoiseApp.DEFAULT_DUT_FREQUENCY
        self._sa_avg = 10
        self._sa_smoothing = 0.1
        self._pn_data = []
        self.build_ui()

    def enable_ui(self, s):
        self._dut_gbox.setEnabled(s)
        self._bb_gbox.setEnabled(s)
        self._meas_gbox.setEnabled(s)

    def build_ui(self):
        """Build the on-screen UI for the phase noise app."""

        vbox = QVBoxLayout()

        hbox = QHBoxLayout()
        gpib_combo = QComboBox()
        gpib_combo.addItems(PhaseNoiseApp.GPIB_CONTROLLER_LIST)
        gpib_combo.currentIndexChanged.connect(
            lambda idx, w=gpib_combo: self.gpib_changed(w, idx))
        line_edit = QLineEdit()
        gpib_combo.setLineEdit(line_edit)
        hbox.addWidget(QLabel("GPIB Controller:"))
        hbox.addWidget(gpib_combo)
        hbox.addStretch(1)
        initialize_btn = QPushButton("Initialize")
        initialize_btn.clicked.connect(self.initialize)
        hbox.addWidget(initialize_btn)

        vbox.addLayout(hbox)

        self._dut_gbox = QGroupBox("DUT and Reference")
        vbox2 = QVBoxLayout()
        self._dut_gbox.setLayout(vbox2)

        hbox2 = QHBoxLayout()
        self._dutf_box = QDoubleSpinBox()
        self._dutf_box.setRange(PhaseNoiseApp.MIN_DUT_FREQUENCY,
                                PhaseNoiseApp.MAX_DUT_FREQUENCY)
        self._dutf_box.setDecimals(3)
        self._dutf_box.setStyleSheet("""QDoubleSpinBox {
                 font-size: 25pt; }""")
        self._dutf_box.setValue(PhaseNoiseApp.DEFAULT_DUT_FREQUENCY)
        self._dutf_box.setSuffix(PhaseNoiseApp.FREQ_SUFFIX)
        self._dutf_box.valueChanged.connect(self.set_dut_freq)
        hbox2.addWidget(QLabel("DUT Freq:"))
        hbox2.addWidget(self._dutf_box)
        hbox2.addStretch(1)
        self._measure_dutf_btn = QPushButton("Measure DUT Sig")
        self._measure_dutf_btn.clicked.connect(self.connect_dut_signal)
        hbox2.addWidget(self._measure_dutf_btn)

        vbox2.addLayout(hbox2)

        hbox2 = QHBoxLayout()
        self._ref_box = QLabel()
        self._ref_box.setStyleSheet("""QLabel { font-size: 25pt; }""")
        self._ref_box.setText('{:.3f} MHz'.format(self.if_freq))
        hbox2.addWidget(QLabel("Ref. Freq:"))
        hbox2.addWidget(self._ref_box)
        hbox2.addStretch(1)
        self._measure_if_btn = QPushButton("Measure IF Sig")
        self._measure_if_btn.clicked.connect(self.connect_if_signal)
        hbox2.addWidget(self._measure_if_btn)

        vbox2.addLayout(hbox2)

        hbox2 = QHBoxLayout()
        self._cal_btn = QPushButton("Calibrate")
        self._cal_btn.clicked.connect(self.calibrate)
        hbox2.addWidget(self._cal_btn)
        hbox2.addStretch(1)

        vbox2.addLayout(hbox2)
        vbox.addWidget(self._dut_gbox)

        self._bb_gbox = QGroupBox("Baseband")
        fbox1 = QFormLayout()
        self._bb_gbox.setLayout(fbox1)

        hbox = QHBoxLayout()
        offsetStartGroup = QButtonGroup(hbox)
        self._offset_start_btns = []
        for offset in PhaseNoiseApp.OFFSET_START.keys():
            rb = QRadioButton("{}".format(offset))
            rb.offset = offset
            rb.toggled.connect(
                lambda state, w=rb: self.set_offset_start(state, w.offset))
            offsetStartGroup.addButton(rb)
            self._offset_start_btns.append(rb)
            hbox.addWidget(rb)
        self._offset_start_btns[0].setChecked(True)
        fbox1.addRow(QLabel('Offset Start Freq:'), hbox)

        hbox = QHBoxLayout()
        offsetStopGroup = QButtonGroup(hbox)
        self._offset_stop_btns = []
        for offset in PhaseNoiseApp.OFFSET_END.keys():
            rb = QRadioButton("{}".format(offset))
            rb.offset = offset
            rb.toggled.connect(
                lambda state, w=rb: self.set_offset_stop(state, w.offset))
            offsetStopGroup.addButton(rb)
            self._offset_stop_btns.append(rb)
            hbox.addWidget(rb)
        self._offset_stop_btns[-1].setChecked(True)
        fbox1.addRow(QLabel('Offset Stop Freq:'), hbox)

        hbox = QHBoxLayout()
        smoothingGroup = QButtonGroup(hbox)
        self._sa_smooth_btns = []
        for ratio in PhaseNoiseApp.SA_SMOOTHING.keys():
            rb = QRadioButton("{}".format(ratio))
            rb.ratio = ratio
            rb.toggled.connect(
                lambda state, w=rb: self.set_sa_smoothing(state, w.ratio))
            smoothingGroup.addButton(rb)
            self._sa_smooth_btns.append(rb)
            hbox.addWidget(rb)
        self._sa_smooth_btns[3].setChecked(True)
        fbox1.addRow(QLabel("SA Smoothing Ratio:"), hbox)

        self._sa_avg_box = QSpinBox()
        self._sa_avg_box.setRange(1, 999)
        self._sa_avg_box.valueChanged.connect(self.set_sa_avg)
        self._sa_avg_box.setValue(10)
        fbox1.addRow(QLabel("SA Averaging:"), self._sa_avg_box)

        vbox.addWidget(self._bb_gbox)

        self._meas_gbox = QGroupBox("Phase Locking")
        vbox2 = QVBoxLayout()
        fbox1 = QFormLayout()
        vbox2.addLayout(fbox1)
        self._meas_gbox.setLayout(vbox2)

        hbox = QHBoxLayout()
        methodGroup = QButtonGroup(hbox)
        self._method_btns = []
        for method in ["Phase Det.", "Manual Lock", "Freq. Discrim."]:
            rb = QRadioButton("{}".format(method))
            rb.method = method
            rb.toggled.connect(
                lambda state, w=rb: self.set_method(state, w.method))
            methodGroup.addButton(rb)
            self._method_btns.append(rb)
            hbox.addWidget(rb)
        self._method_btns[0].setChecked(True)
        self._method_btns[2].setEnabled(False)
        fbox1.addRow(QLabel("Method:"), hbox)

        hbox = QHBoxLayout()
        lockbwGroup = QButtonGroup(hbox)
        self._lockbw_btns = []
        for bw in PhaseNoiseApp.LOCK_BANDWIDTHS.keys():
            rb = QRadioButton(bw)
            rb.bw = PhaseNoiseApp.LOCK_BANDWIDTHS[bw]
            rb.toggled.connect(
                lambda state, w=rb: self.set_lockbw(state, w.bw))
            lockbwGroup.addButton(rb)
            self._lockbw_btns.append(rb)
            hbox.addWidget(rb)
        self._lockbw_btns[1].setChecked(True)
        fbox1.addRow(QLabel("Lock BW Factor:"), hbox)

        self._fmdev_box = QDoubleSpinBox()
        self._fmdev_box.setRange(*PhaseNoiseApp.FMDEV_RANGE)
        self._fmdev_box.setDecimals(1)
        self._fmdev_box.setSingleStep(1000)
        self._fmdev_box.valueChanged.connect(self.set_fmdev)
        self._fmdev_box.setValue(PhaseNoiseApp.DEFAULT_FMDEV)
        fbox1.addRow(QLabel("FM Deviation:"), self._fmdev_box)

        hbox = QHBoxLayout()
        self._capture_btn = QPushButton("Capture")
        self._capture_btn.clicked.connect(self.capture)
        hbox.addWidget(self._capture_btn)
        hbox.addStretch(1)

        vbox2.addLayout(hbox)

        vbox.addWidget(self._meas_gbox)
        self.enable_ui(False)

        hbox = QHBoxLayout()
        self._meas_btn = QPushButton("Measurements...")
        self._meas_btn.clicked.connect(self.show_measure_dialog)
        hbox.addWidget(self._meas_btn)
        hbox.addStretch(1)

        vbox.addLayout(hbox)

        self.container = QWidget()
        self.container.setLayout(vbox)
        self.setCentralWidget(self.container)
        self.setWindowTitle('Phase Noise Measurement')


    @asyncSlot()
    async def initialize(self):
        try:
            self.initialize_ui()
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, self.initialize_hw)
            self.enable_ui(True)
        except RuntimeError as re:
            error_dialog = QErrorMessage(self)
            error_dialog.showMessage(str(re))

    def initialize_ui(self):
        pass

    def initialize_hw(self):
        try:
            self.gpib = GPIB(gpib_ip=self.gpib_ctl_addr)
            self.gpib.initialize()
        except socket.timeout as te:
            self.gpib = None
            raise RuntimeError('GPIB controller is offline or powered down')
        try:
            self.sa = HP8560A(self.gpib, HP8560A_GPIBID)
            sa_id = self.sa.ident
            self.sa.initialize()
        except socket.timeout as te:
            self.sa = None
            raise RuntimeError('HP8560A is offline or powered down')
        try:
            self.sg = SMHU58(self.gpib, SMHU58_GPIBID)
            sg_id = self.sg.ident
            self.sg.initialize()
        except socket.timeout as te:
            self.sg = None
            raise RuntimeError('SMHU58 is offline or powered down')
        try:
            self.pn = HP11729C(self.gpib, HP11729C_GPIBID)
            #pn_id = self.pn.ident
            self.pn.initialize()
        except socket.timeout as te:
            self.pn = None
            raise RuntimeError('HP11729C is offline or powered down')


    @property
    def gpib_ctl_addr(self):
        return self._gpib_ctl_addr

    def gpib_changed(self, combo, idx):
        self._gpib_ctl_addr = combo.itemText(idx)

    @property
    def offset_start(self):
        return self._offset_start

    def set_offset_start(self, state, offset_str):
        if state:
            self._offset_start = PhaseNoiseApp.OFFSET_START[offset_str]

    @property
    def offset_stop(self):
        return self._offset_stop

    def set_offset_stop(self, state, offset_str):
        if state:
            self._offset_stop = PhaseNoiseApp.OFFSET_END[offset_str]

    @property
    def sa_smoothing(self):
        return self._sa_smoothing

    def set_sa_smoothing(self, state, value):
        if state:
            self._sa_smoothing = PhaseNoiseApp.SA_SMOOTHING[value]

    @property
    def sa_avg(self):
        return self._sa_avg

    def set_sa_avg(self, avg):
        self._sa_avg = avg

    @property
    def method(self):
        return self._method

    def set_method(self, state, m):
        if state:
            self._method = m

    @property
    def band(self):
        return self._band

    @property
    def band_center(self):
        l = PhaseNoiseApp.FILTER_BANDS[self.band]
        return l[1] - l[0]

    def set_band(self, f):
        for band, l in PhaseNoiseApp.FILTER_BANDS.items():
            if f > l[0] and f <= l[1]:
                self._band = band
                return
        raise RuntimeError(
            'DUT freq. {} outside of available filter bands'.format(f))

    @property
    def lockbw(self):
        return self._lockbw

    def set_lockbw(self, state, bw):
        if state:
            self._lockbw = bw

    @property
    def fmdev(self):
        return self._fm_dev

    def set_fmdev(self, value):
        self._fm_dev = value

    @property
    def dut_freq(self):
        return self._dut_freq

    def set_dut_freq(self, f):
        self._dut_freq = f
        self.set_band(f)
        if self.band == 1:
            self.if_freq = self.dut_freq
        else:
            self.if_freq = abs(self.band_center - self.dut_freq)
        self._ref_box.setText('{:.3f} MHz'.format(self.if_freq))

    @property
    def if_freq(self):
        return self._if_freq

    @if_freq.setter
    def if_freq(self, f):
        self._if_freq = f

    @property
    def carrier_pwr(self):
        return self._carrier_pwr

    @property
    def pn_data(self):
        return self._pn_data

    def append_pn_data(self, pn_freq, pn_pwr, label=None):
        if not label:
            label = "PN-{}".format(len(self.pn_data)+1)
        data_item = PNDataItem(label, self.dut_freq, list(zip(pn_freq, pn_pwr)),
                               self.lockbw, self.fmdev, self.sa_avg,
                               self.sa_smoothing)
        self.pn_data.append(data_item)
        return self._pn_data

    def clear_pn_data(self):
        self._pn_data.clear()

    @asyncSlot()
    async def connect_dut_signal(self):
        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Information)
        msgBox.setText("Connect DUT signal to SA input.")
        msgBox.setWindowTitle("Connect DUT Signal")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()
        self._measure_dutf_btn.setEnabled(False)
        self._measure_if_btn.setEnabled(False)
        self._cal_btn.setEnabled(False)
        loop = asyncio.get_event_loop()
        f, pwr = await loop.run_in_executor(None, self.measure_signal,
                                            self.dut_freq)
        self._dutf_box.setValue(f)
        self._measure_dutf_btn.setEnabled(True)
        self._measure_if_btn.setEnabled(True)
        self._cal_btn.setEnabled(True)

    @asyncSlot()
    async def connect_if_signal(self):
        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Information)
        msgBox.setText("Connect HP11729C IF output signal to SA input.")
        msgBox.setWindowTitle("Connect IF Signal")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()

        print("band: {}, lockbw: {}".format(self.band, self.lockbw))
        self.pn.band = self.band
        self.pn.lock_range = self.lockbw
        self._measure_dutf_btn.setEnabled(False)
        self._measure_if_btn.setEnabled(False)
        self._cal_btn.setEnabled(False)
        loop = asyncio.get_event_loop()
        f, pwr = await loop.run_in_executor(None, self.measure_signal,
                                            self.if_freq)
        self.if_freq = f
        print("if_freq: {:f}, pwr: {:f}".format(self.if_freq, pwr))
        if pwr < -30.0:
            msgBox = QMessageBox()
            msgBox.setIcon(QMessageBox.Warning)
            msgBox.setText("IF output power is {}.  Check DUT signal.".format(pwr))
            msgBox.setWindowTitle("Check DUT Signal")
            msgBox.setStandardButtons(QMessageBox.Ok)
            returnValue = msgBox.exec()

        self._ref_box.setText('{:.3f} MHz'.format(self.if_freq))
        self._measure_dutf_btn.setEnabled(True)
        self._measure_if_btn.setEnabled(True)
        self._cal_btn.setEnabled(True)

    def set_ref_level(self, freq, span):
        self.sa.freq = freq
        self.sa.fspan = span
        while True:
            self.sa.sweep()
            pwr, f = self.sa.measure_pwr(freq)
            rl = self.sa.ref_level
            if rl > self.sa.max_ref_level - 5.0:
                break
            if pwr > rl:
                self.sa.ref_level = rl + 10
            elif abs(rl - pwr) > 10.0:
                self.sa.ref_level = rl - 10
            else:
                break
        return f, pwr

    def measure_signal(self, freq, span=2.0):
        f, pwr = self.set_ref_level(freq, span)
        self.sa.fspan = 0.05
        self.sa.sweep()
        try:
            pwr, f = self.sa.measure_pwr(f)
            self.sa.cmd('MKCF;')
            self.sa.fspan = 0.01
            self.sa.sweep()
            pwr, f = self.sa.measure_pwr(f)
        except ValueError as ve:
            print(str(ve))
        return f, pwr

    @asyncSlot()
    async def calibrate(self):
        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Information)
        msgBox.setWindowTitle("Connect uW Signal")
        msgBox.setTextFormat(Qt.RichText)
        msgBox.setText("""<p>Connect HP11729C &lt; 10 MHz output to SA input.</p><p><span style="color: red;">IMPORTANT: Use a 100uF DC block on the SA input!</span></p>""")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()

        self.pn.band = self.band
        self.pn.lock_range = self.lockbw
        self.sa.cmd("COUPLE DC;")
        self.sg.cmd("FM:OFF")
        self.sg.set_signal_output(self.if_freq + PhaseNoiseApp.CAL_FREQ_OFFSET,
                                  PhaseNoiseApp.CAL_ATTEN)
        self._cal_btn.setEnabled(False)
        self._capture_btn.setEnabled(False)
        loop = asyncio.get_event_loop()
        f, pwr = await loop.run_in_executor(None, self.set_ref_level,
                                            PhaseNoiseApp.CAL_FREQ_OFFSET, 0.06)
        self.sa.cmd("COUPLE AC;")
        self._carrier_pwr = pwr
        self._ref_level = self.sa.ref_level
        self._atten = self.sa.atten
        self._capture_btn.setEnabled(True)
        self._cal_btn.setEnabled(True)
        print("f: {}, pwr: {}, sa.ref_level: {}".format(f, pwr, self.sa.ref_level))

    @asyncSlot()
    async def capture(self):
        # 1. Set the reference signal (SMHU58) to self.if_freq and output
        #    power of 0.0
        # 2. Ensure that the HP11729C FREQ-CONT DC-FM output (back panel) is
        #    connected to the SMHU58 FM EXT input (front panel)
        # 3. Initially, set the SMHU58 max FM deviation to 5 kHz
        # 4. Set the HP11729C initial lock bandwidth factor to 10
        # 5. Set the HP11729C to 'capture' and sleep for some period
        # 6. Test the HP11729C for phase lock
        # 7. If phase lock set HP11729C for inactive capture and sleep for
        #    some period else go to 10
        # 8. Test the HP11729C for phase lock
        # 9. If phase lock set HP11729C capture inactive and set SA for
        #    first measurement band and take sweep
        # 10. If no phase lock increment FM DEV by 1kHz and go to (5)
        #    If FM DEV == 10kHz stop auto capture
        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Information)
        msgBox.setWindowTitle("Connect uW Signal")
        msgBox.setTextFormat(Qt.RichText)
        msgBox.setText("""<p>Connect HP11729C &lt; 10 MHz output to SA input.</p><p><span style="color: red;">IMPORTANT: Use a 100uF DC block on the SA input!</span></p>""")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()

        self._capture_btn.setEnabled(False)

        self.pn.band = self.band
        self.pn.lock_range = self.lockbw
        self.sg.set_signal_output(self.if_freq, 0.0)
        self.sg.cmd("FM:EXT:DC {}".format(self.fmdev))

        self._capture_btn.setEnabled(True)

        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Information)
        msgBox.setWindowTitle("Initiate Phase Lock")
        msgBox.setTextFormat(Qt.RichText)
        msgBox.setText("""<p>Place the HP11729C in 'Local' mode and then press and release the 'Capture' button.</p><p>After a short period of time the Phase lock indicator above the Capture button should indicate phase lock by illuminating the green LED in the center of the display.  If this doesn't occur some adjustments to the Phase Locking parameters may be required.</p>""")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()


    def sweep_band(self, start, stop, rbw):
        self.sa.cmd("COUPLE DC;")
        self.sa.vbr = self.sa_smoothing
        self.sa.stop_freq = stop / 1e6
        stopFreq = self.sa.stop_freq
        self.sa.start_freq = start / 1e6
        startFreq = self.sa.start_freq
        if rbw > 0:
            self.sa.rbw = rbw
        else:
            self.sa.rbw_auto = True
        reflevel = self.sa.ref_level
        ampl_scale = self.sa.scale

        self.sa.vavg = self.sa_avg
        self.sa.sweep()
        if isinstance(self.sa, HP8560A):
            raw_data = np.array(self.sa.trace_raw_data())
            sdata = reflevel + (ampl_scale * (raw_data - 600)/60)
        else:
            sdata = np.array(self.sa.trace_data())
        self.sa.vavg = 1

        res_bw = self.sa.rbw
        self.sa.cmd("COUPLE AC;")
        correction = - self.carrier_pwr + PhaseNoiseApp.CAL_ATTEN \
                - PhaseNoiseApp.Lf_CONV_FACTOR \
                - (10.0 * log10(1.2 * res_bw)) + PhaseNoiseApp.LOG_CONV_FACTOR
        band_pn = sdata
        band_pn += correction
        band_freq = np.linspace(startFreq, stopFreq, len(band_pn))
        band_freq *= 1e6   # Frequency in Hz
        return band_pn, band_freq

    def show_measure_dialog(self):
        self._pnmeas_dialog = PlotDialog(self)
        self._pnmeas_dialog.show()

    @asyncClose
    async def closeEvent(self, event):
        if self.pn:
            self.pn.reset()
            self.pn.local()
        if self.sg:
            self.sg.reset()
            self.sg.local()
        if self.sa:
            self.sa.reset()
            self.sa.local()
        if self.gpib:
            self.gpib.close()

Measure DUT signal

@asyncSlot()
async def connect_dut_signal(self):
    msgBox = QMessageBox()
    msgBox.setIcon(QMessageBox.Information)
    msgBox.setText("Connect DUT signal to SA input.")
    msgBox.setWindowTitle("Connect DUT Signal")
    msgBox.setStandardButtons(QMessageBox.Ok)
    returnValue = msgBox.exec()
    self._measure_dutf_btn.setEnabled(False)
    self._measure_if_btn.setEnabled(False)
    self._cal_btn.setEnabled(False)
    loop = asyncio.get_event_loop()
    f, pwr = await loop.run_in_executor(None, self.measure_signal,
                                        self.dut_freq)
    self._dutf_box.setValue(f)
    self._measure_dutf_btn.setEnabled(True)
    self._measure_if_btn.setEnabled(True)
    self._cal_btn.setEnabled(True)

@asyncSlot()
async def connect_if_signal(self):
    msgBox = QMessageBox()
    msgBox.setIcon(QMessageBox.Information)
    msgBox.setText("Connect HP11729C IF output signal to SA input.")
    msgBox.setWindowTitle("Connect IF Signal")
    msgBox.setStandardButtons(QMessageBox.Ok)
    returnValue = msgBox.exec()

    print("band: {}, lockbw: {}".format(self.band, self.lockbw))
    self.pn.band = self.band
    self.pn.lock_range = self.lockbw
    self._measure_dutf_btn.setEnabled(False)
    self._measure_if_btn.setEnabled(False)
    self._cal_btn.setEnabled(False)
    loop = asyncio.get_event_loop()
    f, pwr = await loop.run_in_executor(None, self.measure_signal,
                                        self.if_freq)
    self.if_freq = f
    print("if_freq: {:f}, pwr: {:f}".format(self.if_freq, pwr))
    if pwr < -30.0:
        msgBox = QMessageBox()
        msgBox.setIcon(QMessageBox.Warning)
        msgBox.setText("IF output power is {}.  Check DUT signal.".format(pwr))
        msgBox.setWindowTitle("Check DUT Signal")
        msgBox.setStandardButtons(QMessageBox.Ok)
        returnValue = msgBox.exec()

    self._ref_box.setText('{:.3f} MHz'.format(self.if_freq))
    self._measure_dutf_btn.setEnabled(True)
    self._measure_if_btn.setEnabled(True)
    self._cal_btn.setEnabled(True)

def set_ref_level(self, freq, span):
    self.sa.freq = freq
    self.sa.fspan = span
    while True:
        self.sa.sweep()
        pwr, f = self.sa.measure_pwr(freq)
        rl = self.sa.ref_level
        if rl > self.sa.max_ref_level - 5.0:
            break
        if pwr > rl:
            self.sa.ref_level = rl + 10
        elif abs(rl - pwr) > 10.0:
            self.sa.ref_level = rl - 10
        else:
            break
    return f, pwr

def measure_signal(self, freq, span=2.0):
    f, pwr = self.set_ref_level(freq, span)
    self.sa.fspan = 0.05
    self.sa.sweep()
    try:
        pwr, f = self.sa.measure_pwr(f)
        self.sa.cmd('MKCF;')
        self.sa.fspan = 0.01
        self.sa.sweep()
        pwr, f = self.sa.measure_pwr(f)
    except ValueError as ve:
        print(str(ve))
    return f, pwr

Calibration

The calibration proceeds as follows:

  • Ensure that the < 10 MHz output from the HP11729C is connected to the spectrum analyzer via a \(100\mu F\) DC block.

  • Ensure that the FM modulation facility is disabled on the reference signal generator.

  • Set the output of the reference signal generator to the previously measured/calculated IF frequency plus an offset of 50 kHz.

  • Ensure that the spectrum analyzer input coupling is DC.

  • Set the signal generator output level to be -40 dBm.

  • Set the spectrum analyzer reference level so that the reference signal is just below the top of the SA screen. Measure the frequency and power of the reference level.

@asyncSlot()
async def calibrate(self):
    msgBox = QMessageBox()
    msgBox.setIcon(QMessageBox.Information)
    msgBox.setWindowTitle("Connect uW Signal")
    msgBox.setTextFormat(Qt.RichText)
    msgBox.setText("""<p>Connect HP11729C &lt; 10 MHz output to SA input.</p><p><span style="color: red;">IMPORTANT: Use a 100uF DC block on the SA input!</span></p>""")
    msgBox.setStandardButtons(QMessageBox.Ok)
    returnValue = msgBox.exec()

    self.pn.band = self.band
    self.pn.lock_range = self.lockbw
    self.sa.cmd("COUPLE DC;")
    self.sg.cmd("FM:OFF")
    self.sg.set_signal_output(self.if_freq + PhaseNoiseApp.CAL_FREQ_OFFSET,
                              PhaseNoiseApp.CAL_ATTEN)
    self._cal_btn.setEnabled(False)
    self._capture_btn.setEnabled(False)
    loop = asyncio.get_event_loop()
    f, pwr = await loop.run_in_executor(None, self.set_ref_level,
                                        PhaseNoiseApp.CAL_FREQ_OFFSET, 0.06)
    self.sa.cmd("COUPLE AC;")
    self._carrier_pwr = pwr
    self._ref_level = self.sa.ref_level
    self._atten = self.sa.atten
    self._capture_btn.setEnabled(True)
    self._cal_btn.setEnabled(True)
    print("f: {}, pwr: {}, sa.ref_level: {}".format(f, pwr, self.sa.ref_level))

Capture

@asyncSlot()
async def capture(self):
    # 1. Set the reference signal (SMHU58) to self.if_freq and output
    #    power of 0.0
    # 2. Ensure that the HP11729C FREQ-CONT DC-FM output (back panel) is
    #    connected to the SMHU58 FM EXT input (front panel)
    # 3. Initially, set the SMHU58 max FM deviation to 5 kHz
    # 4. Set the HP11729C initial lock bandwidth factor to 10
    # 5. Set the HP11729C to 'capture' and sleep for some period
    # 6. Test the HP11729C for phase lock
    # 7. If phase lock set HP11729C for inactive capture and sleep for
    #    some period else go to 10
    # 8. Test the HP11729C for phase lock
    # 9. If phase lock set HP11729C capture inactive and set SA for
    #    first measurement band and take sweep
    # 10. If no phase lock increment FM DEV by 1kHz and go to (5)
    #    If FM DEV == 10kHz stop auto capture
    msgBox = QMessageBox()
    msgBox.setIcon(QMessageBox.Information)
    msgBox.setWindowTitle("Connect uW Signal")
    msgBox.setTextFormat(Qt.RichText)
    msgBox.setText("""<p>Connect HP11729C &lt; 10 MHz output to SA input.</p><p><span style="color: red;">IMPORTANT: Use a 100uF DC block on the SA input!</span></p>""")
    msgBox.setStandardButtons(QMessageBox.Ok)
    returnValue = msgBox.exec()

    self._capture_btn.setEnabled(False)

    self.pn.band = self.band
    self.pn.lock_range = self.lockbw
    self.sg.set_signal_output(self.if_freq, 0.0)
    self.sg.cmd("FM:EXT:DC {}".format(self.fmdev))

    self._capture_btn.setEnabled(True)

    msgBox = QMessageBox()
    msgBox.setIcon(QMessageBox.Information)
    msgBox.setWindowTitle("Initiate Phase Lock")
    msgBox.setTextFormat(Qt.RichText)
    msgBox.setText("""<p>Place the HP11729C in 'Local' mode and then press and release the 'Capture' button.</p><p>After a short period of time the Phase lock indicator above the Capture button should indicate phase lock by illuminating the green LED in the center of the display.  If this doesn't occur some adjustments to the Phase Locking parameters may be required.</p>""")
    msgBox.setStandardButtons(QMessageBox.Ok)
    returnValue = msgBox.exec()

Application user interface

def enable_ui(self, s):
    self._dut_gbox.setEnabled(s)
    self._bb_gbox.setEnabled(s)
    self._meas_gbox.setEnabled(s)

def build_ui(self):
    """Build the on-screen UI for the phase noise app."""

    vbox = QVBoxLayout()

    hbox = QHBoxLayout()
    gpib_combo = QComboBox()
    gpib_combo.addItems(PhaseNoiseApp.GPIB_CONTROLLER_LIST)
    gpib_combo.currentIndexChanged.connect(
        lambda idx, w=gpib_combo: self.gpib_changed(w, idx))
    line_edit = QLineEdit()
    gpib_combo.setLineEdit(line_edit)
    hbox.addWidget(QLabel("GPIB Controller:"))
    hbox.addWidget(gpib_combo)
    hbox.addStretch(1)
    initialize_btn = QPushButton("Initialize")
    initialize_btn.clicked.connect(self.initialize)
    hbox.addWidget(initialize_btn)

    vbox.addLayout(hbox)

    self._dut_gbox = QGroupBox("DUT and Reference")
    vbox2 = QVBoxLayout()
    self._dut_gbox.setLayout(vbox2)

    hbox2 = QHBoxLayout()
    self._dutf_box = QDoubleSpinBox()
    self._dutf_box.setRange(PhaseNoiseApp.MIN_DUT_FREQUENCY,
                            PhaseNoiseApp.MAX_DUT_FREQUENCY)
    self._dutf_box.setDecimals(3)
    self._dutf_box.setStyleSheet("""QDoubleSpinBox {
             font-size: 25pt; }""")
    self._dutf_box.setValue(PhaseNoiseApp.DEFAULT_DUT_FREQUENCY)
    self._dutf_box.setSuffix(PhaseNoiseApp.FREQ_SUFFIX)
    self._dutf_box.valueChanged.connect(self.set_dut_freq)
    hbox2.addWidget(QLabel("DUT Freq:"))
    hbox2.addWidget(self._dutf_box)
    hbox2.addStretch(1)
    self._measure_dutf_btn = QPushButton("Measure DUT Sig")
    self._measure_dutf_btn.clicked.connect(self.connect_dut_signal)
    hbox2.addWidget(self._measure_dutf_btn)

    vbox2.addLayout(hbox2)

    hbox2 = QHBoxLayout()
    self._ref_box = QLabel()
    self._ref_box.setStyleSheet("""QLabel { font-size: 25pt; }""")
    self._ref_box.setText('{:.3f} MHz'.format(self.if_freq))
    hbox2.addWidget(QLabel("Ref. Freq:"))
    hbox2.addWidget(self._ref_box)
    hbox2.addStretch(1)
    self._measure_if_btn = QPushButton("Measure IF Sig")
    self._measure_if_btn.clicked.connect(self.connect_if_signal)
    hbox2.addWidget(self._measure_if_btn)

    vbox2.addLayout(hbox2)

    hbox2 = QHBoxLayout()
    self._cal_btn = QPushButton("Calibrate")
    self._cal_btn.clicked.connect(self.calibrate)
    hbox2.addWidget(self._cal_btn)
    hbox2.addStretch(1)

    vbox2.addLayout(hbox2)
    vbox.addWidget(self._dut_gbox)

    self._bb_gbox = QGroupBox("Baseband")
    fbox1 = QFormLayout()
    self._bb_gbox.setLayout(fbox1)

    hbox = QHBoxLayout()
    offsetStartGroup = QButtonGroup(hbox)
    self._offset_start_btns = []
    for offset in PhaseNoiseApp.OFFSET_START.keys():
        rb = QRadioButton("{}".format(offset))
        rb.offset = offset
        rb.toggled.connect(
            lambda state, w=rb: self.set_offset_start(state, w.offset))
        offsetStartGroup.addButton(rb)
        self._offset_start_btns.append(rb)
        hbox.addWidget(rb)
    self._offset_start_btns[0].setChecked(True)
    fbox1.addRow(QLabel('Offset Start Freq:'), hbox)

    hbox = QHBoxLayout()
    offsetStopGroup = QButtonGroup(hbox)
    self._offset_stop_btns = []
    for offset in PhaseNoiseApp.OFFSET_END.keys():
        rb = QRadioButton("{}".format(offset))
        rb.offset = offset
        rb.toggled.connect(
            lambda state, w=rb: self.set_offset_stop(state, w.offset))
        offsetStopGroup.addButton(rb)
        self._offset_stop_btns.append(rb)
        hbox.addWidget(rb)
    self._offset_stop_btns[-1].setChecked(True)
    fbox1.addRow(QLabel('Offset Stop Freq:'), hbox)

    hbox = QHBoxLayout()
    smoothingGroup = QButtonGroup(hbox)
    self._sa_smooth_btns = []
    for ratio in PhaseNoiseApp.SA_SMOOTHING.keys():
        rb = QRadioButton("{}".format(ratio))
        rb.ratio = ratio
        rb.toggled.connect(
            lambda state, w=rb: self.set_sa_smoothing(state, w.ratio))
        smoothingGroup.addButton(rb)
        self._sa_smooth_btns.append(rb)
        hbox.addWidget(rb)
    self._sa_smooth_btns[3].setChecked(True)
    fbox1.addRow(QLabel("SA Smoothing Ratio:"), hbox)

    self._sa_avg_box = QSpinBox()
    self._sa_avg_box.setRange(1, 999)
    self._sa_avg_box.valueChanged.connect(self.set_sa_avg)
    self._sa_avg_box.setValue(10)
    fbox1.addRow(QLabel("SA Averaging:"), self._sa_avg_box)

    vbox.addWidget(self._bb_gbox)

    self._meas_gbox = QGroupBox("Phase Locking")
    vbox2 = QVBoxLayout()
    fbox1 = QFormLayout()
    vbox2.addLayout(fbox1)
    self._meas_gbox.setLayout(vbox2)

    hbox = QHBoxLayout()
    methodGroup = QButtonGroup(hbox)
    self._method_btns = []
    for method in ["Phase Det.", "Manual Lock", "Freq. Discrim."]:
        rb = QRadioButton("{}".format(method))
        rb.method = method
        rb.toggled.connect(
            lambda state, w=rb: self.set_method(state, w.method))
        methodGroup.addButton(rb)
        self._method_btns.append(rb)
        hbox.addWidget(rb)
    self._method_btns[0].setChecked(True)
    self._method_btns[2].setEnabled(False)
    fbox1.addRow(QLabel("Method:"), hbox)

    hbox = QHBoxLayout()
    lockbwGroup = QButtonGroup(hbox)
    self._lockbw_btns = []
    for bw in PhaseNoiseApp.LOCK_BANDWIDTHS.keys():
        rb = QRadioButton(bw)
        rb.bw = PhaseNoiseApp.LOCK_BANDWIDTHS[bw]
        rb.toggled.connect(
            lambda state, w=rb: self.set_lockbw(state, w.bw))
        lockbwGroup.addButton(rb)
        self._lockbw_btns.append(rb)
        hbox.addWidget(rb)
    self._lockbw_btns[1].setChecked(True)
    fbox1.addRow(QLabel("Lock BW Factor:"), hbox)

    self._fmdev_box = QDoubleSpinBox()
    self._fmdev_box.setRange(*PhaseNoiseApp.FMDEV_RANGE)
    self._fmdev_box.setDecimals(1)
    self._fmdev_box.setSingleStep(1000)
    self._fmdev_box.valueChanged.connect(self.set_fmdev)
    self._fmdev_box.setValue(PhaseNoiseApp.DEFAULT_FMDEV)
    fbox1.addRow(QLabel("FM Deviation:"), self._fmdev_box)

    hbox = QHBoxLayout()
    self._capture_btn = QPushButton("Capture")
    self._capture_btn.clicked.connect(self.capture)
    hbox.addWidget(self._capture_btn)
    hbox.addStretch(1)

    vbox2.addLayout(hbox)

    vbox.addWidget(self._meas_gbox)
    self.enable_ui(False)

    hbox = QHBoxLayout()
    self._meas_btn = QPushButton("Measurements...")
    self._meas_btn.clicked.connect(self.show_measure_dialog)
    hbox.addWidget(self._meas_btn)
    hbox.addStretch(1)

    vbox.addLayout(hbox)

    self.container = QWidget()
    self.container.setLayout(vbox)
    self.setCentralWidget(self.container)
    self.setWindowTitle('Phase Noise Measurement')

Measure and display

def sweep_band(self, start, stop, rbw):
    self.sa.cmd("COUPLE DC;")
    self.sa.vbr = self.sa_smoothing
    self.sa.stop_freq = stop / 1e6
    stopFreq = self.sa.stop_freq
    self.sa.start_freq = start / 1e6
    startFreq = self.sa.start_freq
    if rbw > 0:
        self.sa.rbw = rbw
    else:
        self.sa.rbw_auto = True
    reflevel = self.sa.ref_level
    ampl_scale = self.sa.scale

    self.sa.vavg = self.sa_avg
    self.sa.sweep()
    if isinstance(self.sa, HP8560A):
        raw_data = np.array(self.sa.trace_raw_data())
        sdata = reflevel + (ampl_scale * (raw_data - 600)/60)
    else:
        sdata = np.array(self.sa.trace_data())
    self.sa.vavg = 1

    res_bw = self.sa.rbw
    self.sa.cmd("COUPLE AC;")
    correction = - self.carrier_pwr + PhaseNoiseApp.CAL_ATTEN \
            - PhaseNoiseApp.Lf_CONV_FACTOR \
            - (10.0 * log10(1.2 * res_bw)) + PhaseNoiseApp.LOG_CONV_FACTOR
    band_pn = sdata
    band_pn += correction
    band_freq = np.linspace(startFreq, stopFreq, len(band_pn))
    band_freq *= 1e6   # Frequency in Hz
    return band_pn, band_freq
class PlotDialog(QDialog):
    """
    """

    X_MAJOR_TICKS = [(2, '100'), (3, '1K'), (4, '10K'),
                     (5, '100K'), (6, '1M'), (7, '10M')]
    X_MINOR_TICKS = [(log10(n), '') for n in np.arange(2e2, 1e3, 1e2)] + \
                    [(log10(n), '') for n in np.arange(2e3, 1e4, 1e3)] + \
                    [(log10(n), '') for n in np.arange(2e4, 1e5, 1e4)] + \
                    [(log10(n), '') for n in np.arange(2e5, 1e6, 1e5)] + \
                    [(log10(n), '') for n in np.arange(2e6, 1e7, 1e6)]

    PLOT_COLORS = [(52,138,189), (183,67,49), (142,186,66), (251,193,94),
                   (152,142,213), (255,181,184), (119,119,119)]


    def __init__(self, pnApp, parent=None):
        super().__init__(parent)

        self._app = pnApp

        self.setWindowTitle("Phase Noise Plots")
        self.build_ui()
        self.plot_pn_data_items()

    def lowpass_filter(self, wn, arr):
        i, u = signal.butter(3, wn, btype='lowpass')
        filtered = signal.filtfilt(i, u, arr)
        return filtered

    def filter_ps_harmonics(self, wn, arr):
        # Note that the indices used here will depend crucially
        # on the band start and stop frequencies as well as the
        # RBW used to acquire the data.
        #  The current numbers are for the following:
        #    start: 100 Hz
        #    stop:  2.0 kHz
        #    rbw:   10 Hz
        for i in range(8, 25):
            arr[i] = np.average(arr[i-5:i])
        for i in range(8, 25):
            arr[i] = np.average(arr[i:i+5])
        for i in range(37, 58):
            arr[i] = np.average(arr[i-5:i])
        for i in range(37, 58):
            arr[i] = np.average(arr[i:i+5])
        for i in range(73, 90):
            arr[i] = np.average(arr[i-5:i])
        for i in range(73, 90):
            arr[i] = np.average(arr[i:i+5])

        # Apply a low pass filter to attenuate remaining
        # power supply harmonics
        return self.lowpass_filter(wn, arr)

    @asyncSlot()
    async def acquire_pn_sweep(self):
        start_band = int(log10(self._app.offset_start)) - 2
        stop_band = int(log10(self._app.offset_stop)) - 3
        loop = asyncio.get_event_loop()
        pn_pwr = np.array([])
        pn_freq = np.array([])

        # Create an empty PlotDataItem...
        plot = self._pn_widget.plot()
        plot.setPen(pg.mkPen(PlotDialog.PLOT_COLORS[
            len(self._app._pn_data) % len(PlotDialog.PLOT_COLORS)]))
        self.acquire_enable(False)
        for band in range(start_band, stop_band+1):
            f_start = PhaseNoiseApp.PN_BANDS[band][0]
            f_stop = PhaseNoiseApp.PN_BANDS[band][1]
            rbw = PhaseNoiseApp.PN_BANDS[band][2]
            wn = PhaseNoiseApp.PN_BANDS[band][3]
            print("f_start={}, f_stop={}, rbw={}".format(
                f_start, f_stop, rbw), flush=True)
            band_pwr, band_freq = await loop.run_in_executor(
                None, self._app.sweep_band, f_start, f_stop, rbw)

            if band == 0:
                # Filter out the power supply harmonics at 150 and 250Hz
                band_pwr = self.filter_ps_harmonics(wn, band_pwr)
            else:
                band_pwr = self.lowpass_filter(wn, band_pwr)

            if len(pn_freq) > 0 and band_freq[0] == pn_freq[-1]:
                pn_pwr = np.concatenate([pn_pwr, band_pwr[1:]])
                pn_freq = np.concatenate([pn_freq, band_freq[1:]])
            else:
                pn_pwr = np.concatenate([pn_pwr, band_pwr])
                pn_freq = np.concatenate([pn_freq, band_freq])
            plot.setData(np.log10(pn_freq), pn_pwr)
        pn_data = self._app.append_pn_data(pn_freq, pn_pwr)
        self._app.sa.cmd("IP;SNGLS;")
        self.acquire_enable(True)

    def acquire_enable(self, en):
        self._acquire_btn.setEnabled(en)
        self._clear_btn.setEnabled(en)
        self._load_btn.setEnabled(en)
        self._save_btn.setEnabled(en)
        self._bbox.button(QDialogButtonBox.Close).setEnabled(en)

    def clear_pn_sweep(self):
        self._pn_widget.clear()

    def load_pn_data(self):
        filter_str = "JSON Data (*.json)"
        name, _ = QFileDialog.getOpenFileName(self, 'Open file', '',
                                              filter_str)
        if name:
            filename = str(name).strip()
            if len(filename):
                with open(filename, 'r') as fd:
                    self._app._pn_data = [PNDataItem.from_json(pn) for pn in json.load(fd)]
                self.plot_pn_data_items()

    def save_pn_data(self):
        filter_str = "JSON Data (*.json)"
        name, _ = QFileDialog.getSaveFileName(self, 'Save configuration', '',
                                              filter_str)
        if name:
            output_file = str(name).strip()
            if len(output_file) == 0:
                return
            with open(output_file, 'w') as fd:
                json.dump(self._app.pn_data, fd, indent=4,
                          default=lambda o: o.__dict__)

    def close(self):
        QDialog.reject(self)

    def build_ui(self):
        vbox = QVBoxLayout()

        pg.setConfigOptions(foreground='k')
        self._pn_widget = pg.PlotWidget(name='Plot1',
                                        background='w',
                                        enableMenu=True)
        self._pn_widget.getPlotItem().showAxis('right', True)
        self._pn_widget.getAxis('right').setStyle(showValues=False)
        self._pn_widget.getPlotItem().showAxis('top', True)
        self._pn_widget.getAxis('top').setStyle(showValues=False)
        x_start = log10(self._app.offset_start)
        x_stop = log10(self._app.offset_stop)
        self._pn_widget.setRange(yRange=(-170, -40),
                                 xRange=(x_start, x_stop))
        self._pn_widget.setMouseEnabled(False, False)
        self._pn_widget.getPlotItem().showGrid(True, True, alpha=0.15)
        tickFont = QFont("Times", 14)
        labelFont = QFont("Times", 16)
        x_axis = self._pn_widget.getAxis('bottom')
        x_axis.setStyle(tickFont=tickFont)
        x_ticks = [
            PlotDialog.X_MAJOR_TICKS[int(x_start)-2:int(x_stop)-1],
            PlotDialog.X_MINOR_TICKS[(int(x_start)-2)*8:(int(x_stop)-2)*8]
        ]
        x_axis.setTicks(x_ticks)
        x_axis.setLabel("Frequency (Hz)")
        x_axis.label.setFont(labelFont)
        y_axis = self._pn_widget.getAxis('left')
        y_axis.setStyle(tickFont=tickFont)
        y_axis.setLabel("Phase Noise (dBc/Hz)")
        y_axis.label.setFont(labelFont)

        vbox.addWidget(self._pn_widget)

        self._bbox = QDialogButtonBox(QDialogButtonBox.Close)
        self._acquire_btn = QPushButton("Acquire")
        self._clear_btn = QPushButton("Clear")
        self._load_btn = QPushButton("Load...")
        self._save_btn = QPushButton("Save...")
        self._bbox.addButton(self._acquire_btn, QDialogButtonBox.ActionRole)
        self._bbox.addButton(self._clear_btn, QDialogButtonBox.ActionRole)
        self._bbox.addButton(self._load_btn, QDialogButtonBox.ActionRole)
        self._bbox.addButton(self._save_btn, QDialogButtonBox.ActionRole)
        self._acquire_btn.clicked.connect(self.acquire_pn_sweep)
        self._clear_btn.clicked.connect(self.clear_pn_sweep)
        self._load_btn.clicked.connect(self.load_pn_data)
        self._save_btn.clicked.connect(self.save_pn_data)
        self._bbox.rejected.connect(self.close)

        vbox.addWidget(self._bbox)

        self.setLayout(vbox)

    def plot_pn_data_items(self):
        if len(self._app.pn_data) > 0:
            for i, pn_data in enumerate(self._app.pn_data):
                plot = self._pn_widget.plot()
                plot.setPen(pg.mkPen(PlotDialog.PLOT_COLORS[
                    i % len(PlotDialog.PLOT_COLORS)]))
                plot.setData(np.log10(pn_data.freq), pn_data.pwr)

Data storage

class PNDataItem(object):
    """Stores phase noise data"""

    def __init__(self, label, carrier_freq, data,
                 lock_bw, fm_dev, sa_avg, sa_smoothing,
                 descr=''):
        self._label = label
        self._carrier_freq = carrier_freq
        self._pn_data = data.copy()
        self._lock_bw = lock_bw
        self._fm_dev = fm_dev
        self._sa_avg = sa_avg
        self._sa_smoothing = sa_smoothing
        self._descr = descr

    @classmethod
    def from_json(cls, data):
        return cls(data['_label'], data['_carrier_freq'], data['_pn_data'],
                   data['_lock_bw'], data['_fm_dev'], data['_sa_avg'],
                   data['_sa_smoothing'], data['_descr'])

    @property
    def label(self):
        return self._label

    @property
    def carrier_freq(self):
        return self.carrier_freq

    @property
    def pwr(self):
        """Phase noise power data (in dBc)"""
        return [t[1] for t in self._pn_data]

    @property
    def freq(self):
        """Phase noise frequency data (in Hz)"""
        return [t[0] for t in self._pn_data]

    @property
    def lock_bw(self):
        """HP11729C lock BW factor used when acquiring data"""
        return self._lock_pw

    @property
    def fmdev(self):
        """Peak FM deviation of ref. signal gen used when acquiring data"""
        return self._fm_dev

    @property
    def sa_avg(self):
        """Number of successive SA traces averaged when acquiring data"""
        return self._sa_avg

    @property
    def sa_smoothing(self):
        """SA video BW to resolution BW used when acquiring data"""
        return self._sa_smoothing

    @property
    def description(self):
        return self._descr

    @description.setter
    def description(self, descr):
        self._descr = descr

Initialization

@asyncSlot()
async def initialize(self):
    try:
        self.initialize_ui()
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self.initialize_hw)
        self.enable_ui(True)
    except RuntimeError as re:
        error_dialog = QErrorMessage(self)
        error_dialog.showMessage(str(re))

def initialize_ui(self):
    pass

def initialize_hw(self):
    try:
        self.gpib = GPIB(gpib_ip=self.gpib_ctl_addr)
        self.gpib.initialize()
    except socket.timeout as te:
        self.gpib = None
        raise RuntimeError('GPIB controller is offline or powered down')
    try:
        self.sa = HP8560A(self.gpib, HP8560A_GPIBID)
        sa_id = self.sa.ident
        self.sa.initialize()
    except socket.timeout as te:
        self.sa = None
        raise RuntimeError('HP8560A is offline or powered down')
    try:
        self.sg = SMHU58(self.gpib, SMHU58_GPIBID)
        sg_id = self.sg.ident
        self.sg.initialize()
    except socket.timeout as te:
        self.sg = None
        raise RuntimeError('SMHU58 is offline or powered down')
    try:
        self.pn = HP11729C(self.gpib, HP11729C_GPIBID)
        #pn_id = self.pn.ident
        self.pn.initialize()
    except socket.timeout as te:
        self.pn = None
        raise RuntimeError('HP11729C is offline or powered down')

Process command line arguments

parser = ArgumentParser(description=
                        '''Measure phase noise.''')

parser.add_argument("--sg", choices=['SMHU58', 'DSG815'], default="SMHU58",
                    help="Reference signal generator in use. Default=SMHU58")
parser.add_argument("-H", "--harmonics", action='store_true',
                    help="Don't suppress power supply harmonics at 150 and 250Hz")

args = parser.parse_args()

Imports

import sys
import copy
import time
from math import sqrt, log10
from enum import Enum
import socket
import asyncio
from argparse import ArgumentParser
import json
import numpy as np
from scipy import signal

from tam import (
    GPIB, PROLOGIX_IPADDR,
    HP8560A, HP8560A_GPIBID, HP11729C, HP11729C_GPIBID,
    SMHU58, SMHU58_GPIBID
)

from qasync import (
    QEventLoop, QThreadExecutor, asyncSlot, asyncClose
)

from PyQt5.QtCore import Qt

from PyQt5.QtGui import QFont

from PyQt5.QtWidgets import (
    QWidget, QLabel, QAbstractSpinBox, QDoubleSpinBox, QVBoxLayout,
    QLineEdit, QHBoxLayout, QGroupBox, QMainWindow, QComboBox,
    QCheckBox, QPushButton, QRadioButton, QButtonGroup, QMessageBox,
    QFormLayout, QErrorMessage, QApplication, QScrollArea,
    QMenu, QFileDialog, QSpinBox, QDialog, QDialogButtonBox,
    QAction, QActionGroup, QStyleFactory
)

import pyqtgraph as pg