Rabi amplitude#

Using pulsed mode, we measure Rabi oscillations by keeping the length of the qubit-control pulse constant and sweeping its amplitude. The control pulse is at the frequency of the qubit and has a \(\sin^2\) envelope, while the resonator-readout pulse is at the frequency of the readout resonator and has square envelope. We sample the response of the readout resonator to determine the state of the qubit. We optionally output \(N\) consecutive control pulses in order to obtain a more accurate measurement of the Rabi period.

Rabi pulse sequence

The full source code for this experiment is available at presto-measure/rabi_amp.py. Here, we first run the Rabi experiment and analyze the data in order to establish the amplitude of the \(\pi\) and \(\pi/2\) pulses, and then we have a more detailed look at the most important parts of the code.

You can create a new experiment and run it on your Presto. Be sure to change the parameters of RabiAmp to match your experiment and change presto_address to match the IP address of your Presto:

from rabi_amp import RabiAmp
import numpy as np

experiment = RabiAmp(
    control_amp_arr=np.linspace(0, 0.5, 101),

presto_address = ""  # your Presto IP address
save_filename = experiment.run(presto_address)

Or you can also load older data:

experiment = RabiAmp.load("data/rabi_amp_20220413_082434.h5")

In either case, we analyze the data to get a nice plot:


In the example above, one control pulse is applied before the readout pulse. When the amplitude of the control pulse is zero the qubit remains in the ground state, so we can conclude that the high value of the readout quadrature I corresponds to the ground state. When the amplitude of the control pulse corresponds to the amplitude of the \(\pi\) pulse, the qubit is in the excited state, and we observe the first minimum at about 5% of the full-scale range (0.05 FS). When the amplitude of the control pulse corresponds to the amplitude of the \(2\pi\) pulse we observe the second maximum at about 10% of the full-scale range, the qubit is in the ground state, and so on. The data is fitted to a cosine function.

Code explanation#

Here we look under the hood of the RabiAmp class and discuss the main parts of the code, you can find the full source at presto-measure/rabi_amp.py.


As this might be your first experiment with the pulsed mode, we will take our time and explain all the details.

We start by creating an instance of the Pulsed class: pls. At this point, we connect to the Presto unit at the IP address presto_address and set up the reference clock (ext_ref_clk). We also tell the API we want to use digital up- and down-conversion, by setting both AdcMode and DacMode to the Mixed variant. If we didn’t want to use the digital mixers, we’d use the default Direct variant instead. See the documentation for the Pulsed class and Advanced tile configuration for more details.

with pulsed.Pulsed(
) as pls:

We then configure the frequency up- and down-conversion with the built-in digital IQ mixers using Hardware.configure_mixer(). We need one mixer on the qubit-control output port, and two mixers on the resonator-readout output and input ports.


freq is the frequency of the numerically-controlled oscillator (NCO), the digital counterpart to a local oscillator (LO) in analog mixing. The NCOs are armed immediately after calling configure_mixer(), but only start when a synchronization event occurs. In our case, the synchronization event is simply provided by the call to Pulsed.run() when we start the experiment later on.

In this experiment, we choose to not use an intermediate frequency (IF), or rather we use an IF of zero. That means that our qubit-control and resonator-readout pulses will be output directly at the respective NCO frequencies. In an external, analog frequency conversion scheme you typically don’t want to use zero IF to avoid \(1/f\) noise and isolate your signal from mixing artifacts like LO leakage and mixer imbalance. Presto, on the other hand, uses digital frequency conversion so these concerns are not applicable and using zero IF is a good way of simplifying the signal chain.

Next, we program the amplitude of the pulses in two look-up tables (LUT) using setup_scale_lut(). Before reaching the digital up-conversion, output pulses go through a scalar multiplier or variable-gain block. There are two multipliers on each output port, each identified by their group (0 or 1) and with an associated LUT with 512 programmable entries storing values from -1 to 1. See the functional schematics of the pulsed output for more details.

pls.setup_scale_lut(self.readout_port, group=0, scales=self.readout_amp)
pls.setup_scale_lut(self.control_port, group=0, scales=self.control_amp_arr)

For the resonator-readout pulse we program a single amplitude readout_amp into the scale LUT, while for the qubit-control pulse we upload an array control_amp_arr containing all the amplitudes we want to sweep.

The scale LUT provides a very efficient way of performing an amplitude sweep: rather than uploading a new pulse with a new amplitude, we just step to the next entry in the LUT! There are also two IF generators on each output port to implement efficient frequency and phase sweeps, but we don’t use them in this experiment.

Next, we create the output pulses by providing templates. Each output group has 8 available templates that can store arbitrary data for up to 1022 ns each, i.e. 1022 complex-valued data points at 1 GS/s when using digital up-conversion (dac_mode is one of DacMode.Mixedxx). Templates can be concatenated, superimposed and played in a loop to create longer and mode complex waveforms. The maximum number of data points in a template depends on the IF sampling rate get_fs("dac") and can be obtained by calling get_max_template_len().

There are two main ways to create a new pulse. For the qubit-control pulse, we provide the raw data points that make up our desired pulse shape using setup_template():

# number of samples in template
control_ns = int(round(self.control_duration * pls.get_fs("dac")))
control_envelope = sin2(control_ns)
control_pulse = pls.setup_template(
    self.control_port, group=0,
    template=control_envelope + 1j * control_envelope,

We first create a NumPy array containing a \(\sin^2\) envelope using the convenience function utils.sin2(). We then set the same template on both I and Q ports by defining the complex array template=control_envelope + 1j*control_envelope.

For the resonator-readout pulse, we use the second way to create a pulse: setup_long_drive(), which is very convenient when we are interested in a constant-amplitude pulse with optionally smooth rise and fall segments. A LongDrive can also encode a very long pulse without using up all the template slots available, and we can resize its duration during an experiment.

readout_pulse = pls.setup_long_drive(
    self.readout_port, group=0,
    amplitude=1.0 + 1j,

We set the amplitudes of both I and Q templates to maximum 1+1j. By doing a bit of trigonometry for mixers we can calculate that in our case (\(\phi_I = \phi_Q = 0\) and \(A_I=A_Q=1\)), the output of the mixer will be \(\sqrt{2}\cos(2\pi\) readout_freq \(t + \pi/4)\), for duration readout_duration. We set envelope=False, so this pulse will not be multiplied with the IF generator and will be output directly at the NCO frequency.

To complete the setup, we configure data acquisition by specifying what input ports we want to store data from with set_store_ports(), and for how long with set_store_duration(). Once we do that, each sampling window we open will store a number of data points equal to sample_duration × get_fs("adc").


Now that we are done with the setup, we move on to describe the pulse sequence that makes up our experiment. Let’s look at the code first, then explain it line by line.

T = 0.0  # s, start at time zero ...

for _ in range(self.num_pulses):
    pls.output_pulse(T, control_pulse)  # Control pulse
    T += self.control_duration

pls.output_pulse(T, readout_pulse)  # Readout
pls.store(T + self.readout_sample_delay)
T += self.readout_duration

pls.next_scale(T, self.control_port)  # Move to next Rabi amplitude
T += self.wait_delay  # Wait for decay

We start at time T = 0, and all pointers of the LUTs (frequency and scale) point to their first entry.

We use output_pulse() to schedule the qubit-control pulse control_pulse to be output at time T. We repeat this num_pulses times in a for loop to output many control pulses back-to-back with no delay.

We then output the resonator-readout pulse readout_pulse just once, and start sampling data with store(). We include a delay readout_sample_delay between the readout pulse and the data acquisition to account for latency in the experimental setup.

Finally, we prepare for the next iteration: next_scale() selects the next value in the scale LUT for the control port (the new amplitude for control_pulse), and we wait for the qubit to decay by simply incrementing the time variable T.

We’re done describing the experiment but, up until now, we didn’t actually output any pulses. To run the experiment, we call run(): Presto will start outputting pulses, acquiring data, stepping parameters and performing interleaved averaging.

nr_amps = len(self.control_amp_arr)
pls.run(period=T, repeat_count=nr_amps, num_averages=self.num_averages)

period is the time between each individual run of the programmed sequence. Multiple runs are repeated back-to-back with exact timing due to repeat_count and num_averages. repeat_count indicates we want to repeat the experiment nr_amps times, each time with a different qubit-control amplitude and storing a separate measured trace. In general, for each repetition we increment all frequency pointers called by next_frequency() and all scale pointers called by next_scale(). At this point, we have a sequence that is period × repeat_count long, and each period contains a unique qubit-control amplitude. We now repeat this larger sequence num_averages times, thus performing interleaved averaging.

In total, the sequence is repeated repeat_count × num_averages times, and will terminate after period × repeat_count × num_averages seconds.

When the experiment is done, we retrieve the averaged acquired data with get_store_data():

self.t_arr, self.store_arr = pls.get_store_data()

t_arr contains the time axis in seconds during one acquisition, with get_fs("adc") sampling rate. store_arr is a three-dimensional NumPy array containing the sampled data, scaled to ±1.0 being full-scale input. The shape of the array is (num_stores * repeat_count, num_ports, smpls_per_store), where num_stores is the number of store events programmed in the sequence (the number of calls to store()), num_ports is the number of inputs ports set with set_store_ports(), and smpls_per_store is the number of samples in one acquisition set by set_store_duration(). Because we are using digital down-conversion, the returned data is complex valued (dtype=np.complex128).