Get familiar with graphs

Understand Boulder Opal graphs and nodes by smoothing a piecewise-constant pulse

In this tutorial, you will develop a practical intuition on how graphs and their components are used in Boulder Opal by defining, smoothing, and plotting a piecewise-constant function.

If you want to learn more about how and why Boulder Opal uses graphs, you can read our Understanding graphs in Boulder Opal topic.

Filtering a piecewise-constant signal

Your task will be to smooth out a piecewise-constant signal, using a Boulder Opal graph and adding the relevant nodes.

A piecewise-constant (PWC) function takes discrete values $\{\Omega_n\}$ at $N$ different segments: $$ \Omega_\mathrm{pwc}(t) = \Omega_n \quad \mathrm{for} \ t \in \left[ \frac{(n-1)T}{N}, \frac{nT}{N} \right) \, . $$

A simple way to smooth functions is to convolve them with a smoothing kernel, $K(t)$. $$ \Omega_\mathrm{smooth}(t) = \Omega_\mathrm{pwc}(t) \ast K(t) = \int_{-\infty}^\infty \Omega_\mathrm{pwc}(\tau) K(t-\tau) \mathrm{d}\tau $$

You will build a graph that creates a piecewise-constant signal and smooths it out. Along the way, you will use different types of graph nodes and operations, which is key to extracting the full potential of graph computations in Boulder Opal.

Import libraries and start a Boulder Opal session

You always begin by importing the required libraries and starting a Q-CTRL API session. To learn more about installing Boulder Opal, please refer to the Get started guide.

import numpy as np
import matplotlib.pyplot as plt
from pprint import pprint
import qctrlvisualizer
from qctrl import Qctrl

# Start a Boulder Opal API session.
qctrl = Qctrl()

Create an empty graph

Instantiate an empty Q-CTRL Python graph object.

graph = qctrl.create_graph()

You can now start adding nodes to the graph to define your desired computation.

Add nodes to graph

Add a Tensor node with the pulse values in Hz

Start by defining a NumPy array with the values of your piecewise-constant pulse in MHz. Then use the graph.multiply method of the graph object to add a node representing the multiplication of each value with 1e6 to turn their unit to Hz.

pulse_values_in_mhz = 2 * np.pi * np.array([0, 2, -3, 3, 2, 4, -1, 0])  # MHz
pulse_values_in_hz = graph.multiply(pulse_values_in_mhz, 1e6)

You add nodes to the graph's structure by calling methods on the graph object, in order to define the target computation. Note that the computation hasn't been executed at this point yet. Therefore, if you now print pulse_values_in_hz, you don't get the value of the graph node in question.

print(pulse_values_in_hz)
<Tensor: name="multiply_#0", operation_name="multiply", shape=(8,)>

Instead, you get a description of the Tensor node you've added to the graph. In particular, you can see its shape, which is that of a 1D tensor with 8 elements, as expected. You can also see the name of the called operation which created the node (in this case, graph.multiply).

Finally, you can see that the Tensor has also been automatically assigned a name ("multiply_#0"), which can be used to extract the node value after executing the graph. You can change the node's name by accessing its name attribute.

pulse_values_in_hz.name = "signal values"
print(pulse_values_in_hz)
<Tensor: name="signal values", operation_name="multiply", shape=(8,)>

Add a Pwc node with the piecewise-constant signal

Use graph.pwc_signal to add a Pwc node representing the original signal (which you will filter later) by passing the values of its eight piecewise-constant segments and its total duration of 1e-6 seconds.

Give it a name so you can retrieve it easily when evaluating the graph. You can do that by passing a string to the name parameter of the graph operation.

signal_duration = 1e-6  # seconds

original_signal = graph.pwc_signal(
    values=pulse_values_in_hz, duration=signal_duration, name="original signal"
)
print(original_signal)
<Pwc: name="original signal", operation_name="pwc_signal", value_shape=(), batch_shape=()>

Again you see a description of the node, in this case of type Pwc, with the name you assigned to it and the operation that created it.

You can also see that it has a value_shape. As it is a scalar function (a signal) it has a value_shape of ().

Being a Pwc, original_signal has durations and values attributes, which you can access. Print them to find out what they are.

print("durations:", original_signal.durations)
print("values:", original_signal.values)
durations: [1.25e-07 1.25e-07 1.25e-07 1.25e-07 1.25e-07 1.25e-07 1.25e-07 1.25e-07]
values: <Tensor: name="getattr_#2", operation_name="getattr", shape=(8,)>

The durations attribute is a NumPy array containing the duration of each piecewise-constant segment of the function. In this case, it has a value of 1.25e-7 in each of its eight segments, which add up to a total duration of 1 ┬Ás. The values attribute is a Tensor with the values of the Pwc at each segment, so in this case it is a 1D Tensor of shape (8,).

Evaluate the graph constructed so far

You can check whether the graph you have built so far is working as expected by evaluating it.

In the previous steps, you assigned a name to each node you wanted to retrieve individually when executing the graph. Now you pass a list with the names of all the nodes of interest to the output_node_names parameter of the qctrl.functions.calculate_graph function.

result = qctrl.functions.calculate_graph(
    graph=graph, output_node_names=["signal values", "original signal"]
)
Your task calculate_graph (action_id="724487") has completed.

Extract results

Now that the graph has been evaluated, the values of the requested nodes can be accessed through the result.output dictionary.

List all available result components, which should coincide with the output_node_names you passed to qctrl.functions.calculate_graph.

print(f"Node results available: {list(result.output.keys())}")
Node results available: ['signal values', 'original signal']

The output of Tensor nodes is a dictionary with a "value" key.

print(result.output["signal values"])
{'value': array([        0.        ,  12566370.61435917, -18849555.92153876,
        18849555.92153876,  12566370.61435917,  25132741.22871834,
        -6283185.30717959,         0.        ])}

The output of Pwc nodes is a list of dictionaries, each of which contain a "value" and a "duration", in this case representing each of the function segments, which you previously defined while adding the node to the graph.

# Using the pprint package for an easy-to-read output.
pprint(result.output["original signal"])
[{'duration': 1.25e-07, 'value': 0.0},
 {'duration': 1.25e-07, 'value': 12566370.614359172},
 {'duration': 1.25e-07, 'value': -18849555.92153876},
 {'duration': 1.25e-07, 'value': 18849555.92153876},
 {'duration': 1.25e-07, 'value': 12566370.614359172},
 {'duration': 1.25e-07, 'value': 25132741.228718344},
 {'duration': 1.25e-07, 'value': -6283185.307179586},
 {'duration': 1.25e-07, 'value': 0.0}]

Plot results

You can pass the result of the executed node named original signal to the Q-CTRL Visualizer's qctrlvisualizer.plot_controls function to visualize it. Note that you can also configure other parameters to suit your display preferences.

qctrlvisualizer.plot_controls(
    plt.figure(), {"original signal": result.output["original signal"]}
)

Add more nodes to graph

Create a linear filter

Now that you have a piecewise-constant signal, the next step before smoothing it is to define the smoothing kernel.

Add a node to the graph representing the filter kernel using graph.gaussian_convolution_kernel with a standard deviation of $3 \times 10^{-8}$ seconds.

convolution_kernel = graph.gaussian_convolution_kernel(std=3e-8)
print(convolution_kernel)
<ConvolutionKernel: operation_name="gaussian_convolution_kernel">

Note that nodes of type ConvolutionKernel don't have a name attribute, so you can't extract their value from the graph after evaluating it.

Convolve the PWC function with the filter

Filter the piecewise-constant signal by convolving it with the kernel using the graph.convolve_pwc operation.

filtered_signal = graph.convolve_pwc(pwc=original_signal, kernel=convolution_kernel)
print(filtered_signal)
<Stf: operation_name="convolve_pwc", value_shape=(), batch_shape=()>

You can see that the output is an Stf node, which represents sampleable functions (usually smooth ones). It has the same value_shape and batch_shape as the original Pwc signal.

Stf nodes don't have a name attribute either, and therefore can't be extracted from the graph. Instead, you can discretize them with the graph.discretize_stf operation (using the same total duration, 1e-6) to create a sampled Pwc, which can then be retrieved from the graph. Make sure you use a large enough segment_count for the discretization, so that the piecewise-constant representation is smooth.

discretized_signal = graph.discretize_stf(
    stf=filtered_signal, duration=1e-6, segment_count=500, name="filtered signal"
)
print(discretized_signal)
<Pwc: name="filtered signal", operation_name="discretize_stf", value_shape=(), batch_shape=()>

This new Pwc node you have added to the graph represents the final smooth filtered signal which you will extract after evaluating the graph.

Evaluate graph

Now you are ready to execute the whole graph you constructed.

Since you have already inspected the output of some intermediate nodes, extract only the final filtered signal.

result = qctrl.functions.calculate_graph(
    graph=graph, output_node_names=["filtered signal"]
)
Your task calculate_graph (action_id="724488") has completed.

Plot results

You can plot the final filtered signal using qctrlvisualizer.plot_controls with its smooth parameter set to True.

qctrlvisualizer.plot_controls(plt.figure(), result.output, smooth=True)

Congratulations! You have worked your way through the fundamentals of Boulder Opal graphs, and can now put your skills to use by solving relevant problems proposed in our several user guides.

You can refer to our topic Understanding graphs in Boulder Opal for further context on what graphs are and why we use them.