Example 12 Singlet fission with bath modes
This example takes a while. (a couple of hours)
- References 
[1]:
import platform
import sys
import pytdscf
print(sys.version)
print(f"pytdscf version = {pytdscf.__version__}")
print(platform.platform())
import jax.extend
print(jax.extend.backend.get_backend().platform)
3.13.2 (main, Feb  4 2025, 14:51:09) [Clang 16.0.0 (clang-1600.0.26.6)]
pytdscf version = 1.1.0
macOS-15.4.1-arm64-arm-64bit-Mach-O
cpu
[2]:
import math
import matplotlib.pyplot as plt
import numpy as np
import sympy
from pympo import AssignManager, OpSite, SumOfProducts
from pympo.utils import export_npz
from pytdscf import (
    BasInfo,
    Boson,
    Exciton,
    Model,
    Simulator,
    TensorHamiltonian,
    TensorOperator,
    units,
)
Model Hamiltonian
where \(H_{\text{ele}}\) is the electronic Hamiltonian consisting of three sites (S1, TT, CT in this order) and represented by following effective Hamiltonian
and \(H_{\text{ph}}\) and \(H_{\text{el-ph}}\) are the phonon and electron-phonon coupling Hamiltonians, respectively.
For detail derivation of this reserovir model, please refer to the reference.
We will set the electronic state as the left terminal site (i=0) and the bath modes as i=1 to N+1 sites.
Spectral density
The previous study employed the spectral density defined as Debye type (Lorentzian decaying)
with a finite cutoff frequency \(\omega_{\text{max}}\).
[3]:
ωc = 0.18  # eV a.k.a charactaristic frequency
λ = 0.10  # eV a.k.a reorganization energy
ωmax = 0.40
def J_debye(ω):
    return 2 * λ * ω * ωc / (ω * ω + ωc * ωc)
omega = np.linspace(0.0, 1.0, 1000)
plt.plot(omega, J_debye(omega), label="Debye")
plt.xlabel("ω (eV)")
plt.ylabel("J(ω)")
plt.axvline(x=ωc, color="red", linestyle="--", label="ωc")
plt.axvline(x=ωmax, color="blue", linestyle="--", label="ωmax 3226cm-1")
plt.legend()
plt.show()
 
The easiest transformation to finite number of harmonic oscillators is just discretizing integral
\(N\) is usually required to be a hundred number to represent the continuous spectral density.
However, there exists a more sophisticated method to map the continuous spectral density to the discrete chain with nearest neighbor coupling.
Orthogonal polynomial mapping
We will define spectral density as
where \(\theta(x)\) is the Heaviside step function. When we set \(g(x) = \omega_{\text{max}} x\) and \(x_{\text{max}}=1\), from the following relation
we can extract constant \(C=2\lambda\omega_c / \pi\) and define measure
and orthogonal monic polynomials \(\{\pi_n\}\) which satisfy
and
where \(\pi_0 = 1\) and \(\pi_{-1} = 0\).
The goal is to find recursion coefficients \(\alpha_n\) and \(\beta_n\) which satisfy
and
First we need to prepare the \(k\)-th moment of the measure up to \(k=2N\).
Then, we determine the coefficients \(\alpha_n\) and \(\beta_n\) recursively.
Note that recursive calculation tends to accumulate errors, so we will use mpmath to calculate the coefficients. (If you don’t have, install mpmath)
[4]:
from mpmath import mp, mpf
mp.dps = 512
def get_momments(n_order, L: float):
    moments = [
        mpf(1) / mpf(2) * mp.log1p(mpf(L) ** 2),
        1 - mp.atan(mpf(L)) / mpf(L),
    ]
    for k in range(2, 2 * n_order + 1):
        moments.append(1 / mpf(k) - moments[-2] / mpf(L) / mpf(L))
    return moments
n_order = 61
L = ωmax / ωc
moments = get_momments(n_order, L)
plt.plot(moments, marker="o")
plt.xlabel("n")
plt.ylabel("moment")
plt.yscale("log")
plt.axhline(0.0, color="black", linestyle="--")
plt.show()
 
[5]:
def polynomial_inner_product(
    poly1: np.ndarray, poly2: np.ndarray, moments: np.ndarray
):
    """
    Calculate the inner product of two polynomials using the moments.
    Args:
        poly1: Coefficients of the first polynomial. [1.0, 2.0, 3.0] means 1 + 2x + 3x^2
        poly2: Coefficients of the second polynomial. [1.0, 2.0, 3.0] means 1 + 2x + 3x^2
        moments: Moments used to calculate the inner product.
    """
    assert len(poly1) + len(poly2) - 1 <= len(moments)
    # convolution = np.convolve(poly1, poly2)
    # return np.dot(convolution, moments[:len(convolution)])
    convolution = [mpf(0)] * (len(poly1) + len(poly2) - 1)
    for i in range(len(poly1)):
        for j in range(len(poly2)):
            convolution[i + j] += poly1[i] * poly2[j]
    return sum(
        c * m
        for c, m in zip(convolution, moments[: len(convolution)], strict=False)
    )
[6]:
def get_coef_alpha_beta_recursive(n_order, moments):
    # monic_coefs = [[1.0]]
    monic_coefs = [[mpf(1)]]
    alpha = []
    beta = []
    norm = []  # <pi_k,pi_k>
    for n in range(n_order):
        # coefs_pi_k = [0.0] + monic_coefs[-1]
        coefs_pi_k = [mpf(0)] + monic_coefs[-1]
        # coefs_x_pi_k = monic_coefs[-1] + [0.0]
        coefs_x_pi_k = monic_coefs[-1] + [mpf(0)]
        assert coefs_x_pi_k[0] == 1.0
        # <pi_k,pi_k>
        alpha_denom = polynomial_inner_product(
            np.array(coefs_pi_k)[::-1],
            np.array(coefs_pi_k)[::-1],
            np.array(moments),
        )
        # <x pi_k,pi_k>
        alpha_num = polynomial_inner_product(
            np.array(coefs_x_pi_k)[::-1],
            np.array(coefs_pi_k)[::-1],
            np.array(moments),
        )
        alpha.append(alpha_num / alpha_denom)
        norm.append(alpha_denom)
        if n >= 1:
            beta_num = norm[-1]
            beta_denom = norm[-2]
            beta.append(beta_num / beta_denom)
        else:
            # beta.append(0.0)
            beta.append(mpf(0))
        monic_coefs.append(coefs_x_pi_k)
        for i in range(len(monic_coefs[-2])):
            monic_coefs[-1][-i - 1] -= alpha[-1] * monic_coefs[-2][-i - 1]
        if n >= 1:
            for i in range(len(monic_coefs[-3])):
                monic_coefs[-1][-i - 1] -= beta[-1] * monic_coefs[-3][-i - 1]
    return monic_coefs, alpha, beta, norm
monic_coefs, alpha, beta, norm = get_coef_alpha_beta_recursive(n_order, moments)
for i in range(len(monic_coefs)):
    plt.plot(
        np.arange(i + 1, 0, -1), monic_coefs[i], label=f"n={i}", marker="o"
    )
plt.legend()
plt.show()
plt.plot(alpha, label="alpha")
plt.plot(beta, label="beta")
plt.legend()
plt.show()
plt.plot(norm, label="norm")
plt.legend()
plt.show()
 
 
 
[7]:
plt.plot(np.abs(np.array(alpha) - 1 / 2) * L, label="L|alpha-1/2|", marker="o")
plt.plot(
    np.abs(1 / 16 - np.array(beta)) * L * L, label="L^2|1/16-beta|", marker="x"
)
plt.yscale("log")
plt.legend()
plt.show()
 
Then, we have the parameters
And total Hamiltonian is
where \(\hat{A}\) is the operator like \(\sum_{k} |k\rangle \langle k|\) acting on the electronic state.
[8]:
backend = "numpy"  # "jax"
c0 = math.sqrt(λ * ωc * math.log(1 + L**2) / math.pi) / units.au_in_eV
omega = np.array(alpha, dtype=np.float64) * ωmax / units.au_in_eV
t = np.sqrt(np.array(beta[1:], dtype=np.float64)) * ωmax / units.au_in_eV
c0_sym = sympy.Symbol("c_0")
omega_syms = [sympy.Symbol(f"omega_{i}") for i in range(len(omega))]
t_syms = [sympy.Symbol(f"t_{i}") for i in range(len(t))]
subs = {}
subs[c0_sym] = c0
for omega_val, omega_sym in zip(omega, omega_syms, strict=True):
    subs[omega_sym] = omega_val
for t_val, t_sym in zip(t, t_syms, strict=True):
    subs[t_sym] = t_val
Setup basis for wavefunction
[9]:
basis = (
    [Boson(8)] * n_order
    + [Exciton(nstate=3, names=["S1", "TT", "CS"])]
    + [Boson(8)] * (2 * n_order)
)
basinfo = BasInfo([basis])
ndim = len(basis)
print(ndim)
sys_site = n_order
184
Setup one particle operator
[10]:
eham = (
    np.array([[0.2, 0.0, -0.05], [0.0, 0.0, -0.05], [-0.05, -0.05, 0.3]])
    / units.au_in_eV
)
b = basis[0].get_annihilation_matrix()
bdag = basis[0].get_creation_matrix()
num = basis[0].get_number_matrix()
bdagb = num
s1 = np.zeros((3, 3))
s1[0, 0] = 1.0  # act only on S1
tt = np.zeros((3, 3))
tt[1, 1] = 1.0  # act only on TT
ct = np.zeros((3, 3))
ct[2, 2] = 1.0  # act only on CT
eham_op = OpSite("H_{" + f"{sys_site}" + "}", sys_site, value=eham)
s1_op = OpSite("S1" + "_{" + f"{sys_site}" + "}", sys_site, value=s1)
tt_op = OpSite(r"\text{TT}" + "_{" + f"{sys_site}" + "}", sys_site, value=tt)
ct_op = OpSite(r"\text{CT}" + "_{" + f"{sys_site}" + "}", sys_site, value=ct)
b_ops = [
    OpSite("b_{" + f"{n}" + "}", n, value=b) if n != sys_site else None
    for n in range(3 * n_order + 1)
]
bdag_ops = [
    OpSite(r"b^\dagger" + "_{" + f"{n}" + "}", n, value=bdag)
    if n != sys_site
    else None
    for n in range(3 * n_order + 1)
]
bdagb_ops = [
    OpSite(
        r"b^\dagger" + "_{" + f"{n}" + "}" + "b_{" + f"{n}" + "}",
        n,
        value=bdagb,
    )
    if n != sys_site
    else None
    for n in range(3 * n_order + 1)
]
Setup potential and kinetic operator
[11]:
sop = SumOfProducts()
sop += eham_op
sop += c0_sym * s1_op * (b_ops[sys_site - 1] + bdag_ops[sys_site - 1])
for i, n in enumerate(range(sys_site - 1, -1, -1)):
    sop += omega_syms[i] * bdagb_ops[n]
    if i != n_order - 1:
        sop += t_syms[i] * (
            b_ops[n] * bdag_ops[n - 1] + bdag_ops[n] * b_ops[n - 1]
        )
sop += c0_sym * ct_op * (b_ops[sys_site + 1] + bdag_ops[sys_site + 1])
for i, n in enumerate(range(sys_site + 1, 3 * n_order, 2)):
    sop += omega_syms[i] * bdagb_ops[n]
    if i != n_order - 1:
        sop += t_syms[i] * (
            b_ops[n] * bdag_ops[n + 1] + bdag_ops[n] * b_ops[n + 1]
        )
sop += c0_sym * tt_op * (b_ops[sys_site + 2] + bdag_ops[sys_site + 2])
for i, n in enumerate(range(sys_site + 2, 3 * n_order + 1, 2)):
    sop += omega_syms[i] * bdagb_ops[n]
    if i != n_order - 1:
        sop += t_syms[i] * (
            b_ops[n] * bdag_ops[n + 1] + bdag_ops[n] * b_ops[n + 1]
        )
sop = sop.simplify()
sop.symbol
[11]:
Setup MPO
[12]:
am_pot = AssignManager(sop)
am_pot.assign()
W_prod = sympy.Mul(*am_pot.Wsym)
assert W_prod[0] == sop.symbol, W_prod[0] - sop.symbol
pot_mpo = am_pot.numerical_mpo(subs=subs)
export_npz(pot_mpo, "singlet_fission_mpo.npz")
# display(*am_pot.Wsym)
Setup Hamiltonian
[13]:
# MPO has legs on (0,1,2, ... ,f-1) sites. This legs are given by tuple key
potential = [
    [{tuple((k, k) for k in range(ndim)): TensorOperator(mpo=pot_mpo)}]
]  # key is ((0,0), 1, 2, ..., ndim-1)
H = TensorHamiltonian(
    ndof=len(basis), potential=potential, kinetic=None, backend=backend
)
core = np.zeros((1, 3, 1))
core[0, 0, 0] = 1.0
s1 = TensorHamiltonian(
    ndof=len(basis),
    potential=[[{(sys_site,): TensorOperator(mpo=[core], legs=(sys_site,))}]],
    kinetic=None,
    backend=backend,
)
operators = {"hamiltonian": H, "S1": s1}
from itertools import chain
sites = chain(
    range(sys_site - 1, -1, -4),
    range(sys_site + 1, 3 * n_order + 1, 8),
    range(sys_site + 2, 3 * n_order + 1, 8),
)
for isite in sites:
    core = np.zeros((1, basis[isite].nprim, 1))
    core[0, :, 0] = np.arange(basis[isite].nprim)
    n = TensorHamiltonian(
        ndof=len(basis),
        potential=[[{(isite,): TensorOperator(mpo=[core], legs=(isite,))}]],
        kinetic=None,
        backend=backend,
    )
    operators[f"N{isite}"] = n
Setup Model (basis, operators, initial states)
[14]:
model = Model(basinfo=basinfo, operators=operators)
model.m_aux_max = 20
# Starts from S1 state
init_boson = [[1.0] + [0.0] * (basis[1].nprim - 1)]
model.init_HartreeProduct = [
    init_boson * n_order + [[1.0, 0.0, 0.0]] + init_boson * (2 * n_order)
]
Execution
[15]:
jobname = "singlet_fission"
simulator = Simulator(jobname=jobname, model=model, backend=backend, verbose=2)
simulator.propagate(
    maxstep=2000,
    stepsize=0.2,
    reduced_density=(
        [(sys_site, sys_site)],
        10,
    ),  # we want to know diagonal_element of (|S1><S1| |CT><CT| |TT><TT| |S1><CT| |S1><TT| |CS><TT|)
    energy=False,
    autocorr=False,
    observables=True,
    observables_per_step=10,
)
18:30:09 | INFO | Log file is ./singlet_fission_prop/main.log
18:30:09 | INFO | Wave function is saved in wf_singlet_fission.pkl
18:30:09 | INFO | Start initial step    0.000 [fs]
18:30:11 | INFO | End     0 step; propagated    0.200 [fs]; AVG Krylov iteration: 5.64
18:32:28 | INFO | End   100 step; propagated   20.200 [fs]; AVG Krylov iteration: 5.88
18:34:43 | INFO | End   200 step; propagated   40.200 [fs]; AVG Krylov iteration: 5.53
18:36:58 | INFO | End   300 step; propagated   60.200 [fs]; AVG Krylov iteration: 5.62
18:39:07 | INFO | End   400 step; propagated   80.200 [fs]; AVG Krylov iteration: 5.41
18:41:08 | INFO | End   500 step; propagated  100.200 [fs]; AVG Krylov iteration: 5.10
18:43:09 | INFO | End   600 step; propagated  120.200 [fs]; AVG Krylov iteration: 5.07
18:45:08 | INFO | End   700 step; propagated  140.200 [fs]; AVG Krylov iteration: 5.22
18:47:08 | INFO | End   800 step; propagated  160.200 [fs]; AVG Krylov iteration: 5.29
18:49:07 | INFO | End   900 step; propagated  180.200 [fs]; AVG Krylov iteration: 5.24
18:51:05 | INFO | Saved wavefunction  199.800 [fs]
18:51:08 | INFO | End  1000 step; propagated  200.200 [fs]; AVG Krylov iteration: 5.23
18:53:09 | INFO | End  1100 step; propagated  220.200 [fs]; AVG Krylov iteration: 5.19
18:55:10 | INFO | End  1200 step; propagated  240.200 [fs]; AVG Krylov iteration: 5.14
18:57:10 | INFO | End  1300 step; propagated  260.200 [fs]; AVG Krylov iteration: 5.17
18:59:11 | INFO | End  1400 step; propagated  280.200 [fs]; AVG Krylov iteration: 5.17
19:01:10 | INFO | End  1500 step; propagated  300.200 [fs]; AVG Krylov iteration: 5.12
19:03:08 | INFO | End  1600 step; propagated  320.200 [fs]; AVG Krylov iteration: 5.10
19:05:11 | INFO | End  1700 step; propagated  340.200 [fs]; AVG Krylov iteration: 5.06
19:07:08 | INFO | End  1800 step; propagated  360.200 [fs]; AVG Krylov iteration: 5.03
19:09:05 | INFO | End  1900 step; propagated  380.200 [fs]; AVG Krylov iteration: 5.02
19:10:58 | INFO | Saved wavefunction  399.800 [fs]
19:11:00 | INFO | End  1999 step; propagated  399.800 [fs]; AVG Krylov iteration: 5.01
19:11:00 | INFO | End simulation and save wavefunction
19:11:00 | INFO | Wave function is saved in wf_singlet_fission.pkl
[15]:
(None, <pytdscf.wavefunction.WFunc at 0x131f67230>)
Check results (reduced densities)
[16]:
import netCDF4 as nc
with nc.Dataset(f"{jobname}_prop/reduced_density.nc", "r") as file:
    density_data_real = file.variables[f"rho_({sys_site}, {sys_site})_0"][:][
        "real"
    ]
    density_data_imag = file.variables[f"rho_({sys_site}, {sys_site})_0"][:][
        "imag"
    ]
    time_data = file.variables["time"][:]
[17]:
plt.title(f"# of site = {len(basis)}, $ω_c$={ωc} [eV], $ωmax$={ωmax} [eV]")
plt.ylabel("Population")
plt.xlabel("Time [fs]")
plt.plot(time_data, density_data_real[:, 0, 0], label="S1")
plt.plot(time_data, density_data_real[:, 1, 1], label="TT")
plt.plot(time_data, density_data_real[:, 2, 2], label="CT")
plt.plot(time_data, density_data_real[:, 0, 2], label="Re|CT><S1|")
plt.legend()
plt.show()
 
[18]:
density_data = np.array(density_data_real) + 1.0j * np.array(density_data_imag)
time_data = np.array(time_data)
from IPython.display import HTML
from pytdscf.util.anim_density_matrix import get_anim
fig, anim = get_anim(
    density_data,
    time_data,
    title="Reduced density matrix",
    time_unit="fs",
    row_names=[r"$|S1\rangle$", r"$|TT\rangle$", r"$|CT\rangle$"],
    col_names=[r"$\langle S1|$", r"$\langle TT|$", r"$\langle CT|$"],
)
HTML(anim.to_jshtml())
[18]: