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
Download
NumGrad.py
(run as$AMSBIN/amspython NumGrad.py
).Download
NumGrad.ipynb
(see also: how to install Jupyterlab in AMS)
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
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)
Numerical Gradients (NumGradJob), hartree/angstrom:
[[ 1.60868100e-08 -1.69669118e-02 4.89757712e-01]
[-1.04939876e-09 1.59476070e-02 -4.59496019e-01]
[-2.43299529e-02 1.43887838e-02 -9.57760819e-03]
[ 2.43299384e-02 1.43887756e-02 -9.57760381e-03]
[-5.15117463e-10 -2.77353210e-02 -1.11275734e-02]]
Analytical Gradients, hartree/angstrom:
[[ 1.60879471e-08 -1.70121112e-02 4.89788974e-01]
[-1.04943735e-09 1.59456436e-02 -4.59495951e-01]
[-2.43388668e-02 1.44021876e-02 -9.58077129e-03]
[ 2.43388522e-02 1.44021793e-02 -9.58076691e-03]
[-5.15150510e-10 -2.77378992e-02 -1.11314841e-02]]
Error Gradients, hartree/angstrom:
[[-1.13706585e-12 4.51994875e-05 -3.12614668e-05]
[ 3.85875469e-14 1.96343429e-06 -6.77586056e-08]
[ 8.91382542e-06 -1.34037122e-05 3.16309721e-06]
[-8.91382498e-06 -1.34037118e-05 3.16309724e-06]
[ 3.30475429e-14 2.57812910e-06 3.91074449e-06]]
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)