Numerical Hessian

This module implements a simple scheme for calculating a numerical Hessian matrix. We define a new job type NumHessJob by extending MultiJob. The constructor (__init__) of this new job accepts several new arguments and simply stores them. These new arguments define the initial Molecule, the type of job used for single point calculations (jobtype), the size and unit of displacement step and the way of extracting gradients from single point results.

Then the prerun() method takes the given Molecule and creates multiple copies of it, each one with one atom displaced along one axis. For each of these molecules an instance of single point job is created and stored in the children dictionary. Settings of NumHessJob are directly passed to children jobs, so creating a NumHessJob strongly resembles creating a regular single point job.

The dedicated Results subclass for NumHessJob takes care of extracting the Hessian from results of all single point jobs. The returned Hessian can optionally be mass weighted.

The source code of the whole module with both aforementioned classes:

from collections import OrderedDict
from itertools import product

from scm.plams.core.basejob import MultiJob
from scm.plams.core.results import Results
import numpy as np
from scm.plams.tools.units import Units

__all__ = ["NumHessJob", "NumHessResults"]  # names exported to the main namespace


class NumHessResults(Results):

    def get_hessian(self, mass_weighted=False):
        j = self.job
        n = len(j.molecule)
        hessian = np.empty((3 * n, 3 * n))

        for atom, axis in product(range(1, n + 1), range(3)):
            v1 = np.array(j.gradient(j.children[(atom, axis, -1)].results))
            v2 = np.array(j.gradient(j.children[(atom, axis, 1)].results))
            hessian[3 * (atom - 1) + axis] = (v2 - v1) / (2 * Units.convert(j.step, "angstrom", "bohr"))

        if mass_weighted:
            masses = [at.mass for at in j.molecule]
            weights = np.empty((n, n))
            for i, j in product(range(n), repeat=2):
                weights[i, j] = masses[i] * masses[j]
            hessian /= np.kron(weights, np.ones((3, 3)))
        return (hessian + hessian.T) / 2


class NumHessJob(MultiJob):
    """A class for calculating numerical hessian.

    The length and unit of the geometry displacement step can be adjusted.
    *gradient* should be a function that takes results of *jobtype* and
    returns energy gradient in hartree/bohr.
    """

    _result_type = NumHessResults

    def __init__(self, molecule, step=0.01, unit="angstrom", jobtype=None, gradient=None, **kwargs):
        MultiJob.__init__(self, children=OrderedDict(), **kwargs)
        self.molecule = molecule
        self.step = Units.convert(step, unit, "angstrom")
        self.jobtype = jobtype  # who is going to calculate single points
        self.gradient = gradient  # function extracting gradients from children

    def prerun(self):  # noqa F811
        for atom, axis, step in product(range(1, 1 + len(self.molecule)), range(3), [-1, 1]):
            vec = [0, 0, 0]
            vec[axis] = self.step * step
            newmol = self.molecule.copy()
            newmol[atom].translate(vec)
            newname = "{}_{}_{}".format(atom, axis, step)
            self.children[(atom, axis, step)] = self.jobtype(name=newname, molecule=newmol, settings=self.settings)

An example usage:

mol = Molecule('methanol.xyz')

s = Settings()
s.input.ams.task = 'SinglePoint'
s.input.ams.Properties.Gradients = 'Yes'
s.input.adf.basis.type = 'DZP'
s.input.adf.symmetry = 'NOSYM'
s.input.adf.xc.gga = 'PW91'
s.runscript.nproc = 1

j = NumHessJob(name='test', molecule=mol, settings=s, jobtype=AMSJob,
               gradient = lambda x: x.get_gradients().reshape(-1))
r = j.run(jobrunner=JobRunner(parallel=True, maxjobs=8))
print(r.get_hessian(mass_weighted=True))