15 - QICKBox RF Daughtercards

Objective: Learn how to use the QICKBox’s RF and Balun daughtercards: setting the digitally controlled attenuators, and shaping the signal chain with the ADMV8818 tunable RF filters. This notebook covers:

  • What the QICKBox is and how its daughtercards map to DAC/ADC channels

  • Setting DAC/ADC attenuators on the RF and Balun daughtercards

  • Bypassing, sweeping, and combining the ADMV8818 low-pass/high-pass filter bands

  • Measuring signal power to check for saturation

Prerequisites:

  • A QICKBox with an RF-out and RF-in daughtercard (and, optionally, Balun-out/Balun-in daughtercards) installed, wired for loopback per the diagram below

  • Completed basic tutorials (00-05) and `06_Generators_And_Readouts.ipynb <../tutorials/06_Generators_And_Readouts.ipynb>`__ for generator/readout/pulse concepts, which are not re-explained here

  • Firmware bitstream built for the QICKBox variant of your board (RFQickSoc216V1 or equivalent)

References:

1. Overview

The QICKBox is a QICK carrier board that breaks DAC/ADC channels out to swappable RF daughtercards, instead of driving SMAs directly off the RFSoC. Two daughtercard types matter here:

  • RF daughtercards (RF-out / RF-in): chains of amplifiers and digitally controlled attenuators tuned to drive a fridge directly, with no additional warm amplification. They also carry an ADMV8818 digitally tunable filter, used to remove spurious Nyquist images on the DAC side and Nyquist-folded noise on the ADC side.

  • Balun daughtercards (Balun-out / Balun-in): a simpler passive path (no attenuators, no filter), useful as a reference/loopback channel or for signals that don’t need the RF chain’s gain and filtering.

Each daughtercard slot exposes several physical DAC/ADC ports; which firmware generator/readout channel a given port maps to depends on the bitstream build.

Wiring for this notebook

┌───────────────────────────────────────────────────────────────────────┐
│                          QICKBox Loopback Wiring                      │
├───────────────────────────────────────────────────────────────────────┤
│  DAC slot 1: RF-out daughtercard      (ports 4-7)                     │
│  DAC slot 2: Balun-out daughtercard   (ports 8-12)                    │
│  ADC slot 2: RF-in daughtercard       (ports 4-5)                     │
│  ADC slot 3: Balun-in daughtercard    (ports 6-7)                     │
│                                                                         │
│  DAC port 4 ──[40 dB attenuator]──► ADC port 5                        │
│  DAC port 5 ──[40 dB attenuator]──► ADC port 4                        │
│  DAC port 8 ─────────────────────► ADC port 6                         │
└───────────────────────────────────────────────────────────────────────┘

Important notes:

  • Adjust the port numbers and firmware channel numbers below to match your own daughtercard slots and cabling – the mapping is fixed by the bitstream, not by this notebook.

  • The RF daughtercards’ amplifiers can saturate (or clip the ADC) if you drive them with too little attenuation. Start with generous attenuation (as in Section 3) and reduce it gradually while watching the readout amplitude.

2. Setup and Initialization

[ ]:
# Standard imports
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
from tqdm.auto import tqdm

from qick.asm_v2 import AveragerProgramV2, QickSweep1D
from qick.rfboard import RFQickSoc216V1

# Connect to the board (adjust the path to your firmware)
BITSTREAM_PATH = 'qick_216_rfbv2.bit'  # <--- CHANGE THIS
soc = RFQickSoc216V1(BITSTREAM_PATH)
soccfg = soc

# Firmware channel numbers for the generators/readouts wired above
GEN_CH_RF = 5
GEN_CH_BALUN = 8
RO_CH_RF = 0
RO_CH_BALUN = 1

# Physical DAC/ADC ports on the RF daughtercards (attenuators/filters are
# addressed by port, not by firmware channel)
DAC_RF = 5
ADC_RF = 4

# Starting attenuations, dB (0 through 31.75) -- generous, to avoid
# saturating the chain before we've characterized it
DAC_ATT = [0, 10]
ADC_ATT = 25

print(f"Firmware: {soc.get_cfg()['fw_version']}")

3. Daughtercard Attenuators

rfb_set_dac_rf/rfb_set_adc_rf set the digitally controlled attenuators on an RF daughtercard’s DAC/ADC port. The DAC side takes two attenuator values (two stages in the chain); the ADC side takes one. Let’s set some starting attenuations and run a loopback pulse through the RF daughtercard to confirm the signal chain is working, using decimated readout so we can look at the pulse shape.

[ ]:
print("set DAC attenuators:", soc.rfb_set_dac_rf(DAC_RF, *DAC_ATT))
print("set ADC attenuators:", soc.rfb_set_adc_rf(ADC_RF, ADC_ATT))


class LoopbackProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        ro_ch = cfg['ro_ch']
        gen_ch = cfg['gen_ch']
        self.declare_gen(ch=gen_ch, nqz=cfg['nqz'], mixer_freq=cfg['mixer_freq'], ro_ch=ro_ch)
        self.declare_readout(ch=ro_ch, length=cfg['ro_len'])
        self.add_readoutconfig(ch=ro_ch, name="myro", freq=cfg['freq'], gen_ch=gen_ch, outsel='product')
        self.add_cosine(ch=gen_ch, name="ramp", length=cfg['ramp_len'], even_length=True)
        self.add_pulse(ch=gen_ch, name="mypulse", ro_ch=ro_ch,
                       style="flat_top",
                       envelope="ramp",
                       freq=cfg['freq'],
                       length=cfg['flat_len'],
                       phase=cfg['phase'],
                       gain=cfg['gain'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="myro", t=0)

    def _body(self, cfg):
        self.delay_auto()
        self.pulse(ch=cfg['gen_ch'], name="mypulse", t=0.0)
        self.trigger(ros=[cfg['ro_ch']], t=cfg['trig_time'])


config = {'gen_ch': GEN_CH_RF,
          'ro_ch': RO_CH_RF,
          'mixer_freq': 6000,
          'freq': 6000,
          'nqz': 2,
          'trig_time': 0.0,
          'ro_len': 3.0,
          'flat_len': 1.0,
          'ramp_len': 1.0,
          'phase': 0,
          'gain': 1.0}

# start the RF filters centered on the pulse frequency (Section 4 covers this call in detail)
soc.rfb_set_dac_filter(DAC_RF, fc=config['freq'] / 1000, ftype='bandpass', bw=1.0)
soc.rfb_set_adc_filter(ADC_RF, fc=config['freq'] / 1000, ftype='bandpass', bw=1.0)

prog = LoopbackProgram(soccfg, reps=1, final_delay=0.5, cfg=config)
iq_list = prog.acquire_decimated(soc, rounds=10)

t = prog.get_time_axis(ro_index=0)
iq = iq_list[0]
plt.plot(t, iq[:, 0], label="I value")
plt.plot(t, iq[:, 1], label="Q value")
plt.plot(t, np.abs(iq.dot((1, 1j))), label="magnitude")
plt.legend()
plt.ylabel("amplitude [ADU]")
plt.xlabel("time [us]");

Observation: with the attenuators and filter both centered on the pulse frequency, you should see a clean flat-top pulse. If the magnitude trace is clipped/flat-topped, add more attenuation; if it’s very small and noisy, reduce it.

4. Daughtercard RF Filters (ADMV8818)

Each RF daughtercard’s ADMV8818 filter has four selectable low-pass bands and four high-pass bands, each tunable through 16 states – 3-bit band select + 4-bit state select per filter type. rfb_set_dac_filter/rfb_set_adc_filter wrap this into a fc/bw/ftype (“bandpass” or “bypass”) interface. To characterize the filter itself, it’s more direct to drive the underlying registers, which is what set_filter below does.

We measure the filter response as an S21 sweep: play a swept-frequency tone and read back the demodulated amplitude at each frequency.

[ ]:
class FreqSweepProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        ro_ch = cfg['ro_ch']
        gen_ch = cfg['gen_ch']
        self.declare_gen(ch=gen_ch, nqz=cfg['nqz'], mixer_freq=cfg['mixer_freq'], ro_ch=ro_ch)
        self.declare_readout(ch=ro_ch, length=cfg['ro_len'])
        self.add_loop("myloop", self.cfg["steps"])
        self.add_readoutconfig(ch=ro_ch, name="myro", freq=cfg['freq'], gen_ch=gen_ch)
        self.add_pulse(ch=gen_ch, name="mypulse", ro_ch=ro_ch,
                       style="const",
                       freq=cfg['freq'],
                       length=cfg['pulse_len'],
                       phase=cfg['phase'],
                       gain=cfg['gain'])

    def _body(self, cfg):
        self.send_readoutconfig(ch=cfg['ro_ch'], name="myro", t=0)
        self.pulse(ch=cfg['gen_ch'], name="mypulse", t=0)
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=cfg['trig_time'])


def measure_s21(gen_ch, ro_ch, nqz, gain, steps=101, dds_range=0.45, overlap=0, plot=False, progress=True):
    # the interpolated-generator frequency range is limited, so stitch several
    # mixer_freq-centered sweeps together to cover a wide band
    soc.clear_interrupts()
    config = {'steps': steps, 'gen_ch': gen_ch, 'ro_ch': ro_ch, 'nqz': nqz,
              'trig_time': 0.4, 'pulse_len': 10.0, 'ro_len': 10.1, 'phase': 0, 'gain': gain}
    allfreqs, allpowers = [], []
    f_dds = soccfg['gens'][gen_ch]['f_dds']
    mixer_freqs = np.arange(0.5, 10000 / f_dds, dds_range * 2 - overlap) * f_dds
    for mixer_freq in tqdm(mixer_freqs, disable=not progress):
        config['mixer_freq'] = mixer_freq
        config['freq'] = QickSweep1D("myloop", mixer_freq - dds_range * f_dds, mixer_freq + dds_range * f_dds)
        prog = FreqSweepProgram(soccfg, reps=10, final_delay=1.0, cfg=config)
        freqs = prog.get_pulse_param('myro', 'freq', as_array=True)
        iq_list = prog.acquire(soc, rounds=1, progress=False)
        powers = 20 * np.log10(np.abs(iq_list[0][0].dot([1, 1j])))
        allfreqs.append(freqs)
        allpowers.append(powers)
        if plot:
            plt.plot(freqs, powers, label="mixer_freq=%f" % (mixer_freq))
    return np.array(allfreqs).flatten(), np.array(allpowers).flatten()


def set_filter(lpf, hpf, filt=0):
    """Drive the ADMV8818 band-select (lpf/hpf) and state (filt) registers directly,
    on both the RF-in and RF-out daughtercards."""
    sw = 0xc0 + (hpf << 3) + lpf
    filt_bits = (filt << 4) + filt
    rfb_ch = soc.adc_chains[ADC_RF]
    with soc.board_sel.enable_context(rfb_ch.card_num):
        rfb_ch.filter.write_reg('WR0_SW', sw)
        rfb_ch.filter.write_reg('WR0_FILTER', filt_bits)
    rfb_ch = soc.dac_chains[DAC_RF]
    with soc.board_sel.enable_context(rfb_ch.card_num):
        rfb_ch.filter.write_reg('WR0_SW', sw)
        rfb_ch.filter.write_reg('WR0_FILTER', filt_bits)

Bypass mode

With the filters in bypass mode there’s no rejection of Nyquist images/aliases, so we need to crank up all of the attenuators to avoid saturating the chain.

[ ]:
print("set DAC attenuators:", soc.rfb_set_dac_rf(DAC_RF, 30, 30))
print("set ADC attenuators:", soc.rfb_set_adc_rf(ADC_RF, 30))
soc.rfb_set_rfadc_attenuator(ADC_RF, 10)
soc.rfb_set_dac_filter(DAC_RF, fc=0, ftype='bypass')
soc.rfb_set_adc_filter(ADC_RF, fc=0, ftype='bypass')

plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, nqz=2, gain=1.0, steps=501, dds_range=0.45, overlap=0.1))
plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.title("bypass mode");

Sweeping bands and states

Let’s work our way through the low-pass and high-pass filter banks: first the four bands (at a fixed state), then all 16 states within one band.

[ ]:
for lpf in tqdm(range(0, 5)):
    set_filter(lpf, 0, 0)
    plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, 2, 1.0, progress=False), label="lpf=%d" % (lpf))
plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.title("LPF bands, state=0")
plt.legend();

[ ]:
for state in tqdm(range(0, 16)):
    set_filter(2, 0, state)
    plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, 2, 1.0, progress=False), label="state=%d" % (state))
plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.title("LPF states, band=2")
plt.legend();

[ ]:
for hpf in tqdm(range(0, 5)):
    set_filter(0, hpf, 0)
    plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, 2, 1.0, progress=False), label="hpf=%d" % (hpf))
plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.title("HPF bands, state=0")
plt.legend();

[ ]:
for state in tqdm(range(0, 16)):
    set_filter(0, 2, state)
    plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, 2, 1.0, progress=False), label="state=%d" % (state))
plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.title("HPF states, band=2")
plt.legend();

Combining LPF + HPF into a bandpass

This is what rfb_set_dac_filter/rfb_set_adc_filter with ftype='bandpass' do for you: they pick a matching LPF/HPF band+state pair to approximate the requested center frequency fc and bandwidth bw. As the passband narrows, more attenuation can be removed for the same signal level – the filter itself is doing some of the rejection work that attenuation was doing in bypass mode.

[ ]:
for fc, bw, dac_att2, adc_att in [(5, 1.0, 30, 30), (5.5, 0.5, 20, 25), (6, 0.5, 20, 25), (7, 1.0, 15, 20)]:
    soc.rfb_set_dac_filter(DAC_RF, fc=fc, ftype='bandpass', bw=bw)
    soc.rfb_set_adc_filter(ADC_RF, fc=fc, ftype='bandpass', bw=bw)
    soc.rfb_set_dac_rf(DAC_RF, 0, dac_att2)
    soc.rfb_set_adc_rf(ADC_RF, adc_att)
    plt.plot(*measure_s21(GEN_CH_RF, RO_CH_RF, nqz=2, gain=1.0, steps=501, dds_range=0.45, overlap=0.1),
             label="fc=%.1f, bw=%.1f" % (fc, bw))

# leave the chain in a safe, heavily attenuated state
soc.rfb_set_dac_rf(DAC_RF, 30, 30)
soc.rfb_set_adc_rf(ADC_RF, 30)

plt.ylabel("S21 [arb. dB]")
plt.xlabel("Frequency [MHz]")
plt.ylim(bottom=0)
plt.xlim((3000, 9000))
plt.legend();

5. Measuring Power and Checking for Saturation

When you change gain, attenuation, or filtering, it’s useful to have a quick way to measure the resulting readout amplitude – both to compare configurations and to check you haven’t saturated the DAC output stage or the ADC input. PeriodicProgram plays a continuous tone so the readout amplitude converges to a steady-state value; measure_gain wraps it into a one-line power measurement for a given generator gain and pair of attenuator settings.

[ ]:
class PeriodicProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        ro_ch = cfg['ro_ch']
        gen_ch = cfg['gen_ch']
        self.declare_gen(ch=gen_ch, nqz=cfg['nqz'], mixer_freq=cfg['mixer_freq'], ro_ch=ro_ch)
        self.declare_readout(ch=ro_ch, length=cfg['ro_len'])
        self.add_readoutconfig(ch=ro_ch, name="myro", freq=cfg['freq'], gen_ch=gen_ch, outsel='product')
        self.add_pulse(ch=gen_ch, name="mypulse", ro_ch=ro_ch,
                       style="const",
                       freq=cfg['freq'],
                       length=cfg['flat_len'],
                       phase=cfg['phase'],
                       gain=cfg['gain'],
                       mode='periodic')
        self.send_readoutconfig(ch=cfg['ro_ch'], name="myro", t=0)
        self.pulse(ch=cfg['gen_ch'], name="mypulse", t=0)

    def _body(self, cfg):
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=cfg['trig_time'], mr=True)


def measure_gain(gain, dac_att1, dac_att2, adc_att):
    config = {'gen_ch': GEN_CH_RF, 'ro_ch': RO_CH_RF, 'mixer_freq': 6000, 'freq': 6000, 'nqz': 2,
              'trig_time': 0.0, 'ro_len': 10.0, 'flat_len': 1.0, 'phase': 0, 'gain': gain}
    soc.rfb_set_dac_rf(DAC_RF, dac_att1, dac_att2)
    soc.rfb_set_adc_rf(ADC_RF, adc_att)
    prog = PeriodicProgram(soccfg, reps=1000, final_delay=0.5, cfg=config)
    mag = np.abs(prog.acquire(soc, progress=False)[0][0].dot([1, 1j]))
    soc.reset_gens()
    soc.clear_interrupts()
    return mag


# baseline, then compare a few knobs against it, each as a dB change
ref_mag = measure_gain(0.5, DAC_ATT[0], DAC_ATT[1], ADC_ATT)
print("reference amplitude: %f" % (ref_mag))

mag = measure_gain(0.5, DAC_ATT[0], DAC_ATT[1] - 3, ADC_ATT)
print("with -3 dB DAC att2: amplitude %f, a %.2f dB change" % (mag, 20 * np.log10(mag / ref_mag)))

mag = measure_gain(0.5, DAC_ATT[0], DAC_ATT[1], ADC_ATT - 3)
print("with -3 dB ADC att: amplitude %f, a %.2f dB change" % (mag, 20 * np.log10(mag / ref_mag)))

Watch for saturation: if the measured amplitude stops scaling linearly as you reduce attenuation (dB change stops matching the attenuator step), you’ve saturated the DAC output amp, the ADC front end, or both – back off and re-measure.

6. Summary

You have learned:

  1. QICKBox daughtercards: RF daughtercards (amplifiers + attenuators + ADMV8818 filter) vs. Balun daughtercards (simple passive path), and how physical ports differ from firmware channels

  2. Attenuators: rfb_set_dac_rf/rfb_set_adc_rf to set per-stage attenuation on a daughtercard port

  3. ADMV8818 filters: bypass mode, sweeping LPF/HPF bands and states, and combining them into a bandpass response via rfb_set_dac_filter/rfb_set_adc_filter

  4. Saturation checking: using a periodic tone and measure_gain to confirm gain/attenuation changes are still in the linear regime

Key Takeaways

  • Filtering and attenuation trade off: a narrower bandpass filter rejects more out-of-band noise/images, letting you run with less attenuation for the same in-band signal level

  • Always start a new configuration with generous attenuation and reduce it while watching the readout amplitude, rather than guessing a low value up front

  • Balun daughtercards are the simplest way to get a reference/loopback channel without the RF chain’s gain and filtering in the way

Next Steps

  • Return to `07_Advanced_Generators_And_Readouts.ipynb <./07_Advanced_Generators_And_Readouts.ipynb>`__ for multiplexed generators/readouts and the PFB readout channelizer, which apply equally to signals routed through these daughtercards

  • See the Firmware Overview for how daughtercard ports map to generator/readout channels in a given bitstream