See also

A Jupyter notebook version of this tutorial can be downloaded here.

banner

Rabi Oscillations#

Tutorial Objective#

The goal of this example is to calibrate the RF drive amplitude needed to coherently control a qubit. After identifying the \(|0\rangle \leftrightarrow |1\rangle\) transition frequency, a Rabi experiment is performed by sweeping the drive amplitude while keeping the pulse frequency and duration fixed. The resulting oscillations in the qubit population are used to identify the amplitude corresponding to a \(\pi\)-pulse, enabling arbitrary state preparation on the Bloch sphere and demonstrating coherent qubit control.

Imports#

[1]:
from __future__ import annotations

import numpy as np
from dependencies.psb import OperatingPoints, PsbEdge

from qblox_scheduler import HardwareAgent, Schedule
from qblox_scheduler.operations import (
    Measure,
    PulseCompensation,
    RampPulse,
    SquarePulse,
    X,
)
from qblox_scheduler.operations.expressions import DType
from qblox_scheduler.operations.loop_domains import arange, linspace
/.venv/lib/python3.14/site-packages/quantify_core/utilities/general.py:13: QCoDeSDeprecationWarning: The `qcodes.utils.helpers` module is deprecated. Please consult the api documentation at https://microsoft.github.io/Qcodes/api/index.html for alternatives.
  from qcodes.utils.helpers import NumpyJSONEncoder

Hardware/Device Configuration Files#

We use configuration files in order to describe the hardware properties (e.g. cluster ip, connected modules, output ports) and quantum device properties (charge sensors, qubits, barriers and their associated properties) in one accessible location, respectively. Check the Getting Started Guide for further information.

Based on the information specified in these files, we establish the hardware and device configurations that determine the system’s connectivity.

[2]:
hw_config_path = "dependencies/configs/tuning_spin_coupled_pair_hardware_config.json"
device_path = "dependencies/configs/spin_with_psb_device_config_2q.yaml"

Experimental Setup#

In order to run this application example, you will need a quantum device that consists of a double quantum dot array (q0 and q1), with a charge sensor (cs0) connected to reflectometry readout. The DC voltages of the quantum device also need to be properly tuned. For example, reservoir gates need to be ramped up for the accumulation devices. The charge sensor can be a quantum dot, quantum point contact (QPC), or single electron transistor (SET).

Hardware Setup#

The HardwareAgent() is the main object for Qblox experiments. It provides an interface to define the quantum device, set up hardware connectivity, run experiments, and receive results. For more information about the HardwareAgent() you can check the Core concepts of qblox-scheduler page.

[3]:
hw_agent = HardwareAgent(hw_config_path, device_path)
hw_agent.connect_clusters()
/.venv/lib/python3.14/site-packages/qblox_scheduler/qblox/hardware_agent.py:499: UserWarning: cluster0: Trying to instantiate cluster with ip 'None'.Creating a dummy cluster.
  warnings.warn(
[4]:
# Device name string should be defined as specified in the hardware configuration file
sensor_0 = hw_agent.quantum_device.get_element("cs0")
qubit_0 = hw_agent.quantum_device.get_element("q0")
qubit_1 = hw_agent.quantum_device.get_element("q1")

As can be observed from the defined quantum devices and the hardware connectivies of these elements, the relevant modules and connections for this tutorial are:

QCM (Module 2):

  • \(\text{O}^{1}\): Gate connection line for qubit 0 (q0).

  • \(\text{O}^{2}\): Gate connection line for qubit 1 (q1).

QRM (Module 4):

  • \(\text{O}^{1}\) and \(\text{I}^{1}\): Charge sensor (cs0) resonator probe and readout connection.

  • \(\text{O}^{2}\) : Charge sensor (cs0) plunger gate connection.

Note: Depending on the frequency requirements, the QRM module can be swapped with a QRC or QRM-RF module.

QCM-RF (Slot 6)

  • \(\text{O}^{1}\): EDSR drive line for q0 and q1.

Rabi Oscillations - Schedule & Measurement#

We further introduce a custom device element and two helper functions to be able to move at controlled speeds or hold at certain points of the charge stability diagram:

PSBEdge - A Device Element to simplify moving along the charge stability diagram#

We generated a custom device element (see the Make custom device elements tutorial) to aid the process of moving along the charge stability diagram for two qubits. The PSBEdge inherits from the SpinEdge class in order to control voltages of the plunger gate ports of the target qubits, as well as the barrier gate between them in one command. The ramp time between points can also be customized. The functions which allow you to implement this are:

  1. ramp Creates a schedule that smoothly ramps the voltages of the plunger gates of the dots from one operating point to another using RampPulse. The barrier in between the dots can also be ramped if so desired. The duration of the ramp is determined by the RampingTimes submodule of PSBEdge.

  2. hold Creates a schedule that holds the double dot system at a given operating point for a fixed duration using SquarePulse. The barrier in between the dots can also be held at a certain voltage point if desired. This is useful when you want to keep the system stable at a specific bias point (e.g., during readout).

Both functions check the parameters defined in the operating points (e.g. control and readout points for qubit and barriers both), fetch the corresponding gate ports, and then generate the appropriate pulses. This further helps with readability and streamlining of the generated schedules.

[5]:
def ramp(op_start: OperatingPoints, op_end: OperatingPoints) -> Schedule:
    """Generate a schedule to ramp gate voltages between two PSB operating points."""
    parent = op_start.parent
    port_dict = parent.port_dict
    ramp_time = parent.ramps.to_dict()[f"{op_start.name}_to_{op_end.name}"]

    ops = op_start.to_dict()
    ope = op_end.to_dict()

    sched = Schedule("")
    ref_op = None
    for key in op_start.parameters:
        amplitude = ope[key] - ops[key]
        offset = ops[key]
        ref_op = sched.add(
            RampPulse(amp=amplitude, offset=offset, duration=ramp_time, port=port_dict[key]),
            ref_pt="start",
            ref_op=ref_op,
        )
    return sched


def hold(op_point: OperatingPoints, duration: float) -> Schedule:
    """Generate a schedule to hold gate voltages at a given PSB operating point."""
    parent = op_point.parent
    port_dict = parent.port_dict

    sched = Schedule("")
    ref_op = None
    for key in op_point.parameters:
        ref_op = sched.add(
            SquarePulse(amp=op_point.to_dict()[key], duration=duration, port=port_dict[key]),
            ref_pt="start",
            ref_op=ref_op,
        )
    return sched

We define all variables for the experiment schedule in one location for ease of access and modification.

[6]:
repetitions = 2
# Out of the two qubits involded (`q0` and `q1`) psb_target is pulsed to shift its state
# psb_ancilla participates passively in PSB readout
psb_target = qubit_0  # The qubit which will be the target of drive pulses. It is also one of the two qubits involved in PSB readout
psb_ancilla = qubit_1  # The qubit which will be used as the second partner for PSB readout
q0_q1 = PsbEdge(psb_target, psb_ancilla)
pulse_amplitudes = np.linspace(0.5, 0.75, 5)
sensor = sensor_0
init_duration = 300e-9
wait_at_readout_duration = 1e-6
control_duration = 1e-6
sensor = sensor_0

# Operating points for the qubits, as extracted from charge stability diagram measurements.
# Note that Cluster output and acquisition parameters are normalized to –1.0…+1.0 in software.
# This is a unitless representation and does not directly equal the actual physical voltages.
# Consult the specific module’s hardware specs to map to real output ranges.
q0_q1.control.target_gate_voltage = 0.1
q0_q1.control.ancilla_gate_voltage = -0.2
q0_q1.control.barrier_gate_voltage = 0.3

q0_q1.readout.target_gate_voltage = 0.0
q0_q1.readout.ancilla_gate_voltage = 0.0
q0_q1.readout.barrier_gate_voltage = 0.0

q0_q1.init.target_gate_voltage = -0.1
q0_q1.init.ancilla_gate_voltage = 0.25
q0_q1.init.barrier_gate_voltage = 0.01


# Ramp times in ns
q0_q1.ramps.init_to_readout = 5e-6
q0_q1.ramps.readout_to_control = 150e-9
q0_q1.ramps.control_to_readout = 2e-6
q0_q1.ramps.control_to_init = 500e-9

# Add the edge with defined parameters to the Hardware Agent
hw_agent.quantum_device.add_edge(q0_q1)

Using the PSBEdge and the helper functions defined above, we generate a schedule for implementing a Rabi oscillations experiment. An overview of the entire pulse schedule can be summarized as the following:

  • Pulse the dots and the barrier at the initialization point

  • Perform an initial measurement at the initialization point. Store the data in acq_channel 0.

  • Ramp to readout point

  • Perform a second measurement at the readout point. Store the data in acq_channel 1.

  • Ramp to control point

  • Apply an \(R_X\) pulse of varying amplitude to the target qubit

  • Ramp to readout point

  • Perform a third measurement at the readout point. Store the data in acq_channel 2.

Finally, we add pulse compensation to the designated ports of the entire schedule. This corrects the pulse decay caused by the Bias-T, ensuring the qubit receives the intended signal. For more information, refer to the Correcting for signal distortions due to Bias Tee application example.

[7]:
uncompensated_schedule = Schedule("schedule")

compensated_schedule = Schedule("pulse_compensated schedule")

# The following schedule sweeps over the set amount of repetitions, as well as a range of pulse amplitudes set by the user.
with (
    compensated_schedule.loop(arange(0, repetitions, 1, DType.NUMBER)),
    compensated_schedule.loop(
        linspace(start=0.1, stop=0.5, num=11, dtype=DType.AMPLITUDE)
    ) as pulse_amp,
):
    # INITIALIZATION
    ref_pulse_init = uncompensated_schedule.add(
        hold(q0_q1.init, sensor.measure.pulse_duration + init_duration)
    )
    uncompensated_schedule.add(
        Measure(sensor.name, acq_channel=0),
        ref_op=ref_pulse_init,
        ref_pt="start",
        rel_time=init_duration,
    )
    # Note: Only acq_channel1 and acq_channel2 are required for PSB readout; acq_channel0 is mainly a diagnostic. Comparing acq_channel0 with acq_channel1 helps identify whether issues stem from improper initialization or from a mispositioned readout point.

    # RAMP TO READOUT POINT
    ref_readout_ramp = uncompensated_schedule.add(
        ramp(q0_q1.init, q0_q1.readout), ref_op=ref_pulse_init
    )

    # Wait at the readout point while pulsing the gate ports of both qubits and the barrier
    ref_hold = uncompensated_schedule.add(hold(q0_q1.readout, wait_at_readout_duration))

    # Do a measurement at the readout point
    ref_pulse_wait = uncompensated_schedule.add(
        hold(
            q0_q1.readout,
            sensor.measure.pulse_duration,
        ),
        ref_op=ref_hold,
    )
    uncompensated_schedule.add(
        Measure(sensor.name, acq_channel=1),
        ref_op=ref_pulse_wait,
        ref_pt="start",
        rel_time=50e-9,
    )

    # RAMP TO CONTROL POINT
    ref_control_ramp = uncompensated_schedule.add(
        ramp(
            q0_q1.readout,
            q0_q1.control,
        ),
        ref_op=ref_hold,
    )

    ref_control = uncompensated_schedule.add(hold(q0_q1.control, control_duration))
    # DRIVE PULSE
    # Vary the amplitude of the X pulse in order to perform amplitude Rabi
    uncompensated_schedule.add(
        X(qubit=psb_target.name, amp180=pulse_amp, duration=control_duration),
        ref_op=ref_control,
        ref_pt="start",
    )

    # RAMP BACK TO READOUT POINT
    ref_readout_ramp = uncompensated_schedule.add(ramp(q0_q1.control, q0_q1.readout))

    # Wait at the readout point while pulsing the gate ports of both qubits and the barrier
    ref_hold = uncompensated_schedule.add(hold(q0_q1.readout, wait_at_readout_duration))

    # Do a measurement at the readout point
    ref_pulse_wait = uncompensated_schedule.add(
        hold(q0_q1.readout, sensor.measure.pulse_duration),
        ref_op=ref_hold,
    )
    uncompensated_schedule.add(
        Measure(sensor.name, acq_channel=2),
        ref_op=ref_pulse_wait,
        ref_pt="start",
        rel_time=50e-9,
    )

    # Add pulse compensation to correct distortion of fast pulses introduced by the bias-T.
    # The body of the pulse schedule in within the uncompensated schedule, while the version that includes pulse compensation is simply referred to as the compensated schedule.
    compensated_schedule.add(
        PulseCompensation(
            uncompensated_schedule,
            max_compensation_amp={
                "q0:gt": 0.1,  # value depends on set-up
                "q1:gt": 0.1,  # value depends on set-up
                "q0_q1:gt": 0.58,  # value depends on set-up
            },
            time_grid=4e-9,
            sampling_rate=1e9,
        )
    )
/tmp/ipykernel_363/3763333451.py:32: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
  SquarePulse(amp=op_point.to_dict()[key], duration=duration, port=port_dict[key]),
/tmp/ipykernel_363/3763333451.py:16: FutureWarning: amp is deprecated as an argument to RampPulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
  RampPulse(amp=amplitude, offset=offset, duration=ramp_time, port=port_dict[key]),
/tmp/ipykernel_363/3763333451.py:16: FutureWarning: offset is deprecated as an argument to RampPulse and will be removed in qblox-scheduler >= 2.0; use voltage_offset instead.
  RampPulse(amp=amplitude, offset=offset, duration=ramp_time, port=port_dict[key]),

Now, we will run the schedule. See the documentation on the QRM Module and Readout Sequencers for information on how the signal is processed upon acquisition.

[8]:
rabi_ds = hw_agent.run(compensated_schedule)
rabi_ds
[8]:
<xarray.Dataset> Size: 96B
Dimensions:            (acq_index_0: 1, acq_index_1: 1, acq_index_2: 1)
Coordinates:
  * acq_index_0        (acq_index_0) int64 8B 0
    loop_repetition_0  (acq_index_0) int64 8B 0
  * acq_index_1        (acq_index_1) int64 8B 0
    loop_repetition_1  (acq_index_1) int64 8B 0
  * acq_index_2        (acq_index_2) int64 8B 0
    loop_repetition_2  (acq_index_2) int64 8B 0
Data variables:
    0                  (acq_index_0) complex128 16B (nan+nanj)
    1                  (acq_index_1) complex128 16B (nan+nanj)
    2                  (acq_index_2) complex128 16B (nan+nanj)
Attributes:
    tuid:     20260415-120417-524-310b94

You may plot the pulse diagram of a schedule for verification. Below, we plot a pulse diagram of 2 repetitions and a drive amplitude sweep of 11 points between 0.1 and 0.5, as we have defined in the above sections of this script. For inspecting the pulse schedule in more detail and verify the behavior, you may reduce the number of repetitions and set a fewer number of pulse_amplitudes.

For example, if you set repetitions to 1, and reduce the pulse_amplitudes array to a length of 1, you will observe only one cycle of the pulse sequence. In this manner, it will be easier to check and verify the details of the core pulse cycle. For example, you can check whether the timing of pulses are correct with respect to one another. Once you are convinced that your pulse sequence is correct, you can increase the length of the pulse_amplitudes array in order to check the behavior of the pulse parameter you sweep over time. As a last step, you can increase the number of repetitions to the desired value.

[9]:
hw_agent.compile(compensated_schedule).plot_pulse_diagram(plot_backend="plotly")

The quantum device settings can be saved after every experiment for allowing later reference into experiment settings.

[10]:
hw_agent.quantum_device.to_json_file("./dependencies/configs", add_timestamp=True)
[10]:
'./dependencies/configs/spin_with_psb_device_config_2026-04-15_12-04-21_UTC.json'