Source code for calorine.nep.nep

import contextlib
import os
import warnings
from collections import Counter
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union

import numpy as np
from ase import Atoms
from ase.calculators.singlepoint import SinglePointCalculator

import _nepy
from calorine.env import calorine_getenv
from calorine.nep.model import Model, _get_nep_contents
from calorine.nep.tensor_conventions import reduced6_to_full_3x3


def _get_atomic_properties(
    structure: Atoms,
) -> Tuple[List[float], List[str], List[float]]:
    """Fetches cell, symbols and positions for a structure. Since NEP_CPU requires a cell, if the
    structure has no cell a default cubic cell with a side of 100 Å will be used.

    Parameters
    ----------
    structure
        Atoms object representing the structure.

    Returns
    -------
    List[float]
        Cell vectors
    List[str]
        Atomic species
    List[float]
        Atom positions
    List[float]
        Atom masses
    """
    if structure.cell.rank == 0:
        warnings.warn('Using default unit cell (cubic with side 100 Å).')
        set_default_cell(structure)

    c = structure.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 = structure.get_chemical_symbols()
    positions = list(
        structure.get_positions().T.flatten()
    )  # [x1, ..., xN, y1, ... yN,...]
    masses = structure.get_masses()
    return cell, symbols, positions, masses


def _setup_nepy(
    model_filename: str,
    natoms: int,
    cell: List[float],
    symbols: List[str],
    positions: List[float],
    masses: List[float],
    debug: bool,
) -> _nepy.NEPY:
    """Sets up an instance of the NEPY pybind11 interface to NEP_CPU.

    Parameters
    ----------
    model_filename
        Path to model.
    natoms:
        Number of atoms in the structure.
    cell:
        Cell vectors.
    symbols:
        Atom species.
    positions:
        Atom positions.
    masses:
        Atom masses.
    debug:
        Flag to control if the output from NEP_CPU will be printed.

    Returns
    -------
    NEPY
        NEPY interface
    """
    # Ensure that `model_filename` exists to avoid segfault in pybind11 code
    if not os.path.isfile(model_filename):
        raise ValueError(f'{Path(model_filename)} does not exist')

    # Disable output from C++ code by default
    if debug:
        nepy = _nepy.NEPY(model_filename, natoms, cell, symbols, positions, masses)
    else:
        with open(os.devnull, 'w') as f:
            with contextlib.redirect_stdout(f):
                with contextlib.redirect_stderr(f):
                    nepy = _nepy.NEPY(
                        model_filename, natoms, cell, symbols, positions, masses
                    )
    return nepy


def set_default_cell(structure: Atoms, box_length: float = 100):
    """Adds a cubic box to an Atoms object. Atoms object is edited in-place.

    Parameters
    ----------
    structure
        Structure to add box to
    box_length
        Cubic box side length in Å, by default 100
    """
    structure.set_cell([[box_length, 0, 0], [0, box_length, 0], [0, 0, box_length]])
    structure.center()


[docs] def get_descriptors( structure: Atoms, model_filename: str, debug: bool = False ) -> np.ndarray: """Calculates the NEP descriptors for a given structure. A NEP model defined by a nep.txt can additionally be provided to get the NEP3 model specific descriptors. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. debug Flag to toggle debug mode. Prints GPUMD output. Defaults to ``False``. Returns ------- Descriptors for the supplied structure, with shape (number_of_atoms, descriptor components) """ local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) all_descriptors = nepy.get_descriptors() descriptors_per_atom = np.array(all_descriptors).reshape(-1, natoms).T return descriptors_per_atom
[docs] def get_latent_space( structure: Atoms, model_filename: Union[str, None] = None, debug: bool = False ) -> np.ndarray: """Calculates the latent space representation of a structure, i.e, the activiations in the hidden layer. A NEP model defined by a `nep.txt` file needs to be provided. Parameters ---------- structure Input structure model_filename Path to NEP model. Defaults to None. debug Flag to toggle debug mode. Prints GPUMD output. Defaults to False. Returns ------- Activation with shape `(natoms, N_neurons)` """ if model_filename is None: raise ValueError('Model is undefined') local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) latent = nepy.get_latent_space() latent = np.array(latent).reshape(-1, natoms).T return latent
[docs] def get_potential_forces_and_virials( structure: Atoms, model_filename: Optional[str] = None, debug: bool = False ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """Calculates the per-atom potential, forces and virials for a given structure. A NEP model defined by a `nep.txt` file needs to be provided. Parameters ---------- structure Input structure model_filename Path to NEP model. Defaults to None. debug Flag to toggle debug mode. Prints GPUMD output. Defaults to False. Returns ------- potential with shape `(natoms,)` forces with shape `(natoms, 3)` virials with shape `(natoms, 9)` """ if model_filename is None: raise ValueError('Model is undefined') model_type = _get_nep_contents(model_filename)[0]['model_type'] if model_type != 'potential': raise ValueError( 'A NEP model trained for predicting energies and forces must be used.' ) local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) energies, forces, virials = nepy.get_potential_forces_and_virials() forces_per_atom = np.array(forces).reshape(-1, natoms).T # Raw, row-major full-3x3 per atom [xx,xy,xz,yx,yy,yz,zx,zy,zz]; # documented in src/nepy/nep.h and matching the accumulation pattern in # src/nepy/nep.cpp. virials_per_atom = np.array(virials).reshape(-1, natoms).T return np.array(energies), forces_per_atom, virials_per_atom
[docs] def get_polarizability( structure: Atoms, model_filename: Optional[str] = None, debug: bool = False, ) -> np.ndarray: """Calculates the polarizability tensor for a given structure. A NEP model defined by a ``nep.txt`` file needs to be provided. The model must be trained to predict the polarizability. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. Defaults to ``None``. debug Flag to toggle debug mode. Prints GPUMD output. Defaults to ``False``. Returns ------- polarizability with shape ``(3, 3)`` """ if model_filename is None: raise ValueError('Model is undefined') model_type = _get_nep_contents(model_filename)[0]['model_type'] if model_type != 'polarizability': raise ValueError( 'A NEP model trained for predicting polarizability must be used.' ) local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) # Components are in NEP_REDUCED6_ORDER (xx,yy,zz,xy,yz,zx); see # `find_polarizability` in src/nepy/nep.cpp. pol = nepy.get_polarizability() polarizability = reduced6_to_full_3x3(pol) return polarizability
[docs] def get_dipole( structure: Atoms, model_filename: Optional[str] = None, debug: bool = False, ) -> np.ndarray: """Calculates the dipole for a given structure. A NEP model defined by a ``nep.txt`` file needs to be provided. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. Defaults to ``None``. debug Flag to toggle debug mode. Prints GPUMD output. Defaults to ``False``. Returns ------- dipole with shape ``(3,)`` """ if model_filename is None: raise ValueError('Model is undefined') model_type = _get_nep_contents(model_filename)[0]['model_type'] if model_type != 'dipole': raise ValueError('A NEP model trained for predicting dipoles must be used.') local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) dipole = np.array(nepy.get_dipole()) return dipole
[docs] def get_dipole_gradient( structure: Atoms, model_filename: Optional[str] = None, backend: str = 'c++', method: str = 'central difference', displacement: float = 0.01, charge: float = 1.0, nep_command: Optional[str] = None, debug: bool = False, ) -> np.ndarray: """Calculates the dipole gradient for a given structure using finite differences. A NEP model defined by a `nep.txt` file needs to be provided. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. Defaults to ``None``. backend Backend to use for computing dipole gradient with finite differences. One of ``'c++'`` (CPU), ``'python'`` (CPU) and ``'nep'`` (GPU). Defaults to ``'c++'``. method Method for computing gradient with finite differences. One of 'forward difference' and 'central difference'. Defaults to 'central difference' displacement Displacement in Å to use for finite differences. Defaults to ``0.01``. charge System charge in units of the elemental charge. Used for correcting the dipoles before computing the gradient. Defaults to ``1.0``. nep_command Command for running the NEP executable. Default: ``nep``, or the value of the ``CALORINE_NEP_COMMAND`` environment variable if set. debug Flag to toggle debug mode. Prints GPUMD output (if applicable). Defaults to ``False``. Returns ------- dipole gradient with shape ``(N, 3, 3)`` """ if model_filename is None: raise ValueError('Model is undefined') model_type = _get_nep_contents(model_filename)[0]['model_type'] if model_type != 'dipole': raise ValueError('A NEP model trained for predicting dipoles must be used.') local_structure = structure.copy() if backend == 'c++': dipole_gradient = _dipole_gradient_cpp( local_structure, model_filename, displacement=displacement, method=method, charge=charge, debug=debug, ) elif backend == 'python': dipole_gradient = _dipole_gradient_python( local_structure, model_filename, displacement=displacement, charge=charge, method=method, ) elif backend == 'nep': dipole_gradient = _dipole_gradient_nep( local_structure, model_filename, displacement=displacement, method=method, charge=charge, nep_command=nep_command, ) else: raise ValueError(f'Invalid backend {backend}') return dipole_gradient
def _dipole_gradient_cpp( structure: Atoms, model_filename: str, method: str = 'central difference', displacement: float = 0.01, charge: float = 1.0, debug: bool = False, ) -> np.ndarray: """Calculates the dipole gradient with finite differences, using NEP_CPU. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. method Method for computing gradient with finite differences. One of ``'forward difference'`` and ``'central difference'``. Defaults to ``'central difference'`` displacement Displacement in Å to use for finite differences. Defaults to ``0.01``. 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)`` """ 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') local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) dipole_gradient = np.array( nepy.get_dipole_gradient(displacement, implemented_methods[method], charge) ).reshape(natoms, 3, 3) return dipole_gradient def _dipole_gradient_python( structure: Atoms, model_filename: str, method: str = 'central difference', displacement: float = 0.01, charge: float = 1.0, ) -> np.ndarray: """Calculates the dipole gradient with finite differences, using the Python and get_dipole(). Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. method Method for computing gradient with finite differences. One of ``'forward difference'`` and ``'central difference'``. Defaults to ``'central difference'`` displacement Displacement in Å to use for finite differences. Defaults to ``0.01``. 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)`` """ if displacement <= 0: raise ValueError('Displacement must be > 0 Å') N = len(structure) if method == 'forward difference': # Correct all dipole by the permanent dipole, charge * center of mass d = ( get_dipole(structure, model_filename) + charge * structure.get_center_of_mass() ) d_forward = np.zeros((N, 3, 3)) for atom in range(N): for cartesian in range(3): copy = structure.copy() positions = copy.get_positions() positions[atom, cartesian] += displacement copy.set_positions(positions) d_forward[atom, cartesian, :] = ( get_dipole(copy, model_filename) + charge * copy.get_center_of_mass() ) gradient = (d_forward - d[None, None, :]) / displacement elif method == 'central difference': d_forward = np.zeros((N, 3, 3)) d_backward = np.zeros((N, 3, 3)) for atom in range(N): for cartesian in range(3): # Forward displacements copy_forward = structure.copy() positions_forward = copy_forward.get_positions() positions_forward[atom, cartesian] += displacement copy_forward.set_positions(positions_forward) d_forward[atom, cartesian, :] = ( get_dipole(copy_forward, model_filename) + charge * copy_forward.get_center_of_mass() ) # Backwards displacement copy_backward = structure.copy() positions_backward = copy_backward.get_positions() positions_backward[atom, cartesian] -= displacement copy_backward.set_positions(positions_backward) d_backward[atom, cartesian, :] = ( get_dipole(copy_backward, model_filename) + charge * copy_backward.get_center_of_mass() ) gradient = (d_forward - d_backward) / (2 * displacement) elif method == 'second order central difference': # Coefficients from # https://en.wikipedia.org/wiki/Finite_difference_coefficient#Central_finite_difference d_forward_one_h = np.zeros((N, 3, 3)) d_forward_two_h = np.zeros((N, 3, 3)) d_backward_one_h = np.zeros((N, 3, 3)) d_backward_two_h = np.zeros((N, 3, 3)) for atom in range(N): for cartesian in range(3): copy = structure.copy() positions = copy.get_positions() # Forward displacements positions[atom, cartesian] += displacement # + h copy.set_positions(positions) d_forward_one_h[atom, cartesian, :] = ( get_dipole(copy, model_filename) + charge * copy.get_center_of_mass() ) positions[atom, cartesian] += displacement # + 2h total copy.set_positions(positions) d_forward_two_h[atom, cartesian, :] = ( get_dipole(copy, model_filename) + charge * copy.get_center_of_mass() ) # Backwards displacement positions[atom, cartesian] -= 3 * displacement # 2h - 3h = -h copy.set_positions(positions) d_backward_one_h[atom, cartesian, :] = ( get_dipole(copy, model_filename) + charge * copy.get_center_of_mass() ) positions[atom, cartesian] -= displacement # - 2h total copy.set_positions(positions) d_backward_two_h[atom, cartesian, :] = ( get_dipole(copy, model_filename) + charge * copy.get_center_of_mass() ) c0 = -1.0 / 12.0 c1 = 2.0 / 3.0 gradient = ( c0 * d_forward_two_h + c1 * d_forward_one_h - c1 * d_backward_one_h - c0 * d_backward_two_h ) / displacement else: raise ValueError(f'Invalid method {method} for calculating gradient') return gradient def _dipole_gradient_nep( structure: Atoms, model_filename: str, method: str = 'central difference', displacement: float = 0.01, charge: float = 1.0, nep_command: Optional[str] = None, ) -> np.ndarray: """Calculates the dipole gradient with finite differences, using the NEP executable. Parameters ---------- structure Input structure model_filename Path to NEP model in ``nep.txt`` format. method Method for computing gradient with finite differences. One of ``'forward difference'`` and ``'central difference'``. Defaults to ``'central difference'`` displacement Displacement in Å to use for finite differences. Defaults to 0.01 Å. Note that results are possibly unreliable for displacemen < 0.01, which might be due to rounding errors. charge System charge in units of the elemental charge. Used for correcting the dipoles before computing the gradient. Defaults to 1.0. nep_command Command for running the NEP executable. Default: ``nep``, or the value of the ``CALORINE_NEP_COMMAND`` environment variable if set. Returns ------- dipole gradient with shape ``(N, 3, 3)`` """ if displacement <= 0: raise ValueError('Displacement must be > 0 Å') if displacement < 1e-2: warnings.warn( 'Dipole gradients with nep are unstable for displacements < 0.01 Å.' ) N = len(structure) if method == 'forward difference': structure = _set_dummy_energy_forces(structure) structures = [structure] # will hold 3N+1 structures # Correct for the constant dipole, by adding charge * center of mass corrections = np.zeros((3 * N + 1, 3)) corrections[0] = charge * structure.get_center_of_mass() for atom in range(N): for cartesian in range(3): copy = structure.copy() positions = copy.get_positions() positions[atom, cartesian] += displacement copy.set_positions(positions) copy = _set_dummy_energy_forces(copy) structures.append(copy) corrections[1 + atom * 3 + cartesian] = ( charge * copy.get_center_of_mass() ) dipoles = ( _predict_dipole_batch(structures, model_filename, nep_command) * N ) # dipole/atom, shape (3N+1, 3) dipoles += corrections d = dipoles[0, :] d_forward = dipoles[1:].reshape(N, 3, 3) gradient = (d_forward - d[None, None, :]) / displacement elif method == 'central difference': structures_forward = [] # will hold 3N structures structures_backward = [] # will hold 3N structures # Correct for the constant dipole, by adding charge * center of mass corrections_forward = np.zeros((3 * N, 3)) corrections_backward = np.zeros((3 * N, 3)) for atom in range(N): for cartesian in range(3): # Forward displacements copy_forward = structure.copy() positions_forward = copy_forward.get_positions() positions_forward[atom, cartesian] += displacement copy_forward.set_positions(positions_forward) copy_forward = _set_dummy_energy_forces(copy_forward) structures_forward.append(copy_forward) corrections_forward[atom * 3 + cartesian] = ( charge * copy_forward.get_center_of_mass() ) # Backwards displacement copy_backward = structure.copy() positions_backward = copy_backward.get_positions() positions_backward[atom, cartesian] -= displacement copy_backward.set_positions(positions_backward) copy_backward = _set_dummy_energy_forces(copy_backward) structures_backward.append(copy_backward) corrections_backward[atom * 3 + cartesian] = ( charge * copy_backward.get_center_of_mass() ) structures = structures_forward + structures_backward dipoles = ( _predict_dipole_batch(structures, model_filename, nep_command) * N ) # dipole/atom, shape (6N, 3) d_forward = dipoles[: 3 * N, :] d_backward = dipoles[3 * N :, :] d_forward += corrections_forward d_backward += corrections_backward d_forward = d_forward.reshape(N, 3, 3) d_backward = d_backward.reshape(N, 3, 3) gradient = (d_forward - d_backward) / (2 * displacement) else: raise ValueError(f'Invalid method {method} for calculating gradient') return gradient def _set_dummy_energy_forces(structure: Atoms) -> Atoms: """Sets the energies and forces of structure to zero. Parameters ---------- structure Input structure Returns ------- Copy of structure, with SinglePointCalculator with zero energy and force. """ from ase.calculators.singlepoint import SinglePointCalculator copy = structure.copy() N = len(copy) energy = 0 forces = np.zeros((N, 3)) dummy = SinglePointCalculator(copy, **{'energy': energy, 'forces': forces}) copy.calc = dummy return copy def _predict_dipole_batch( structures: List[Atoms], model_filename: str, nep_command: Optional[str] = None ) -> np.ndarray: """Predicts dipoles for a set of structures using the NEP executable Note that the units are in (dipole units)/atom. Parameters ---------- structure Input structures model_filename Path to NEP model in ``nep.txt`` format. nep_command Command for running the NEP executable. Default: ``nep``, or the value of the ``CALORINE_NEP_COMMAND`` environment variable if set. Returns ------- Predicted dipoles, with shape (len(structures), 3). """ import shutil from os.path import join as join_path from subprocess import run from tempfile import TemporaryDirectory from calorine.nep import read_model, write_nepfile, write_structures with TemporaryDirectory() as directory: shutil.copy2(model_filename, join_path(directory, 'nep.txt')) model = read_model(model_filename) parameters = dict( prediction=1, mode=1, version=model.version, n_max=[model.n_max_radial, model.n_max_angular], type=[len(model.types), *model.types], cutoff=[model.radial_cutoff, model.angular_cutoff], basis_size=[model.n_basis_radial, model.n_basis_radial], l_max=[model.l_max_3b, model.l_max_4b, model.l_max_5b], neuron=model.n_neuron, ) write_nepfile(parameters, directory) file = join_path(directory, 'train.xyz') with warnings.catch_warnings(): # this function only ever predicts dipoles; the input stress of the # structures is irrelevant and usually absent, so the resulting # warning is expected warnings.filterwarnings( 'ignore', message='Failed to retrieve stresses for structure', category=UserWarning) write_structures(file, structures) # Execute nep nep_command = nep_command or calorine_getenv('NEP_COMMAND') completed = run([nep_command], cwd=directory, capture_output=True) completed.check_returncode() # Read results dipoles = np.loadtxt(join_path(directory, 'dipole_train.out')) if len(dipoles.shape) == 1: dipoles = dipoles.reshape(1, -1) return dipoles[:, :3] def _check_components_polarizability_gradient(component: Union[str, List[str]]) -> List[int]: """ Verifies that the selected components are ok. """ allowed_components = { 'x': 0, 'y': 1, 'z': 2, } # Check if chosen components are ok if component == 'full': components_to_compute = [0, 1, 2] else: components_list = [component] if isinstance(component, str) else component components_to_compute = [] for c in components_list: if c in allowed_components.keys(): components_to_compute.append(allowed_components[c]) elif c == 'full': raise ValueError('Write ``component="full"`` to get all components.') else: raise ValueError(f'Invalid component {c}') assert len(components_list) == len(components_to_compute), \ 'Number of components to compute does not match.' return components_to_compute
[docs] def get_polarizability_gradient( structure: Atoms, model_filename: Optional[str] = None, displacement: float = 0.01, component: Union[str, List[str]] = 'full', debug: bool = False, ) -> np.ndarray: """Calculates the dipole gradient for a given structure using finite differences. A NEP model defined by a ``nep.txt`` file needs to be provided. This function computes the derivatives using the second-order central difference method with a C++ backend. Parameters ---------- structure Input structure. model_filename Path to NEP model in ``nep.txt`` format. Defaults to ``None``. 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, 6)``. Multiple components may be specified. Defaults to ``full``. debug Flag to toggle debug mode. Prints GPUMD output (if applicable). Defaults to ``False``. Returns ------- polarizability gradient with shape ``(N, C, 6)`` where ``C`` is the number of components chosen. """ if model_filename is None: raise ValueError('Model is undefined') model_type = _get_nep_contents(model_filename)[0]['model_type'] if model_type != 'polarizability': raise ValueError('A NEP model trained for predicting polarizability must be used.') components_to_compute = _check_components_polarizability_gradient(component) local_structure = structure.copy() polarizability_gradient = _polarizability_gradient_cpp( local_structure, model_filename, displacement=displacement, components=components_to_compute, debug=debug, ) return polarizability_gradient
def _polarizability_gradient_to_3x3(pg): """ Converts a polarizability gradient tensor with shape (Natoms, 3, 6) to (Natoms, 3, 3, 3). The 6 items in the polarizability gradient are in NEP_REDUCED6_ORDER (xx, yy, zz, xy, yz, zx), the same convention as `get_polarizability`: src/nepy/nep.cpp computes the gradient via finite differences of repeated `find_polarizability` calls, so it inherits that function's component order directly. """ return reduced6_to_full_3x3(pg) def _polarizability_gradient_cpp( structure: Atoms, model_filename: str, displacement: float, components: List[int], debug: bool = False, ) -> np.ndarray: """Calculates the polarizability gradient with finite differences, using NEP_CPU. Parameters ---------- structure Input structure. model_filename Path to NEP model in ``nep.txt`` format. displacement Displacement in Å to use for finite differences. Defaults to ``0.01``. components List of components to compute. Integer values from 0 to 2 that corresponds to indices in the ordered list [x, y, z]. Index 1 corresponds to the derivative with regards to only the y positions of the atoms, and so forth. Returns ------- dipole gradient with shape ``(N, len(components), 3, 3)`` """ if displacement <= 0: raise ValueError('Displacement must be > 0 Å') # TODO possibly use components later to only move atoms in one cartesian direction local_structure = structure.copy() natoms = len(local_structure) cell, symbols, positions, masses = _get_atomic_properties(local_structure) nepy = _setup_nepy( model_filename, natoms, cell, symbols, positions, masses, debug ) pg = np.array( nepy.get_polarizability_gradient(displacement, components) ).reshape(natoms, 3, 6) # Convert to 3x3 polarizability_gradient = _polarizability_gradient_to_3x3(pg) return polarizability_gradient[:, components, :, :] # Only return the relevant components
[docs] def determine_energy_offsets( structures: List[Atoms], reference: Union[str, Model, None] = None, command: Optional[str] = None, ) -> Dict[str, float]: """Determines a per-species energy offset (reference energy shift) ``mu_i`` for a set of structures, by fitting ``E_target - E_reference = sum_i n_i * mu_i`` via ordinary least squares, where ``n_i`` is the number of atoms of species ``i`` in a structure. The fit never includes a constant term, since a structure-size-independent constant would distort per-atom energy comparisons between structures of different sizes. If ``reference`` is ``None``, the offsets are fitted directly against the target energies of ``structures`` themselves, which is useful, for example, to obtain isolated-atom-style reference energies before training a model from scratch. If ``reference`` is given, either as a path to a NEP model in ``nep.txt`` format or as a :class:`Model <calorine.nep.model.Model>` object, the offsets are instead fitted against the residual between the target energies and the energies predicted by ``reference``, e.g., to correct for an energy-scale mismatch when fine-tuning a reference model such as NEP89 on data from a different reference method. Parameters ---------- structures Structures with target energies attached, e.g., via an ASE calculator. reference Path to a NEP model in ``nep.txt`` format, or a :class:`Model <calorine.nep.model.Model>` object, to fit the offsets against. If ``None``, the offsets are fitted directly against the target energies of ``structures``. command Command used to invoke the ``nep`` executable when ``reference`` is given. Default: ``nep``, or the value of the ``CALORINE_NEP_COMMAND`` environment variable if set. Returns ------- Per-species energy offsets in eV, keyed by chemical symbol. """ target_energies = np.array([atoms.get_potential_energy() for atoms in structures]) if reference is None: reference_energies = np.zeros(len(structures)) else: from calorine.tools import batch_predict_properties predicted_structures = batch_predict_properties(structures, reference, command=command) reference_energies = np.array( [atoms.get_potential_energy() for atoms in predicted_structures]) species = sorted({s for atoms in structures for s in atoms.get_chemical_symbols()}) X = np.zeros((len(structures), len(species))) for i, atoms in enumerate(structures): counts = Counter(atoms.get_chemical_symbols()) for j, s in enumerate(species): X[i, j] = counts.get(s, 0) y = target_energies - reference_energies coeffs, *_ = np.linalg.lstsq(X, y, rcond=None) return dict(zip(species, coeffs))
[docs] def apply_energy_offsets(structures: List[Atoms], offsets: Dict[str, float]) -> List[Atoms]: """Applies a per-species energy offset, as determined by :func:`determine_energy_offsets <calorine.nep.determine_energy_offsets>`, to the target energies of a set of structures. Forces and stresses, if present, are passed through unchanged. Parameters ---------- structures Structures with target energies attached, e.g., via an ASE calculator. offsets Per-species energy offsets in eV, keyed by chemical symbol, as returned by :func:`determine_energy_offsets <calorine.nep.determine_energy_offsets>`. Returns ------- A new list of :class:`Atoms <ase.Atoms>` objects, in the same order as :attr:`structures`, with corrected target energies. The input :attr:`structures` are not modified. """ missing_species = { s for atoms in structures for s in atoms.get_chemical_symbols()} - offsets.keys() if missing_species: raise ValueError( f'offsets is missing the following species found in structures: ' f'{sorted(missing_species)}') corrected = [] for atoms in structures: new_atoms = atoms.copy() results = atoms.calc.results if atoms.calc is not None else {} properties = {} if 'energy' in results: counts = Counter(atoms.get_chemical_symbols()) shift = sum(counts.get(s, 0) * offsets[s] for s in counts) properties['energy'] = atoms.get_potential_energy() - shift if 'forces' in results: properties['forces'] = atoms.get_forces() if 'stress' in results: properties['stress'] = atoms.get_stress() new_atoms.calc = SinglePointCalculator(new_atoms, **properties) corrected.append(new_atoms) return corrected