Implementation
Let's take a closer look at each step involved from defining a system to arriving at a numerical solution of the underlying time dynamics.
Hilbert spaces
The first step in treating a system with QuantumCumulants.jl is to specify the Hilbert space on which the system is defined. There are two types of Hilbert spaces implemented, namely FockSpace
and NLevelSpace
. The first describes systems whose operators follow the fundamental bosonic commutation relations (such as the quantum harmonic oscillator), whereas the latter describes systems consisting of a finite number of energy levels with an arbitrary energy difference in between (such as atoms).
A FockSpace
simply needs a name in order to be defined:
hf = FockSpace(:fock1)
NLevelSpace
requires a name as well as labels for the energy levels. For example
h_atom = NLevelSpace(:atom, (:g,:e))
defines an NLevelSpace
with the name :atom
and the two levels labeled by :g
and :e
, respectively. Note that the levels can be labeled by (almost) anything. For example, NLevelSpace(:two_level, (1,2))
would define a Hilbert space describing a system with the two discrete energy levels labeled by 1
and 2
. Specifically for numbers, there is also the short-hand method to write NLevelSpace(:five_level, 5)
which creates a system with levels 1:5
. Note that by default the first level in the list of all levels is designated as the ground state. This can be changed by specifying the ground state explicitly as a third argument to NLevelSpace
, e.g. NLevelSpace(:four_level, 4, 2)
would designate the state 2
as the ground state. The ground state projector will be eliminated during simplification (see below).
Composite systems are generally described by a ProductSpace
, i.e. a Hilbert space that consists of multiple subspaces. Each subspace is either a FockSpace
or an NLevelSpace
. They can be created using the tensor
function or the unicode symbol ⊗
[\otimes]. For example
h_prod1 = tensor(hf, h_atom)
h_prod2 = tensor(h_prod1, NLevelSpace(:three_level, 3))
h_prod3 = tensor(hf, h_atom, NLevelSpace(:three_level, 3)) # == h_prod2
creates two product spaces. The first, h_prod1
, consists of the previously defined FockSpace(:fock1)
and NLevelSpace(:atom, (:g,:e))
. The second one, h_prod2
, adds in another NLevelSpace(:three_level, 3)
. In principle arbitrarily many systems can be combined this way.
Operators (a.k.a. q-numbers)
Once the Hilbert space of the system has been defined, we can proceed by defining operators, or q-numbers, on them. They are the fundamental building blocks of symbolic expressions in QuantumCumulants.jl. Again, there are essentially two kinds of operators implemented: the quantum harmonic destruction operator Destroy
which acts on a FockSpace
, as well as a Transition
operator which describes a transition between any two levels on an NLevelSpace
. These operators can only be defined on the corresponding Hilbert spaces. Note that there is no intrinsic reason that prevents us from implementing more types of operators (see below), there was simply no need to do that so far.
Here are a few examples:
hf = FockSpace(:fock)
a = Destroy(hf, :a)
h_atom = NLevelSpace(:atom,(:g,:e))
σge = Transition(h_atom, :σ, :g, :e)
σ = Transition(h_atom, :σ)
@assert isequal(σge, σ(:g,:e)) # true
As you can see, the destruction operator Destroy
is created on a FockSpace
and given a name. The transition operator, however, additionally requires you to specify the levels between which it describes the transition. Defining a transition without levels specified creates a callable instance which needs to be called with valid level labels before one can actually use it in any algebraic expressions. Note that in Bra-Ket notation, the transition operator Transition(h, i, j)
is simply $|i\rangle \langle j|$. Also, the bosonic creation operator is simply given by the adjoint
of Destroy
.
These fundamental operators are all of subtypes of QSym
, and constitute the basic symbolic building blocks for the noncommutative algebra used in QuantumCumulants.jl. They can be combined using standard algebraic functions.
ex_fock = 0.1*a'*a
ex_trans = im*(σ(:g,:e) - σ(:e,:g))
Note that only operators that are defined on the same Hilbert space can be algebraically combined. The resulting expressions are stored as QTerm
types.
In composite systems, we also need to specify on which subsystem the respective operator acts. This information is important as operators acting on different subsystems commute with one another, but operators acting on the same one do not. When multiplying together operators in a composite systems, they are automatically ordered according to the order of Hilbert spaces. It's specified by an additional argument when creating operators.
h_prod = FockSpace(:fock1) ⊗ FockSpace(:fock2)
a = Destroy(h_prod,:a,1)
b = Destroy(h_prod,:b,2)
a*b # a*b
b*a # a*b
a'*b*a # a'*a*b
If a subspace occurs only once in a ProductSpace
, the choice on which an operator acts is unique and can therefore be omitted on construction.
h_prod = FockSpace(:fock1) ⊗ FockSpace(:fock2) ⊗ NLevelSpace(:atom,(:g,:e))
σ = Transition(h_prod, :σ) # no need to specify acts_on
For convenience, there is also a macro that can be used to construct operators:
h = FockSpace(:fock) ⊗ NLevelSpace(:two_level, 2)
@qnumbers a::Destroy(h) σ::Transition(h)
ex = a'*σ(1,2) + a*σ(2,1)
Symbolic parameters (a.k.a. c-numbers)
Commutative numbers (c-numbers) are represented by SymbolicUtils.Sym
from the SymbolicUtils.jl package and a custom subtype to Number
called CNumber
. They are generally assumed to be complex numbers and can be defined with the cnumbers
function or the corresponding macro @cnumbers
. You can use them together with q-numbers to build symbolic expressions describing the Hamiltonian, e.g.
h = FockSpace(:fock)
@cnumbers ω η
@qnumbers a::Destroy(h)
H = ω*a'*a + η*(a + a')
Real numbers (r-numbers) are similar to c-numbers, except that they are their own complex conjugate. They can be defined with the rnumbers
function or the corresponding macro @rnumbers
.
@rnumbers ω η
ω' # ω
exp(1im*η)*(exp(1im*η))' # 1
Operator expressions and commutation relations
The equations of motion of q-numbers are determined by evaluating commutators. This can be done by using fundamental commutation relations, which are immediately applied whenever operators are combined in an algebraic expression.
For the quantum harmonic oscillator destruction operator $a$, we have the canonical commutator
\[[a,a^\dagger] = 1.\]
Within the framework, we choose normal ordering, which surmounts to the rewriting rule
\[a a^\dagger ~\Rightarrow~ a^\dagger a +1.\]
For transition operators $\sigma^{ij}$ denoting a transition from level $j$ to level $i$, on the other hand, we have a rule for products,
\[\sigma^{ij}\sigma^{kl} ~\Rightarrow~ \delta_{jk}\sigma^{il},\]
which is implemented as rewriting rule just so. Additionally, we use the fact that in a system with levels $\{1,...,n\}$
\[\sum_{j=1}^n \sigma^{jj} = 1\]
in order to eliminate the projector on the ground state. This reduces the amount of equations required for each NLevelSpace
by 1. Note that, as mentioned before, the ground state is by default chosen to be the first (but this can be changed). Hence, the default rewriting rule to eliminate the ground-state projector is
\[\sigma^{11} ~\Rightarrow~ 1 - \sum_{j=2}^n \sigma^{jj}.\]
Any expression involving operators is stored as a QTerm
type. The expression trees are structured such that the application of commutation relations can be done efficiently. There are two concrete subtypes of QTerm
, namely QMul
representing a multiplication and QAdd
representing an addition. Methods of multiplication and addition are implemented such that QSym < QMul < QAdd
, i.e. a multiplication can only consist of numbers and fundamental operators (it cannot contain another multiplication or addition) and QAdd
is always at the highest level possibly containing numbers, QSym
s and QMul
s (but no other QAdd
s). This makes it easy and efficient to recurse through the expression tree and find pairs of operators that should be rewritten according to some commutation relation.
Note that only simplification using commutation relations is implemented directly in QuantumCumulants.jl. For any other simplification routines, operators are averaged (without applying a cumulant expansion) which makes them numbers. Those numbers are stored as expressions in SymbolicUtils.jl and simplified according to standard simplification rules. Afterwards, they can be converted back into QTerm
expressions.
Here's a short example:
h = FockSpace(:fock)
@qnumbers a::Destroy(h)
a*a' # returns a'*a + 1
In order to derive equations of motion, you need to specify a Hamiltonian and the operator (or a list of operators) of which you want to derive the Heisenberg equations and pass them to meanfield
, which stores both the operator as well as the average equations. In the end, we only want to work with averages.
@cnumbers ω η
H = ω*a'*a + η*(a + a') # Driven cavity Hamiltonian
me = meanfield([a, a'*a], H)
\begin{align} \frac{d}{dt} \langle a\rangle &= -1 i \eta -1 i \langle a\rangle \omega \\ \frac{d}{dt} \langle a^\dagger a\rangle &= 1 i \langle a\rangle \eta -1 i \langle a^\dagger\rangle \eta \end{align}
Cumulant expansion
Averaging (using average
) and the cumulant_expansion
are essential to convert the system of q-number equations to c-number equations. Averaging alone converts any operator product to a c-number, yet you will not arrive at a closed set of equations without truncating at a specific order. An average is stored as a symbolic expression. Specifically, the average of an operator op
is internally represented by SymbolicUtils.Term{AvgSym}(sym_average, [op])
.
The order of an average is given by the number of constituents in the product. For example
h = FockSpace(:fock)
@qnumbers a::Destroy(h)
avg1 = average(a)
get_order(avg1) # 1
avg2 = average(a'*a)
get_order(avg2) # 2
The cumulant expansion can then be used to express any average by averages up to a specified order (see also the theory section):
cumulant_expansion(avg2, 1)
average(a'*a, 1) # short-hand for cumulant_expansion(average(a'*a), 1)
When deriving the equations of motion using the meanfield
function, the cumulant_expansion
is immediately applied if you specify an order, e.g meafield(ops,H;order=2)
. Before you can actually solve the system of equations, you need to ensure that it is complete, i.e. there are no averages missing. This can be checked with find_missing
. Alternatively, you can automatically complete a system of equations using the complete
function which will internally use find_missing
to look for missing averages and derive equations for those.
Numerical solution
Finally, in order to actually solve a system of equations, we need to convert a set of equations to an ODESystem
, which represents a symbolic set of ordinary differential equations. ODESystem
s are part of the ModelingToolkit.jl framework, which allows for generating fast numerical functions that can be directly used in the OrdinaryDiffEq.jl package. On top of that, ModelingToolkit.jl also offers a variety of additional functionality, such as the symbolic computation of Jacobians for better stability and performance.
To obtain an ODESystem
from MeanfieldEquations
, you simply need to call the constructor:
using ModelingToolkit
@named sys = ODESystem(me)
Finally, to obtain a numerical solution we can construct an ODEProblem
and solve it.
using OrdinaryDiffEq
p0 = (ω => 1.0, η => 0.1)
u0 = zeros(ComplexF64, length(me))
prob = ODEProblem(sys,u0,(0.0,1.0),p0)
sol = solve(prob, RK4())
Now, the state of the system at each time-step is stored in sol.u
. To access one specific solution, you can simply type e.g. sol[average(a)]
to obtain the time evolution of the expectation value $\langle a \rangle$.
Calculating the initial state
When trying to solve a system of equations numerically, it can sometimes become tricky to find the correct initial state. In the above, we simply did u0 = zeros(ComplexF64, length(me))
, since that was a viable initial state. However, things become more involved when you have, say, a superposition of two coherent states in a harmonic oscillator as starting point,
\[|\psi_0\rangle = \frac{1}{\sqrt{2}} \left( |\alpha\rangle + |\beta\rangle \right),\]
where $\alpha, \beta \in \mathbb{C}$ are the respective complex amplitudes. While computing the first-order expectation values such as \langle \psi_0 | a |\psi_0\rangle
is still simple enough, things become more tricky in higher orders and when mixing in another Hilbert space (e.g. an atom in a cavity). Since the system of equations can become quite large, this may result in quite some manual effort when trying to calculate all initial values. And we hate manual effort.
These expectations values are, however, only difficult to calculate symbolically, yet are easy enough to compute numerically. QuantumCumulants therefore offers a convenient integration to QuantumOpticsBase.jl, which allows you to quickly calculate the initial expectation values of a system of equations from a given numerical initial state. The function is called initial_values
. For example, we could use it in the above example to compute a coherent initial states
using QuantumOpticsBase
b = FockBasis(10)
alpha = 0.3 + 0.4im
psi_0 = coherentstate(b, alpha)
u0 = initial_values(me, psi_0)
Note that you can also compute initial values for mixed states. You simply have to use a density operator in the function call.
u0 = initial_values(me, dm(psi_0))
Mapping levels for NLevelSpace
The conversion to a numeric representation between FockSpace
and FockBasis
is always uniquely defined. However, there is some freedom of choice when it comes to NLevelSpace
and the equivalent of NLevelBasis
, specifically when using symbolic levels. While it is clear that a symbolic Transition
operator should map to a numeric transition
, the choice of which level represents maps to which basis state in the NLevelBasis
is not fixed.
When using numeric level representations, the initial_values
and to_numeric
methods default to using the same numbered basis state:
using QuantumCumulants, QuantumOpticsBase
h = NLevelSpace(:TwoLevelAtom, (1, 2))
b = NLevelBasis(2)
s = Transition(h, :s, 1, 2)
@assert to_numeric(s, b) == transition(b, 1, 2)
The order here can be overridden using the level_map
keyword. When using symbolic levels, the level_map
keyword is required.
using QuantumCumulants, QuantumOpticsBase
h = NLevelSpace(:TwoLevelAtom, (:g, :e))
b = NLevelBasis(2)
s = Transition(h, :s, :g, :e)
level_map = Dict(:g => 1, :e => 2)
@assert to_numeric(s, b; level_map=level_map) == transition(b, 1, 2)
Numeric averages and conversion
While the examples so far were relatively simple and would have been easy to calculate by hand, things quickly become more difficult whenever product spaces and higher-order products are involved.
Behind the scenes, initial_values
just uses the numeric_average
method in order to compute the numeric expectation value for the given operators and states. This method in turn calls into the numeric conversion to_numeric
and then uses QuantumOpticsBase.expect
on the result in order to calculate the respective expectation values for the given state and operators numerically. Should you need to compute numerical averages from a symbolic one for a given numerical state you can also call numeric_average
directly.
using QuantumCumulants, QuantumOpticsBase
hfock = FockSpace(:cavity)
hnlevel = NLevelSpace(:ThreeLevelAtom, (:a, :b, :c))
h = hfock ⊗ hnlevel
a = Destroy(h, :a)
s = Transition(h, :s, :a, :c)
levelmap = Dict(
:a => 3,
:b => 2,
:c => 1,
)
bfock = FockBasis(10)
bnlevel = NLevelBasis(3)
psi = coherentstate(bfock, 0.3) ⊗ (nlevelstate(bnlevel, 1) + nlevelstate(bnlevel, 3)) / sqrt(2)
avg = average(a' * s)
avg_num = numeric_average(avg, psi; level_map=levelmap)
Similarly, you can also just obtain the numerical representation of an operator by directly calling to_numeric
and a given basis.
b = bfock ⊗ bnlevel
a_num = to_numeric(a, b)
Note that to_numeric
returns a SparseOperator
for single operators, but a LazyTensor
operator whenever a product space is involved. Lazy evaluation of tensor products is incredibly useful here, as symbolically easy to treat systems can become quite large numerically.
When a large number of Hilbert spaces is involved, it can even become tricky to store a single Ket
. In order to overcome this limitation, QuantumOpticsBase also offers lazy evaluation of state products, allowing you to compute expectation values and initial states for very large product states.
psi_lazy = LazyKet(b, (coherentstate(bfock, 0.3), (nlevelstate(bnlevel, 1) + nlevelstate(bnlevel, 3)) / sqrt(2)),)
avg_num_lazy = numeric_average(avg, psi_lazy; level_map=levelmap)
@assert isapprox(avg_num, avg_num_lazy)
The q-number interface
While there are currently only two different Hilbert spaces and two different types of fundamental operators implemented, their implementations are somewhat generic. This means that one can implement custom operator types along with some commutation relations for rewriting. The requirements for that are:
- Custom operator types need to be subtypes of
QSym
. Base.:*(::Operator1, ::Operator2)
: A multiplication method that rewrites according to a commutation relation has to be implemented.QuantumCumulants.ismergeable(::Operator1, ::Operator2) = true
is required so pairs ofOperator1
andOperator2
are detected in longer expressions and rewritten according to their commutation relation.- Optional: custom Hilbert space type matching the new operators.
Example: Harmonic oscillator quadratures
To illustrate, say we would like to implement the quantum harmonic oscillator in terms of the position operator $x$ and the momentum operator $p$ rather than the ladder operators. They fulfill the commutation relation
\[[x,p] = i\]
and we will use it to rewrite occurrences of $xp \Rightarrow px + i$. For simplicity, we will define them on a FockSpace
instead of defining a custom Hilbert space as well.
using QuantumCumulants
struct Position <: QSym
hilbert
name
aon
metadata
end
Position(hilbert, name, aon; metadata=QuantumCumulants.source_metadata(:Position, name)) =
Position(hilbert, name, aon, metadata)
struct Momentum <: QSym
hilbert
name
aon
metadata
end
Momentum(hilbert, name, aon; metadata=QuantumCumulants.source_metadata(:Momentum, name)) =
Momentum(hilbert, name, aon, metadata)
Note that any subtype to QSym
needs to have the four fields shown above, and the associated outer constructor. The outer constructor is needed for the interface to Symbolics.jl. More fields could be added, but the four shown here are always required. Now, for methods we simply need:
QuantumCumulants.ismergeable(::Position,::Momentum) = true
Base.:*(x::Position, p::Momentum) = im + p*x
Base.isequal(a::Position, b::Position) = isequal(a.hilbert, b.hilbert) && isequal(a.name, b.name) && isequal(a.aon, b.aon)
Base.isequal(a::Momentum, b::Momentum) = isequal(a.hilbert, b.hilbert) && isequal(a.name, b.name) && isequal(a.aon, b.aon)
The Base.isequal
methods do not compare metadata fields. Note that if your subtypes of QSym
have type parameters, you must also implement a method of Base.hash
such that isequal(x,y)
implies hash(x) == hash(y)
.
We can now use our new operator types in expressions and derive equations of motion for them.
h = FockSpace(:oscillator)
x = Position(h,:x,1)
p = Momentum(h,:p,1)
@cnumbers ω m
H = p^2/(2m) + 0.5m*ω^2*x^2
eqs = meanfield([x,p],H)
\begin{align} \frac{d}{dt} \langle x\rangle &= \frac{4 \langle p\rangle }{4 m} \\ \frac{d}{dt} \langle p\rangle &= -1.0 m \langle x\rangle \omega^{2} \end{align}