Context-Based DSL Style¶
As described in the Declarative DSL Style tutorial, you can choose between the imperative, the context-based, and the declarative DSL style when writing experiments. This tutorial describes the context-based style.
Like the imperative style, the context-based style provides a syntax where the structure of the experiment
matches the structure of the Python code you write and makes extensive use of the Python with statement.
In addition, it provides an experiment context that reduces the amount of boilerplate and results in more compact and readable experiments.
The context-based style supports writing experiments in two ways:
- Using signal lines directly.
- Using qubits.
The two ways overlap substantially -- only how the experiment context is created differs. This tutorial covers both.
Imports¶
Everything one needs to build experiments using the context-based DSL is available in laboneq.simple.dsl. Let's import it now along with the rest of laboneq.simple:
from laboneq.simple import *
Add create a demonstration device setup to work with:
# Example device setup creator:
from laboneq.contrib.example_helpers.generate_device_setup import (
generate_device_setup_qubits,
)
# Helpers:
from laboneq.contrib.example_helpers.plotting.plot_helpers import plot_simulation
# specify the number of qubits you want to use
number_of_qubits = 2
# generate the device setup and the qubit objects using a helper function
device_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": 6,
"options": None,
}
],
include_flux_lines=True,
server_host="localhost",
setup_name=f"my_{number_of_qubits}_fixed_qubit_setup",
)
q0, q1 = qubits[:2]
use_emulation = True
# create a session
session = Session(device_setup)
# connect to session
session.connect(do_emulation=use_emulation)
Writing experiments with signals¶
We write an experiment by defining a function and decorating it with the @dsl.experiment decorator. When the function is called, it will create an experiment context and any sections or operations used inside the function will be added to the experiment being built. The decorated function will return the experiment created.
Let's create an completely empty experiment to see how this works:
@dsl.experiment()
def empty_experiment():
# the experiment context is active inside the function
...
And now we call the function to create the experiment:
empty_experiment()
The experiment we just created is not very useful -- it plays no pulses and has no signals or sections. Let's write a more useful experiment that plays a constant pulse of a given amplitude and length:
@dsl.experiment(signals=["drive"])
def constant_drive_experiment(amplitude, length, count):
"""An experiment that plays a constant drive pulse.
Arguments:
amplitude:
The amplitude of the pulse to play.
length:
The length of the pulse to play (s).
count:
The number of acquire loop iterations to perform.
"""
with dsl.acquire_loop_rt(count=count):
with dsl.section(name="play-drive-pulse"):
dsl.play(
"drive", dsl.pulse_library.const(amplitude=amplitude, length=length)
)
In the function above we've used a features we haven't seen yet:
@dsl.experiment(signals=["drive"]): We can specify the signals used by the experiment when calling thedsl.experimentdecorator.def constant_drive_experiment(amplitude, length, count): We can pass parameters to our experiment function and use them within the experiment being created.dsl.section: We can create sections using awithblock just as we do with the imperative DSL.dsl.play: We can perform operations inside sections blocks.
The section created is automatically added to the experiment and the play operation is automatically added to the surrounding section.
Let's call constant_drive_experiment and examine the experiment it returns:
exp = constant_drive_experiment(amplitude=0.5, length=10e-9, count=10)
exp
The experiment has been created but the signals are not yet mapped. We'll need to associate them with a logical signal from a device setup before we can compile the experiment.
The values we supplied for the amplitude (0.5) and length (10e-9) of the pulse have been filled in.
Let's map the logical signal and compile the experiment:
exp.map_signal("drive", "q0/drive")
exp.signals["drive"]
compiled_experiment = session.compile(exp)
Let's examine the compiled experiment by running the output simulator:
# Simulate experiment
# Plot simulated output signals with helper function
plot_simulation(
compiled_experiment,
start_time=0,
length=15e-9,
plot_width=10,
plot_height=3,
)
You've now written your first experiment using the context-based DSL. All of the other features extend this basic structure. The other features can be organized into two main categories -- those that add sections and those that add operations:
Functions that add sections:
- acquire_loop_rt (adds an acquire loop)
- add (adds an already created section)
- case (adds a
casesection)
Functions that add operations:
There are also a few other functions that are helpful:
Other:
- active_section (returns the currently active section)
- experiment (the
experimentdecorator we have just used) - experiment_calibration (returns the calibration of the current experiment)
- map_signal (maps an experiment signal on the current experiment)
- uid (returns a unique identifier that is unique to the current experiment)
- pulse_library (convenient access to the pulse library module)
The reference documentation for all the other methods can be found here.
We'll look at some of the functions that are unique to the context-base DSL later in this tutorial.
Writing experiments with qubits¶
We've just seen how to write context-based DSL experiments that use signals directly. Now let's see how to write experiments that take qubits as arguments.
The two big changes are:
- We'll use the
@dsl.qubit_experimentdecorator instead of the@dsl.experimentdecorator. - When we apply operations to our signals we'll pass the qubit signal (e.g.
qubit.signals["drive"]) instead of the experiment signal name (e.g."drive").
Here's an empty experiment that takes a qubit as an argument:
@dsl.qubit_experiment
def empty_qubit_experiment(q):
# the experiment context is active inside the function
...
Let's run it and examine the experiment. We'll use q0 the first qubit from our device setup:
empty_qubit_experiment(q0)
Notice that in the experiment above:
- all the qubit signal lines have been added as experiment signals
- the experiment signals are already mapped to the appropriate logical signals
- the signal line calibration generated by the qubit has been set
If we call our function with a different qubit it will create an experiment with the new qubits lines mapped.
The qubit signals are determined by calling the .experiment_signals method of the QuantumElement class.
The empty_qubit_experiment accepts only a single qubit, but @dsl.qubit_experiment supports functions that take multiple quantum elements or even lists or tuples of quantum elements as positional arguments:
@dsl.qubit_experiment
def empty_multi_qubit_experiment(q, other_qubits):
# the experiment context is active inside the function
...
empty_multi_qubit_experiment(q0, [q1])
Now we're ready to write the qubit equivalent of a simple constant drive pulse experiment:
@dsl.qubit_experiment
def constant_qubit_drive_experiment(q, amplitude, length, count):
"""An experiment that plays a constant pulse on the qubit drive line.
Arguments:
amplitude:
The amplitude of the pulse to play.
length:
The length of the pulse to play (s).
count:
The number of acquire loop iterations to perform.
"""
with dsl.acquire_loop_rt(count=count):
with dsl.section(name="play-drive-pulse"):
dsl.play(
q.signals["drive"],
pulse_library.const(amplitude=amplitude, length=length),
)
And we can run it to obtain the experiment. This time we'll use qubit q1:
exp = constant_qubit_drive_experiment(q1, amplitude=0.5, length=10e-9, count=10)
exp
Since the experiment signals are already mapped and the calibration applied, we can compile the experiment without any further work:
compiled_experiment = session.compile(exp)
And again we can examine the compiled experiment by running the output simulator:
# Simulate experiment
# Plot simulated output signals with helper function
plot_simulation(
compiled_experiment,
start_time=0,
length=15e-9,
plot_width=10,
plot_height=3,
signals=["q1/drive"],
)
When working with qubit experiments, it can be useful to write quantum operations for your qubits. These are documented in their own tutorial and are available in laboneq.simple.dsl for convenience:
- QuantumOperations (the base class for defining sets of quantum operations)
- quantum_operation (a decorator to use for defining individual quantum operations)
Sweeps¶
Sweeps are common in tune-up experiments and dsl.sweep works a little differently to the other context-based DSL functions that create sections. Instead of returning the section, it returns the sweep parameter.
Here is a small experiment that performs an amplitude sweep of a drive pulse using dsl.sweep:
@dsl.qubit_experiment
def qubit_sweep_experiment(q, amplitudes, count):
"""A simple sweep."""
amplitude_sweep = SweepParameter("amplitude_sweep", amplitudes)
with dsl.acquire_loop_rt(count=count):
with dsl.sweep(amplitude_sweep) as amplitude:
dsl.play(
q.signals["drive"],
pulse_library.const(amplitude=1.0, length=10e-9),
amplitude=amplitude,
)
Note how in with dsl.sweep(amplitude_sweep) as amplitude the amplitude is the sweep parameter (and not the created section).
In this particular case we also need to pass the amplitude sweep directly to dsl.play because LabOne Q does not support sweeping the amplitude pulse parameter:
pulse_library.const(amplitude=1.0, length=10e-9),
amplitude=amplitude, # <-- we pass the amplitude sweep here
Let's build and compile the experiment so we can examine the output with the output simulator:
exp = qubit_sweep_experiment(q0, amplitudes=[0.1, 0.2, 0.3], count=10)
compiled_experiment = session.compile(exp)
# Simulate experiment
# Plot simulated output signals with helper function
plot_simulation(
compiled_experiment,
start_time=0,
length=0.6e-6,
plot_width=10,
plot_height=3,
signals=["q0/drive"],
)
Above we can see the ten iterations of the acquire loop, each with a sweep of the pulse amplitude through three different values, just as we expected.
Accessing the experiment calibration¶
@dsl.qubit_experiment
def qubit_frequency_sweep_experiment(q, frequencies, count):
"""A simple sweep."""
frequency_sweep = SweepParameter("frequency_sweep", frequencies)
calibration = dsl.experiment_calibration()
signal_calibration = calibration[q.signals["drive"]]
signal_calibration.oscillator.frequency = frequency_sweep
# Note: Here we set the modulation type to software so that
# we can see the frequency modulation in the output
# simulator. In a real experiment one would likely choose
# to omit the line below.
signal_calibration.oscillator.modulation_type = ModulationType.SOFTWARE
with dsl.acquire_loop_rt(count=count):
with dsl.sweep(frequency_sweep):
dsl.play(
q.signals["drive"],
pulse_library.const(amplitude=1.0, length=100e-9),
)
exp = qubit_frequency_sweep_experiment(
q0,
frequencies=[5.1e9, 5.2e9, 5.3e9],
count=10,
)
compiled_experiment = session.compile(exp)
Let's look at the output simulator. This time we'll zoom into just the first acquire loop iteration by setting length=0.4e-6 so that we can see the frequency changes more clearly:
# Simulate experiment
# Plot simulated output signals with helper function
plot_simulation(
compiled_experiment,
start_time=0,
length=0.4e-6,
plot_width=10,
plot_height=3,
signals=["q0/drive"],
)
Setting section properties¶
When writing experiments one sometimes needs to set section parameters such as section alignment, section length, and whether the section is required to be on the system grid or not.
Usually either the function creating the section, for example dsl.acquire_loop_rt or dsl.section, allows passing the section parameter as an argument, or the section object is available and the parameter can be set directly on it.
Sometimes when using sweeps or writing quantum operations the current section is not immediately accessible. In these cases one can use dsl.active_section to retrieve the current section and set its attributes.
Let's look at an example where we write a function that right aligns the current section:
def right_align():
"""Right align the current section."""
section = dsl.active_section()
section.alignment = SectionAlignment.RIGHT
@dsl.qubit_experiment
def qubit_sweep_experiment_with_minimum_length(q, amplitudes, count):
"""A simple sweep."""
amplitude_sweep = SweepParameter("amplitude_sweep", amplitudes)
with dsl.acquire_loop_rt(count=count):
# We add a bigger pulse so we can see that the sweep ends
# up being right aligned in the output simulator:
with dsl.section():
dsl.play(
q.signals["drive"],
pulse_library.const(amplitude=1.0, length=10e-9),
)
with dsl.sweep(amplitude_sweep) as amplitude:
right_align() # --> here we right align the section
dsl.play(
q.signals["drive"],
pulse_library.const(amplitude=1.0, length=10e-9),
amplitude=amplitude,
)
exp = qubit_sweep_experiment_with_minimum_length(
q0,
amplitudes=[0.1, 0.2, 0.3],
count=10,
)
compiled_experiment = session.compile(exp)
In the output simulator plot below one can see that the sweep is right aligned so that the last pulse from the sweep touches the start of our bigger reference pulse in the next acquire loop iteration:
# Simulate experiment
# Plot simulated output signals with helper function
plot_simulation(
compiled_experiment,
start_time=0,
length=0.6e-6,
plot_width=10,
plot_height=3,
signals=["q0/drive"],
)
Mapping signals while building an experiment¶
Previously when writing experiments with signals we saw that we had to call experiment.map_signal(...) to map the experiment signal after the experiment was created.
Using dsl.map_signal(...) we can pass the logical signal as a parameter to the function that builds the experiment and have the function apply the mapping for us right away:
@dsl.experiment(signals=["drive"])
def auto_map_drive_experiment(amplitude, length, count, drive_signal):
"""An experiment that plays a constant drive pulse.
Arguments:
amplitude:
The amplitude of the pulse to play.
length:
The length of the pulse to play (s).
count:
The number of acquire loop iterations to perform.
"""
dsl.map_signal("drive", drive_signal)
with dsl.acquire_loop_rt(count=count):
with dsl.section(name="play-drive-pulse"):
dsl.play("drive", pulse_library.const(amplitude=amplitude, length=length))
exp = auto_map_drive_experiment(
amplitude=0.5,
length=10e-9,
count=10,
drive_signal=q0.signals["drive"],
)
Examining the experiment we can see that the signal is already mapped:
exp.signals["drive"]
And we can compile the experiment immediately:
compiled_experiment = session.compile(exp)
Avoiding specifying unique identifiers¶
When working with the context-based DSL one should seldom have to explicitly set unique identifiers (uids) for sections. Set a section name instead. If a section has no UID, one is generated using the section name as the prefix.
Let's see how this works:
@dsl.experiment()
def using_names_experiment():
"""A demonstration of section naming."""
with dsl.section(name="section_a"):
pass
with dsl.section(name="section_b"):
pass
with dsl.section(name="section_a"): # another section named 'section_a'
pass
Now we can build the experiment and examine the generated UIDs:
exp = using_names_experiment()
exp
Let's go through the UIDs one by one:
- the first
section_ahas a UID generated from its name (section_a_0). section_bhas a UID with the prefixx(section_b_0).- the second
section_aalso has a UID with from its name (section_a_1).
Note that the generated UIDs are unique only to a particular experiment. If we create the experiment again, it will have the same UIDs:
exp = using_names_experiment()
exp
This is a great feature because it means that you can rely on the generated UIDs being consistent.
Now you know all the basics of using the context-based DSL! Don't forget to have a look at the reference documentation for all the functions we didn't cover in this tutorial.