Time-dependent operators
This tutorial explains how to define time-dependent Hamiltonians – and more generally time-dependent operators – in Dynamiqs. There are currently four supported formats: constant operator, piecewise constant operator, constant operator modulated by a time-dependent factor, or arbitrary time-dependent operator defined by a function.
import dynamiqs as dq
import jax.numpy as jnp
The TimeArray
type
In Dynamiqs, time-dependent operators are defined with TimeArray
objects. These objects can be called at arbitrary times, and return the corresponding array at that time. For example to define the Hamiltonian
$$
H_x(t)=\cos(2\pi t)\sigma_x
$$
>>> f = lambda t: jnp.cos(2.0 * jnp.pi * t)
>>> Hx = dq.modulated(f, dq.sigmax()) # initialize a modulated time-array
>>> Hx(1.0)
Array([[0.+0.j, 1.+0.j],
[1.+0.j, 0.+0.j]], dtype=complex64)
>>> Hx.shape
(2, 2)
Time-arrays support common arithmetic operations with scalars, regular arrays and other time-array objects. For example to define the Hamiltonian $$ H(t) = \sigma_z + 2 H_x(t) - \sin(\pi t) \sigma_y $$
>>> g = lambda t: jnp.sin(jnp.pi * t)
>>> Hy = dq.modulated(g, dq.sigmay())
>>> H = dq.sigmaz() + 2 * Hx - Hy
>>> H(1.0)
Array([[ 1.+0.j, 2.-0.j],
[ 2.+0.j, -1.+0.j]], dtype=complex64)
Finally, time-arrays also support common utility functions, such as .conj()
, or .reshape()
. More details can be found in the TimeArray
API page.
Defining a TimeArray
Constant operators
A constant operator is defined by
$$
O(t) = O_0
$$
for any time \(t\), where \(O_0\) is an arbitrary operator. The most practical way to define constant operators is using array-like objects. They can also be instantiated as TimeArray
instances using the dq.constant()
function. For instance, to define the Pauli operator \(H = \sigma_z\), you can use any of the following syntaxes:
import dynamiqs as dq
H = dq.sigmaz()
import numpy as np
H = np.array([[1, 0], [0, -1]])
import jax.numpy as jnp
H = jnp.array([[1, 0], [0, -1]])
import qutip as qt
H = qt.sigmaz()
H = [[1, 0], [0, -1]]
Note
Common operators are available as utility functions, see the list of available operators in the Python API.
Piecewise constant operators
A piecewise constant (PWC) operator takes constant values over some time intervals. It is defined by $$ O(t) = \left(\sum_{k=0}^{N-1} c_k\; \Omega_{[t_k, t_{k+1}[}(t)\right) O_0 $$ where \(c_k\) are constant values, \(\Omega_{[t_k, t_{k+1}[}\) is the rectangular window function defined by \(\Omega_{[t_a, t_b[}(t) = 1\) if \(t \in [t_a, t_b[\) and \(\Omega_{[t_a, t_b[}(t) = 0\) otherwise, and \(O_0\) is a constant operator.
In Dynamiqs, PWC operators are defined by three array-like objects:
times
: the time points \((t_0, \ldots, t_N)\) defining the boundaries of the time intervals, of shape (N+1,),values
: the constant values \((c_0, \ldots, c_{N-1})\) for each time interval, of shape (..., N),array
: the array defining the constant operator \(O_0\), of shape (n, n).
To construct a PWC operator, these three arguments must be passed to the dq.pwc()
function, which returns a TimeArray
object. When called at some time \(t\), this object then returns an array with shape (..., n, n). For example, let us define a PWC operator \(H(t)\) with constant value \(3\sigma_z\) for \(t\in[0, 1[\) and \(-2\sigma_z\) for \(t\in[1, 2[\):
>>> times = [0.0, 1.0, 2.0]
>>> values = [3.0, -2.0]
>>> array = dq.sigmaz()
>>> H = dq.pwc(times, values, array)
>>> H
PWCTimeArray(shape=(2, 2), dtype=complex64)
The returned object can be called at different times:
>>> H(-1.0)
Array([[ 0.+0.j, 0.+0.j],
[ 0.+0.j, -0.+0.j]], dtype=complex64)
>>> H(0.0)
Array([[ 3.+0.j, 0.+0.j],
[ 0.+0.j, -3.+0.j]], dtype=complex64)
>>> H(0.5)
Array([[ 3.+0.j, 0.+0.j],
[ 0.+0.j, -3.+0.j]], dtype=complex64)
>>> H(1.0)
Array([[-2.+0.j, -0.+0.j],
[-0.+0.j, 2.-0.j]], dtype=complex64)
>>> H(1.5)
Array([[-2.+0.j, -0.+0.j],
[-0.+0.j, 2.-0.j]], dtype=complex64)
>>> H(2.0)
Array([[ 0.+0.j, 0.+0.j],
[ 0.+0.j, -0.+0.j]], dtype=complex64)
Note
The argument times
must be sorted in ascending order, but does not need to be evenly spaced. When calling the resulting time-array object at time \(t\), the returned array is the operator \(c_k\ O_0\) corresponding to the interval \([t_k, t_{k+1}[\) in which the time \(t\) falls. If \(t\) does not belong to any time intervals, the returned array is null.
Batching PWC operators
The batching of the returned time-array is specified by values
. For example, to define a PWC operator batched over a parameter \(\theta\):
>>> thetas = jnp.linspace(0, 1.0, 11) # (11,)
>>> times = [0.0, 1.0, 2.0]
>>> values = thetas[:, None] * jnp.array([3.0, -2.0]) # (11, 2)
>>> array = dq.sigmaz()
>>> H = dq.pwc(times, values, array)
>>> H.shape
(11, 2, 2)
Modulated operators
A modulated operator is defined by $$ O(t) = f(t) O_0 $$ where \(f(t)\) is an time-dependent scalar. In Dynamiqs, modulated operators are defined by:
f
: a Python function with signaturef(t: float) -> Scalar | Array
that returns the modulating factor \(f(t)\) for any time \(t\), as a scalar or an array of shape (...),array
: the array defining the constant operator \(O_0\), of shape (n, n).
To construct a modulated operator, these two arguments must be passed to the dq.modulated()
function, which returns a TimeArray
object. When called at some time \(t\), this object then returns an array with shape (..., n, n). For example, let us define the modulated operator \(H(t)=\cos(2\pi t)\sigma_x\):
>>> f = lambda t: jnp.cos(2.0 * jnp.pi * t)
>>> H = dq.modulated(f, dq.sigmax())
>>> H
ModulatedTimeArray(shape=(2, 2), dtype=complex64)
The returned object can be called at different times:
>>> H(0.5)
Array([[-0.+0.j, -1.+0.j],
[-1.+0.j, -0.+0.j]], dtype=complex64)
>>> H(1.0)
Array([[0.+0.j, 1.+0.j],
[1.+0.j, 0.+0.j]], dtype=complex64)
Batching modulated operators
The batching of the returned time-array is specified by the array returned by f
. For example, to define a modulated Hamiltonian \(H(t)=\cos(\omega t)\sigma_x\) batched over the parameter \(\omega\):
>>> omegas = jnp.linspace(0.0, 1.0, 11) # (11,)
>>> f = lambda t: jnp.cos(omegas * t)
>>> H = dq.modulated(f, dq.sigmax())
>>> H.shape
(11, 2, 2)
Function with additional arguments
To define a modulated operator with a function that takes arguments other than time (extra *args
and **kwargs
), you can use functools.partial()
. For example:
>>> import functools
>>> def pulse(t, omega, amplitude=1.0):
... return amplitude * jnp.cos(omega * t)
>>> # create function with correct signature (t: float) -> Array
>>> f = functools.partial(pulse, omega=1.0, amplitude=5.0)
>>> H = dq.modulated(f, dq.sigmax())
Discontinuous function
If there is a discontinuous jump in the function values, you should use the optional
argument discontinuity_ts
to enforce adaptive step size solvers to stop at these
times (i.e., right before, and right after the jump).
Arbitrary time-dependent operators
An arbitrary time-dependent operator is defined by $$ O(t) = f(t) $$ where \(f(t)\) is a time-dependent operator. In Dynamiqs, arbitrary time-dependent operators are defined by:
f
: a Python function with signaturef(t: float) -> Array
that returns the operator \(f(t)\) for any time \(t\), as an array of shape (..., n, n).
To construct an arbitrary time-dependent operator, pass this argument to the dq.timecallable()
function, which returns a TimeArray
object. This object then returns an array with shape (..., n, n) when called at any time \(t\).
For example, let us define the arbitrary time-dependent operator \(H(t)=\begin{pmatrix}t & 0\\0 & 1 - t\end{pmatrix}\):
>>> f = lambda t: jnp.array([[t, 0], [0, 1 - t]])
>>> H = dq.timecallable(f)
>>> H
CallableTimeArray(shape=(2, 2), dtype=float32)
The returned object can be called at different times:
>>> H(0.5)
Array([[0.5, 0. ],
[0. , 0.5]], dtype=float32)
>>> H(1.0)
Array([[1., 0.],
[0., 0.]], dtype=float32)
The function f
must return a JAX array (not an array-like object!)
An error is raised if the function f
does not return a JAX array. This error concerns any other array-like objects. This is enforced to avoid costly conversions at every time step of the numerical integration.
Batching arbitrary time-dependent operators
The batching of the returned time-array is specified by the array returned by f
. For example, to define an arbitrary time-dependent operator batched over a parameter \(\theta\):
>>> thetas = jnp.linspace(0, 1.0, 11) # (11,)
>>> f = lambda t: thetas[:, None, None] * jnp.array([[t, 0], [0, 1 - t]])
>>> H = dq.timecallable(f)
>>> H.shape
(11, 2, 2)
Function with additional arguments
To define an arbitrary time-dependent operator with a function that takes arguments other than time (extra *args
and **kwargs
), you can use functools.partial()
. For example:
>>> import functools
>>> def func(t, a, amplitude=1.0):
... return amplitude * jnp.array([[t, a], [a, 1 - t]])
>>> # create function with correct signature (t: float) -> Array
>>> f = functools.partial(func, a=1.0, amplitude=5.0)
>>> H = dq.timecallable(f)
Discontinuous function
If there is a discontinuous jump in the function values, you should use the optional
argument discontinuity_ts
to enforce adaptive step size solvers to stop at these
times (i.e., right before, and right after the jump).