Q-CTRL logo

How to reuse graph definitions in different calculations

Reapply graph nodes for multiple applications

In Boulder Opal, graph definitions can be easily carried over between different graphs. For example, the nodes used to create a Hamiltonian can first be used in an optimization task, and then later used in simulation. This is especially useful if one is changing properties of the graph that could not be easily batched together, for example, the dimension of the Fock space or the structure of the Hamiltonian. If instead you want to perform a simulation using the results of an optimization in a single calculation, see the relevant user guide.

Below we show an example of reusing nodes for optimization and simulation in separate graphs.

Summary workflow

1. Define a function for the nodes to be reused

For convenience, start by creating a function that contains the nodes to be reused. One of the arguments of the function should be graph so that the function can be used to add nodes on to the graph. The function should also be passed any parameters required to define the nodes. After these nodes are added to graph, any nodes required to compute the graph (for example the Hamiltonian) should be returned.

For example, one might define a function to create an infidelity node for a given Hamiltonian and some target:

def create_infidelity(graph, hamiltonian):
    target = graph.target(operator=graph.pauli_matrix("Y"))
    return graph.infidelity_pwc(
        hamiltonian=hamiltonian,
        target=target,
        name="infidelity",
    )

2. Define and execute the computational graph

Set up a graph object to carry out some task, be it to perform a control optimization or simulation, using the previously defined function. With the graph object created, an optimization can be run using the qctrl.functions.calculate_optimization function or a simulation can be run using the qctrl.functions.calculate_graph.

3. Define and execute other graphs

Reusing the convenience function, define a new graph that carries out a related task. For example, if the first calculation is an optimization, then the new graph might be a noisy simulation or a quasi-static parameter scan with the optimized results.

Worked example: Reusing nodes for simulation and optimization of a qubit

In this example we will implement a Y gate in a noisy single-qubit system. The system is described by a Hamiltonian of the form, \begin{equation} H(t) = \alpha(t) \sigma_{z} + \frac{1}{2}\left(\gamma(t)\sigma_{-} + \gamma^*(t)\sigma_{+}\right) + \delta \sigma_{z} + \beta(t) \sigma_{z} \end{equation} where, $\sigma_{z}$ is the Pauli Z operator, $\sigma_{\pm}$ are the qubit ladder operators, $\alpha(t)$ and $\gamma(t)$ are, respectively, real and complex optimizable time-dependent controls, $\delta$ is the qubit detuning, and, $\beta(t)$ is a dephasing noise process.

First we will optimize $\alpha(t)$ and $\gamma(t)$ to produce a Y gate in the absence of any noise, $\beta(t)=0$. Then we will optimize the controls in the presence of a dephasing amplitude on the order of $\beta(t)=\beta_0$. Finally we will test how both sets of controls perform in the presence of real stochastic noise sampled from a normal distribution centered at $\beta_0$. We see that for the robust controls optimized in the presence of constant noise, the associated infidelity is significantly lower than for the controls optimized without any noise.

To reduce the amount of code required nodes are reused by defining functions to create the optimiziable controls, compute the Hamiltonian and to compute infidelity. In total three graphs are used, two for optimizating the controls and another for simulation.

def create_optimizable_controls(graph):
    """Create the optimizable controls α(t) and γ(t)."""
    # Maximum value for |α(t)|.
    alpha_max = 2 * np.pi * 0.25e6  # rad/s

    # Real PWC signal representing α(t).
    alpha = graph.utils.real_optimizable_pwc_signal(
        segment_count=segment_count,
        duration=duration,
        minimum=-alpha_max,
        maximum=alpha_max,
        name="$\\alpha$",
    )

    # Maximum value for |γ(t)|.
    gamma_max = 2 * np.pi * 0.5e6  # rad/s

    # Complex PWC signal representing γ(t)
    gamma = graph.utils.complex_optimizable_pwc_signal(
        segment_count=segment_count,
        duration=duration,
        maximum=gamma_max,
        name="$\\gamma$",
    )
    return alpha, gamma


def create_noiseless_hamiltonian(graph, alpha_control, gamma_control):
    """Create the noiseless Hamiltonian from the controls α(t) and γ(t)."""
    # Detuning δ.
    delta = 2 * np.pi * 0.25e6  # rad/s
    detuning_hamiltonian = delta * graph.pauli_matrix("Z")

    # Control Hamiltonian.
    control_hamiltonian = alpha_control * graph.pauli_matrix(
        "Z"
    ) + graph.hermitian_part(gamma_control * graph.pauli_matrix("M"))

    # Total Hamiltonian.
    return detuning_hamiltonian + control_hamiltonian


def create_infidelity(graph, hamiltonian, noise_operators=None):
    """
    Create the infidelity node with the option to pass a noise operator.
    If a noise operator is passed then the operational infidelity plus
    filter function values is calculated.
    """
    # Target operation node.
    target = graph.target(operator=graph.pauli_matrix("Y"))

    # Compute infidelity.
    graph.infidelity_pwc(
        hamiltonian=hamiltonian,
        noise_operators=noise_operators,
        target=target,
        name="infidelity",
    )
# Import packages.
import numpy as np
from qctrl import Qctrl

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

# Pulse parameters.
segment_count = 50
duration = 10e-6  # s

# Dephasing noise amplitude.
beta_0 = 2 * np.pi * 20e3  # rad/s
# Noiseless optimized controls.

graph = qctrl.create_graph()

# Use convenience functions to create the computational graph.
alpha, gamma = create_optimizable_controls(graph)
hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)
create_infidelity(graph, hamiltonian)

optimization_result = qctrl.functions.calculate_optimization(
    graph=graph,
    cost_node_name="infidelity",
    output_node_names=["$\\alpha$", "$\\gamma$"],
)

cost_noiseless = optimization_result.cost
print(f"Optimized noiseless cost: {optimization_result.cost:.3e}")

# Retrieve values of the PWC controls α(t) and γ(t).
_, alpha_values_noiseless, _ = qctrl.utils.pwc_pairs_to_arrays(
    optimization_result.output["$\\alpha$"]
)
_, gamma_values_noiseless, _ = qctrl.utils.pwc_pairs_to_arrays(
    optimization_result.output["$\\gamma$"]
)
Your task calculate_optimization (action_id="1242885") has completed.
Optimized noiseless cost: 2.220e-15
# Robust optimized controls.

graph = qctrl.create_graph()

# Use convenience functions to create the computational graph.
alpha, gamma = create_optimizable_controls(graph)
hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)

# This time passing a constant dephasing term with amplitude beta
# to `compute_infidelity`.
noise_operators = [beta_0 * graph.pauli_matrix("Z")]
create_infidelity(graph, hamiltonian, noise_operators)

optimization_result = qctrl.functions.calculate_optimization(
    graph=graph,
    cost_node_name="infidelity",
    output_node_names=["$\\alpha$", "$\\gamma$"],
)

cost_robust = optimization_result.cost
print(f"Optimized robust cost: {optimization_result.cost:.3e}")

# Retrieve values of the robust PWC controls α(t) and γ(t).
_, alpha_values_robust, _ = qctrl.utils.pwc_pairs_to_arrays(
    optimization_result.output["$\\alpha$"]
)
_, gamma_values_robust, _ = qctrl.utils.pwc_pairs_to_arrays(
    optimization_result.output["$\\gamma$"]
)
Your task calculate_optimization (action_id="1242886") has completed.
Optimized robust cost: 2.829e-13
# Run this number of simulations with different noise trajectories.
simulation_count = 100

# Create a new Boulder Opal graph.
graph = qctrl.create_graph()

# Create a batch of real PWC signals representing α(t) using the optimized values.
alpha_values = np.array([alpha_values_noiseless, alpha_values_robust])
alpha = graph.pwc_signal(values=alpha_values, duration=duration)

# Create a batch of complex PWC signals representing γ(t) using the optimized values.
gamma_values = np.array([gamma_values_noiseless, gamma_values_robust])
gamma = graph.pwc_signal(values=gamma_values, duration=duration)

# System Hamiltonian.
system_hamiltonian = create_noiseless_hamiltonian(graph, alpha, gamma)

# Noise Hamiltonian.
noise = graph.random_normal(
    shape=(simulation_count, 2, segment_count),
    mean=beta_0,
    standard_deviation=2e4,
    seed=0,
)
noise_pwc = graph.pwc_signal(noise, duration=duration, name="noise_pwc")
noise_hamiltonian = noise_pwc * graph.pauli_matrix("Z")

# Combine the system and the noise Hamiltonian to get the total Hamiltonian.
hamiltonian = system_hamiltonian + noise_hamiltonian

create_infidelity(graph, hamiltonian)

result = qctrl.functions.calculate_graph(graph=graph, output_node_names=["infidelity"])

infidelity = result.output["infidelity"]["value"]

print("Infidelity of pulse optimized without noise")
print(f"\tMean: {infidelity[:, 0].mean():.3}")
print(f"\tStd : {infidelity[:, 0].std():.3}")

print("\nInfidelity of pulse optimized with noise")
print(f"\tMean: {infidelity[:, 1].mean():.3}")
print(f"\tStd : {infidelity[:, 1].std():.3}")
Your task calculate_graph (action_id="1242887") has completed.
Infidelity of pulse optimized without noise
	Mean: 0.31
	Std : 0.0137

Infidelity of pulse optimized with noise
	Mean: 0.0047
	Std : 0.00202

This notebook was run using the following package versions. It should also be compatible with newer versions of the Q-CTRL Python package.

Package version
Python 3.9.10
numpy 1.21.5
scipy 1.7.3
qctrl 19.4.0
qctrlcommons 17.1.1
qctrltoolkit 1.8.0