Energy determination of parameters#
import numpy as np
import veloxchem as vlx
Order expansion of parameters#
The wave function parameters are expanded in orders of the external field
such that \(\kappa_{ai}= 0\) in the limit of \(F = 0\).
Conversely,
Linear response equation#
The first-order response can be determined from the field-derivative of the variational condition
We here view the \(ai\)-pair of unoccupied/occupied orbital indices as a compound index \(n\) (and similarly for \(bj\) and \(m\)), inviting us to introduce a compact matrix notation to arrive at the linear response equation.
In order, we have introduced:
The Hermitian electronic Hessian
with blocks
The (unknown) response vector
with
And the property gradient
with
Baker–Campbell–Hausdorff expansion#
We have
and let us adopt the electric-dipole approximation
It is then straightforward to evaluate that electronic Hessian and the property gradient. We get
and
Spin-adapted excitation operators#
For closed-shell reference states in VeloxChem, we are using normalized spin-adapted excitation operators that will result in
and
and, in this case, the block dimension equals
Illustration#
Setup#
h2o_xyz = """3
O 0.000000000000 0.000000000000 0.000000000000
H 0.000000000000 0.740848095288 0.582094932012
H 0.000000000000 -0.740848095288 0.582094932012
"""
scf_drv = vlx.ScfRestrictedDriver()
scf_drv.ostream.mute()
molecule = vlx.Molecule.read_xyz_string(h2o_xyz)
basis = vlx.MolecularBasis.read(molecule, "6-31g", ostream=None)
scf_results = scf_drv.compute(molecule, basis)
epsilon = scf_results["E_alpha"] # orbital energies
erimo_drv = vlx.MOIntegralsDriver() # ERI blocks in the MO basis
ovov = erimo_drv.compute_in_memory(molecule, basis, scf_drv.mol_orbs, "chem_ovov")
oovv = erimo_drv.compute_in_memory(molecule, basis, scf_drv.mol_orbs, "chem_oovv")
print("(ov|ov):", ovov.shape)
print("(oo|vv):", oovv.shape)
(ov|ov): (5, 8, 5, 8)
(oo|vv): (5, 5, 8, 8)
nocc = molecule.number_of_alpha_electrons()
norb = basis.get_dimension_of_basis(molecule)
nvirt = norb - nocc
ndim = nocc * nvirt
print("Number of occupied orbitals:", nocc)
print("Number of virtual orbitals :", nvirt)
print("Block dimension :", ndim)
Number of occupied orbitals: 5
Number of virtual orbitals : 8
Block dimension : 40
Electronic Hessian#
We determine the electronic Hessian
def delta(i, j):
if i == j:
delta = 1
else:
delta = 0
return delta
E2 = np.zeros((2 * ndim, 2 * ndim)) # real valued
A = np.zeros((ndim, ndim))
B = np.zeros((ndim, ndim))
m = 0
for j in range(nocc):
for b in range(nvirt):
n = 0
for i in range(nocc):
for a in range(nvirt):
A[m, n] = (
(epsilon[a + nocc] - epsilon[i]) * delta(a, b) * delta(i, j)
+ 2 * ovov[j, b, i, a]
- oovv[j, i, b, a]
)
B[m, n] = -2 * ovov[j, b, i, a] + ovov[j, a, i, b]
n += 1
m += 1
E2[:ndim, :ndim] = A
E2[:ndim, ndim:] = B
E2[ndim:, :ndim] = B
E2[ndim:, ndim:] = A
Two observations can be made:
matrix block \(A\) is diagonal dominant with diagonal elements approximately equal to orbital energy differences (in particular for core electron transitions)
elements of \(B\) are comparatively small
core, homo, lumo = 0, 4, 5 # Python indexing of orbitals
print(f"{'1s-LUMO':>48s}{'HOMO-LUMO':>17s}")
print(" " * 41 + "=" * 25)
print(
"Diagonal elements of A-block of E2 :"
+ f"{A[0,0]:14.8f}"
+ f"{A[(nocc-1)*nvirt,(nocc-1)*nvirt]:14.8f}"
)
print(
"Associated orbital energy differences:"
+ f"{epsilon[lumo] - epsilon[core]:14.8f}"
+ f"{epsilon[lumo] - epsilon[homo]:14.8f}\n"
)
print(f"Maximum element of B-block of E2 : {np.max(B):12.8f}")
1s-LUMO HOMO-LUMO
=========================
Diagonal elements of A-block of E2 : 20.34024945 0.36669362
Associated orbital energy differences: 20.76493335 0.70960170
Maximum element of B-block of E2 : 0.14823088
We ensure that our result equals the electronic Hessian obtained with the get_e2()
method of the LinearResponseEigenSolver
class.
lres_drv = vlx.LinearResponseEigenSolver()
lres_drv.ostream.mute()
E2_ref = lres_drv.get_e2(molecule, basis, scf_results)
np.testing.assert_allclose(E2, E2_ref, atol=1.0e-6)
Property gradient#
We determine the property gradient
for the operator \(\hat{\mu}_z\).
dipole_drv = vlx.ElectricDipoleIntegralsDriver()
dipole_mats = dipole_drv.compute(molecule, basis)
mu_z_ao = -1.0 * dipole_mats.z_to_numpy()
C = scf_results["C_alpha"]
mu_z = np.einsum("ap, ab, bq -> pq", C, mu_z_ao, C)
B1 = np.zeros(2 * ndim, dtype=complex) # imaginary valued
g = []
for j in range(nocc):
for b in range(nocc, norb):
g.append(-1j * np.sqrt(2) * mu_z[j, b])
g = np.array(g)
B1[:ndim] = g
B1[ndim:] = g.conj()
We ensure that our result equals the reference property gradient obtained with the get_prop_grad()
method of the LinearResponseSolver
class.
lrs_drv = vlx.LinearResponseSolver()
lrs_drv.ostream.mute()
B1_ref = lrs_drv.get_prop_grad(
"electric_dipole", "z", molecule, basis, scf_drv.scf_tensors
)[0]
np.testing.assert_allclose(B1.imag, B1_ref, atol=1.0e-12)
Response vector#
We determine the response vector
N = -np.matmul(np.linalg.inv(E2), B1)
We ensure that our result equals the reference response vector obtained with the get_full_vector()
method.
lrs_drv.a_operator = "electric dipole"
lrs_drv.b_operator = "electric dipole"
lrs_drv.a_components = ["z"]
lrs_drv.b_components = ["z"]
lrs_drv.frequencies = [0.0]
lrs_results = lrs_drv.compute(molecule, basis, scf_results)
N_ref = np.zeros(2 * ndim)
N_ref[:ndim] = -lrs_results["solutions"][("z", 0.0)].get_full_vector(1)
N_ref[ndim:] = lrs_results["solutions"][("z", 0.0)].get_full_vector(1)
np.testing.assert_allclose(N.imag, N_ref, atol=1.0e-6)
We note that the imaginary “\(i\)” is left out in the response vector provided by VeloxChem.
Response function#
A1 = B1 # property gradient of the observable
print(f"Response function: {np.dot(A1.conj(), N).real:10.6f}")
Response function: -4.298489
print(f"Reference value: {lrs_results['response_functions'][('z', 'z', 0.0)]:10.6f}")
Reference value: -4.298489
We note that our result is in perfect agreement with the reference value obtained from the VeloxChem program.