Calculating potential energy surfaces, forces and non-adiabatic couplings with the Newns-Anderson Hamiltonian
This tutorial focuses using NQCModels.jl to build a Newns-Anderson model of a molecule chemisorped to a surface. In the process we will also see how one can use NQCCalculators.jl to evaulate and update important properties of the model.
The Newns-Anderson Hamiltonian
The Newns-Anderson model is a lattice Hamiltonian that parameterises a small set of impurity states" coupled to a large number of
bath” states. Within the context of surface science the impurity" states are taken to represent the electronic eigenstates of a molecular adsorbate and the
bath” states the band structure that characterises the material on which it is adsorbed. In second-quantised form of the model Hamiltonian reads \[
\hat{H} = \sum_{a}{\varepsilon_{a}\hat{n}_{a}} + \sum_{k}{\varepsilon_{k}\hat{n}_{k}} + \sum_{a,k}{V_{ak}(\hat{c}_{a}^\dagger\hat{c}_{k} + \hat{c}_{k}^\dagger\hat{c}_{a})}.
\] Here, the subscript \(a\) runs over the adsorbate states and \(k\) the bath states. \(\varepsilon_a\) and \(\varepsilon_k\) are the energies of their respective states which the double sum over \(V_{ak}\) encodes the couplings between every given pair of surface and adsorbate states.
The model implemented in NQCModels.jl has a single adsorbate energy state coupled to a bath of energy states clustered around the fermi-level representing a either a metallic or semiconducting system (depending on the chosen discretisation scheme). The model is also taken to be in the ``wideband limit” where the \(k\) dependence of the couplings is dropped and the density of states (DOS) is approximated via variable sparsity of sampling in the bath state discritisation (Shenvi2009). In after these simplifications the Hamiltonian is able to be written down in a simplified matrix form, \[ \begin{bmatrix} \varepsilon_{a} & V_1 & V_2 & \cdots & V_k \\ V_1 & \varepsilon_1 & 0 & \cdots & 0 \\ V_2 & 0 & \varepsilon_2 & \cdots & 0 \\ \vdots & \vdots & \vdots & \ddots & \vdots \\ V_k & 0 & 0 & \cdots & \varepsilon_k \end{bmatrix} \]
Dependencies
The following packages are all that is required to complete this tutorial. If you don’t want to add them to your default Julia environment simply type ] activate 'NQCD Newns-Anderson tutorial'
into the REPL before adding the packages.
using Unitful, UnitfulAtomic
using DelimitedFiles
using LinearAlgebra
using Combinatorics
using NQCModels
using NQCCalculators
using CairoMakie
using ColorSchemes, Colors
Building a Newns-Anderson model
Newns-Anderson model in NQCModels is given its other common name, AndersonHolstein()
and requires a QuantumModel
as input that paramterises both the quantum states of the adsorbate far from the surface and its coupling strength as a function of its distance from the surface. Alongside this impurity_model
input, AndersonHolstein()
also requries the input of a WideBandBathDiscretisation
object which is used to construct the bath energies and is generated automatically by a collection of discretisation functions.
The first thing to do when building a Newns-Anderson model with NQCModels is to choose what kind of impurity model and bath discretisation you would like. Some sensible defaults are the ErpenbeckThoss()
model and a ShenviGaussLegendre()
discretisation.
= NQCModels.QuantumModels.ErpenbeckThoss(;Γ=0.01) #Gamma controls the coupling strength with the surface and can be properly parameterised with DFT, but 0.01 is a sensible default for the purpose of testing
impurity_model
= austrip(-270 * u"eV") #austrip converts unitful quantities into atomic units, the default units for NQCD
bandmin = austrip(270 * u"eV")
bandmax = 100 #this controls the number of bath states, after including the additional single adsorbate state the AndersonHolstein Hamiltonain becomes a (nstates + 1, nstates + 1) matrix
nstates
= ShenviGaussLegendre(nstates, bandmin, bandmax)
bath
= AndersonHolstein(impurity_model, bath) NAmodel
This code creates an instance of the Newns-Anderson model, stored as a julia struct
. This struct contains all of the information you’ve input along with the number of electrons (calculated automatically by the discretisation method), the fermi level (defaults to 0.0 but can be set via the kwarg fermi_level
) and an additional quantity saved as couplings_rescale
that we will cover later on in the tutorial. For now we shall see what we can achieve with the NQCModels and the NAmodel struct
.
NQCCalculators and Adiabatic Potential Energy Surfaces
The most obvious first step is to construct the matrix representation of the Newns-Anderson model that we saw above this can be using the NQCModels function potential()
. This function takes the model we have already built along with a position argument that encodes the distance between the adsorbate and the surface. This position is wrapped in hcat()
as in general NQCD functions take position as a matrix.
= NQCModels.potential(NAmodel, hcat(1.0)) Hamiltonian_mat
This matrix should have the same structure as the matrix shown above, a structure characteristic of the Anderson model in the diabatic basis. Finding the adiabatic eigenstates of the system at the given height can be easily achieved using the LinearAlgebra.eigen()
function. Of more interest in dynamics is the values and derivatives of these eigenstates at range of heights, the potential energy surfaces (PESs) of the system.
Before plotting the potential energy surfaces it would be useful to briefly discuss the applications of NQCModels to dynamics simulations and in doing so mention how it interfaces with the suite of dynamics software available in NQCDynamics.jl. The interface between the dynamics algorithms and the library of Hamiltonians and analytic potentials stored in NQCModels is handled by a third package called NQCCalculators.jl, this package uses the model to build a new type of struct called a cache
. This cache contains the current instances of all the values that can be calculated from the model and can be automatically updated at each time-step using the update_cache!()
function. To create a cache one uses the Create_Cache()
function which takes as input the model, the number of atoms subject to the model and a type T
that should almost always be set to Float64
= Create_Cache(NAmodel, 1, Float64)
NAcache
NAcache.potentialupdate_cache!(NAcache, hcat(1.0))
NAcache.potential
Building Adiabatic Potential Energy Surfaces
We are now ready to generate a plot of the adiabatic potnetial energy surfaces of our model, for both ground and excited states. We have already shown how to construct a model of an adsorbate on a metal surface, so for this section we will instead plot the adiabatic eigenstates of a molecule chemisorbed to a semiconductor. For this we employ the LuHertlMaurer() model, an analytic potential fit to DFT data for H/Ge(111) and the GapGaussLegendre() discretisation which uses a modification of the GaussLegendre algorithm to create a set of bathstates for a surface with bandgap.
= NQCModels.QuantumModels.LuHertlMaurer()
impurity_model = austrip(-25 * u"eV")
bandmin = austrip(25 * u"eV")
bandmax = austrip(0.49 * u"eV")
gapwidth = 150
nstates
= GapGaussLegendre(nstates, bandmin, bandmax, gapwidth)
bath
= AndersonHolstein(impurity_model, bath; couplings_rescale = 2.5)
NAmodel = Create_Cache(NAmodel, 1, Float64) NAcache
Now we have built the model and constructed a cache for it, the steps required to plot the adiabatic excited states are the following:
- Generate a ground state electronic occupation vector, a fock state where the first \(N_{\text{el}}\) elements are
1
and the rest of it are0
. - Generate a large number of combinatoric permutations of excited states with a fixed number of electrons \(N_{\text{el}}\) and within some given active space in order to obtain a random collection of low energy excited states.
- Diagonalise the Newns-Anderson Hamiltonian \(H_{\text{NA}}\) at a given height \(x\) and sum the eigenvalues \(\varepsilon_i\) of occupied states to obtain the total many-body energy of a given excited state.
- Looping over each of the generated excited states and a variety of positions \(x\), repeat step 3 to produce the excited state potential energy surfaces.
These steps are all performed by the adiabatic_surfaces()
function below which will output a sorted list of PESs in the form of a Vector{<:Vector}
(a vector of vectors).
"""
adiabatic_surfaces computes the potential energy surfaces, the nonadiabatic couplings between them and the forces due to their derivatives.
The surfaces are sorted according to their energy farthest from the surface.
Output:
energies_raw : sorted adiabatic PES energies
U_0s : the state independent, external potential at each point
configurations : configuration vectors sorted according to the adiabatic PES energy at the last grid point
couplings: the non-adiabatic coupling matrix between the single-electron adiabatic states at each evaluated position
GS_forces: the forces due to the groundstate PES at each evaluated position
"""
function adiabatic_surfaces(NAcache, x_ang::AbstractArray{Float64})
## The number of bath states
= NQCModels.nstates(NAcache)
nstates
## The number of electrons
= NQCModels.nelectrons(NAcache) #NQCModels.nelectrons(model) -> Int(nstates/2)
nelectrons
= austrip.(x_ang * u"Å")
x = hcat.(x)
matrix_x = zero(x)
U_0s= zero(x)
∂U_0s= [zero(NAcache.eigen.values) for _ in CartesianIndices(x)]
eigs
#NAcache.nonadiabatic_coupling is stored as a Matrix{<:Matrix}, here the exterior matrix is 1x1 so below we only access the internal structure to store the values
= [zero(NAcache.nonadiabatic_coupling[1]) for _ in CartesianIndices(x)]
couplings = [zero(NAcache.eigen.values) for _ in CartesianIndices(x)]
derivative_eigen
for (i, r) in enumerate(matrix_x)
update_cache!(NAcache, r)
= ustrip(auconvert(u"eV", NQCModels.state_independent_potential(NAcache.model, r)))
U_0s[i] = ustrip(auconvert(u"eV/Å", NQCModels.state_independent_derivative(NAcache.model, r)[1]))
∂U_0s[i] .= NAcache.eigen.values # length of eigs is 200(grid points); eigs[1] has 101 eigenstates
eigs[i] .= NAcache.nonadiabatic_coupling[1]
couplings[i] = eigvals(NAcache.derivative[1]) #-sum(eigen(NAcache.derivative[1]).values[1:nelectrons])
derivative_eigen[i] #println(i)
end
# groundstate configuration
= vcat(ones(nelectrons), zeros(nstates-nelectrons))
config
= multiset_permutations(config, nstates)
perms = []
configurations = []
forces = []
energies = []
sortpoints """
This setting makes sure that each PES has the same second quantization configuration
"""
# loop over all the configurational permutations from groundstate to higher excited states
for config in Iterators.take(perms, 20000)
= [sum(e[config .!= 0]) for e in eigs] # sum the eigenvalues of the occupied states at each grid point
E = [-sum(d[config .!= 0]) for d in derivative_eigen] # calculate forces via summing across the eigenvalue derivatives of occupied eigenstates at each grid point
F push!(energies, ustrip.(auconvert.(u"eV", E)))
push!(forces, ustrip.(auconvert.(u"eV/Å", F)))
push!(sortpoints, E[end]) # record the adiabatic PES energy at the last grid point
push!(configurations, config)
end
= sortperm(sortpoints)
perm = energies[perm] # sort the energies according to the adiabatic PES energy at the last grid point
energies_raw = forces[perm]
forces_raw = configurations[perm] # sort the configuration according to the adiabatic PES energy at the last grid point
configurations return energies_raw, U_0s, configurations, couplings, forces_raw, ∂U_0s
end
** Plotting the Adiabatic PESs **
Now that a function has been written to find the ground and excited state adiabatic potential energy surfaces, it’s time to set up some functions to visualise the surfaces we can calculate.
function plot_surfaces!(NAcache, ax, x_ang::AbstractArray{Float64}, gapwidth, groundstate_align_zero::Bool, reduce_PES::Bool)
= adiabatic_surfaces(NAcache, x_ang)[1:2]
energies_raw, U_0s
if groundstate_align_zero
= energies_raw[1][end] + U_0s[end]
groundstate_energy_end = broadcast(.-, energies_raw, groundstate_energy_end) #broadcast to every PESs
energies_raw end
if reduce_PES
= vcat(collect(1:2),collect(3:12:20),collect(21:50:2000))
plotted_indices else
= collect(1:2000)
plotted_indices end
# extract only the indexed PESs
= energies_raw[plotted_indices] # energies_raw[1] groundstate
energies
@info "The depth of the ground state well is $(minimum(energies[1] .+ U_0s) - (energies[1] .+ U_0s)[end]) eV"
# first excited state - ground state
= energies[2] .- energies[1]
f_minus_g = maximum(max.(f_minus_g))
gap_max = minimum(min.(f_minus_g))
gap_min
@info "The maximum gap of ground state and first excited state is $gap_max eV"
@info "The minimum gap of ground state and first excited state is $gap_min eV"
= []
color_range ## Plot PES ##
for e in energies
= (e[end] - energies[1][end]) / (energies[end][end] - energies[1][end])
c push!(color_range, c)
end
= [ColorSchemes.get(colormap, 1-c) for c in color_range]
colors
= []
excited_PES_energy_5A for (i, e) in Iterators.reverse(enumerate(energies))
= e .+ U_0s # NA Hamiltonian = adiabatic eigenvalues + U_0
y lines!(ax, x_ang, y, color=colors[i], linewidth= i==1 ? 4 : 1)
if y[end] < 1.92 && y[end] > 0.4
push!(excited_PES_energy_5A, y[end])
end
end
= energies[1][end] + U_0s[end]
ground_end
= readdlm("resources/Newns-Anderson-tutorial/DFT_HGe111_restatom.txt")[:,3:4]
DFT :,2] = DFT[:,2] .- DFT[end,2]
DFT[= ground_end - DFT[end,2] # adiabatic ground state - DFT ground state
aglinment = DFT[:,2] .+ aglinment # align the DFT ground state to the adiabatic ground states
y_DFT
lines!(ax, DFT[:,1], y_DFT, label="DFT PES",linestyle=:dash, color=:blue, linewidth=3)
## bandgap##
hlines!(ax, [ground_end + ustrip(auconvert(u"eV", gapwidth))], color=:black, linestyle=:dash, label="Band Gap", linewidth=3)
hlines!(ax, [ground_end], color=:black, linestyle=:dash,linewidth=3)
end
function CustomAxis(f; kwargs...)
= Axis(f; kwargs...)
my_ax = Axis(f; xaxisposition=:top, yaxisposition=:right, kwargs...)
extra_ax
linkaxes!(my_ax, extra_ax)
hidespines!(extra_ax)
hidedecorations!(extra_ax, ticks=false, minorticks=false)
return my_ax
end
function plot_adiabatic_PES(NAcache, positions::AbstractVector, gapwidth::Float64; groundstate_align_zero::Bool=true, reduce_PES::Bool=false)
## set plotting theme ##
= ColorScheme(parse.(Colorant, ["#045275", "#089099", "#7CCBA2", "#FCDE9C", "#F0746E", "#DC3977", "#7C1D6F"]))
colorscheme = ColorScheme(parse.(Colorant, ["#FCE1A4","#FABF7B","#F08F6E","#E05C5C","#D12959","#AB1866","#6E005F"]))
colormap
## create plotting figure ##
= -2
ylimitslow = 4
ylimitsup
= Figure(figure_padding=(5, 5, 5, 5), size=(480, 720/MathConstants.golden),fontsize=17)
fig
# PES axis
= CustomAxis(fig[1,1]; xlabel="x / Å", limits=(1, 6, ylimitslow, ylimitsup), ylabel = "PES: Energy /eV", xgridvisible = false, ygridvisible = false)
ax1 = ""
ax1.title
# plot DFT groundstate and adiabatic surfaces
plot_surfaces!(NAcache, ax1, positions, gapwidth, groundstate_align_zero, reduce_PES)
# label
Legend(fig[1,1], ax1, tellwidth=false, tellheight=false, valign=:top, halign=:right, margin=(5, 5, 5, 5), orientation=:horizontal)
return fig
end
Now we have constructed the model and written the functions needed to perform the calculations and plotting of the potential energy surfaces, all that remains is to set the range of positions over which the calculation is run and call the functions.
= range(0.8, 6, length=200) #range of distances in Å for the plot to be calculated over
x_ang plot_adiabtic_PES(NAcache, x_ang, gapwidth)
Calling these functions together should return this plot:
Plotting the Forces and Non-Adiabatic Couplings
This is no the only thing we can do with the adiabatic_surfaces!()
function, however. Using update_cache!()
also gives the user direct access to the Hamiltonian derivative which can be used to calculate forces and non-adiabatic couplings, values which are vital to propagating non-adiabatic dynamics. To show how these values change over the trajectory we can write a simple plotting script to visualise the net forces due to the potential energy surfaces and the nonadiabtic couplings between the ground and first excited states.
function plot_derivatives_and_couplings(NAcache, positions)
## Create Figure ##
= Figure(figure_padding=(5, 5, 5, 5), size=(480, 720/MathConstants.golden),fontsize=17)
fig
# generate axis
= 0.0
ylimitslow = 3.0
ylimitsup = CustomAxis(fig[1,1]; limits=(0.8, 6.0, -3.0, 3.0), ylabel = "Forces (eV/Å)", xgridvisible = false, ygridvisible = false)
ax1 = CustomAxis(fig[2,1]; xlabel="x (Å)", limits=(0.8, 6.0, 0.0, 0.04), ylabel = "Couplings (Å⁻¹)", xgridvisible = false, ygridvisible = false)
ax2 = "Forces due to PESs"
ax1.title = "Non-adiabaitc Couplings"
ax2.title
## Calculate Forces and Couplings##
= adiabatic_surfaces(NAcache, positions)
_, _, _, couplings, forces_raw, ∂U_0s = vcat(collect(1:2),collect(3:12:20),collect(21:50:2000))
plotted_indices = forces_raw[plotted_indices]
forces
## Plot Forces ##
= []
color_range for f in forces
= (f[end] - forces[1][1])
c push!(color_range, c)
end
= [ColorSchemes.get(colormap, 1-c) for c in color_range]
colors
for (i, f) in Iterators.reverse(enumerate(forces))
= f .+ ∂U_0s
f lines!(ax1, positions, f, color=colors[i], linewidth = 0.2)#i==1 ? 4 : 1)
println(length(forces))
end
## Plot Couplings ##
= []
c for (x,_) in enumerate(couplings)
push!(c, couplings[x][1,2])
end
= ustrip.(auconvert.(u"Å^-1", c))
c println(c)
lines!(ax2, positions, c, color=colors[18], linewidth = 1.0)
return fig
end
plot_derivatives_and_couplings(NAcache, x_ang)
These plotting routines should produce the following plot:
Reference:
- Philip W. Anderson. Localized magnetic states in metals. Physical Review 124.1 (1961): 41.
- Newns, D. M. Self-Consistent Model of Hydrogen Chemisorption. Physical Review 178, no. 3 (1969): 1123–35.
- Xuexun Lu, Nils Hertl, and Reinhard J. Maurer. A Haldane–Anderson Hamiltonian Model for Hyperthermal Hydrogen Scattering from a Semiconductor Surface. arXiv:2508.13360 (2025).
- Xuexun Lu, Matthew Larkin and Nils Hertl. System-bath models. NQCD Documentation.