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 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 an Ohmic environment with exponential cut-off in two ways:
First we fit the spectral density with a set of underdamped brownian oscillator functions.
Second, we evaluate the correlation functions, and fit those with a certain choice of exponential functions.
In each case we will use the fit parameters to determine the correlation function expansion co-efficients needed to construct a description of the bath (i.e. a BosonicBath
object) to supply to the HEOMSolver
so that we can solve for the system dynamics.
import contextlib
import dataclasses
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,
expect,
liouvillian,
sigmax,
sigmaz,
spost,
spre,
)
from qutip.nonmarkov.heom import (
HEOMSolver,
BosonicBath,
)
# Import mpmath functions for evaluation of gamma and zeta
# functions in the expression for the correlation:
from mpmath import mp
mp.dps = 15
mp.pretty = True
%matplotlib inline
Let's define some helper functions for plotting results and timing how long operations take:
def coth(x):
""" Vectorized hyperbolic cotangent of x. """
return 1. / np.tanh(x)
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)
if color == 'rand':
axes.plot(
result.times, exp,
c=np.random.rand(3,), label=label, **kw,
)
else:
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.0 # Energy of the 2-level system.
Del = 0.2 # Tunnelling term
Hsys = 0.5 * eps * sigmaz() + 0.5 * Del * sigmax()
# Initial state of the system.
rho0 = basis(2, 0) * basis(2, 0).dag()
# 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()
Before we begin fitting, let us examine the analytic expressions for the correlation and spectral density functions and write Python equivalents.
The correlation function is given by (see, e.g., http://www1.itp.tu-berlin.de/brandes/public_html/publications/notes.pdf for a derivation, equation 7.59, but with a factor of $\pi$ moved into the definition of the correlation function):
\begin{align} C(t) =& \: \frac{1}{\pi}\alpha \omega_{c}^{1 - s} \beta^{- (s + 1)} \: \times \\ & \: \Gamma(s + 1) \left[ \zeta \left(s + 1, \frac{1 + \beta \omega_c - i \omega_c t}{\beta \omega_c}\right) + \zeta \left(s + 1, \frac{1 + i \omega_c t}{\beta \omega_c}\right) \right] \end{align}where $\Gamma$ is the Gamma function and
\begin{equation} \zeta(z, u) \equiv \sum_{n=0}^{\infty} \frac{1}{(n + u)^z}, \; u \neq 0, -1, -2, \ldots \end{equation}is the generalized Zeta function. The Ohmic case is given by $s = 1$.
The corresponding spectral density for the Ohmic case is:
\begin{equation} J(\omega) = \omega \alpha e^{- \frac{\omega}{\omega_c}} \end{equation}def ohmic_correlation(t, alpha, wc, beta, s=1):
""" The Ohmic bath correlation function as a function of t
(and the bath parameters).
"""
corr = (
(1 / np.pi) * alpha * wc**(1 - s) * beta**(-(s + 1)) * mp.gamma(s + 1)
)
z1_u = (1 + beta * wc - 1.0j * wc * t) / (beta * wc)
z2_u = (1 + 1.0j * wc * t) / (beta * wc)
# Note: the arguments to zeta should be in as high precision as possible.
# See http://mpmath.org/doc/current/basics.html#providing-correct-input
return np.array([
complex(corr * (mp.zeta(s + 1, u1) + mp.zeta(s + 1, u2)))
for u1, u2 in zip(z1_u, z2_u)
], dtype=np.complex128)
def ohmic_spectral_density(w, alpha, wc):
""" The Ohmic bath spectral density as a function of w
(and the bath parameters).
"""
return w * alpha * np.e**(-w / wc)
def ohmic_power_spectrum(w, alpha, wc, beta):
""" The Ohmic bath power spectrum as a function of w
(and the bath parameters).
"""
return (
w * alpha * np.e**(-abs(w) / wc) *
((1 / (np.e**(w * beta) - 1)) + 1) * 2
)
Finally, let's set the bath parameters we will work with and write down some measurement operators:
# Bath parameters:
@dataclasses.dataclass
class OhmicBathParameters:
""" Ohmic bath parameters. """
Q: object = dataclasses.field(default_factory=sigmaz, repr=False)
alpha: float = 3.25
T: float = 0.5
wc: float = 1.0
s: float = 1
def __post_init__(self):
self.beta = 1 / self.T
def replace(self, **kw):
return dataclasses.replace(self, **kw)
obp = OhmicBathParameters()
And set the cut-off for the HEOM hierarchy:
# HEOM parameters:
# The max_depth defaults to 5 so that the notebook executes more
# quickly. Change it to 11 to wait longer for more accurate results.
max_depth = 5
We begin by fitting the spectral density, using a series of $k$ underdamped harmonic oscillators case with the Meier-Tannor form (J. Chem. Phys. 111, 3365 (1999); https://doi.org/10.1063/1.479669):
\begin{equation} J_{\mathrm approx}(\omega; a, b, c) = \sum_{i=0}^{k-1} \frac{2 a_i b_i w}{((w + c_i)^2 + b_i^2) ((w - c_i)^2 + b_i^2)} \end{equation}where $a, b$ and $c$ are the fit parameters and each is a vector of length $k$.
# Helper functions for packing the paramters a, b and c into a single numpy
# array as required by SciPy's curve_fit:
def pack(a, b, c):
""" Pack parameter lists for fitting. """
return np.concatenate((a, b, c))
def unpack(params):
""" Unpack parameter lists for fitting. """
N = len(params) // 3
a = np.array(params[:N])
b = np.array(params[N:2 * N])
c = np.array(params[2 * N:])
return a, b, c
# The approximate spectral density and a helper for fitting the approximate
# spectral density to values calculated from the analytical formula:
def spectral_density_approx(w, a, b, c):
""" Calculate the fitted value of the function for the given
parameters.
"""
return np.sum(
2 * a[:, None] * np.multiply.outer(b, w) / (
((w + c[:, None])**2 + b[:, None]**2) *
((w - c[:, None])**2 + b[:, None]**2)
),
axis=0,
)
def fit_spectral_density(J, w, alpha, wc, N):
""" Fit the spectral density with N underdamped oscillators. """
sigma = [0.0001] * len(w)
J_max = abs(max(J, key=abs))
guesses = pack([J_max] * N, [wc] * N, [wc] * N)
lower_bounds = pack([-100 * J_max] * N, [0.1 * wc] * N, [0.1 * wc] * N)
upper_bounds = pack([100 * J_max] * N, [100 * wc] * N, [100 * wc] * N)
params, _ = curve_fit(
lambda x, *params: spectral_density_approx(w, *unpack(params)),
w, J,
p0=guesses,
bounds=(lower_bounds, upper_bounds),
sigma=sigma,
maxfev=1000000000,
)
return unpack(params)
With the spectral density approximation $J_{\mathrm approx}(w; a, b, c)$ implemented above, we can now perform the fit and examine the results.
w = np.linspace(0, 25, 20000)
J = ohmic_spectral_density(w, alpha=obp.alpha, wc=obp.wc)
params_k = [
fit_spectral_density(J, w, alpha=obp.alpha, wc=obp.wc, N=i+1)
for i in range(4)
]
Let's plot the fit for each $k$ and examine how it improves with an increasing number of terms:
for k, params in enumerate(params_k):
lam, gamma, w0 = params
y = spectral_density_approx(w, lam, gamma, w0)
print(f"Parameters [k={k}]: lam={lam}; gamma={gamma}; w0={w0}")
plt.plot(w, J, w, y)
plt.show()
Parameters [k=0]: lam=[6.14746382]; gamma=[1.77939431]; w0=[0.1]
Parameters [k=1]: lam=[3.26249548 2.2118894 ]; gamma=[1.43449472 1.24955712]; w0=[1.80554824 0.1 ]
Parameters [k=2]: lam=[1.12689181 2.33978096 1.65007407]; gamma=[1.00851471 1.0936228 1.18462034]; w0=[0.13355831 1.37012837 2.69761285]
Parameters [k=3]: lam=[ 7.9159269 0.60083713 -4.40789259 0.01058514]; gamma=[2.29618997 1.00246805 4.29908162 0.30736325]; w0=[0.1 0.1 3.98168657 0.1 ]
The fit with four terms looks good. Let's take a closer look at it by plotting the contribution of each term of the fit:
# The parameters for the fit with four terms:
lam, gamma, w0 = params_k[-1]
print(f"Parameters [k={len(params_k) - 1}]: lam={lam}; gamma={gamma}; w0={w0}")
Parameters [k=3]: lam=[ 7.9159269 0.60083713 -4.40789259 0.01058514]; gamma=[2.29618997 1.00246805 4.29908162 0.30736325]; w0=[0.1 0.1 3.98168657 0.1 ]
# Plot the components of the fit separately:
def spectral_density_ith_component(w, i, lam, gamma, w0):
""" Return the i'th term of the approximation for the spectral density. """
return (
2 * lam[i] * gamma[i] * w /
(((w + w0[i])**2 + gamma[i]**2) * ((w - w0[i])**2 + gamma[i]**2))
)
def plot_spectral_density_fit_components(J, w, lam, gamma, w0):
""" Plot the individual components of a fit to the spectral density. """
fig, axes = plt.subplots(1, 1, sharex=True, figsize=(8, 8))
axes.plot(w, J, 'r--', linewidth=2, label="original")
for i in range(len(lam)):
axes.plot(
w, spectral_density_ith_component(w, i, lam, gamma, w0),
linewidth=2,
label=f"fit component {i}",
)
axes.set_xlabel(r'$w$', fontsize=28)
axes.set_ylabel(r'J', fontsize=28)
axes.legend()
return fig
plot_spectral_density_fit_components(J, w, lam, gamma, w0);
And let's also compare the power spectrum of the fit and the analytical spectral density:
def plot_power_spectrum(alpha, wc, beta, lam, gamma, w0, save=True):
""" Plot the power spectrum of a fit against the actual power spectrum. """
w = np.linspace(-10, 10, 50000)
s_orig = ohmic_power_spectrum(w, alpha=alpha, wc=wc, beta=beta)
s_fit = (
spectral_density_approx(w, lam, gamma, w0) *
((1 / (np.e**(w * beta) - 1)) + 1) * 2
)
fig, axes = plt.subplots(1, 1, sharex=True, figsize=(8, 8))
axes.plot(w, s_orig, 'r', linewidth=2, label="original")
axes.plot(w, s_fit, 'b', linewidth=2, label="fit")
axes.set_xlabel(r'$\omega$', fontsize=28)
axes.set_ylabel(r'$S(\omega)$', fontsize=28)
axes.legend()
if save:
fig.savefig('powerspectrum.eps')
plot_power_spectrum(obp.alpha, obp.wc, obp.beta, lam, gamma, w0, save=False)
Now that we have a good fit to the spectral density, we can calculate the Matsubara expansion terms for the BosonicBath
from them. At the same time we will calculate the Matsubara terminator for this expansion.
def matsubara_coefficients_from_spectral_fit(lam, gamma, w0, beta, Q, Nk):
""" Calculate the Matsubara co-efficients for a fit to the spectral
density.
"""
# initial 0 value with the correct dimensions:
terminator = 0. * spre(Q)
# the number of matsubara expansion terms to include in the terminator:
terminator_max_k = 1000
ckAR = []
vkAR = []
ckAI = []
vkAI = []
for lamt, Gamma, Om in zip(lam, gamma, w0):
ckAR.extend([
(lamt / (4 * Om)) * coth(beta * (Om + 1.0j * Gamma) / 2),
(lamt / (4 * Om)) * coth(beta * (Om - 1.0j * Gamma) / 2),
])
for k in range(1, Nk + 1):
ek = 2 * np.pi * k / beta
ckAR.append(
(-2 * lamt * 2 * Gamma / beta) * ek /
(
((Om + 1.0j * Gamma)**2 + ek**2) *
((Om - 1.0j * Gamma)**2 + ek**2)
)
)
terminator_factor = 0
for k in range(Nk + 1, terminator_max_k):
ek = 2 * np.pi * k / beta
ck = (
(-2 * lamt * 2 * Gamma / beta) * ek /
(
((Om + 1.0j * Gamma)**2 + ek**2) *
((Om - 1.0j * Gamma)**2 + ek**2)
)
)
terminator_factor += ck / ek
terminator += terminator_factor * (
2 * spre(Q) * spost(Q.dag())
- spre(Q.dag() * Q)
- spost(Q.dag() * Q)
)
vkAR.extend([
-1.0j * Om + Gamma,
1.0j * Om + Gamma,
])
vkAR.extend([
2 * np.pi * k * obp.T + 0.j
for k in range(1, Nk + 1)
])
ckAI.extend([
-0.25 * lamt * 1.0j / Om,
0.25 * lamt * 1.0j / Om,
])
vkAI.extend([
-(-1.0j * Om - Gamma),
-(1.0j * Om - Gamma),
])
return ckAR, vkAR, ckAI, vkAI, terminator
def generate_spectrum_results(obp, params, Nk, max_depth):
""" Run the HEOM with the given bath parameters and
and return the results of the evolution.
"""
lam, gamma, w0 = params
ckAR, vkAR, ckAI, vkAI, terminator = (
matsubara_coefficients_from_spectral_fit(
lam, gamma, w0, beta=obp.beta, Q=obp.Q, Nk=Nk,
)
)
Ltot = liouvillian(Hsys) + terminator
tlist = np.linspace(0, 30 * np.pi / Del, 600)
options = Options(
nsteps=15000, store_states=True, rtol=1e-12, atol=1e-12, method="bdf",
)
# This problem is a little stiff, so we use the BDF method to solve
# the ODE ^^^
with timer("RHS construction time"):
bath = BosonicBath(obp.Q, ckAR, vkAR, ckAI, vkAI)
HEOM_spectral_fit = HEOMSolver(
Ltot, bath, max_depth=max_depth, options=options,
)
with timer("ODE solver time"):
results_spectral_fit = (HEOM_spectral_fit.run(rho0, tlist))
return results_spectral_fit
Below we generate results for different convergence parameters (number of terms in the fit, number of matsubara terms, and depth of the hierarchy). For the parameter choices here, we need a relatively large depth of around '11', which can be a little slow.
# Generate results for different number of lorentzians in fit:
results_spectral_fit_pk = [
generate_spectrum_results(obp, params, Nk=1, max_depth=max_depth)
for params in params_k
]
plot_result_expectations([
(
result, P11p, 'rand',
f"P11 (spectral fit) $k_J$={pk + 1}",
)
for pk, result in enumerate(results_spectral_fit_pk)
]);
RHS construction time: 0.0213778018951416 ODE solver time: 0.47254276275634766 RHS construction time: 0.09638023376464844 ODE solver time: 1.0719399452209473 RHS construction time: 0.40880918502807617 ODE solver time: 4.369450807571411 RHS construction time: 0.9264869689941406 ODE solver time: 14.90989375114441
# generate results for different number of Matsubara terms per Lorentzian
# for max number of Lorentzians:
Nk_list = range(2, 4)
results_spectral_fit_nk = [
generate_spectrum_results(obp, params_k[-1], Nk=Nk, max_depth=max_depth)
for Nk in Nk_list
]
plot_result_expectations([
(
result, P11p, 'rand',
f"P11 (spectral fit) K={nk}",
)
for nk, result in zip(Nk_list, results_spectral_fit_nk)
]);
RHS construction time: 1.4925916194915771 ODE solver time: 23.911180019378662 RHS construction time: 2.057482957839966 ODE solver time: 61.37712264060974
# Generate results for different depths:
Nc_list = range(2, max_depth)
results_spectral_fit_nc = [
generate_spectrum_results(obp, params_k[-1], Nk=1, max_depth=Nc)
for Nc in Nc_list
]
plot_result_expectations([
(
result, P11p, 'rand',
f"P11 (spectral fit) $N_C={nc}$",
)
for nc, result in zip(Nc_list, results_spectral_fit_nc)
]);
RHS construction time: 0.02372145652770996 ODE solver time: 0.44444847106933594 RHS construction time: 0.08523702621459961 ODE solver time: 1.0894746780395508 RHS construction time: 0.36461472511291504 ODE solver time: 4.844106435775757
We now combine the fitting and correlation function data into one large plot.
def correlation_approx_matsubara(t, ck, vk):
""" Calculate the approximate real or imaginary part of the
correlation function from the matsubara expansion co-efficients.
"""
ck = np.array(ck)
vk = np.array(vk)
return np.sum(ck[:, None] * np.exp(-vk[:, None] * t), axis=0)
def plot_cr_fit_vs_actual(t, ckAR, vkAR, C, axes):
""" Plot the C_R(t) fit. """
yR = correlation_approx_matsubara(t, ckAR, vkAR)
axes.plot(
t, np.real(C),
"r", linewidth=3, label="Original",
)
axes.plot(
t, np.real(yR),
"g", dashes=[3, 3], linewidth=2, label="Reconstructed",
)
axes.legend(loc=0)
axes.set_ylabel(r'$C_R(t)$', fontsize=28)
axes.set_xlabel(r'$t\;\omega_c$', fontsize=28)
axes.locator_params(axis='y', nbins=4)
axes.locator_params(axis='x', nbins=4)
axes.text(0.15, 0.85, "(a)", fontsize=28, transform=axes.transAxes)
def plot_ci_fit_vs_actual(t, ckAI, vkAI, C, axes):
""" Plot the C_I(t) fit. """
yI = correlation_approx_matsubara(t, ckAI, vkAI)
axes.plot(
t, np.imag(C),
"r", linewidth=3, label="Original",
)
axes.plot(
t, np.real(yI),
"g", dashes=[3, 3], linewidth=2, label="Reconstructed",
)
axes.legend(loc<