Source code for dftd4.ase

# This file is part of dftd4.
# SPDX-Identifier: LGPL-3.0-or-later
#
# dftd4 is free software: you can redistribute it and/or modify it under
# the terms of the Lesser GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# dftd4 is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# Lesser GNU General Public License for more details.
#
# You should have received a copy of the Lesser GNU General Public License
# along with dftd4.  If not, see <https://www.gnu.org/licenses/>.
"""
ASE Support
-----------

`ASE calculator <https://wiki.fysik.dtu.dk/ase/>`_ implementation
for the ``dftd4`` program.

This module provides a basic single point calculator implementations
to integrate the ``dftd4`` API into existing ASE workflows.
To use DFTD4 as dispersion correction the ``ase.calculators.mixing``
module can be used to combine DFTD4 with a DFT calculator using
the ``SumCalculator``.

Supported properties by this calculator are:

- energy (free_energy)
- forces
- stress

Supported keywords are

======================== ============ ============================================
 Keyword                  Default      Description
======================== ============ ============================================
 method                   None         Method to calculate dispersion for
 params_tweaks            None         Optional dict with the damping parameters
 cache_api                True         Reuse generate API objects (recommended)
 model                    d4           Used dispersion Model (D4S or D4 (default))
 realspace_cutoff         None         Optional realspace cutoff settings
======================== ============ ============================================

Example
-------
>>> from ase.build import molecule
>>> from dftd4.ase import DFTD4
>>> atoms = molecule('H2O')
>>> atoms.calc = DFTD4(method="TPSS")
>>> atoms.get_potential_energy()
-0.007310393443152083
>>> atoms.calc.set(method="PBE")
{'method': 'PBE'}
>>> atoms.get_potential_energy()
-0.005358475432239303
>>> atoms.get_forces()
array([[-0.        , -0.        ,  0.00296845],
       [-0.        ,  0.00119152, -0.00148423],
       [-0.        , -0.00119152, -0.00148423]])

The params_tweaks dict contains the damping parameters, at least s8, a1 and a2
must be provided

======================== =========== ============================================
 Tweakable parameter      Default     Description
======================== =========== ============================================
 s6                       1.0         Scaling of the dipole-dipole dispersion
 s8                       None        Scaling of the dipole-quadrupole dispersion
 s9                       1.0         Scaling of the three-body dispersion energy
 a1                       None        Scaling of the critical radii
 a2                       None        Offset of the critical radii
 alp                      16.0        Exponent of the zero damping (ATM only)
======================== =========== ============================================

Either method or s8, a1 and a2 must be provided, s9 can be used to overwrite
the ATM scaling if the method is provided in the model.
Disabling the three-body dispersion (s9=0.0) changes the internal selection rules
for damping parameters of a given method and prefers special two-body only
damping parameters if available!

Example
-------
>>> from dftd4.ase import DFTD4
>>> calc = DFTD4(params_tweaks={"s8": 0.5, "a1": 0.4, "a2": 4.0})
>>> calc.set(params_tweaks={"s8": 0.6, "a1": 0.5, "a2": 3.0})
{'params_tweaks': {'s8': 0.6, 'a1': 0.5, 'a2': 3.0}}
>>> calc.set(params_tweaks={}, method="TPSS")
{'params_tweaks': {}, 'method': 'TPSS'}

The realspace_cutoff option defines all cutoff values used for computing
realspace summations.

======== ============= ===================================================
 Name     Default       Description
======== ============= ===================================================
 disp2    60.0 * Bohr   Cutoff for the pairwise dispersion energy
 disp3    40.0 * Bohr   Cutoff for the three-body dispersion energy
 cn       30.0 * Bohr   Cutoff for the coordination number calculation
 width2   0.0           Smooth cutoff width for the pairwise dispersion
 width3   0.0           Smooth cutoff width for the three-body dispersion
======== ============= ===================================================

The realspace_cutoff dict can contain ``disp2``, ``disp3``, and ``cn`` cutoffs,
as well as smooth cutoff widths ``width2`` and ``width3``. Values are expected
in Angstrom.
To set values in Bohr, e.g. to match the default values, use the ASE units module
for converting them. By multiplying with Bohr the values are converted to Angstrom.
An empty dict will reset the cutoff values to the library defaults.
The smooth cutoff widths are optional but highly recommended to avoid discontinuities
especially for small cutoff values or periodic systems (recommended are 0.05 Bohr).

Example
-------
>>> from ase.units import Bohr
>>> from dftd4.ase import DFTD4
>>> calc = DFTD4(method="TPSS")
>>> calc.set(realspace_cutoff={"width2": 0.05 * Bohr, "width3": 0.05 * Bohr})
{'realspace_cutoff': {'width2': 0.02645886052819206, 'width3': 0.02645886052819206}}
>>> calc.set(realspace_cutoff={})  # reset to library defaults
{'realspace_cutoff': {}}
"""

from typing import List, Optional

try:
    from ase.atoms import Atoms
    from ase.calculators.calculator import (
        CalculationFailed,
        Calculator,
        InputError,
        all_changes,
    )
    from ase.calculators.mixing import SumCalculator
    from ase.units import Bohr, Hartree
except ModuleNotFoundError as e:
    raise ModuleNotFoundError("This submodule requires ASE installed") from e

from .interface import DampingParam, DispersionModel


# Fallbacks for incomplete realspace_cutoff dictionaries; an empty dict keeps
# the library defaults untouched. Values match the Fortran realspace_cutoff
# defaults and are stored in Angstrom for ASE inputs.
DEFAULT_DISP2_CUTOFF = 60.0 * Bohr
DEFAULT_DISP3_CUTOFF = 40.0 * Bohr
DEFAULT_CN_CUTOFF = 30.0 * Bohr


[docs] class DFTD4(Calculator): """ ASE calculator for DFT-D4 related methods. The DFTD4 class can access all methods exposed by the ``dftd4`` API. Example ------- >>> from ase.build import molecule >>> from ase.calculators.mixing import SumCalculator >>> from ase.calculators.nwchem import NWChem >>> from dftd4.ase import DFTD4 >>> atoms = molecule('H2O') >>> atoms.calc = SumCalculator([DFTD4(method="PBE"), NWChem(xc="PBE")]) """ implemented_properties = [ "energy", "forces", "stress", ] default_parameters = { "method": None, "params_tweaks": {}, "cache_api": True, "model": "d4", "realspace_cutoff": {}, } _disp = None def __init__( self, atoms: Optional[Atoms] = None, **kwargs, ): """Construct the dftd4 dispersion model object.""" Calculator.__init__(self, atoms=atoms, **kwargs)
[docs] def add_calculator(self, other: Calculator) -> Calculator: """ Convenience function to allow DFTD4 to combine itself with another calculator by returning a SumCalculator: Example ------- >>> from ase.build import molecule >>> from ase.calculators.emt import EMT >>> from dftd4.ase import DFTD4 >>> atoms = molecule("C60") >>> atoms.calc = DFTD4(method="pbe").add_calculator(EMT()) >>> atoms.get_potential_energy() 6.348142387048062 >>> [calc.get_potential_energy() for calc in atoms.calc.calcs] [-6.015477436263984, 12.363619823312046] """ return SumCalculator([self, other])
[docs] def set(self, **kwargs) -> dict: """Set new parameters to dftd4""" changed_parameters = Calculator.set(self, **kwargs) # Always reset the calculation if parameters change if changed_parameters: self.reset() return changed_parameters
[docs] def reset(self) -> None: """Clear all information from old calculation""" Calculator.reset(self) if not self.parameters.cache_api: self._disp = None
def _check_api_calculator(self, system_changes: List[str]) -> None: """Check state of API calculator and reset if necessary""" # Changes in positions and cell parameters can use a normal update _reset = system_changes.copy() if "positions" in _reset: _reset.remove("positions") if "cell" in _reset: _reset.remove("cell") # Invalidate cached calculator and results object if _reset: self._disp = None else: if system_changes and self._disp is not None: try: _cell = self.atoms.cell self._disp.update( self.atoms.positions / Bohr, _cell / Bohr, ) # An exception in this part means the geometry is bad, # still we will give a complete reset a try as well except RuntimeError: self._disp = None def _create_api_calculator(self) -> DispersionModel: """Create a new API calculator object""" try: _cell = self.atoms.cell _periodic = self.atoms.pbc _charge = self.atoms.get_initial_charges().sum() disp = DispersionModel( self.atoms.numbers, self.atoms.positions / Bohr, _charge, _cell / Bohr, _periodic, model=self.parameters.get("model"), ) except RuntimeError: raise InputError("Cannot construct dispersion model for dftd4") return disp def _apply_realspace_cutoff(self, disp: DispersionModel) -> None: """Apply optional realspace cutoff settings to the API calculator.""" cutoff = self.parameters.get("realspace_cutoff") if not cutoff: return try: disp.set_realspace_cutoff( disp2=cutoff.get("disp2", DEFAULT_DISP2_CUTOFF) / Bohr, disp3=cutoff.get("disp3", DEFAULT_DISP3_CUTOFF) / Bohr, cn=cutoff.get("cn", DEFAULT_CN_CUTOFF) / Bohr, width2=cutoff.get("width2", 0.0) / Bohr, width3=cutoff.get("width3", 0.0) / Bohr, ) except RuntimeError: raise InputError("Cannot update realspace cutoff for dftd4") def _create_damping_param(self) -> DampingParam: """Create a new API damping parameter object""" try: dpar = DampingParam( method=self.parameters.get("method"), **self.parameters.get("params_tweaks", {}), ) except RuntimeError: raise InputError("Cannot construct damping parameter for dftd4") return dpar
[docs] def calculate( self, atoms: Optional[Atoms] = None, properties: List[str] = None, system_changes: List[str] = all_changes, ) -> None: """Perform actual calculation with by calling the dftd4 API""" if not properties: properties = ["energy"] Calculator.calculate(self, atoms, properties, system_changes) self._check_api_calculator(system_changes) if self._disp is None: self._disp = self._create_api_calculator() self._apply_realspace_cutoff(self._disp) _dpar = self._create_damping_param() try: _res = self._disp.get_dispersion(param=_dpar, grad=True) except RuntimeError: raise CalculationFailed("dftd4 could not evaluate input") # These properties are guaranteed to exist for all implemented calculators self.results["energy"] = _res.get("energy") * Hartree self.results["free_energy"] = self.results["energy"] self.results["forces"] = -_res.get("gradient") * Hartree / Bohr # stress tensor is only returned for periodic systems if self.atoms.pbc.any(): _stress = _res.get("virial") * Hartree / self.atoms.get_volume() self.results["stress"] = _stress.flat[[0, 4, 8, 5, 2, 1]]