The HEOM method solves the dynamics and steady state of a system and its environment, the latter of which is encoded in a set of auxiliary density matrices.
In this example we show the evolution of a single two-level system in contact with a single Bosonic environment. The properties of the system are encoded in a Hamiltonian, and a coupling operator which describes how it is coupled to the environment.
The Bosonic environment is implicitly assumed to obey a particular Hamiltonian (see paper), the parameters of which are encoded in the spectral density, and subsequently the free-bath correlation functions.
In the example below we show how to model the overdamped Drude-Lorentz Spectral Density, commonly used with the HEOM. We show how to do this using the Matsubara, Pade and fitting decompositions, and compare their convergence.
The Drude-Lorentz spectral density is:
$$J_D(\omega)= \frac{2\omega\lambda\gamma}{{\gamma}^2 + \omega^2}$$where $\lambda$ scales the coupling strength, and $\gamma$ is the cut-off frequency. We use the convention, \begin{equation*} C(t) = \int_0^{\infty} d\omega \frac{J_D(\omega)}{\pi}[\coth(\beta\omega) \ \cos(\omega \tau) - i \sin(\omega \tau)] \end{equation*}
With the HEOM we must use an exponential decomposition:
\begin{equation*} C(t)=\sum_{k=0}^{k=\infty} c_k e^{-\nu_k t} \end{equation*}As an example, the Matsubara decomposition of the Drude-Lorentz spectral density is given by:
\begin{equation*} \nu_k = \begin{cases} \gamma & k = 0\\ {2 \pi k} / {\beta } & k \geq 1\\ \end{cases} \end{equation*}\begin{equation*} c_k = \begin{cases} \lambda \gamma (\cot(\beta \gamma / 2) - i) \ & k = 0\\ 4 \lambda \gamma \nu_k / \{(nu_k^2 - \gamma^2)\beta \} \ & k \geq 1\\ \end{cases} \end{equation*}Note that in the above, and the following, we set $\hbar = k_\mathrm{B} = 1$.
import contextlib
import time
import numpy as np
from matplotlib import pyplot as plt
from scipy.optimize import curve_fit
import qutip
from qutip import (
Options,
basis,
brmesolve,
destroy,
expect,
liouvillian,
qeye,
sigmax,
sigmaz,
spost,
spre,
tensor,
)
from qutip.nonmarkov.heom import (
BosonicBath,
DrudeLorentzBath,
DrudeLorentzPadeBath,
HEOMSolver,
HSolverDL,
)
%matplotlib inline
Let's define some helper functions for calculating correlation function expansions, plotting results and timing how long operations take:
def cot(x):
"""Vectorized cotangent of x."""
return 1.0 / np.tan(x)
def dl_matsubara_params(lam, gamma, T, nk):
"""Calculation of the real and imaginary expansions of the Drude-Lorenz
correlation functions.
"""
ckAR = [lam * gamma * cot(gamma / (2 * T))]
ckAR.extend(
4
* lam
* gamma
* T
* 2
* np.pi
* k
* T
/ ((2 * np.pi * k * T) ** 2 - gamma**2)
for k in range(1, nk + 1)
)
vkAR = [gamma]
vkAR.extend(2 * np.pi * k * T for k in range(1, nk + 1))
ckAI = [lam * gamma * (-1.0)]
vkAI = [gamma]
return ckAR, vkAR, ckAI, vkAI
def dl_corr_approx(t, nk):
"""Drude-Lorenz correlation function approximation.
Approximates the correlation function at each time t to nk exponents.
"""
c = lam * gamma * (-1.0j + cot(gamma / (2 * T))) * np.exp(-gamma * t)
for k in range(1, nk):
vk = 2 * np.pi * k * T
c += (4 * lam * gamma * T * vk / (vk**2 - gamma**2)) * np.exp(
-vk * t
)
return c
def plot_result_expectations(plots, axes=None):
"""Plot the expectation values of operators as functions of time.
Each plot in plots consists of (solver_result, measurement_operation,
color, label).
"""
if axes is None:
fig, axes = plt.subplots(1, 1, sharex=True, figsize=(8, 8))
fig_created = True
else:
fig = None
fig_created = False
# add kw arguments to each plot if missing
plots = [p if len(p) == 5 else p + ({},) for p in plots]
for result, m_op, color, label, kw in plots:
exp = np.real(expect(result.states, m_op))
kw.setdefault("linewidth", 2)
axes.plot(result.times, exp, color, label=label, **kw)
if fig_created:
axes.legend(loc=0, fontsize=12)
axes.set_xlabel("t", fontsize=28)
return fig
@contextlib.contextmanager
def timer(label):
"""Simple utility for timing functions:
with timer("name"):
... code to time ...
"""
start = time.time()
yield
end = time.time()
print(f"{label}: {end - start}")
And let us set up the system Hamiltonian, bath and system measurement operators:
# Defining the system Hamiltonian
eps = 0.5 # Energy of the 2-level system.
Del = 1.0 # Tunnelling term
Hsys = 0.5 * eps * sigmaz() + 0.5 * Del * sigmax()
# Initial state of the system.
rho0 = basis(2, 0) * basis(2, 0).dag()
# System-bath coupling (Drude-Lorentz spectral density)
Q = sigmaz() # coupling operator
# Bath properties:
gamma = 0.5 # cut off frequency
lam = 0.1 # coupling strength
T = 0.5
beta = 1.0 / T
# HEOM parameters
NC = 5 # cut off parameter for the bath
Nk = 2 # terms in the Matsubara expansion of the correlation function
# Times to solve for
tlist = np.linspace(0, 50, 1000)
# Define some operators with which we will measure the system
# 1,1 element of density matrix - corresonding to groundstate
P11p = basis(2, 0) * basis(2, 0).dag()
P22p = basis(2, 1) * basis(2, 1).dag()
# 1,2 element of density matrix - corresonding to coherence
P12p = basis(2, 0) * basis(2, 1).dag()
Now we are ready to begin. Let's look at the shape of the spectral density given the bath parameters we defined above:
def plot_spectral_density():
"""Plot the Drude-Lorentz spectral density"""
w = np.linspace(0, 5, 1000)
J = w * 2 * lam * gamma / (gamma**2 + w**2)
fig, axes = plt.subplots(1, 1, sharex=True, figsize=(8, 8))
axes.plot(w, J, "r", linewidth=2)
axes.set_xlabel(r"$\omega$", fontsize=28)
axes.set_ylabel(r"J", fontsize=28)
plot_spectral_density()
Next we calculate the exponents using the Matsubara decompositions. Here we split them into real and imaginary parts.
The HEOM code will optimize these, and reduce the number of exponents when real and imaginary parts have the same exponent. This is clearly the case for the first term in the vkAI and vkAR lists.
ckAR, vkAR, ckAI, vkAI = dl_matsubara_params(nk=Nk, lam=lam, gamma=gamma, T=T)
Having created the lists which specify the bath correlation functions, we
create a BosonicBath
from them and pass the bath to the HEOMSolver
class.
The solver constructs the "right hand side" (RHS) determinining how the system and auxiliary density operators evolve in time. This can then be used to solve for dynamics or steady-state.
Below we create the bath and solver and then solve for the dynamics by
calling .run(rho0, tlist)
.
options = Options(nsteps=15000, store_states=True, rtol=1e-14, atol=1e-14)
with timer("RHS construction time"):
bath = BosonicBath(Q, ckAR, vkAR, ckAI, vkAI)
HEOMMats = HEOMSolver(Hsys, bath, NC, options=options)
with timer("ODE solver time"):
resultMats = HEOMMats.run(rho0, tlist)
RHS construction time: 0.0194089412689209 ODE solver time: 0.4119691848754883
plot_result_expectations(
[
(resultMats, P11p, "b", "P11 Mats"),
(resultMats, P12p, "r", "P12 Mats"),
]
);
In practice, one would not perform this laborious expansion for the
Drude-Lorentz correlation function, because QuTiP already has a class,
DrudeLorentzBath
, that can construct this bath for you. Nevertheless,
knowing how to perform this expansion will allow you to construct your own
baths for other spectral densities.
Below we show how to use this built-in functionality:
# Compare to built-in Drude-Lorentz bath:
with timer("RHS construction time"):
bath = DrudeLorentzBath(Q, lam=lam, gamma=gamma, T=T, Nk=Nk)
HEOM_dlbath = HEOMSolver(Hsys, bath, NC, options=options)
with timer("ODE solver time"):
result_dlbath = HEOM_dlbath.run(rho0, tlist) # normal 115
RHS construction time: 0.01860356330871582 ODE solver time: 0.3935091495513916
plot_result_expectations(
[
(result_dlbath, P11p, "b", "P11 (DrudeLorentzBath)"),
(result_dlbath, P12p, "r", "P12 (DrudeLorentzBath)"),
]
);
We also provide a legacy class, HSolverDL
, which calculates the
Drude-Lorentz correlation functions automatically, to be backwards
compatible with the previous HEOM solver in QuTiP:
# Compare to legacy class:
# The legacy class performs the above collation of coefficients automatically,
# based upon the parameters for the Drude-Lorentz spectral density.
with timer("RHS construction time"):
HEOMlegacy = HSolverDL(Hsys, Q, lam, T, NC, Nk, gamma, options=options)
with timer("ODE solver time"):
resultLegacy = HEOMlegacy.run(rho0, tlist) # normal 115
RHS construction time: 0.008342504501342773 ODE solver time: 0.33513593673706055
plot_result_expectations(
[
(resultLegacy, P11p, "b", "P11 Legacy"),
(resultLegacy, P12p, "r", "P12 Legacy"),
]
);
To speed up convergence (in terms of the number of exponents kept in the Matsubara decomposition), We can treat the $Re[C(t=0)]$ component as a delta-function distribution, and include it as Lindblad correction. This is sometimes known as the Ishizaki-Tanimura Terminator.
In more detail, given
\begin{equation*} C(t)=\sum_{k=0}^{\infty} c_k e^{-\nu_k t} \end{equation*}since $\nu_k=\frac{2 \pi k}{\beta }$, if $1/\nu_k$ is much much smaller than other important time-scales, we can approximate, $ e^{-\nu_k t} \approx \delta(t)/\nu_k$, and $C(t)=\sum_{k=N_k}^{\infty} \frac{c_k}{\nu_k} \delta(t)$
It is convenient to calculate the whole sum $ C(t)=\sum_{k=0}^{\infty} \frac{c_k}{\nu_k} = 2 \lambda / (\beta \gamma)
, and subtract off the contribution from the finite number of Matsubara terms that are kept in the hierarchy, and treat the residual as a Lindblad.
This is clearer if we plot the correlation function with a large number of Matsubara terms:
def plot_correlation_expansion_divergence():
"""We plot the correlation function with a large number of Matsubara terms
to show that the real part is slowly diverging at t = 0.
"""
t = np.linspace(0, 2, 100)
# correlation coefficients with 15k and 2 terms
corr_15k = dl_corr_approx(t, 15_000)
corr_2 = dl_corr_approx(t, 2)
fig, ax1 = plt.subplots(figsize=(12, 7))
ax1.plot(
t, np.real(corr_2), color="b", linewidth=3, label=r"Mats = 2 real"
)
ax1.plot(
t, np.imag(corr_2), color="r", linewidth=3, label=r"Mats = 2 imag"
)
ax1.plot(
t, np.real(corr_15k), "b--", linewidth=3, label=r"Mats = 15000 real"
)
ax1.plot(
t, np.imag(corr_15k), "r--", linewidth=3, label=r"Mats = 15000 imag"
)
ax1.set_xlabel("t")
ax1.set_ylabel(r"$C$")
ax1.legend()
plot_correlation_expansion_divergence();
Let us evaluate the result including this Ishizaki-Tanimura terminator:
# Run HEOM solver and include the Ishizaki-Tanimura terminator
# Notes:
#
# * when using the built-in DrudeLorentzBath, the terminator (L_bnd) is
# available from bath.terminator().
#
# * in the legacy HSolverDL function the terminator is included automatically
# if the parameter bnd_cut_approx=True is used.
op = -2 * spre(Q) * spost(Q.dag()) + spre(Q.dag() * Q) + spost(Q.dag() * Q)
approx_factr = (2 * lam / (beta * gamma)) - 1j * lam
approx_factr -= lam * gamma * (-1.0j + cot(gamma / (2 * T))) / gamma
for k in range(1, Nk + 1):
vk = 2 * np.pi * k * T
approx_factr -= (4 * lam * gamma * T * vk / (vk**2 - gamma**2)) / vk
L_bnd = -approx_factr * op
Ltot = -1.0j * (spre(Hsys) - spost(Hsys)) + L_bnd
Ltot = liouvillian(Hsys) + L_bnd
options = Options(nsteps=15000, store_states=True, rtol=1e-14, atol=1e-14)
with timer("RHS construction time"):
bath = BosonicBath(Q, ckAR, vkAR, ckAI, vkAI)
HEOMMatsT = HEOMSolver(Ltot, bath, NC, options=options)
with timer("ODE solver time"):
resultMatsT = HEOMMatsT.run(rho0, tlist)
RHS construction time: 0.016932249069213867 ODE solver time: 0.43618273735046387
plot_result_expectations(
[
(resultMatsT, P11p, "b", "P11 Mats + Term"),
(resultMatsT, P12p, "r", "P12 Mats + Term"),
]
);
Or using the built-in Drude-Lorentz bath we can write simply:
options = Options(nsteps=15000, store_states=True, rtol=1e-14, atol=1e-14)
with timer("RHS construction time"):
bath = DrudeLorentzBath(Q, lam=lam, gamma=gamma, T=T, Nk=Nk)
_, terminator = bath.terminator()
Ltot = liouvillian(Hsys) + terminator
HEOM_dlbath_T = HEOMSolver(Ltot, bath, NC, options=options)
with timer("ODE solver time"):
result_dlbath_T = HEOM_dlbath_T.run(rho0, tlist)
RHS construction time: 0.02012801170349121 ODE solver time: 0.39751601219177246
plot_result_expectations(
[
(result_dlbath_T, P11p, "b", "P11 Mats (DrudeLorentzBath + Term)"),
(result_dlbath_T, P12p, "r", "P12 Mats (DrudeLorentzBath + Term)"),
]
);
We can compare the solution obtained from the QuTiP Bloch-Redfield solver:
DL = (
f"2*pi* 2.0 * {lam} / (pi * {gamma} * {beta}) if (w == 0) else "
f"2*pi*(2.0*{lam}*{gamma} *w /(pi*(w**2+{gamma}**2))) "
f"* ((1/(exp((w) * {beta})-1))+1)"
)
options = Options(nsteps=15000, store_states=True, rtol=1e-12, atol=1e-12)
with timer("ODE solver time"):
resultBR = brmesolve(
Hsys, rho0, tlist, a_ops=[[sigmaz(), DL]], options=options
)
ODE solver time: 3.998835563659668
plot_result_expectations(
[
(resultMats, P11p, "b", "P11 Mats"),
(resultMats, P12p, "r", "P12 Mats"),
(resultMatsT, P11p, "b--", "P11 Mats + Term"),
(resultMatsT, P12p, "r--", "P12 Mats + Term"),
(resultBR, P11p, "g--", "P11 Bloch Redfield"),
(resultBR, P12p, "g--", "P12 Bloch Redfield"),
]
);
The Matsubara decomposition is not the only option. We can also use the faster-converging Pade decomposition.
def deltafun(j, k):
if j == k:
return 1.0
else:
return 0.0
def pade_eps(lmax):
Alpha = np.zeros((2 * lmax, 2 * lmax))
for j in range(2 * lmax):
for k in range(2 * lmax):
# Fermionic (see other example notebooks):
# Alpha[j][k] = (deltafun(j, k+1) + deltafun(j, k-1))
# / sqrt((2 * (j + 1) - 1) * (2 * (k + 1) - 1))
# Bosonic:
Alpha[j][k] = (deltafun(j, k + 1) + deltafun(j, k - 1)) / np.sqrt(
(2 * (j + 1) + 1) * (2 * (k + 1) + 1)
)
eigvalsA = np.linalg.eigvalsh(Alpha)
eps = [-2 / val for val in eigvalsA[0:lmax]]
return eps
def pade_chi(lmax):
AlphaP = np.zeros((2 * lmax - 1, 2 * lmax - 1))
for j in range(2 * lmax - 1):
for k in range(2 * lmax - 1):
# Fermionic:
# AlphaP[j][k] = (deltafun(j, k + 1) + deltafun(j, k - 1))
# / sqrt((2 * (j + 1) + 1) * (2 * (k + 1) + 1))
# Bosonic [this is +3 because +1 (bose) + 2*(+1) (from bm+1)]:
AlphaP[j][k] = (deltafun(j, k + 1) + deltafun(j, k - 1)) / np.sqrt(
(2 * (j + 1) + 3) * (2 * (k + 1) + 3)
)
eigvalsAP = np.linalg.eigvalsh(AlphaP)
chi = [-2 / val for val in eigvalsAP[0:lmax - 1]]
return chi
def pade_kappa_epsilon(lmax):
eps = pade_eps(lmax)
chi = pade_chi(lmax)
kappa = [0]
prefactor = 0.5 * lmax * (2 * (lmax + 1) + 1)
for j in range(lmax):
term = prefactor
for k in range(lmax - 1):
term *= (chi[k] ** 2 - eps[j] ** 2) / (
eps[k] ** 2 - eps[j] ** 2 + deltafun(j, k)
)
for k in range(lmax - 1, lmax):
term /= eps[k] ** 2 - eps[j] ** 2 + deltafun(j, k)
kappa.append(term)
epsilon = [0] + eps
return kappa, epsilon
def pade_corr(tlist, lmax):
kappa, epsilon = pade_kappa_epsilon(lmax)
eta_list = [lam * gamma * (cot(gamma * beta / 2.0) - 1.0j)]
gamma_list = [gamma]
if lmax > 0:
for ll in range(1, lmax + 1):
eta_list.append(
(kappa[ll] / beta)
* 4
* lam
* gamma
* (epsilon[ll] / beta)
/ ((epsilon[ll] ** 2 / beta**2) - gamma**2)
)
gamma_list.append(epsilon[ll] / beta)
c_tot = []
for t in tlist:
c_tot.append(
sum(
[
eta_list[ll] * np.exp(-gamma_list[ll] * t)
for ll in range(lmax + 1)
]
)
)
return c_tot, eta_list, gamma_list
tlist_corr = np.linspace(0, 2, 100)
cppLP, etapLP, gampLP = pade_corr(tlist_corr, 2)
corr_15k = dl_corr_approx(tlist_corr, 15_000)
corr_2k = dl_corr_approx(tlist_corr, 2)
fig, ax1 = plt.subplots(figsize=(12, 7))
ax1.plot(
tlist_corr,
np.real(cppLP),
color="b",
linewidth=3,
label=r"real pade 2 terms",
)
ax1.plot(
tlist_corr,
np.real(corr_15k),
"r--",
linewidth=3,
label=r"real mats 15000 terms",
)
ax1.plot(
tlist_corr,
np.real(corr_2k),
"g--",
linewidth=3,
label=r"real mats 2 terms",
)
ax1.set_xlabel("t")
ax1.set_ylabel(r"$C$")
ax1.legend()
fig, ax1 = plt.subplots(figsize=(12, 7))
ax1.plot(
tlist_corr,
np.real(cppLP) - np.real(corr_15k),
color="b",
linewidth=3,
label=r"pade error",
)
ax1.plot(
tlist_corr,
np.real(corr_2k) - np.real(corr_15k),
"r--",
linewidth=3,
label=r"mats error",
)
ax1.set_xlabel("t")
ax1.set_ylabel(r"Error")
ax1.legend();