Download
Download this file as Jupyter notebook: variables.ipynb.
Variables in programs
Variables are a central part of Keysight’s Quantum Control System and represent classical inputs and outputs of quantum programs. Due to the short time scales imposed by quantum decoherence, these variables are processed on field-programmable gate arrays (FPGAs) and application-specific integrated circuits (ASICs) that are integrated into the control hardware. This integration allows for real-time readouts and updates of variables, and enables decision logic based on measurement outcomes.
There are three types of scalars that need to be processed in quantum computations:
Integers, e.g., to describe the classical output of a measurement.
Real numbers, e.g., to describe the amplitude, frequency or duration of waveforms.
Complex numbers, e.g., to describe the integrated result of an analog measurement.
Each of these scalar types can be grouped into arrays. We then want to be able to do the following:
Define the inputs and outputs of programs.
Define variables and use them to define multiple instructions such that whenever the value of the variable changes, it is changed in all instructions that use it.
Define one or more variables and use their values at runtime to determine which instruction to execute.
Define classical processing of arrays, slices thereof, and scalars that are to be performed during the execution of a quantum program.
To support the above, Keysight’s Quantum Control System includes the Scalar
and Array
classes, which encapsulate a variable
name
, an underlying scalar dtype
, and a value
that may be uninitialized.
The value
can be set on initialization and/or changed afterwards. When the
value
is set, it will be coerced to the underlying dtype
if possible and raise
an error if it is not possible.
The default dtype
is complex
because it is the broadest scalar type.
[2]:
import keysight.qcs as qcs
# define a constant waveform and a delay that both have the same uninitialized duration
duration = qcs.Scalar("duration", dtype=float)
assert duration.name == "duration"
assert duration.dtype is float
const_pulse = qcs.DCWaveform(duration, qcs.ConstantEnvelope(), amplitude=1)
delay = qcs.Delay(duration)
assert const_pulse.duration.value is None
assert delay.duration.value is None
Setting the duration updates both const_pulse
and delay
:
[3]:
duration.value = 1e-6
assert const_pulse.duration.value == 1e-6
assert delay.duration.value == 1e-6
Setting a variable to an invalid type raises a TypeError
and leaves the value
unchanged:
[4]:
try:
duration.value = 1j
except TypeError:
pass
assert duration.value == 1e-6
We can also create read-only variables that effectively act as constants in an
experiment. Trying to set the value
of a read-only variable raises a
ValueError
and leaves it unchanged:
[5]:
read_only = qcs.Scalar("duration", value=1, dtype=int, read_only=True)
try:
read_only.value = 2
except ValueError:
pass
assert read_only.value == 1
Arrays
An Array
has the same name
, dtype
, and
value
properties as a Scalar
, but adds a
shape
property which specifies the number of dimensions and the size of each
dimension. The dimensions of an undefined size can be specified as None
and will
be inferred when the value
is set. The shape
and value
cannot both be
passed as arguments as they are redundant and must be specified as keyword arguments
to avoid ambiguity.
[6]:
# specify an empty 1D array with two rows
array = qcs.Array("array", shape=(2,))
assert array.value is None
# setting the value will set any unknown sizes
array.value = [1, 2]
assert array.shape == (2,)
assert all(array.value == [1, 2])
We can access individual elements or regularly spaced subsets in an
Array
using the same syntax as
basic NumPy slicing. This
slicing syntax extends Python slicing syntax to multi-dimensional arrays.
Note
We only support basic NumPy slicing because it returns a view of the underlying data, whereas advanced NumPy slicing creates copies.
[7]:
# Create a 2x3 array with integer values
array = qcs.Array("array", value=[[1, 2], [3, 4], [4, 5]])
# retrieve the first row
assert (array[0].value == [1, 2]).all()
# retrieve the first column
assert (array[:, 0].value == [1, 3, 4]).all()
Slices are valid even if the value
of the
Array
being sliced is None
. Setting the
value
of the Array
will automatically update
the value
of all its slices.
Note
To avoid potential edge cases, an Array
can only
be sliced if all elements of its shape
are specified.
[8]:
# create an empty array with two rows
array = qcs.Array("array", shape=(2,), dtype=int)
# store the first value in a slice
arslice = array[0]
assert arslice.value is None
# populate the array with values
array.value = [0, 1]
# the slice value is now updated
assert arslice.value == 0
Likewise, setting the value
of a slice will also update the
Array
's value
:
[9]:
array = qcs.Array("array", value=[[0, 1]])
# store the first value in a slice
arslice = array[0, 0]
assert arslice.value == 0
# change the value of the slice
arslice.value = 2
# the array value is now updated
assert (array.value == [[2, 1]]).all()
Implicit variables
For convenient and consistent interfaces, we allow scalar arguments to be specified by
a number and implicitly cast them to a read-only variable (or constant) with an empty
string as its name
, where the dtype
depends on the class.
[10]:
delay = qcs.Delay(1e-6)
assert delay.duration.value == 1e-6
Dependent variables
Dependent variables can be specified as arithmetic expressions of existing variables.
[11]:
var = qcs.Scalar("var", dtype=int)
result = 0.5 * var + 1
Note that such an expression is not a temporary variable assignment, but rather a
stationary definition. The value of result
is always set using the value of
var
.
[12]:
# the value of result is None before var is used
assert result.value is None
# result updates accordingly with var
var.value = 3
assert result.value == 2.5
Expressions can use any combination of scalars, arrays, and slices. Array operations are element-wise.
[13]:
coeff = qcs.Scalar("c", value=0.5, dtype=float)
res1 = qcs.Array("res1", value=[5, 2], dtype=int)
res2 = qcs.Array("res2", value=[2, 1], dtype=int)
res3 = qcs.Array("res3", value=[0, 1, 7], dtype=int)
result = 2 * coeff * res1[0] * res2 + res3[:2] / res2 + 5
assert (result.value == [15, 11]).all()
Dependent variables are convenient as instruction arguments that depend on a common variable.
[14]:
# define an amplitude variable that may update over the course of the program
amp = qcs.Scalar("amp", dtype=float)
# multiple pulses depend on the variable amplitude
pulse1 = qcs.DCWaveform(80e-9, qcs.GaussianEnvelope(), 0.98 * amp)
pulse2 = qcs.DCWaveform(80e-9, qcs.GaussianEnvelope(), 0.90 * amp)
Hashing
Python makes heavy use of hashable collections, such as sets and dictionaries, to enable lookups to be performed with an average-case cost that is independent of the collection’s size. For variables and any classes that contain them as attributes to be able to be inserted in a hashable collection, they need to define hash and equality functions. There are two requirements for a hash function:
The hash must not change over the lifetime of an object.
The hash of two equal objects must be the same.
To satisfy these requirements, we make the hash of a variable independent of the
variable’s value
so that changing that value
does not affect the hash of
the variable.
[15]:
var = qcs.Scalar("var")
initial_hash = hash(var)
var.value = 1e-6
assert hash(var) == initial_hash
Download
Download this file as Jupyter notebook: variables.ipynb.