from __future__ import annotations
import contextlib
import os
from tempfile import TemporaryFile
from typing import List, Union
import numpy as np
from ase import Atoms
from ase.calculators.calculator import Calculator, all_changes
from ase.stress import full_3x3_to_voigt_6_stress
import _nepy
from calorine.nep.model import _get_nep_contents
from calorine.nep.nep import _check_components_polarizability_gradient, \
_polarizability_gradient_to_3x3
[docs]
class CPUNEP(Calculator):
"""This class provides an ASE calculator for `nep_cpu`,
the in-memory CPU implementation of GPUMD.
Parameters
----------
model_filename : str
Path to file in ``nep.txt`` format with model parameters
atoms : Atoms
Atoms to attach the calculator to
label : str
Label for this calclator
debug : bool, optional
Flag to toggle debug mode. Prints GPUMD output. Defaults to False.
Raises
------
FileNotFoundError
Raises :class:`FileNotFoundError` if :attr:`model_filename` does not point to a valid file.
ValueError
Raises :class:`ValueError` atoms are not defined when trying to get energies and forces.
Example
-------
>>> calc = CPUNEP('nep.txt')
>>> atoms.calc = calc
>>> atoms.get_potential_energy()
"""
implemented_properties = [
'energy',
'energies',
'forces',
'stress',
]
debug = False
nepy = None
def __init__(
self,
model_filename: str,
atoms: Atoms | None = None,
label: str | None = None,
debug: bool = False,
):
self.debug = debug
if not os.path.exists(model_filename):
raise FileNotFoundError(f'{model_filename} does not exist.')
self.model_filename = str(model_filename)
# Get model type from first row in nep.txt
header, _ = _get_nep_contents(self.model_filename)
self.model_type = header['model_type']
self.supported_species = set(header['types'])
self.nep_version = header['version']
if self.model_type == 'dipole':
# Only available for dipole models
self.implemented_properties = ['dipole']
elif self.model_type == 'polarizability':
# Only available for polarizability models
self.implemented_properties = ['polarizability']
# Initialize atoms, results and nepy - note that this is also done in Calculator.__init__()
if atoms is not None:
self.set_atoms(atoms)
parameters = {'model_filename': model_filename}
Calculator.__init__(self, label=label, atoms=atoms, **parameters)
if atoms is not None:
self._setup_nepy()
def __str__(self) -> str:
def indent(s: str, i: int) -> str:
s = '\n'.join([i * ' ' + line for line in s.split('\n')])
return s
parameters = '\n'.join(
[f'{key}: {value}' for key, value in self.parameters.items()]
)
parameters = indent(parameters, 4)
using_debug = '\nIn debug mode' if self.debug else ''
s = f'{self.__class__.__name__}\n{parameters}{using_debug}'
return s
def _setup_nepy(self):
"""
Creates an instance of the NEPY class and attaches it to the calculator object.
The output from `nep.cpp` is only written to STDOUT if debug == True
"""
if self.atoms is None:
raise ValueError('Atoms must be defined when calculating properties.')
if self.atoms.cell.rank == 0:
raise ValueError('Atoms must have a defined cell.')
natoms = len(self.atoms)
self.natoms = natoms
c = self.atoms.get_cell(complete=True).flatten()
cell = [c[0], c[3], c[6], c[1], c[4], c[7], c[2], c[5], c[8]]
symbols = self.atoms.get_chemical_symbols()
positions = list(
self.atoms.get_positions().T.flatten()
) # [x1, ..., xN, y1, ... yN,...]
masses = self.atoms.get_masses()
# Disable output from C++ code by default
if self.debug:
self.nepy = _nepy.NEPY(
self.model_filename, self.natoms, cell, symbols, positions, masses
)
else:
with TemporaryFile('w') as f:
with contextlib.redirect_stdout(f):
self.nepy = _nepy.NEPY(
self.model_filename,
self.natoms,
cell,
symbols,
positions,
masses,
)
[docs]
def set_atoms(self, atoms: Atoms):
"""Updates the Atoms object.
Parameters
----------
atoms : Atoms
Atoms to attach the calculator to
"""
species_in_atoms_object = set(np.unique(atoms.get_chemical_symbols()))
if not species_in_atoms_object.issubset(self.supported_species):
raise ValueError('Structure contains species that are not supported by the NEP model.')
self.atoms = atoms
self.results = {}
self.nepy = None
def _update_symbols(self):
"""Update atom symbols in NEPY."""
symbols = self.atoms.get_chemical_symbols()
self.nepy.set_symbols(symbols)
def _update_masses(self):
"""Update atom masses in NEPY"""
masses = self.atoms.get_masses()
self.nepy.set_masses(masses)
def _update_cell(self):
"""Update cell parameters in NEPY."""
c = self.atoms.get_cell(complete=True).flatten()
cell = [c[0], c[3], c[6], c[1], c[4], c[7], c[2], c[5], c[8]]
self.nepy.set_cell(cell)
def _update_positions(self):
"""Update atom positions in NEPY."""
positions = list(
self.atoms.get_positions().T.flatten()
) # [x1, ..., xN, y1, ... yN,...]
self.nepy.set_positions(positions)
def calculate(
self,
atoms: Atoms = None,
properties: List[str] = None,
system_changes: List[str] = all_changes,
):
"""Calculate energy, per atom energies, forces, stress and dipole.
Parameters
----------
atoms : Atoms, optional
System for which to calculate properties, by default None
properties : List[str], optional
Properties to calculate, by default None
system_changes : List[str], optional
Changes to the system since last call, by default all_changes
"""
if properties is None:
properties = self.implemented_properties
Calculator.calculate(self, atoms, properties, system_changes)
if self.nepy is None:
# Create new NEPY interface
self._setup_nepy()
# Update existing NEPY interface
for change in system_changes:
if change == 'positions':
self._update_positions()
elif change == 'numbers':
self._update_symbols()
self._update_masses()
elif change == 'cell':
self._update_cell()
if 'dipole' in properties:
dipole = np.array(self.nepy.get_dipole())
self.results['dipole'] = dipole
elif 'polarizability' in properties:
pol = np.array(self.nepy.get_polarizability())
polarizability = np.array([
[pol[0], pol[3], pol[5]],
[pol[3], pol[1], pol[4]],
[pol[5], pol[4], pol[2]]
])
self.results['polarizability'] = polarizability
elif 'descriptors' in properties:
descriptors = np.array(self.nepy.get_descriptors())
descriptors_per_atom = descriptors.reshape(-1, self.natoms).T
self.results['descriptors'] = descriptors_per_atom
else:
energies, forces, virials = self.nepy.get_potential_forces_and_virials()
energies_per_atom = np.array(energies)
energy = energies_per_atom.sum()
forces_per_atom = np.array(forces).reshape(-1, self.natoms).T
virials_per_atom = np.array(virials).reshape(-1, self.natoms).T
stress = -(np.sum(virials_per_atom, axis=0) / self.atoms.get_volume()).reshape((3, 3))
stress = full_3x3_to_voigt_6_stress(stress)
self.results['energy'] = energy
self.results['forces'] = forces_per_atom
self.results['stress'] = stress
[docs]
def get_dipole_gradient(
self,
displacement: float = 0.01,
method: str = 'central difference',
charge: float = 1.0,
):
"""Calculates the dipole gradient using finite differences.
Parameters
----------
displacement
Displacement in Å to use for finite differences. Defaults to 0.01 Å.
method
Method for computing gradient with finite differences.
One of 'forward difference' and 'central difference'.
Defaults to 'central difference'
charge
System charge in units of the elemental charge.
Used for correcting the dipoles before computing the gradient.
Defaults to 1.0.
Returns
-------
dipole gradient with shape `(N, 3, 3)` where ``N`` are the number of atoms.
"""
if 'dipole' not in self.implemented_properties:
raise ValueError('Dipole gradients are only defined for dipole NEP models.')
if displacement <= 0:
raise ValueError('displacement must be > 0 Å')
implemented_methods = {
'forward difference': 0,
'central difference': 1,
'second order central difference': 2,
}
if method not in implemented_methods.keys():
raise ValueError(f'Invalid method {method} for calculating gradient')
if self.nepy is None:
# Create new NEPY interface
self._setup_nepy()
dipole_gradient = np.array(
self.nepy.get_dipole_gradient(
displacement, implemented_methods[method], charge
)
).reshape(self.natoms, 3, 3)
return dipole_gradient
[docs]
def get_polarizability(
self,
atoms: Atoms = None,
properties: List[str] = None,
system_changes: List[str] = all_changes,
) -> np.ndarray:
"""Calculates the polarizability tensor for the current structure.
The model must have been trained to predict the polarizability.
This is a wrapper function for :func:`calculate`.
Parameters
----------
atoms : Atoms, optional
System for which to calculate properties, by default None
properties : List[str], optional
Properties to calculate, by default None
system_changes : List[str], optional
Changes to the system since last call, by default all_changes
Returns
-------
polarizability with shape ``(3, 3)``
"""
if properties is None:
properties = self.implemented_properties
if 'polarizability' not in properties:
raise ValueError('Polarizability is only defined for polarizability NEP models.')
self.calculate(atoms, properties, system_changes)
return self.results['polarizability']
[docs]
def get_polarizability_gradient(
self,
displacement: float = 0.01,
component: Union[str, List[str]] = 'full',
) -> np.ndarray:
"""Calculates the dipole gradient for a given structure using finite differences.
This function computes the derivatives using the second-order central difference
method with a C++ backend.
Parameters
----------
displacement
Displacement in Å to use for finite differences. Defaults to ``0.01``.
component
Component or components of the polarizability tensor that the gradient
should be computed for.
The following components are available: `x`, `y`, `z`, `full`
Option ``full`` computes the derivative whilst moving the atoms in each Cartesian
direction, which yields a tensor of shape ``(N, 3, 3, 3)``,
where ``N`` is the number of atoms.
Multiple components may be specified.
Defaults to ``full``.
Returns
-------
polarizability gradient with shape ``(N, C, 3, 3)`` with ``C`` components chosen.
"""
if 'polarizability' not in self.implemented_properties:
raise ValueError('Polarizability gradients are only defined'
' for polarizability NEP models.')
if displacement <= 0:
raise ValueError('displacement must be > 0 Å')
if self.nepy is None:
# Create new NEPY interface
self._setup_nepy()
component_array = _check_components_polarizability_gradient(component)
pg = np.array(
self.nepy.get_polarizability_gradient(
displacement, component_array
)
).reshape(self.natoms, 3, 6)
polarizability_gradient = _polarizability_gradient_to_3x3(self.natoms, pg)
return polarizability_gradient[:, component_array, :, :]
[docs]
def get_descriptors(
self,
atoms: Atoms = None,
properties: List[str] = None,
system_changes: List[str] = all_changes,
) -> np.ndarray:
"""Calculates the descriptor tensor for the current structure.
This is a wrapper function for :func:`calculate`.
Parameters
----------
atoms : Atoms, optional
System for which to calculate properties, by default None
properties : List[str], optional
Properties to calculate, by default None
system_changes : List[str], optional
Changes to the system since last call, by default all_changes
Returns
-------
descriptors with shape ``(number_of_atoms, descriptor_components)``
"""
self.calculate(atoms, ['descriptors'], system_changes)
return self.results['descriptors']