06 - Generators and Readouts

Objective: Learn how to use waveform generators and optimize readout resonators. This notebook covers:

  • Generator configuration and waveform styles

  • Readout resonator tuning and optimization

  • IQ mixing and signal processing

  • Frequency sweeps for resonator characterization

1. Setup

[1]:
# Standard imports
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
import logging

from qick import *
from qick.asm_v2 import AveragerProgramV2

# Setup logging
logging.basicConfig(level=logging.INFO, format='%(levelname)-8s [%(filename)s:%(lineno)d] %(message)s')
logging.getLogger("qick_processor").setLevel(logging.WARNING)

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

# Define hardware channels
GEN_CH = 0      # Generator channel (DAC)
RO_CH = 0       # Readout channel (ADC)

2. Understanding Waveform Generators

The QICK board has multiple DAC channels that can generate arbitrary waveforms. Each generator can produce:

  • Constant pulses (style="const"): Square pulses with constant amplitude

  • Arbitrary/envelope pulses (style="arb"): Shaped pulses using a predefined envelope

  • Flat-top pulses (style="flat_top"): Gaussian rise/fall with constant middle section

Let’s explore each type.

3. Generator Configuration: Constant Pulse

The simplest waveform is a constant pulse. It has a flat amplitude over its duration.

[2]:
class ConstantPulseProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        # Constant pulse - square shape
        self.add_pulse(
            ch=cfg['gen_ch'],
            name="const_pulse",
            style="const",
            freq=cfg['freq'], ro_ch=cfg['ro_ch'],
            length=cfg['pulse_len'],
            phase=cfg['phase'],
            gain=cfg['gain']
        )

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

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

# Configure and run
config_const = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 100.0,
    'pulse_len': 0.2,
    'phase': 0,
    'gain': 0.8,
    'trig_time': 0.25,
    'ro_len': 0.5
}

prog = ConstantPulseProgram(soccfg, reps=1, final_delay=1.0, cfg=config_const)
iq_data = prog.acquire_decimated(soc, rounds=10)
time_axis = prog.get_time_axis(ro_index=0)

plt.figure(figsize=(10, 4))
plt.plot(time_axis, iq_data[0][:,0], label='I')
plt.plot(time_axis, iq_data[0][:,1], label='Q')
plt.legend()
plt.xlabel('Time (µs)')
plt.ylabel('Amplitude (a.u.)')
plt.title('Constant Pulse - I/Q Components')
plt.grid(True)
plt.show()
../_images/tutorials_06_Generators_And_Readouts_5_1.png

Observation: The constant pulse produces a square-shaped response. The I and Q components oscillate at the carrier frequency (100 MHz in this case).

4. Shaped Pulses with Envelopes

Shaped pulses reduce spectral leakage and are essential for qubit control. The QICK supports:

  • Gaussian envelopes: Smooth rise and fall

  • Flat-top pulses: Gaussian edges with a constant top (ideal for readout resonators)

[3]:
class ShapedPulseProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        # Define a Gaussian envelope
        # The envelope length should be sufficient to capture the full pulse
        ramp_len = 0.15
        self.add_gauss(
            ch=cfg['gen_ch'],
            name="gauss_env",
            sigma=ramp_len / 8,  # Steepness of the Gaussian
            length=ramp_len,
            even_length=True
        )

        # Gaussian pulse (arbitrary envelope)
        self.add_pulse(
            ch=cfg['gen_ch'], name="gauss_pulse",
            style="arb", envelope="gauss_env",
            freq=cfg['freq'], ro_ch=cfg['ro_ch'], phase=cfg['phase'], gain=cfg['gain']
        )

        # Flat-top pulse (good for readout)
        self.add_pulse(
            ch=cfg['gen_ch'], name="flattop_pulse",
            style="flat_top", envelope="gauss_env",
            freq=cfg['freq'], ro_ch=cfg['ro_ch'], length=0.2, phase=cfg['phase'], gain=cfg['gain']
        )

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

    def _body(self, cfg):
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=cfg['trig_time'], ddr4=True)
        self.pulse(ch=cfg['gen_ch'], name="gauss_pulse", t=0, tag='gauss')
        self.pulse(ch=cfg['gen_ch'], name="flattop_pulse", t=0.5, tag='flattop')

config_shaped = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 100.0,
    'phase': 0,
    'gain': 0.8,
    'trig_time': 0.1,
    'ro_len': 1.0
}

prog = ShapedPulseProgram(soccfg, reps=1, final_delay=0.5, cfg=config_shaped)
iq_data = prog.acquire_decimated(soc, rounds=10)
time_axis = prog.get_time_axis(ro_index=0)

# Separate the two pulses
magnitude = np.abs(iq_data[0].dot([1, 1j]))

plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(time_axis, magnitude, label='Magnitude')
plt.axvline(x=0.25, color='r', linestyle='--', alpha=0.5, label='Gaussian peak')
plt.axvline(x=0.75, color='g', linestyle='--', alpha=0.5, label='Flat-top peak')
plt.xlabel('Time (µs)')
plt.ylabel('Magnitude (a.u.)')
plt.title('Shaped Pulses: Magnitude')
plt.legend()
plt.grid(True)

plt.subplot(1, 2, 2)
plt.plot(time_axis, iq_data[0][:,0], label='I')
plt.plot(time_axis, iq_data[0][:,1], label='Q')
plt.xlabel('Time (µs)')
plt.ylabel('Amplitude (a.u.)')
plt.title('Shaped Pulses: I/Q Components')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
../_images/tutorials_06_Generators_And_Readouts_8_1.png

Key points about shaped pulses:

  • Gaussian pulses minimize spectral sidebands, reducing crosstalk

  • Flat-top pulses maintain constant amplitude at the top, ideal for readout resonators

  • The envelope is applied to the pulse shape, not the carrier frequency

5. IQ Mixing and Quadrature Signals

The QICK uses IQ mixing to generate arbitrary phase shifts. I (in-phase) and Q (quadrature) components are 90° apart. By adjusting the phase parameter, you can rotate the pulse in the IQ plane.

[4]:
class IQMixingProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        # Same pulse at different phases
        for phase in cfg['phases']:
            self.add_pulse(
                ch=cfg['gen_ch'],
                name=f"pulse_{phase}",
                style="const",
                freq=cfg['freq'], ro_ch=cfg['ro_ch'],
                length=cfg['pulse_len'],
                phase=phase,
                gain=cfg['gain']
            )

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

    def _body(self, cfg):
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=cfg['trig_time'], ddr4=True)
        for i, phase in enumerate(cfg['phases']):
            self.pulse(ch=cfg['gen_ch'], name=f"pulse_{phase}", t=i * 0.3)

config_iq = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 500.0,
    'pulse_len': 0.1,
    'gain': 0.8,
    'trig_time': 0.05,
    'ro_len': 1.5,
    'phases': [0, 45, 90, 135, 180, 225, 270, 315]
}

prog = IQMixingProgram(soccfg, reps=1, final_delay=0.5, cfg=config_iq)
iq_data = prog.acquire_decimated(soc, rounds=10)
time_axis = prog.get_time_axis(ro_index=0)

# Extract magnitude at each pulse center
pulse_centers = [0.15 + i * 0.3 for i in range(len(config_iq['phases']))]
idx_centers = [np.argmin(np.abs(time_axis - t)) for t in pulse_centers]
iq_at_centers = iq_data[0][idx_centers, :]

plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(time_axis, iq_data[0][:,0], label='I')
plt.plot(time_axis, iq_data[0][:,1], label='Q')
plt.xlabel('Time (µs)')
plt.ylabel('Amplitude (a.u.)')
plt.title('IQ Mixing - Multiple Phases')
plt.legend()
plt.grid(True)

plt.subplot(1, 2, 2)
plt.plot(iq_at_centers[:,0], iq_at_centers[:,1], 'o-', markersize=8)
for i, phase in enumerate(config_iq['phases']):
    plt.annotate(f'{phase}°', (iq_at_centers[i,0], iq_at_centers[i,1]))
plt.axhline(0, color='k', linestyle='-', alpha=0.3)
plt.axvline(0, color='k', linestyle='-', alpha=0.3)
plt.xlabel('I')
plt.ylabel('Q')
plt.title('IQ Constellation')
plt.axis('equal')
plt.grid(True)
plt.show()
../_images/tutorials_06_Generators_And_Readouts_11_1.png

Explanation of IQ mixing:

  • Phase shifts rotate the pulse vector in the IQ plane

  • A pulse with phase 0° appears on the I axis

  • A pulse with phase 90° appears on the Q axis

  • The constellation plot shows the relationship between I and Q for different phases

6. Readout Resonator Characterization

Readout resonators are used to measure qubit states. To optimize the readout, we need to find the resonator’s resonant frequency. This is done by sweeping the readout frequency and measuring the response.

[5]:
class ReadoutSweepProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        # Define the readout pulse (flat-top recommended for readout)
        ramp_len = 0.05
        self.add_gauss(ch=cfg['gen_ch'], name="ramp",
                       sigma=ramp_len/8, length=ramp_len, even_length=True)

        self.add_pulse(
            ch=cfg['gen_ch'], name="readout_pulse",
            style="flat_top", envelope="ramp",
            freq=cfg['freq'], ro_ch=cfg['ro_ch'], length=cfg['pulse_len'], phase=0, gain=cfg['gain']
        )

        # Configure readout - freq will be swept
        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

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

# Frequency sweep parameters
freqs = np.linspace(490, 510, 41)  # MHz
results_i = []
results_q = []

base_config = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'pulse_len': 0.5,
    'gain': 0.5,
    'trig_time': 0.1,
    'ro_len': 0.8
}

print("Sweeping readout frequency...")
for f in freqs:
    config = base_config.copy()
    config['freq'] = f
    prog = ReadoutSweepProgram(soccfg, reps=1, final_delay=0.5, cfg=config)
    iq_data = prog.acquire_decimated(soc, rounds=100)  # 100 averages for clean data

    # Average the I/Q data over the pulse duration
    # (take mean after the pulse has settled)
    start_idx = int(0.2 / prog.get_time_axis(ro_index=0)[1])  # 0.2 us after trigger
    i_avg = np.mean(iq_data[0][start_idx:, 0])
    q_avg = np.mean(iq_data[0][start_idx:, 1])
    results_i.append(i_avg)
    results_q.append(q_avg)

results_mag = np.sqrt(np.array(results_i)**2 + np.array(results_q)**2)
results_phase = np.arctan2(results_q, results_i) * 180 / np.pi

plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(freqs, results_mag, 'o-', linewidth=2, markersize=4)
plt.xlabel('Frequency (MHz)')
plt.ylabel('|IQ| (a.u.)')
plt.title('Readout Resonator - Magnitude Response')
plt.grid(True)

plt.subplot(1, 2, 2)
plt.plot(freqs, results_phase, 'o-', linewidth=2, markersize=4)
plt.xlabel('Frequency (MHz)')
plt.ylabel('Phase (degrees)')
plt.title('Readout Resonator - Phase Response')
plt.grid(True)
plt.tight_layout()
plt.show()

# Find the resonance frequency (minimum magnitude for a dispersive readout)
resonance_idx = np.argmin(results_mag)
resonance_freq = freqs[resonance_idx]
print(f"\nResonance frequency: {resonance_freq:.2f} MHz")
print(f"Maximum magnitude: {results_mag[resonance_idx]:.3f} a.u.")
Sweeping readout frequency...
../_images/tutorials_06_Generators_And_Readouts_14_42.png

Resonance frequency: 509.50 MHz
Maximum magnitude: 390.140 a.u.

Interpreting the readout sweep:

  • The magnitude response shows a dip at the resonance frequency

  • The phase response shows a sharp transition (dispersion) at resonance

  • For optimal readout, you typically operate slightly detuned from resonance

  • The exact optimal point depends on your measurement protocol (e.g., homodyne vs. heterodyne)

7. Optimizing Readout Parameters

Once you find the resonance frequency, you can optimize other parameters like readout length and gain.

[6]:
class ReadoutOptimizationProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        ramp_len = 0.05
        self.add_gauss(ch=cfg['gen_ch'], name="ramp",
                       sigma=ramp_len/8, length=ramp_len, even_length=True)

        self.add_pulse(
            ch=cfg['gen_ch'], name="readout_pulse",
            style="flat_top", envelope="ramp",
            freq=cfg['freq'], ro_ch=cfg['ro_ch'], length=cfg['pulse_len'], phase=0, gain=cfg['gain']
        )

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

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

# Use the found resonance frequency
optimal_freq = resonance_freq if 'resonance_freq' in dir() else 500.0

# Sweep readout length
lengths = np.linspace(0.2, 2.0, 19)
snr_results = []

print("Optimizing readout length...")
for l in lengths:
    config = {
        'gen_ch': GEN_CH,
        'ro_ch': RO_CH,
        'freq': optimal_freq,
        'pulse_len': l,
        'gain': 0.5,
        'trig_time': 0.1,
        'ro_len': l + 0.1  # Readout slightly longer than pulse
    }
    prog = ReadoutOptimizationProgram(soccfg, reps=1, final_delay=0.5, cfg=config)
    iq_data = prog.acquire_decimated(soc, rounds=100)

    # Estimate SNR from signal magnitude
    signal_mag = np.abs(iq_data[0].dot([1, 1j]))
    signal_peak = np.max(signal_mag)
    noise_std = np.std(signal_mag[:50])  # Noise before the pulse
    snr = signal_peak / noise_std if noise_std > 0 else 0
    snr_results.append(snr)

plt.figure(figsize=(10, 5))
plt.plot(lengths, snr_results, 'o-', linewidth=2, markersize=6)
plt.xlabel('Readout Pulse Length (µs)')
plt.ylabel('Signal-to-Noise Ratio (SNR)')
plt.title('Readout Optimization - SNR vs. Pulse Length')
plt.grid(True)
plt.show()

optimal_idx = np.argmax(snr_results)
optimal_length = lengths[optimal_idx]
print(f"\nOptimal readout length: {optimal_length:.2f} µs")
print(f"Maximum SNR: {snr_results[optimal_idx]:.1f}")
Optimizing readout length...
../_images/tutorials_06_Generators_And_Readouts_17_20.png

Optimal readout length: 1.50 µs
Maximum SNR: 7927.4

8. Summary

You have learned:

  • How to configure generators with different pulse styles (constant, Gaussian, flat-top)

  • How IQ mixing works and how phase shifts affect the IQ plane

  • How to characterize a readout resonator via frequency sweeps

  • How to optimize readout parameters like pulse length

Key takeaways:

  • Use shaped pulses (Gaussian or flat-top) for better spectral purity

  • IQ mixing allows arbitrary phase control of your pulses

  • Readout resonators should be characterized before experiments

  • The optimal readout frequency is often slightly detuned from resonance

Next steps: Proceed to `07_Advanced_Generators_And_Readouts <./07_Advanced_Generators_And_Readouts.ipynb>`__ to learn about the DDR4 and MR buffers for high-speed data capture.