See also

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

banner

Hahn Echo Measurement#

Tutorial Objective#

In this application example, we present Hahn echo experiment which is a sequence that mitigates dephasing from slowly varying frequency noise. A \(\frac{\pi}{2}\) pulse prepares a superposition, followed by free evolution for \(\frac{\tau}{2}\), during which phase errors accumulate. A \(\pi\) pulse at the midpoint inverts the phase evolution, refocusing these errors during the second \(\frac{\tau}{2}\) interval and partially restoring coherence at time \(\tau\). This refocusing is effective for low-frequency noise, leading to an extended coherence time \(T_2^{\mathrm{echo}}\) compared to Ramsey time \(T_2^*\).

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,
    SquarePulse,
    X,
)
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 q0 and q1.

Hahn Echo - 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:

  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

# 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)
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

# 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. For example, QCM output range is +-2.5V.
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 Hahn Echo measurement. 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 a \(\frac{\pi}{2}\) pulse to the target qubit.

  • Wait for a half of the variable ‘delay_time’ after the \(\frac{\pi}{2}\) pulse.

  • Apply a \(\pi\) pulse to the target qubit.

  • Wait for a half of the variable ‘delay_time’ after the \(\pi\) pulse.

  • Apply a \(\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_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]:
compensated_schedule = Schedule("pulse compensated schedule")

# The following schedule sweeps over the set amount of repetitions, as well as a range of delay times (after the $\pi$ pulse) set by the user.
with (
    compensated_schedule.loop(arange(0, repetitions, 1, DType.NUMBER)),
    compensated_schedule.loop(arange(20e-9, 8e-6, 800e-9, dtype=DType.TIME)) as delay_time,
):
    Hahn_Echo_schedule = Schedule("schedule")
    # INITIALIZATION
    ref_pulse_init = Hahn_Echo_schedule.add(
        hold(q0_q1.init, sensor.measure.pulse_duration + init_duration)
    )
    Hahn_Echo_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 = Hahn_Echo_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 = Hahn_Echo_schedule.add(hold(q0_q1.readout, wait_at_readout_duration))

    # Do a measurement at the readout point
    ref_pulse_wait = Hahn_Echo_schedule.add(
        hold(
            q0_q1.readout,
            sensor.measure.pulse_duration,
        ),
        ref_op=ref_hold,
    )
    Hahn_Echo_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 = Hahn_Echo_schedule.add(
        ramp(
            q0_q1.readout,
            q0_q1.control,
        ),
        ref_op=ref_pulse_wait,
    )

    ref_control = Hahn_Echo_schedule.add(hold(q0_q1.control, control_duration))
    # DRIVE PULSES
    # Apply a pi/2 pulse
    Hahn_Echo_schedule.add(
        X90(psb_target.name),
        ref_op=ref_control,
        ref_pt="end",
    )

    # Apply a pi pulse after delay_time/2
    Hahn_Echo_schedule.add(X(psb_target.name), rel_time=delay_time / 2)

    # Apply a pi/2 pulse after delay_time/2
    Hahn_Echo_schedule.add(X90(psb_target.name), rel_time=delay_time / 2)

    # RAMP BACK TO READOUT POINT
    ref_readout_ramp = Hahn_Echo_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 = Hahn_Echo_schedule.add(hold(q0_q1.readout, wait_at_readout_duration))

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

    compensated_schedule.add(
        PulseCompensation(
            Hahn_Echo_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]:
hahn_echo_ds = hw_agent.run(compensated_schedule)
hahn_echo_ds
[8]:
<xarray.Dataset> Size: 960B
Dimensions:            (acq_index_0: 10, acq_index_1: 10, acq_index_2: 10)
Coordinates:
  * acq_index_0        (acq_index_0) int64 80B 0 1 2 3 4 5 6 7 8 9
    loop_repetition_0  (acq_index_0) float64 80B 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0
  * acq_index_1        (acq_index_1) int64 80B 0 1 2 3 4 5 6 7 8 9
    loop_repetition_1  (acq_index_1) float64 80B 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0
  * acq_index_2        (acq_index_2) int64 80B 0 1 2 3 4 5 6 7 8 9
    loop_repetition_2  (acq_index_2) float64 80B 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0
Data variables:
    0                  (acq_index_0) complex128 160B (nan+nanj) ... (nan+nanj)
    1                  (acq_index_1) complex128 160B (nan+nanj) ... (nan+nanj)
    2                  (acq_index_2) complex128 160B (nan+nanj) ... (nan+nanj)
Attributes:
    tuid:     20260402-065352-951-b011ee

You may plot the pulse diagram of a schedule for verification. Below, we plot a pulse diagram of 2 repetitions and a delay time sweep of 10 points between \(20e^{-9}\) and \(8e^{-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 delay_times.

For example, if you set repetitions to 1, and reduce the delay_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 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 number of the delay_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-53-56_UTC.json'