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

Ramsey Spectroscopy#
Tutorial Objective#
In this tutorial, we demonstrate how to implement a typical Ramsey spectroscopy experiment which is used to precisely characterize a two-level quantum system. By applying two \(\frac{\pi}{2}\) pulses separated by a variable delay, we observe interference fringes that arise from the phase accumulated between the system’s natural transition frequency and the applied drive frequency. This method allows us to measure the \(|0\rangle \rightarrow |1\rangle\) transition frequency with high precision, beyond the resolution of simple spectroscopy. We can also extract the \(T_2^*\) dephasing time by observing the decay of the Ramsey fringe contrast as the delay time between the \(\frac{\pi}{2}\) pulses is increased.
Imports#
[1]:
from __future__ import annotations
from dependencies.psb import OperatingPoints, PsbEdge
from qblox_scheduler import HardwareAgent, Schedule
from qblox_scheduler.operations import (
X90,
Measure,
PulseCompensation,
RampPulse,
SetClockFrequency,
SquarePulse,
)
from qblox_scheduler.operations.expressions import DType
from qblox_scheduler.operations.loop_domains import arange
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:485: 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
q0andq1.
Ramsey Spectroscopy - 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 generate a custom device element (see the Make custom device elements tutorial) to simplify 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:
rampCreates a schedule that smoothly ramps the voltages of the plunger gates of the dots from one operating point to another usingRampPulse. The barrier in between the dots can also be ramped if so desired. The duration of the ramp is determined by theRampingTimessubmodule ofPSBEdge.holdCreates a schedule that holds the double dot system at a given operating point for a fixed duration usingSquarePulse. 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
# Readout via PSB utilizes the transition between the effective (1,1) and (2,0) charge configurations. We denote the two dots as (psb_target, psb_ancilla) to distinguish their roles during the measurement process.
# Out of the two qubits involded (`q0` and `q1`) psb_target is pulsed to shift its state. This is effectively done where both electrons are on one of the dots.
# 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)
frequency_detuning = 1e6 # Hz
sensor = sensor_0
# We define some time slots we will use in the schedule in advance. You can examine the schedule below to see where they are implemented. Defining these values here allows for easier modification of parameters globally.
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 Ramsey Spectroscopy experiment. An overview of the entire pulse schedule can be summarized as the following:
Pulse the dots and the barrier in between the dots at the initialization point
Perform an initial measurement at the initialization point. Store the data in
acq_channel0.Ramp to readout point
Perform a second measurement at the readout point. Store the data in
acq_channel1.Ramp to control point
Apply a \(\frac{\pi}{2}\) pulse to the target qubit.
Wait for a variable
idle_timeafter the first \(\frac{\pi}{2}\) pulse.Apply a second \(\frac{\pi}{2}\) pulse to the target qubit.
Ramp to readout point
Perform a third measurement at the readout point. Store the data in
acq_channel2.
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 on pulse compensation, 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 delay times (between the two $\frac{\pi}{2}$ pulses) set by the user.
with (
compensated_schedule.loop(arange(0, repetitions, 1, DType.NUMBER)),
compensated_schedule.loop(arange(20e-9, 1e-6, 400e-9, dtype=DType.TIME)) as idle_time,
):
# 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))
# 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_pulse_wait,
)
ref_control = uncompensated_schedule.add(hold(q0_q1.control, control_duration))
# DRIVE PULSE
# Apply the first pi/2 pulse
first_pi_over_two = uncompensated_schedule.add(
X90(psb_target.name),
ref_op=ref_control,
ref_pt="start",
)
# Implement a phase kick:
# Detune the qubit's clock by the required frequency detuning
uncompensated_schedule.add(
SetClockFrequency(
clock=f"{psb_target.name}.f_larmor",
clock_freq_new=psb_target.clock_freqs.f_larmor + frequency_detuning,
)
)
# After the idle period, reset the qubit clock frequency to its original value
uncompensated_schedule.add(
SetClockFrequency(
clock=f"{psb_target.name}.f_larmor",
clock_freq_new=psb_target.clock_freqs.f_larmor,
),
rel_time=idle_time,
)
# Apply the second pi/2 pulse
uncompensated_schedule.add(X90(psb_target.name), rel_time=idle_time)
# RAMP BACK TO READOUT POINT
ref_readout_ramp = uncompensated_schedule.add(
ramp(q0_q1.control, q0_q1.readout), ref_op=ref_control
)
# 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,
)
)
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]:
ramsey_ds = hw_agent.run(compensated_schedule)
ramsey_ds
[8]:
<xarray.Dataset> Size: 288B
Dimensions: (acq_index_0: 3, acq_index_1: 3, acq_index_2: 3)
Coordinates:
* acq_index_0 (acq_index_0) int64 24B 0 1 2
loop_repetition_0 (acq_index_0) float64 24B 0.0 0.0 0.0
* acq_index_1 (acq_index_1) int64 24B 0 1 2
loop_repetition_1 (acq_index_1) float64 24B 0.0 0.0 0.0
* acq_index_2 (acq_index_2) int64 24B 0 1 2
loop_repetition_2 (acq_index_2) float64 24B 0.0 0.0 0.0
Data variables:
0 (acq_index_0) complex128 48B (nan+nanj) ... (nan+nanj)
1 (acq_index_1) complex128 48B (nan+nanj) ... (nan+nanj)
2 (acq_index_2) complex128 48B (nan+nanj) ... (nan+nanj)
Attributes:
tuid: 20260402-064957-942-f5993bYou may plot the pulse diagram of a schedule for verification. Below, we plot a pulse diagram of 2 repetitions and a delay time (between the two \(\frac{\pi}{2}\) pulses) sweep of 11 points between \(20e^{-9}\) and \(1e^{-6}\), as we have defined in the above sections of this script. In order to inspect the pulse schedule in more detail, you may reduce the number of repetitions and set a fewer number of idle_times.
For example, if you set repetitions to 1, and reduce the idle_times 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 instance, 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 number of the idle_time parameters that you will loop over 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-02_06-49-59_UTC.json'