Setup

Setting up a quantum system

In order to use the Q-CTRL Python package to analyze and improve your quantum system, you must first translate your system into the language used by the Q-CTRL ecosystem. In this notebook we demonstrate the most common aspects of this translation process.

Imports and initialization

All usage of the Q-CTRL Python package begins by importing the qctrl package and starting a session.

# Essential imports
import numpy as np
from qctrl import Qctrl

# Plotting imports
import attr
import matplotlib.pyplot as plt
from qctrlvisualizer import plot_controls

# Starting a session with the API
qctrl = Qctrl()

Worked example: two-qubit system for a filter function calculation

To demonstrate the procedure, we present a detailed worked example. Specifically, we consider a two-qubit system represented by the following Hamiltonian:

\begin{align*} H(t) = & \frac{\nu_a}{2} \sigma_{z,a} + \frac{\nu_b}{2} \sigma_{z,b} + \frac{\Omega_a(t)}{2} \sigma_{-,a} + \frac{\Omega^*_a(t)}{2} \sigma_{+,a} + \frac{\Omega_b(t)}{2} \sigma_{-,b} + \frac{\Omega^*_b(t)}{2} \sigma_{+,b}\\ & + \frac{\Lambda(t)}{2} \sigma_{-,a} \sigma_{+,b} + \frac{\Lambda^*(t)}{2} \sigma_{+,a} \sigma_{-,b} + \frac{\Delta_a(t)}{2} \sigma_{z,a} + \frac{\Delta_b(t)}{2} \sigma_{z,b} + \eta_a(t) \sigma_{z,a} + \eta_b(t)\sigma_{z,b}, \end{align*}

where $\nu_k$ are the qubit detunings, $\Omega_k(t)$ are time-dependent Rabi rates (for example created by a microwave drive), $\Lambda(t)$ is a time-dependent coupling between the qubits, $\Delta_k(t)$ are time-dependent clock shifts, $\eta_k(t)$ are small, slowly-varying, stochastic dephasing noise processes and $\sigma_{\{+,-,z\}, k}$ are the Pauli matrices.

We consider custom pulses defined by the piecewise-constant functions:

\begin{align*} \Omega_a(t) &= \begin{cases} 2\pi\times 1.5e^{0.5\pi i}\times 10^{6} & 0\leq t\leq 2\times 10^{-6}\\ 0 & 2\times 10^{-6}\leq t\leq 4\times 10^{-6}\\ 2\pi\times 0.5e^{-0.5\pi i}\times 10^{6} & 4\times10^{-6}\leq t\leq 6\times 10^{-6} \end{cases}\\ \Omega_b(t) &= \begin{cases} 2\pi\times 1.0e^{0.25\pi i}\times 10^{6} & 0\leq t\leq 2\times 10^{-6}\\ 0 & 2\times 10^{-6}\leq t\leq 4\times 10^{-6}\\ 2\pi\times 0.5e^{1.25\pi i}\times 10^{6} & 4\times10^{-6}\leq t\leq 6\times 10^{-6} \end{cases}\\ \Lambda(t) &= \begin{cases} 0 & 0\leq t\leq 2\times 10^{-6}\\ 2\pi\times 1.0e^{\pi i}\times 10^{6} & 2\times 10^{-6}\leq t\leq 4\times 10^{-6}\\ 0 & 4\times10^{-6}\leq t\leq 6\times 10^{-6} \end{cases}\\ \Delta_a(t) &= \begin{cases} 2\pi\times 10.0\times 10^{6} & 0\leq t\leq 2\times 10^{-6}\\ 0 & 2\times 10^{-6}\leq t\leq 4\times 10^{-6}\\ 2\pi\times -10.0\times 10^{6} & 4\times10^{-6}\leq t\leq 6\times 10^{-6} \end{cases}\\ \Delta_b(t) &= \begin{cases} 2\pi\times -1.0\times 10^{6} & 0\leq t\leq 2\times 10^{-6}\\ 0 & 2\times 10^{-6}\leq t\leq 4\times 10^{-6}\\ 2\pi\times 1.5\times 10^{6} & 4\times10^{-6}\leq t\leq 6\times 10^{-6} \end{cases} \end{align*}

Such a setup would be useful for performing simulation or characterizing susceptibility to noise via filter functions. In this case we create Python objects suitable for calculating a filter function, but the same procedure applies to other computations too.

Interpreting a quantum Hamiltonian as a Q-CTRL system

For several functions in the Q-CTRL Python package, the first step in analyzing your system is to split the Hamiltonian of your quantum system into a sum of controls and noises (for the full mathematical details of this decomposition, see the reference documentation for the qctrl.functions.calculate_coherent_simulation function).

Following the Q-CTRL convention, the two-qubit Hamiltonian above is represented by six controls:

  • a drift control term with operator $\nu_a \sigma_{z,a}/2 + \nu_b \sigma_{z,b}/2$,
  • a drive control term with operator $\sigma_{-,a}/2$ and complex control $\Omega_a(t)$,
  • a drive control term with operator $\sigma_{-,b}/2$ and complex control $\Omega_b(t)$,
  • a drive control term with operator $\sigma_{-,a} \sigma_{+,b}/2$ and complex control $\Lambda(t)$,
  • a shift control term with operator $\sigma_{z,a}/2$ and real control $\Delta_a(t)$,
  • a shift control term with operator $\sigma_{z,b}/2$ and real control $\Delta_b(t)$,

and two noises:

  • a drift noise with operator $\sigma_{z,a}$,
  • a drift noise with operator $\sigma_{z,b}$.

With this decomposition in hand, we may proceed to set up the appropriate Python objects.

Creating the control pulses

Each drive and shift control term in your system must have an associated control pulse, which describes the piecewise-constant scalar-valued function of time that modulates the control operator. A piecewise-constant function of time is represented as a list of segment objects, each describing the duration and value of a constant segment of the function. The function is obtained by concatenating these segments in time. The qctrl.types.RealSegmentInput and qctrl.types.ComplexSegmentInput objects represent such segments for real and complex piecewise-constant functions of time, respectively.

# Define physical constants
segment_duration = 1 * 1e-6 #s

# Define control pulses
drive_A_control = [
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 1.5*np.exp(0.5j*np.pi) * 1e6,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=0,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 0.5*np.exp(-0.5*np.pi) * 1e6,
    ),
]

drive_B_control = [
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 1.0*np.exp(0.25j*np.pi) * 1e6,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=0,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 1.0*np.exp(1.25j*np.pi) * 1e6,
    ),
]

coupling_control = [
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=0,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 1.0*np.exp(1.0j*np.pi) * 1e6,
    ),
    qctrl.types.ComplexSegmentInput(
        duration=segment_duration,
        value=0,
    ),
]

clock_A_control = [
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 10.0 * 1e6,
    ),
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=0,
    ),
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=2*np.pi * -10.0 * 1e6,
    ),
]

clock_B_control = [
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=2*np.pi * -1.0 * 1e6,
    ),
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=0,
    ),
    qctrl.types.RealSegmentInput(
        duration=segment_duration,
        value=2*np.pi * 1.5 * 1e6,
    ),
]

Visualizing the control terms

The piecewise-constant control terms can be visualized using the Q-CTRL Python Visualizer package, as shown below. Also note that the Python objects can be conveniently converted into dictionaries using the attr.asdict function.

plot_controls(
    plt.figure(),
    {
        "$\Omega_a(t)$": map(attr.asdict, drive_A_control),
        "$\Delta_a(t)$": map(attr.asdict, clock_A_control),
    },
)

Creating the control terms

Each control term in your quantum system is represented as a Python object specific to the type of computation you are performing. In the case of filter functions, the relevant objects are qctrl.types.filter_function.Drive, qctrl.types.filter_function.Shift, and qctrl.types.filter_function.Drift. These objects wrap an operator and, in the case of drives and shifts, a control.

# Define standard matrices
identity = np.array([[1., 0.],[0., 1.]], dtype=np.complex)
sigma_z = np.array([[1., 0.],[0., -1.]], dtype=np.complex)
sigma_m = np.array([[0., 1.],[0., 0.]], dtype=np.complex)

# Define physical constants
nu_a = 2*np.pi * 0.5 * 1e6 #Hz
nu_b = 2*np.pi * 0.5 * 1e6 #Hz

# Define control objects
drift = qctrl.types.filter_function.Drift(
    operator=nu_a*np.kron(sigma_z,identity)/2 + nu_b*np.kron(sigma_z,identity)/2,
)

drive_A = qctrl.types.filter_function.Drive(
    operator=np.kron(sigma_m,identity)/2,
    control=drive_A_control,
)

drive_B = qctrl.types.filter_function.Drive(
    operator=np.kron(identity,sigma_m)/2,
    control=drive_B_control,
)

coupling = qctrl.types.filter_function.Drive(
    operator=np.kron(sigma_m,sigma_m.T)/2,
    control=coupling_control,
)

clock_A = qctrl.types.filter_function.Shift(
    operator=np.kron(sigma_z,identity)/2,
    control=clock_A_control,
)

clock_B = qctrl.types.filter_function.Shift(
    operator=np.kron(identity,sigma_z)/2,
    control=clock_B_control,
)

Creating the noises

Each noise in your quantum system is represented as a drive, shift, or drift object with the noise field set. In this example we have only drift noises, which are created as new terms in addition to the control terms defined above. In other cases you might need to consider control noises, which are represented by setting the noise field of the control terms.

The data in the noise field depends on the specific computation you are performing. In the case of filter functions, the noise is simply a boolean indicating that the term is perturbed by noise.

dephasing_A = qctrl.types.filter_function.Drift(
    operator=np.kron(sigma_z,identity),
    noise=True,
)

dephasing_B = qctrl.types.filter_function.Drift(
    operator=np.kron(identity,sigma_z),
    noise=True,
)

Calling a function

With the Python objects representing the input data set up, you can perform computations using the functions in the Q-CTRL Python package. In this case we show how to call the qctrl.functions.calculate_filter_function function.

filter_function_result = qctrl.functions.calculate_filter_function(
    duration=3 * 1e-6,
    frequencies=np.linspace(0, 1e6, 100),
    drives=[drive_A, drive_B, coupling],
    shifts=[clock_A, clock_B],
    drifts=[drift, dephasing_A],
)
100%|██████████| 100/100 [00:02<00:00, 34.30it/s]

Extracting results

Each function in the Q-CTRL Python package returns a standard Python object containing the computed data. Below we show a simple example of how to extract such data.

print(filter_function_result.samples[0])
Sample(frequency=0.0, inverse_power=6.960984281704791e-12, inverse_power_uncertainty=1.3954036619251473e-13)

Extracting metadata

The functions from the Q-CTRL Python package also return an action object that contains metadata about its execution. Currently the main useful piece of metadata is the status value, which gives information about how the function terminated. More metadata will be populated in the action object in a future release.

print(filter_function_result.action.status)
SUCCESS

Summary

We have shown the general procedure for setting up Python objects representing a quantum system, passing these objects to a function in the Q-CTRL Python package, and extracting data from the result. The other user guides explain the specific inputs and outputs of each function in more detail. You can also visit the reference documentation to see the full details of all functions and types in the Q-CTRL Python package.

Example: one-qubit system for a simulation

Next we present a full example of a one-qubit system set up for simulation. We consider the Hamiltonian:

\begin{align*} H(t) = & \frac{\nu}{2} \sigma_z + \frac{\Omega(t)}{2} \sigma_- + \frac{\Omega^*(t)}{2} \sigma_+ + \frac{\Delta(t)}{2} \sigma_z, \end{align*}

where $\nu$ is the qubit detuning, $\Omega(t)$ is a time-dependent Rabi rate, $\Delta(t)$ is a time-dependent clock shift, and $\sigma_k$ are the Pauli matrices.

In this case we use primitive control pulses $\Omega(t) = 2\pi\times 1.0e^{0.5\pi i} \times 10^6$ and $\Delta(t) = 2\pi\times 0.2\times 10^6$ (for $0\leq t\leq 2\times 10^{-6}$).

Below we show how to set up the controls and noises for a simulation of this system via the qctrl.functions.coherent simulation function. In this example we also show how to create dictionaries instead of class instances for the control pulses. Any inputs to callables in the qctrl.functions or qctrl.types namespaces can be provided as either classes or dictionaries; while using classes is safer and can benefit from autocompletion functionality, using dictionaries can lead to easier syntax in simple situations.

# Define physical constants
nu = 2*np.pi * 0.5 * 1e6 #Hz
duration = 2*10e-6 #s

# Define control pulses 
drive_control = [
    {
        'duration': duration,
        'value': 2*np.pi * 1.0*np.exp(0.5j*np.pi) * 1e6,
    },
]

shift_control = [
    {
        'duration': duration,
        'value': 2*np.pi * 0.2 * 1e6,
    },
]

# Define control objects
drift = qctrl.types.coherent_simulation.Drift(
    operator=nu*sigma_z/2,
)

drive = qctrl.types.coherent_simulation.Drive(
    operator=sigma_m/2,
    control=drive_control,
)

clock = qctrl.types.coherent_simulation.Shift(
    operator=sigma_z/2,
    control=shift_control,
)

# Call the simulation function
simulation_result = qctrl.functions.calculate_coherent_simulation(
    duration=duration,
    drives=[drive],
    shifts=[clock],
    drifts=[drift],
)

# Extract data from the result
print(simulation_result.samples[-1].evolution_operator)
100%|██████████| 100/100 [00:02<00:00, 34.75it/s]
[[ 0.26959181-5.52229725e-01j  0.78889961+1.87892933e-18j]
 [-0.78889961+1.12516347e-18j  0.26959181+5.52229725e-01j]]