See also

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

Parametrize schedules using DeviceElements#

Overview#

In this tutorial, you’ll learn how to parameterize your schedules using DeviceElements. This allows you to write flexible, reusable Python functions that generate Qblox Schedule objects based on the parameters of your physical qubits or other device elements.

Introducing the notion of gates: Describing an entire experimental sequence (executing for example a quantum algorithm) only in terms of pulses and low-level acquisition can quickly get heavy and error-prone. Indeed, in general logical operations and measures that one performs on qubits involve several pulses (e.g \(\hat{CZ}\) (controlled-Z)), in a large space of parameters, all qubit dependent. To considerably simplify –for the users– the description of a Schedule, each {class}DeviceElement sub-class in the librart (BasicTransmonElement, BasicSpinElement, …) is provided with a collection of available gates (device-level operations). When gates are present in a Schedule, HardwareAgent.run will substitute appropriate device-level operations with pulse-level operations according to DUT parameters. But this feature which is internal to QBS, and to replicate this behavior, we will create a pulse-level schedule parametrized by a DeviceElement. Ultimately, you will be able to output the same pulse sequences, but with a slightly different syntax.

For more information on compilation, the interested reader could refer to the page dedicated to qubits and gates

Creating Your Parametrized Schedule Functions#

In the case where a gate can not be applied to your DeviceElement , you can define your own schedule function, a regular Python function that returns a ``Schedule``. A simple example looks like this:

def my_param_sched(qubit) -> Schedule:
    sched = Schedule(f"My custom schedule for {qubit.name}")
    # add operations here
    return sched

Such functions can optionally take one or more DeviceElements as arguments, allowing you to parameterize the generated schedule using physical properties (e.g., pulse amplitude, duration, or detuning). These custom schedules behave like any other Schedule: you can add them to larger schedules or compile them for execution.

From now on, we will walk through an example with a double quantum dot system on which you want to perform a Pauli Spin Blockade (PSB) readout. We will consider two key operating charge states in your system :

  1. Control stage : (1,1) charge configuration where spin state of both qubits can be manipulated.

  2. Readout stage : (2,0) charge configuration where both charges will end up in the same quantum dot if spin state are opposite.

Note here that we are not adding the sensor in this example for simplicity. This tutorial will focus specifically on how to operate at these two operating points.

[1]:
from typing import Any

from configs.psb import OperatingPoints, PsbEdge

from qblox_scheduler import (
    BasicSpinElement,
    HardwareAgent,
    QuantumDevice,
    Schedule,
)
from qblox_scheduler.operations import RampPulse, SquarePulse, X
from qblox_scheduler.operations.expressions import DType
from qblox_scheduler.operations.loop_domains import linspace

Description of Quantum Device & Hardware configuration.#

For this example we will use the PsbEdge that was created in the previous tutorial (todo link!)

[2]:
q0 = BasicSpinElement("q0")
q1 = BasicSpinElement("q1")
q0_q1 = PsbEdge(q0, q1)

q0_q1.control.parent_voltage = 0.1
q0_q1.control.child_voltage = 0.2
q0_q1.control.barrier_voltage = 0.3

q0.clock_freqs.f_larmor = 8.1e9
q0.rxy.amp180 = 0
q0.rxy.duration = 60e-9

quantum_device = QuantumDevice("qd")
quantum_device.add_element(q0)
quantum_device.add_element(q1)
quantum_device.add_edge(q0_q1)

hw_config_path = "configs/tuning_spin_coupled_pair_hardware_config.json"

hw_agent = HardwareAgent(
    hardware_configuration=hw_config_path, quantum_device_configuration=quantum_device
)

Parametrizable Schedule#

So far, we have defined operating points that describe the voltages value associated to each charge state for the parent and child qubit gates, and the barrier gate. We also added the SchedulerSubmodule ramping times that stores the time to go from one stage to the other. The next step is to use these information to generate actual schedules that output the desired pulse sequence.

Nonetheless, instead of hardcoding pulse sequences, we will make our schedules parametrizable. In that case, the pulse amplitudes, offsets, and durations will be taken directly from the parameters stored in the Edge (here the PSBEdge). As a result, if you later update the operating points, ramping times or apply the function to another instance of PSBEdge, your schedules will automatically adapt meaning no manual rewriting needed.

We introduce two helper functions:

  1. ``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 the RampingTimes submodule of the PSBEdge.

  2. ``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 (both qubits and barrier gates), fetch the corresponding hardware ports, and then generate the appropriate pulses.

[3]:
def ramps(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

Display of the schedules#

Now that we have defined our schedule functions (ramps and hold), we can put them together into complete pulse sequences and visualize them using the built-in plotting tools.

We’ll look at two examples:

  1. A simple schedule that just moves between operating points.

  2. A more advanced schedule that mimics a Rabi experiment using real-time loops.

Example 1 — Moving between operating points#

In this first example, we create a minimal schedule that shows how to move from one operating point to another inside the stability diagram.

The sequence goes as follows:

  • Ramp from the readout point to the control point.

  • Hold at the control point for 200 ns.

  • Ramp back to the readout point.

  • Hold again at the readout point for 200 ns.

This simple cycle illustrates how the pulses evolve in time when navigating between two operating points.

[4]:
sched = Schedule("")
sched.add(ramps(q0_q1.readout, q0_q1.control))
sched.add(hold(q0_q1.control, 200e-9))
sched.add(ramps(q0_q1.control, q0_q1.readout))
sched.add(hold(q0_q1.readout, 200e-9))

hw_agent.compile(sched)
hw_agent.latest_compiled_schedule.plot_pulse_diagram()
[4]:
(<Figure size 1000x621 with 1 Axes>,
 <Axes: title={'center': ' schedule 1 repeated 1 times'}, xlabel='Time [ns]', ylabel='$\\dfrac{V}{V_{max}}$'>)
../../../../_images/products_qblox_scheduler_tutorials_any_parametrize_your_schedules_10_1.png

Example 2 — Rabi-like sequence with real-time loops#

In the second example, we build a more complex schedule that resembles a Rabi experiment.

Here, we introduce Real-Time Loops (RT-Loops) to sweep over different pulse amplitudes directly in hardware, producing a highly efficient Q1ASM program.

The sequence inside the loop is:

  • Ramp from readout to control.

  • Hold at the control point for 200 ns.

  • Apply an X rotation on qubit 0 with varying amplitude (controlled by the RT-loop).

  • Ramp back from control to readout.

  • Hold again at the readout point for 200 ns.

This demonstrates how you can combine parametrizable schedules with quantum gates and real-time parameter sweeps, giving you the flexibility to implement typical calibration experiments.

[5]:
sched = Schedule("")

with sched.loop(linspace(0, 0.1, 5, dtype=DType.AMPLITUDE)) as amp:
    sched.add(ramps(q0_q1.readout, q0_q1.control))
    ref_op = sched.add(hold(q0_q1.control, 200e-9))
    sched.add(X(q0.name, amp180=amp), rel_time=20e-9, ref_pt="start")
    sched.add(ramps(q0_q1.control, q0_q1.readout), ref_op=ref_op)
    sched.add(hold(q0_q1.readout, 200e-9))

hw_agent.compile(sched)
hw_agent.latest_compiled_schedule.plot_pulse_diagram()
[5]:
(<Figure size 1000x621 with 1 Axes>,
 <Axes: title={'center': ' schedule 2 repeated 1 times'}, xlabel='Time [μs]', ylabel='$\\dfrac{V}{V_{max}}$'>)
../../../../_images/products_qblox_scheduler_tutorials_any_parametrize_your_schedules_12_1.png

Overriding#

You have probably noticed that using sched.add(ramps(q0_q1.control, q0_q1.readout)) can feel a bit rigid. It takes a configuration file or object and creates a schedule according to it. The main limitation is that, before having static operating points, you might need to find the optimal points first.

For instance, a basic experiment could involve sweeping the readout point using a real-time loop or a Python loop. However, loops and static configurations don’t always work well together.

Don’t worry, we have you covered. Let’s introduce the concept of overriding. We will slightly modify the previous function and show how to use loops effectively with overrides.

Error handling - Convenience function#

The first step is to create a function that checks for errors. You want to sweep parameters, but you also want to make sure the sweep makes sense. If a parameter does not exist in the DeviceElement, the function will raise a ValueError.

To make this easier, we will flatten the tree structure of the dictionaries we use. This allows you to compare the list of keys you provide against the available keys.

[6]:
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)


overrides = {"init": {"parent_voltage": 0.1}}
validate_override(q0_q1, overrides)

# Let us make a typo mystake !
overrides = {"init": {"parent_voltages": 0.1}}
try:
    validate_override(q0_q1, overrides)
    print("❌ Expected ValueError, but no exception was raised.")

except ValueError as e:
    print(f"✅ Caught expected ValueError: {e}")
✅ Caught expected ValueError: Missing attribute 'init.parent_voltages'

Using **Kwargs to override the DeviceElement#

We are now going to update the two functions we created so they accept **kwargs, which is a powerful tool for overriding parameters.

Before we do that, we need another convenient function that retrieves a value from a nested dictionary. The built-in dict.get does not work for nested dictionaries, so we create a function called get_nested.

We will also update ramps and hold to accept **kwargs. If the latter contains values for certain elements, they will take priority over the values stored in the DeviceElement, without modifying it.

[7]:
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 ramps(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

Display the new Schedule#

[8]:
overrides = {"control": {"parent_voltage": 0.7}, "ramps": {"readout_to_control": 3e-7}}

sched = Schedule("")
sched.add(ramps(q0_q1.readout, q0_q1.control))
sched.add(hold(q0_q1.control, 200e-9))
sched.add(ramps(q0_q1.control, q0_q1.readout))
sched.add(hold(q0_q1.readout, 200e-9))

sched.add(ramps(q0_q1.readout, q0_q1.control, **overrides))
sched.add(hold(q0_q1.control, 200e-9, **overrides))
sched.add(ramps(q0_q1.control, q0_q1.readout, **overrides))
sched.add(hold(q0_q1.readout, 200e-9, **overrides))


hw_agent.compile(sched)
hw_agent.latest_compiled_schedule.plot_pulse_diagram()
[8]:
(<Figure size 1000x621 with 1 Axes>,
 <Axes: title={'center': ' schedule 1 repeated 1 times'}, xlabel='Time [μs]', ylabel='$\\dfrac{V}{V_{max}}$'>)
../../../../_images/products_qblox_scheduler_tutorials_any_parametrize_your_schedules_19_1.png