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.
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(
readout_freq=6.2e9,
control_freq=4.2e9,
readout_amp=0.1,
control_amp_arr=np.linspace(0, 0.5, 101),
readout_duration=2.5e-6,
control_duration=20e-9,
sample_duration=2.5e-6,
readout_port=1,
control_port=4,
sample_port=1,
wait_delay=100e-6,
readout_sample_delay=0e-9,
num_averages=100,
num_pulses=10,
)
presto_address = "192.168.88.65" # 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:
experiment.analyze()
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.
Note
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(
address=presto_address,
ext_ref_clk=ext_ref_clk,
adc_mode=AdcMode.Mixed,
dac_mode=DacMode.Mixed,
) 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.
pls.hardware.configure_mixer(
freq=self.readout_freq,
in_ports=self.sample_port,
out_ports=self.readout_port,
)
pls.hardware.configure_mixer(
freq=control_nco,
out_ports=self.control_port,
)
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,
duration=self.readout_duration,
amplitude=1.0 + 1j,
envelope=False,
)
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")
.
pls.set_store_ports(self.sample_port)
pls.set_store_duration(self.sample_duration)
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
).