Quantum Operations¶
LabOne Q provides a natural language for describing and implementing quantum circuits using quantum operations. To learn more about what quantum operations are in LabOne Q, have a look here
In this tutorial, we will show you how to define a set of quantum operation and how to use it in a LabOne Q Experiments implementing an experimental pulse sequence.
Before we start, let's first define a DeviceSetup and three Transmon qubits. We will use the latter when calling our quantum operations.
Device Setup and Qubits¶
We will define our DeviceSetup and 3 Transmon qubits using the helper function generate_device_setup_qubits.
from __future__ import annotations # needed for type setting in python 3.9
from laboneq.contrib.example_helpers.generate_device_setup import (
generate_device_setup_qubits,
)
# specify the number of qubits you want to use
number_of_qubits = 3
# generate the device setup using a helper function
setup, qubits = generate_device_setup_qubits(
number_qubits=number_of_qubits,
pqsc=[{"serial": "DEV10001"}],
hdawg=[
{
"serial": "DEV8001",
"number_of_channels": 8,
"options": None,
}
],
shfqc=[
{
"serial": "DEV12001",
"number_of_channels": 6,
"readout_multiplex": 3,
"options": None,
}
],
include_flux_lines=True,
multiplex_drive_lines=True, # adds drive_ef
server_host="localhost",
setup_name="device_setup",
)
Defining a set of quantum operations¶
Quantum operations are implemented as methods of a class inheriting from
dsl.QuantumOperations. Below, we write a new class called TransmonOperations acting on
LabOne Q Transmon qubits and containing only one simple rx operation:
from laboneq.dsl.quantum.transmon import Transmon
from laboneq.simple import *
class TransmonOperations(dsl.QuantumOperations):
QUBIT_TYPES = Transmon
@dsl.quantum_operation
def rx(
self,
q: Transmon,
amplitude: float | SweepParameter,
length: float | SweepParameter,
phase: float = 0.0,
) -> None:
pulse_parameters = {"function": "drag", "beta": 0.01, "sigma": 0.21}
rx_pulse = dsl.create_pulse(pulse_parameters, name="rx_pulse")
dsl.play(
q.signals["drive"],
amplitude=amplitude,
length=length,
phase=phase,
pulse=rx_pulse,
)
Let's understand what the code above is doing:
We start be defining a new class called
TransmonOperations, inheriting fromdsl.QuantumOperations.We specify
QUBIT_TYPES = Transmon, saying that this class contains operations onTransmonqubits.We define the
rxoperation by decorating a method calledrxwith the decorator@dsl.quantum_operation. Therxoperation takes a qubit as the first input argument. The first input argument(s) of any quantum operation must be the qubit(s). This is expected by the@dsl.quantum_operationdecorator.We use
dsl.create_pulseto create a pulse functional from the LabOne Q pulse library. We specify the pulse type "drag" underpulse_parameters["function"]. The pulse type must correspond to the name of a pulse functional in the LabOne Q pulse library or one that was registered by the user as described here.Finally, we have the
playcommand acting on the qubit signal line called "drive_line".
Examining the set of operations¶
Let's instantiate our class of quantum operations and learn how to examine it.
qops = TransmonOperations()
The quantum operations have the attribute QUBIT_TYPES which specifies the type of qubits supported by the quantum operations object we've created. In our case, that's the Transmon:
qops.QUBIT_TYPES
Next, we list the operations contained in TransmonOperations:
qops.keys()
We see a list with the one operation we have implemented, rx.
Let's now inspect the docstring and the source code of this operation. Being able to do this is very useful if you do not have access to the source code.
# docstring
qops.rx?
# source code
qops.rx.src
In addition to .src each quantum operation also has three special attributes:
.op: This returns the function that implements the quantum operation. In our case, this is the bare, undecoratedrxmethod we've defined above..omit_section(...): This method builds the quantum operation but without a containing section and without reserving the qubit signals. This is useful if one wants to define a quantum operation in terms of another, but not have deeply nested sections..omit_reserves(...): The method builds the quantum operation but doesn't reserve the qubit signals. This is useful if you want to manage the reserved signals yourself.
We'll use .omit_section and .omit_reserves once we've seen how to register a quantum operation to an existing set.
qops.rx.op
Calling a quantum operation¶
Calling a quantum operation by itself produces a LabOne Q section:
section = qops.rx(qubits[0], 1, 50e-9)
section
Some things to note about the section:
- The section name is the name of the quantum operation, followed by the UIDs of the qubits it is applied to.
- The section UID is automatically generated from the name.
- The section starts by reserving all the signal lines of the qubit it operates on so that operations acting on the same qubits never overlap.
Registering a new quantum operation to an existing set¶
To add a new operation to an existing set do one of the following:
if the class definition is available (
TransmonOperationsabove), then you can add a new operation to the source code of this class and reinstantiateqops = TransmonOperations().register a new quantum operation to an existing set.
Let's have a look at how you can do the latter.
Say you want to register a new operation called simple_rx to the existing set qops. You can do this in three ways:
- Using the
@qops.registerdecorator, whereqopshere is the name we have chosen for our instance ofTransmonOperations. Applying the decoratorqops.registerwraps the functionsimple_rxin a quantum operation and registers it with our current set of operations,qops.
@qops.register
def simple_rx(self, q, amplitude, phase=0):
"""A square-shaped RX pulse of fixed length (50 ns)."""
dsl.play(
q.signals["drive"],
amplitude=amplitude,
phase=phase,
length=50e-9, # fix the length
pulse=dsl.pulse_library.const(),
)
We can confirm that our new operation is registered by checking that its in our set of operations, or by looking it up as an attribute or element of our operations:
"simple_rx" in qops
qops.simple_rx
qops["simple_rx"]
qops.keys()
Let's run our new operation and examine the section it produces:
qops.simple_rx(qubits[0], 1)
If an operation with the same name already exists it will be replaced, so the next two code cells will replace the above definition of simple_rx.
- Using the
@dsl.quantum_operationand theregistermethod ofqops. Passing the decoratedsimple_rxtoqops.registerregisters it with our current set of operations,qops.
@dsl.quantum_operation
def simple_rx(self, q, amplitude, phase=0):
"""A square-shaped RX pulse of fixed length (50 ns)."""
dsl.play(
q.signals["drive"],
amplitude=amplitude,
phase=phase,
length=50e-9, # fix the length
pulse=dsl.pulse_library.const(),
)
qops.register(simple_rx)
- Using both the
@qops.registerand@dsl.quantum_operationdecorators:
@qops.register
@dsl.quantum_operation
def simple_rx(self, q, amplitude, phase=0):
"""A square-shaped RX pulse of fixed length (50 ns)."""
dsl.play(
q.signals["drive"],
amplitude=amplitude,
phase=phase,
length=50e-9, # fix the length
pulse=dsl.pulse_library.const(),
)
Aliases for quantum operations¶
We can also create aliases for existing quantum operations that are already registered by assigning additional names for them:
qops["rx_fixed_length"] = qops.simple_rx
qops.keys()
Replacing a quantum operation¶
You can easily replace a quantum operation with another by simple assignment. Let's replace our original rx operation with simple_rx.
qops["rx"] = qops.simple_rx # replace the rx gate
Check that the section produced by calling the new rx operation is the same as that produced by calling simple_rx above:
qops.rx(qubits[0], 1)
The original rx operation is still available in qops.BASE_OPS, which contains the original quantum operations defined in the class implementation.
Let's put the original rx implementation back so that we don't confuse ourselves later:
qops["rx"] = qops.BASE_OPS["rx"]
Using omit_section¶
Let's say that we'd like to write an x180 operation that reuses the rx_fixed_length operation. An x180 operation is essentially and rx with the phase fixed at 0 degrees and the amplitude always given by the $\pi$-pulse amplitude of the qubit. Let's assume the $\pi$-pulse amplitude is 0.75. We can write then define our x180 operation as:
@qops.register
def x180(self, q):
pi_amp = 0.75
return self.rx_fixed_length(q, amplitude=pi_amp, phase=0)
However, when we call this we will have deeply nested sections and many signal lines reserved. This obscures the structure of our experiment:
section = qops.x180(qubits[0])
section
We can remove the extra section and signal reservations by calling our inner operation using .omit_section instead:
@qops.register
def x180(self, q):
pi_amp = 0.75
return self.rx_fixed_length.omit_section(q, amplitude=pi_amp, phase=0)
Note how much simpler the section structure looks now:
section = qops.x180(qubits[0])
section
The .omit_section attribute also gives you greater control over the timing when creating your Experiment pulse sequence. You can choose to only add this wrapping Section when you need it.
Using omit_reserves¶
By default the Section created by a quantum operation reserves all of the qubit signals so that two operations on the same qubit cannot overlap. In some circumstances, you one might wish to not reserve the qubit signals and to manage the avoidance of overlaps yourself.
In these cases .omit_reserves is helpful.
Let's look at what the x180 section looks like without the reserves:
section = qops.x180.omit_reserves(qubits[0])
section
Setting section attributes¶
Sometimes an operation will need to set special section attributes such as on_system_grid.
This can be done by retrieving the current section and directly manipulating it.
To demonstrate, we'll create an operation whose section is required to be on the system grid:
@qops.register
def op_on_system_grid(self, q):
section = dsl.active_section()
section.on_system_grid = True
# ... play pulses, etc.
And then call it to confirm that the section has indeed been set to be on the grid:
section = qops.op_on_system_grid(qubits[0])
section.on_system_grid
Accessing experiment calibration¶
When an Experiment pulse sequence is created from a function decorated with @qubit_experiment, its calibration is initialized from the qubits it operates on. Typically oscillator frequencies and other properties of the SignalCalibrations are set.
Sometimes it may be useful for quantum operations to access or manipulate this configuration. They can do this by calling dsl.experiment_calibration, which returns the calibration set for the current experiment.
Note:
The experiment calibration is only accessible if there is an
Experiment, so quantum operations that callexperiment_calibrationcan only be called when creating anExperimentand will raise an exception otherwise.There is only a single experiment calibration per experiment, so if multiple quantum operations modify the same calibration items, only the last modification will be retained.
Here is how we define a quantum operation that accesses the calibration:
@qops.register
def op_that_examines_signal_calibration(self, q):
calibration = dsl.experiment_calibration()
signal_calibration = calibration[q.signals["drive"]]
# ... examine or set calibration, play pulses, etc, e.g.:
signal_calibration.oscillator.frequency = 0.2121e9
Near-time quantum operations¶
Most quantum operations are used inside real-time acquisition loops. That is, they are intended to be called inside a dsl.acquire_loop_rt block.
Some operations must be called in near-time, that is, outside the dsl.acquire_loop_rt block. In particular, operations that call near-time callback functions using dsl.call must be declared as near-time operations.
Let's see how to write such an operation:
@qops.register
@dsl.quantum_operation(neartime=True)
def set_dc_bias(qops, qubit, voltage):
dsl.call("set_dc_bias", voltage=voltage)
The @dsl.quantum_operation(neartime=True) decorator marks the operation as near-time. The function dsl.call makes a near-time callback to a pre-defined near-time function registered to the Session as described here. We have not done this in this example.
The section created looks as follows:
section = qops.set_dc_bias(qubits[0], 1.5)
section
Note that the execution_type is set to ExecutionType.NEAR_TIME. This ensures that the LabOne Q compiler will raise an error if the operation is called inside the dsl.acquire_loop_rt block.
The section also does not reserve any signals. A near-time operation does not use any signals, since operations on signals are real-time.
Broadcasting quantum operations¶
The general idea behind this features was explained in the page on the concepts of quantum operations. Here, we will show you how this works at the moment.
Note that the broadcasting feature is currently an experimental feature and might still change in the future.
We activate broadcasting just by supplying a list of qubits instead of a single qubit, like so:
sections = qops.x180(qubits)
It created one section for each of our qubits:
[section.name for section in sections]
Note that the sections returned are in the same order as the list of qubits we provided. This ordering is guaranteed by the broadcasting machinery so you can rely on it if you need to.
If we look at one of these sections, we can see it looks just like the section created by calling the operation with the corresponding single qubit.
Here is the section for qubit q2:
sections[2]
What about operations that take additional parameters like rx?
In these cases you can choose whether to supply one value for the parameter for all the qubits, or one value for each qubit.
We'll try a single value for all qubits first:
sections = qops.rx_fixed_length(qubits, amplitude=0.25)
If we take a look at the amplitudes of the pulses of each qubit, we'll see that they're all the same:
def print_rx_amplitudes(sections):
"""Print the amplitude of rx operation pulses."""
print("Amplitudes")
print("----------")
for section in sections:
print(section.children[-1].amplitude)
print_rx_amplitudes(sections)
Now let's try passing a different amplitude for each qubit:
sections = qops.rx_fixed_length(
qubits, amplitude=[1 / (i + 1) for i in range(len(qubits))]
)
print_rx_amplitudes(sections)
What happens if you supply a different number of amplitudes and qubits? You will get an error like this:
import numpy as np
try:
# only one amplitude is supplied but there are 3 qubits
sections = qops.rx(qubits, [np.pi])
except ValueError as e:
print(e)
Broadcasting is powerful and a little complex. Just remember that it generates one operation section for each qubit.
If you need to write a quantum operation that should never be broadcast, for example an operation such as a QFT (Quantum Fourier Transform) that already takes in a list of qubits, one can use @quantum_operation(broadcast=False) like this:
@dsl.quantum_operation(broadcast=False)
def x180_never_broadcast(qop, qubits):
for q in qubits:
qops.x180(q)
qops.register(x180_never_broadcast)
Now when we call x180_never_broadcast with a list of qubits it will not use the broadcast functionality but just call the operation we implemented:
section = qops.x180_never_broadcast(qubits)
section.name
As you can see, it returned just one section that applies X90 gates to each qubit.
Combining quantum operations¶
In general, quantum processing units are complicated objects with different kinds of quantum elements, each with their own set of quantum operations. In these cases, it may be useful to combine quantum operations to deal with such a mixed set of quantum elements.
In the examples above, we defined the TransmonOperations, which are designed to act solely on Transmon qubits. Let us consider the case where we also have a different kind of qubit called AlternativeTransmon, with its own set of operations called AlternativeTransmonOperations.
class AlternativeTransmon(QuantumElement):
REQUIRED_SIGNALS = ("acquire", "drive", "measure")
class AlternativeTransmonOperations(dsl.QuantumOperations):
QUBIT_TYPES = AlternativeTransmon
@dsl.quantum_operation
def rx(
self,
q: AlternativeTransmon,
amplitude: float | SweepParameter,
length: float | SweepParameter,
phase: float = 0.0,
) -> None:
pulse_parameters = {"function": "drag", "beta": 0.02, "sigma": 0.42}
rx_pulse = dsl.create_pulse(pulse_parameters, name="rx_pulse")
dsl.play(
q.signals["drive"],
amplitude=amplitude,
length=length,
phase=phase,
pulse=rx_pulse,
)
As we can see, the AlternativeTransmonOperations class acts solely on AlternativeTransmon objects and also has a method named rx with slightly different values of beta and sigma in its pulse_parameters.
Let us now combine these quantum operations into a single quantum operations class called CombinedOperations.
class CombinedOperations(TransmonOperations, AlternativeTransmonOperations):
pass
combined_qops = CombinedOperations()
Since TransmonOperations support Transmon qubits and AlternativeTransmonOperations support AlternativeTransmon qubits, our combined class can support both.
combined_qops.QUBIT_TYPES
By examining the keys of this combined instance, we can see that CombinedOperations still only has one operation rx.
combined_qops.keys()
However, the operation rx is a MultiMethod, which means that it is a dictionary of functions that are dispatched based on their type signature. Let us take a closer look at what this means.
We can start by re-examining the source code of the rx operation.
combined_qops.rx.src
Here, we can see that there are two versions of the rx function that are registered. One with the type signature ('object', 'AlternativeTransmon', 'float' | 'SweepParameter', 'float' | 'SweepParameter', 'float') and the other with the type signature
('object', 'Transmon', 'float' | 'SweepParameter', 'float' | 'SweepParameter', 'float').
Note that, in LabOne Q, we only dispatch based on type signatures that are of the same length, and only on the QuantumElement types. Therefore, for the purposes of dispatching, the type of other parameters in the type signature is considered object. Keyword arguments are ignored. We can examine the MultiMethod dictionary to see precisely how the rx operation is dispatched.
combined_qops.rx.op
In the example above, our MultiMethod has two entries: one where the first positional argument is a Transmon, and one where the first positional argument is an AlternativeTransmon. To verify that this MultiMethod is working as expected, let us try to call rx with different types and check that the beta and sigma values are reported correctly.
qubit = qubits[0]
print(f"Calling rx with {type(qubit).__name__} argument:")
section = combined_qops.rx(qubit, 1, 50e-9)
print(section)
alt_qubit = AlternativeTransmon(
uid="alt_q0",
signals={
"acquire": "alt_q0/acquire",
"drive": "alt_q0/drive",
"measure": "alt_q0/measure",
},
)
print(f"Calling rx with {type(alt_qubit).__name__} argument:")
alt_section = combined_qops.rx(alt_qubit, 1, 50e-9)
print(alt_section)
As expected, the two sections are effectively the same, apart from the beta and sigma values in their pulse_parameters. This shows that the function dispatching is working correctly.
This tutorial has introduced the quantum operations feature of LabOne Q.
Check out the LabOne Q Applications Library, where you can find our implementation of a set of quantum operations for superconducting tunable-transmon qubits.