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",
"zsync": 0,
"number_of_channels": 8,
"options": None,
}
],
shfqc=[
{
"serial": "DEV12001",
"zsync": 1,
"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.simple import *
from laboneq.dsl.quantum.transmon import Transmon
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 onTransmon
qubits.We define the
rx
operation by decorating a method calledrx
with the decorator@dsl.quantum_operation
. Therx
operation 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_operation
decorator.We use
dsl.create_pulse
to 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
play
command 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, undecoratedrx
method 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 (
TransmonOperations
above), 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.register
decorator, whereqops
here is the name we have chosen for our instance ofTransmonOperations
. Applying the decoratorqops.register
wraps the functionsimple_rx
in 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_operation
and theregister
method ofqops
. Passing the decoratedsimple_rx
toqops.register
registers 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.register
and@dsl.quantum_operation
decorators:
@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_calibration
can only be called when creating anExperiment
and 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.
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.