Numerical gradients

This module implements a simple numerical differentiation scheme with respect to molecular coordinates. We define a new job type NumGradJob 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 accuracy of the numerical differentiation scheme (npoints) and size and unit of displacement step.

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 NumGradJob are directly passed to children jobs, so creating a NumGradJob strongly resembles creating a regular single point job.

The dedicated Results subclass for NumGradJob takes care of extracting the gradient from the results of all single point jobs. Any function that takes results of a single point job and returns a single number can be used with the get_gradient method defined in NumGradResults

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

from collections import OrderedDict
from itertools import product

from scm.plams.core.basejob import MultiJob
from scm.plams.core.results import Results

__all__ = ["NumGradJob", "NumGradResults"]  # names exported to the main namespace


class NumGradResults(Results):

    def _get_gradient_component(self, atom, coord, extract):
        """Get gradient component for *atom* along *coord*.

        *atom* should be integer, *coord* a single character string with 'x', 'y' or 'z'.
        *extract* should be a function that takes results of a single point job and
        returns a single number.
        """
        energies = [extract(self.job.children[(atom, coord, i)].results) for i in self.job.steps]
        coeffs, denom = self.job._coeffs[self.job.npoints]
        return sum([c * e for c, e in zip(coeffs, energies)]) / (denom * self.job.step)

    def get_gradient(self, extract):
        """Get the full gradient vector. Returns a list of length 3N,
        where N is the number of atoms in the calculated molecule.

        *extract* should be a function that takes results of a single point job and
        returns a single number.
        """
        return [
            self._get_gradient_component(atom, coord, extract)
            for atom, coord in product(range(1, 1 + len(self.job.molecule)), "xyz")
        ]


class NumGradJob(MultiJob):
    """A class for calculating numerical gradients of energy
    (or some other single-valued property) with respect to
    cartesian displacements.

    The differentiation is done using the final difference method
    with 2, 4, 6 or 8 points. The length of the step can be adjusted.
    """

    _result_type = NumGradResults

    # finite difference coefficients for different number of points:
    _coeffs = {
        2: ([-1, 1], 2),
        4: ([1, -8, 8, -1], 12),
        6: ([-1, 9, -75, 75, -9, 1], 60),
        8: ([3, -32, 168, -672, 672, -168, 32, -3], 840),
    }

    def __init__(self, molecule, npoints=2, step=0.01, unit="angstrom", jobtype=None, **kwargs):
        # initialize parent class and store all additional constructor arguments
        MultiJob.__init__(self, children=OrderedDict(), **kwargs)
        self.molecule = molecule
        self.npoints = npoints
        self.step = step
        self.unit = unit
        self.jobtype = jobtype  # who is going to calculate single points

    def prerun(self):  # noqa F811
        self.steps = list(range(-(self.npoints // 2), 0)) + list(range(1, self.npoints // 2 + 1))

        for atom, axis, i in product(range(1, 1 + len(self.molecule)), "xyz", self.steps):
            v = (
                self.step * i if axis == "x" else 0,
                self.step * i if axis == "y" else 0,
                self.step * i if axis == "z" else 0,
            )
            newmol = self.molecule.copy()
            newmol[atom].translate(v, self.unit)
            newname = "{}_{}_{}".format(atom, axis, i)
            # settings of NumGradJob are directly passed to children single point jobs
            self.children[(atom, axis, i)] = self.jobtype(name=newname, molecule=newmol, settings=self.settings)

To follow along, either

Worked Example

Initialization

import numpy as np
import multiprocessing
from scm.plams.recipes.numgrad import NumGradJob
from scm.plams import config, JobRunner, AMSJob, Settings, AMSResults, Units, init

# this line is not required in AMS2025+
init()
PLAMS working folder: /path/plams/NumGrad/plams_workdir.003

Parallelization

config.default_jobrunner = JobRunner(parallel=True, maxjobs=multiprocessing.cpu_count())

Define molecule

mol = AMSJob.from_input(
    """
System
  Atoms
    C       0.000000000000       0.138569980000       0.355570700000
    O       0.000000000000       0.187935770000      -1.074466460000
    H       0.882876920000      -0.383123830000       0.697839450000
    H      -0.882876940000      -0.383123830000       0.697839450000
    H       0.000000000000       1.145042790000       0.750208830000
  End
End
"""
).molecule[""]

Setup and run jobs

s = Settings()
s.input.AMS.Task = "SinglePoint"
s.input.ForceField.Type = "UFF"
s.runscript.nproc = 1

j = NumGradJob(name="numgrad", molecule=mol, settings=s, jobtype=AMSJob)
r = j.run()
r.wait()

s_analytical = s.copy()
s_analytical.input.AMS.Properties.Gradients = "Yes"
analytical_job = AMSJob(name="analytical", molecule=mol, settings=s_analytical)
analytical_job.run()
[05.03|16:16:05] JOB numgrad STARTED
[05.03|16:16:05] Waiting for job numgrad to finish
[05.03|16:16:05] JOB numgrad RUNNING
[05.03|16:16:05] JOB numgrad/1_x_-1 STARTED
[05.03|16:16:05] JOB numgrad/1_x_1 STARTED
[05.03|16:16:05] JOB numgrad/1_y_-1 STARTED
[05.03|16:16:05] JOB numgrad/1_y_1 STARTED
[05.03|16:16:05] JOB numgrad/1_z_-1 STARTED
[05.03|16:16:05] JOB numgrad/1_z_1 STARTED
[05.03|16:16:05] JOB numgrad/2_x_-1 STARTED
[05.03|16:16:05] JOB numgrad/2_x_1 STARTED
... (PLAMS log lines truncated) ...




<scm.plams.interfaces.adfsuite.ams.AMSResults at 0x77d85d1da100>



[05.03|16:16:11] JOB analytical RUNNING
[05.03|16:16:11] JOB analytical FINISHED
[05.03|16:16:11] JOB analytical SUCCESSFUL

Complete Python code

#!/usr/bin/env amspython
# coding: utf-8

# ## Initialization

import numpy as np
import multiprocessing
from scm.plams.recipes.numgrad import NumGradJob
from scm.plams import config, JobRunner, AMSJob, Settings, AMSResults, Units, init

# this line is not required in AMS2025+
init()


# ## Parallelization

config.default_jobrunner = JobRunner(parallel=True, maxjobs=multiprocessing.cpu_count())


# ## Define molecule

mol = AMSJob.from_input(
    """
System
  Atoms
    C       0.000000000000       0.138569980000       0.355570700000
    O       0.000000000000       0.187935770000      -1.074466460000
    H       0.882876920000      -0.383123830000       0.697839450000
    H      -0.882876940000      -0.383123830000       0.697839450000
    H       0.000000000000       1.145042790000       0.750208830000
  End
End
"""
).molecule[""]


# ## Setup and run jobs

s = Settings()
s.input.AMS.Task = "SinglePoint"
s.input.ForceField.Type = "UFF"
s.runscript.nproc = 1

j = NumGradJob(name="numgrad", molecule=mol, settings=s, jobtype=AMSJob)
r = j.run()
r.wait()

s_analytical = s.copy()
s_analytical.input.AMS.Properties.Gradients = "Yes"
analytical_job = AMSJob(name="analytical", molecule=mol, settings=s_analytical)
analytical_job.run()


# ## Print results

numerical_gradients = np.array(r.get_gradient(AMSResults.get_energy)).reshape(-1, 3)
analytical_gradients = np.array(analytical_job.results.get_gradients()).reshape(-1, 3) * Units.convert(
    1.0, "bohr^-1", "angstrom^-1"
)

print("Numerical Gradients (NumGradJob), hartree/angstrom:")
print(numerical_gradients)
print("Analytical Gradients, hartree/angstrom:")
print(analytical_gradients)
print("Error Gradients, hartree/angstrom:")
print(numerical_gradients - analytical_gradients)