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

PSB Readout Point Sweep#
Tutorial Objective#
The objective of this tutorial is to perform a plunger gate voltage sweep of the qubits around the inderdot transition region in order to identify the optimal point for readout. This reveals an ‘eye’-shaped feature in the charge sensor signal, corresponding to the transition between the (1,1) and (2,0) charge states. The readout point is chosen near the center of the eye, where the system can energetically allow a singlet state to tunnel to (2,0), while a triplet remains blockaded in (1,1) state due to Pauli exclusion. This difference in final charge configuration produces a detectable shift in the sensor signal, allowing spin states to be distinguished during readout.
Imports
[1]:
from __future__ import annotations
from typing import Any
import numpy as np
from dependencies.analysis_utils import PSBReadoutPointSweepAnalysis
from dependencies.psb import OperatingPoints, PsbEdge
from dependencies.simulated_data import get_psb_readout_sweep_data
from qblox_scheduler import (
HardwareAgent,
Schedule,
)
from qblox_scheduler.enums import BinMode
from qblox_scheduler.operations import (
Measure,
PulseCompensation,
RampPulse,
SquarePulse,
)
/.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()
# 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")
hw_opts = hw_agent.hardware_configuration.hardware_options
cluster = hw_agent._clusters["cluster0"].cluster
/.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(
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.
PSB Readout Point Sweep#
In order to apply PSB Readout in a concise and readable manner, we implement a custom defined ‘’’Edge’’’ that is configured for this purpose. For details on how to configure such a custom device element or edge, see the Make Your Own Device Elements tutorial.
[4]:
q0_q1 = PsbEdge(qubit_0, qubit_1)
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
hw_agent.quantum_device.add_edge(q0_q1)
We further introduce two helper functions to be able to move along or hold at certain points of the charge stability diagram:
``ramps`` Creates a schedule that smoothly ramps the voltages from one operating point to another using
RampPulse. The duration of the ramp is determined by theRampingTimessubmodule of thePSBEdge.``hold`` Creates a schedule that holds the system at a given operating point for a fixed duration using
SquarePulse. This is useful when you want to keep the system stable at a specific bias point (e.g., during readout).
Both functions loop over the parameters defined in the operating points (e.g. control and readout points and both for qubits and barrier gates), fetch the corresponding hardware ports, and then generate the appropriate pulses.
These functions are slightly different and rely on additional helpers compared to the ones defined for the PSB Readout Application Example, as we now need to override the readout point value to perform a sweep of this value in a given range. For more information on the specific changes introduced here, see the tutorial on Parametrizing your schedules using DeviceElements.
[5]:
def validate_override(obj: PsbEdge, override: dict, path: str = "") -> None:
"""
Validate that all keys in `override` exist in obj.
Raises:
ValueError: If any key in `override` is not found.
"""
for key, value in override.items():
full_key = f"{path}.{key}" if path else key
if not hasattr(obj, key):
raise ValueError(f"Missing attribute '{full_key}'")
obj_val = getattr(obj, key)
if obj_val is None:
raise ValueError(f"Attribute '{full_key}' is None")
if isinstance(value, dict):
validate_override(obj_val, value, full_key)
def get_nested(d: dict, keys: list, default: Any = None) -> Any: # noqa: ANN401
"""Safely get a value from nested dict using a list/tuple of keys."""
for key in keys:
if isinstance(d, dict) and key in d:
d = d[key]
else:
return default
return d
def ramp(op_start: OperatingPoints, op_end: OperatingPoints, **kwargs: dict) -> Schedule:
"""
Generate a schedule to ramp gate voltages between two PSB operating points.
Allows overriding gate amplitudes via kwargs, just like `ramps`.
"""
parent = op_start.parent
validate_override(parent, kwargs)
port_dict = parent.port_dict
parent_dict = parent.to_dict()
ramp_time = get_nested(
kwargs,
["ramps", f"{op_start.name}_to_{op_end.name}"],
parent_dict["ramps"][f"{op_start.name}_to_{op_end.name}"],
)
sched = Schedule("")
ref_op = None
for key in op_start.parameters:
amp_stop = get_nested(kwargs, [op_end.name, key], parent_dict[op_end.name][key])
amp_start = get_nested(kwargs, [op_start.name, key], parent_dict[op_start.name][key])
amplitude = amp_stop - amp_start
offset = amp_start
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, **kwargs: dict) -> Schedule:
"""
Generate a schedule to hold gate voltages at a given PSB operating point.
Allows overriding gate amplitudes via kwargs, just like `ramps`.
"""
parent = op_point.parent
validate_override(parent, kwargs)
port_dict = parent.port_dict
parent_dict = parent.to_dict()
sched = Schedule("")
ref_op = None
for key in op_point.parameters:
# Get the amplitude from kwargs if provided, otherwise use the value from op_point
amplitude = get_nested(kwargs, [op_point.name, key], parent_dict[op_point.name][key])
ref_op = sched.add(
SquarePulse(amp=amplitude, duration=duration, port=port_dict[key]),
ref_pt="start",
ref_op=ref_op,
)
return sched
PSB Readout Point Sweep - Schedule & Measurement#
Using the PSBEdge and the helper functions defined above, we generate a schedule for implementing a PSB Readout sweep experiment.
We define all variables for the experiment schedule in one location for ease of access and modification.
[6]:
init_duration = 300e-9
wait_duration = 1e-6
repetitions = 5000
sensor = sensor_0
We further define a dictionary, which will be used to override the readout point values to sweep over a given range.
[7]:
readout_override_values = np.linspace(0.0, 0.6, 31)
overrides = []
for value in readout_override_values:
new_dict: dict[str, dict[str, float]] = {
"readout": {
"target_gate_voltage": value,
"ancilla_gate_voltage": -value,
}
}
overrides.append(new_dict)
[8]:
compensated_schedule = Schedule("pulse_compensation", repetitions=repetitions)
for dict_idx, _ in enumerate(readout_override_values):
schedule = Schedule("inner schedule")
# INITIALIZATION
ref_pulse_init = schedule.add(hold(q0_q1.init, sensor.measure.pulse_duration + init_duration))
schedule.add(
Measure(sensor.name, acq_channel=0),
ref_op=ref_pulse_init,
ref_pt="start",
rel_time=init_duration,
)
# RAMP TO READOUT POINT
# Move to readout point whilst sweeping this point via the override dict
ref_readout_ramp = schedule.add(
ramp(q0_q1.init, q0_q1.readout, **overrides[dict_idx]), ref_op=ref_pulse_init
)
# Wait at the readout point while pulsing the gate ports of both qubits and the barrier
ref_hold = schedule.add(hold(q0_q1.readout, wait_duration, **overrides[dict_idx]))
# Do a measurement at the readout point
ref_pulse_wait = schedule.add(
hold(
q0_q1.readout,
sensor.measure.pulse_duration,
**overrides[dict_idx],
),
ref_op=ref_hold,
)
schedule.add(
Measure(sensor.name, acq_channel=1, bin_mode=BinMode.APPEND),
ref_op=ref_pulse_wait,
ref_pt="start",
rel_time=50e-9,
)
# RAMP TO CONTROL POINT
schedule.add(ramp(q0_q1.readout, q0_q1.control, **overrides[dict_idx]), ref_op=ref_hold)
# RAMP BACK TO READOUT POINT
ref_readout_ramp = schedule.add(ramp(q0_q1.control, q0_q1.readout, **overrides[dict_idx]))
# Wait at the readout point while pulsing the gate ports of both qubits and the barrier
ref_hold = schedule.add(hold(q0_q1.readout, wait_duration, **overrides[dict_idx]))
# Do a measurement at the readout point
ref_pulse_wait = schedule.add(
hold(
q0_q1.readout,
sensor.measure.pulse_duration,
**overrides[dict_idx],
),
ref_op=ref_hold,
)
schedule.add(
Measure(sensor.name, acq_channel=2, bin_mode=BinMode.APPEND),
ref_op=ref_pulse_wait,
ref_pt="start",
rel_time=50e-9,
)
compensated_schedule.add(
PulseCompensation(
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_8281/3165958433.py:85: FutureWarning: amp is deprecated as an argument to SquarePulse and will be removed in qblox-scheduler >= 2.0; use amplitude instead.
SquarePulse(amp=amplitude, duration=duration, port=port_dict[key]),
/tmp/ipykernel_8281/3165958433.py:58: 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_8281/3165958433.py:58: 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.
[9]:
readout_point_sweep_ds = hw_agent.run(compensated_schedule)
readout_point_sweep_ds
[9]:
<xarray.Dataset> Size: 5MB
Dimensions: (acq_index_0: 31, acq_index_1: 31, repetition: 5000,
acq_index_2: 31)
Coordinates:
* acq_index_0 (acq_index_0) int64 248B 0 1 2 3 4 5 6 ... 24 25 26 27 28 29 30
* acq_index_1 (acq_index_1) int64 248B 0 1 2 3 4 5 6 ... 24 25 26 27 28 29 30
* repetition (repetition) int64 40kB 0 1 2 3 4 ... 4995 4996 4997 4998 4999
* acq_index_2 (acq_index_2) int64 248B 0 1 2 3 4 5 6 ... 24 25 26 27 28 29 30
Data variables:
0 (acq_index_0) complex128 496B (nan+nanj) ... (nan+nanj)
1 (repetition, acq_index_1) complex128 2MB (nan+nanj) ... (nan...
2 (repetition, acq_index_2) complex128 2MB (nan+nanj) ... (nan...
Attributes:
tuid: 20260415-121052-726-43cd44Analysis#
[10]:
if cluster.is_dummy:
readout_point_sweep_ds[0].data = get_psb_readout_sweep_data(
readout_override_values, repetitions
)[0]
readout_point_sweep_ds[1].data = get_psb_readout_sweep_data(
readout_override_values, repetitions
)[1]
readout_point_sweep_ds[2].data = get_psb_readout_sweep_data(
readout_override_values, repetitions
)[2]
[11]:
analysis = PSBReadoutPointSweepAnalysis(readout_point_sweep_ds)
analysis.display_figs_mpl()
The quantum device settings can be saved after every experiment for allowing later reference into experiment settings.
[12]:
hw_agent.quantum_device.to_json_file("./dependencies/configs", add_timestamp=True)
[12]:
'./dependencies/configs/spin_with_psb_device_config_2026-04-15_12-10-55_UTC.json'