qml.pulse

Pulse programming is used in a variety of quantum systems for low-level control of quantum operations. A time-dependent electromagnetic field tuned to the characteristic energies is applied, leading to a time-dependent Hamiltonian interaction \(H(t)\). Driving the system with such an electromagnetic field for a fixed time window is a pulse program. This pulse program can be tuned to implement the higher level gates used for quantum computation.

The pulse module provides functions and classes used to simulate pulse-level control of quantum systems.

It contains a ParametrizedHamiltonian and ParametrizedEvolution class for describing time-dependent Hamiltonian interactions. The pulse module also includes several convenience functions for defining pulses.

The pulse module is written for jax and will not work with other machine learning frameworks typically encountered in PennyLane. It requires separate installation, see jax.readthedocs.io.

For a demonstration of the basic pulse functionality in PennyLane and running a ctrl-VQE example, see our demo on differentiable pulse programming.

Overview

Time evolution classes

ParametrizedHamiltonian(coeffs, observables)

Callable object holding the information representing a parametrized Hamiltonian.

ParametrizedEvolution(H[, params, t, ...])

Parametrized evolution gate, created by passing a ParametrizedHamiltonian to the evolve() function

Convenience Functions

constant(scalar, time)

Returns the given scalar, for use in defining a ParametrizedHamiltonian with a trainable coefficient.

pwc(timespan)

Takes a time span and returns a callable for creating a function that is piece-wise constant in time.

pwc_from_function(timespan, num_bins)

Decorates a smooth function, creating a piece-wise constant function that approximates it.

rect(x[, windows])

Takes a scalar or a scalar-valued function, x, and applies a rectangular window to it, such that the returned function is x inside the window and 0 outside it.

Hardware Compatible Hamiltonians

rydberg_interaction(register[, wires, ...])

Returns a ParametrizedHamiltonian representing the interaction of an ensemble of Rydberg atoms due to the Rydberg blockade

rydberg_drive(amplitude, phase, detuning, wires)

Returns a ParametrizedHamiltonian representing the action of a driving laser field

transmon_interaction(qubit_freq, ...[, ...])

Returns a ParametrizedHamiltonian representing the circuit QED Hamiltonian of a superconducting transmon system.

transmon_drive(amplitude, phase, freq, wires)

Returns a ParametrizedHamiltonian representing the drive term of a transmon qubit.

Creating a parametrized Hamiltonian

The pulse module provides a framework to create a time-dependent Hamiltonian of the form

\[H(\{v_j\}, t) = H_\text{drift} + \sum_j f_j(v_j, t) H_j\]

with constant operators \(H_j\) and scalar functions \(f_j(v_j, t)\) that may depend on parameters \(p\) and time \(t\).

Defining a ParametrizedHamiltonian requires coefficients and operators, where some of the coefficients are callables. The callables defining the parametrized coefficients must have the call signature (p, t), where p can be a float, list or jnp.array. These functions should be defined using jax.numpy rather than numpy where relevant.

import pennylane as qml
from jax import numpy as jnp

# defining the coefficients fj(p, t) for the two parametrized terms
f1 = lambda p, t: p * jnp.sin(t) * (t - 1)
f2 = lambda p, t: p[0] * jnp.cos(p[1]* t ** 2)

# defining the operations for the three terms in the Hamiltonian
XX = qml.X(0) @ qml.X(1)
YY = qml.Y(0) @ qml.Y(1)
ZZ = qml.Z(0) @ qml.Z(1)

There are two ways to construct a ParametrizedHamiltonian from the coefficients and operators:

# Option 1
H1 =  2 * XX + f1 * YY + f2 * ZZ

# Option 2
coeffs = [2, f1, f2]
ops = [XX, YY, ZZ]
H2 =  qml.dot(coeffs, ops)

Warning

When initializing a ParametrizedHamiltonian via a list of parametrized coefficients, it is possible to create a list of multiple coefficients of the same form iteratively using lambda functions, i.e.

coeffs = [lambda p, t: p * t for _ in range(3)].

Be careful when defining coefficients using lambda functions within a list comprehension. Avoid doing coeffs = [lambda p, t: p * t**i for i in range(3)], which will only use the final index i=2 in the lambda and will thus behave as coeffs = [(lambda p, t: p * t**2)] * 3. Instead, use coeffs = [lambda p, t, power=i: p * t**power for i in range(3)]

The ParametrizedHamiltonian is a callable, and can return an Operator if passed a set of parameters and a time at which to evaluate the coefficients \(f_j\).

>>> H1
(
    2 * X(0) @ X(1)
  + <lambda>(params_0, t) * Y(0) @ Y(1)
  + <lambda>(params_1, t) * Z(0) @ Z(1)
)
>>> params = [1.2, [2.3, 3.4]]  # f1 takes a single parameter, f2 takes 2
>>> H1(params, t=0.5)
(
    2 * (X(0) @ X(1))
  + -0.2876553231625218 * (Y(0) @ Y(1))
  + 1.517961235535459 * (Z(0) @ Z(1))
)

When passing parameters, ensure that the order of the coefficient functions and the order of the parameters match.

When initializing a ParametrizedHamiltonian, terms defined with fixed coefficients have to come before parametrized terms to prevent discrepancy in the wire order.

Note

The ParametrizedHamiltonian must be Hermitian at all times. This is not explicitly checked; ensuring a correctly defined Hamiltonian is the responsibility of the user.

ParametrizedEvolution

During a pulse program spanning time \((t_0, t_1)\), the state evolves according to the time-dependent Schrodinger equation

\[\frac{\partial}{\partial t} |\psi\rangle = -i H(t) |\psi\rangle\]

realizing a unitary evolution \(U(t_0, t_1)\) of the input state, i.e.

\[|\psi(t_1)\rangle = U(t_0, t_1) |\psi(t_0)\rangle\]

A ParametrizedEvolution is this solution \(U(t_0, t_1)\) to the time-dependent Schrödinger equation for a ParametrizedHamiltonian.

The ParametrizedEvolution class uses a numerical ordinary differential equation solver (see jax.experimental.ode). It can be created using the evolve() function:

from jax import numpy as jnp

f1 = lambda p, t: p * jnp.sin(t) * (t - 1)
H = 2 * qml.X(0) + f1 * qml.Y(1)
ev = qml.evolve(H)
>>> ev
ParametrizedEvolution(wires=[0, 1])

The initial ParametrizedEvolution does not have parameters defined, and so will not have a matrix defined. To obtain an Operator with a matrix, we have to pass parameters and a time interval:

>>> ev([1.2], t=[0, 4]).matrix()
Array([[-0.14115842+0.j        ,  0.03528605+0.j        ,
         0.        -0.95982337j,  0.        +0.23993255j],
       [-0.03528605+0.j        , -0.14115842+0.j        ,
         0.        -0.23993255j,  0.        -0.95982337j],
       [ 0.        -0.95982337j,  0.        +0.23993255j,
        -0.14115842+0.j        ,  0.03528605+0.j        ],
       [ 0.        -0.23993255j,  0.        -0.95982337j,
        -0.03528605+0.j        , -0.14115842+0.j        ]],      dtype=complex64)

The parameters can be updated by calling the ParametrizedEvolution again with different inputs.

Additional options with regards to how the matrix is calculated can be passed to the ParametrizedEvolution along with the parameters, as keyword arguments:

>>> qml.evolve(H)(params=[1.2], t=[0, 4], atol=1e-6, mxstep=1)
ParametrizedEvolution(Array(1.2, dtype=float32, weak_type=True), wires=[0, 1])

The available keyword arguments can be found in in ParametrizedEvolution. If not specified, they will default to predetermined values.

Using qml.evolve in a QNode

The ParametrizedEvolution can be implemented in a QNode. We will evolve the following ParametrizedHamiltonian:

from jax import numpy as jnp

f1 = lambda p, t: jnp.sin(p * t)
H = f1 * qml.Y(0)

Now we can execute the evolution of this Hamiltonian in a QNode and compute its gradient:

import jax

jax.config.update("jax_enable_x64", True)

dev = qml.device("default.qubit", wires=1)

@jax.jit
@qml.qnode(dev, interface="jax")
def circuit(params):
    qml.evolve(H)(params, t=[0, 10])
    return qml.expval(qml.Z(0))
>>> params = [1.2]
>>> circuit(params)
Array(0.96632722, dtype=float64)
>>> jax.grad(circuit)(params)
[Array(2.35694829, dtype=float64)]

We can use the decorator jax.jit to compile this execution just-in-time. This means the first execution will typically take a little longer with the benefit that all following executions will be significantly faster. JIT-compiling is optional, and one can remove the decorator when only single executions are of interest. See the jax docs on jitting for more information.

Warning

To find the simultaneous evolution of the two operators, it is important that they are included in the same evolve(). For two non-commuting ParametrizedHamiltonian’s, applying qml.evolve(H1)(params, t=[0, 10]) followed by qml.evolve(H2)(params, t=[0, 10]) will not apply the two pulses simultaneously, despite the overlapping time window. Instead, they will be evolved over the same timespan, but without taking into account how the evolution of H1 affects H2.

See Usage Details of ParametrizedEvolution for a detailed example.