"""A calculator based on the OpenKIM project.
This module defines three object.
OpenKIMcalculator is a calculator class that interfaces to any OpenKIM V2 model.
OpenKIMparameters is a dictionary-like object that gives access to any
mutable parameters of the OpenKIM model, the object is created by
calling OpenKIMcalculator.get_parameters().
OpenKIMavailable is a function returning a list of available OpenKIM models.
"""
from ase.calculators.calculator import Calculator
from asap3 import _asap, AsapError
from asap3.Internal.ListOfElements import ListOfElements
from asap3.Internal.BuiltinPotentials import get_cell_heights, smallest_pbc_direction, AsapPotential
from ase.data import atomic_numbers, chemical_symbols
import numpy as np
from copy import copy
import subprocess
import shutil
import os
from collections.abc import MutableMapping
import numbers
[docs]
class OpenKIMcalculator(_asap.OpenKIMcalculator, AsapPotential):
"""A calculator interfacing to the OpenKIM models.
Parameters
----------
name : str
The long name of the KIM Model.
atoms : ase.Atoms, optional
If given, ``set_atoms`` is called immediately. By default no atoms
are set.
stress : bool, optional
Set to ``False`` to refrain from calculating the global virial, even
if the model supports it. The default is ``True``.
stresses : bool, optional
As ``stress``, but for atomic virials / stresses. The default is
``True``.
verbose : bool, optional
Set to ``True`` to print additional information during neighbor list
matching. The default is ``False``.
"""
def __init__(self, name, atoms=None,
stress=True, stresses=True, verbose=False):
_asap.OpenKIMcalculator.__init__(self, name, verbose)
self.kimname = name
self.atoms = None
self.support_stress = stress
self.support_stresses = stresses
self.verbose = verbose
if atoms is not None:
self.set_atoms(atoms)
def set_atoms(self, atoms, *args):
"""Set the atoms belonging to this calculator.
Parameters
----------
atoms : ase.Atoms
The atoms to attach to this calculator.
*args : optional
Internal access object passed on to the C++ implementation.
Raises
------
RuntimeError
If the OpenKIM model does not support one of the elements present in
the atoms.
Notes
-----
The function ``set_atoms(atoms)`` is defined in C++ and just calls this
Python function. This ensures that this Python function is called
regardless of whether ``set_atoms`` is called from C++ or Python.
"""
# Inform the calculator about the translation from atomic numbers to particle types.
self.z_to_typecode = {}
elements = ListOfElements(atoms)
for e in elements:
symbol = chemical_symbols[e]
try:
code = self.get_type_code(chemical_symbols[e])
except AsapError:
raise RuntimeError("The OpenKIM model '%s' does not support element Z=%i (%s)."
% (self.kimname, e, chemical_symbols[e]))
self.z_to_typecode[e] = code
if self.verbose:
print("Translation: Z = %i -> id = %i" % (e, code))
self.set_translation(self.z_to_typecode)
# Check the compute arguments required by the Model.
computearguments = self._get_compute_arguments()
requiredargs = ['numberOfParticles', 'particleSpeciesCodes', 'particleContributing',
'coordinates', 'partialEnergy', 'partialForces', 'GetNeighborList']
for a in requiredargs:
if computearguments[a] == 'notSupported':
raise AsapError(f"The OpenKIM model '{self.kimname}' does not support '{a}'")
del computearguments[a] # Processed
self.supported_calculations = {}
for k in ('partialEnergy', 'partialForces'):
self.supported_calculations[k] = True
# partialParticleEnergy is optional, but we will calculate it if possible
arg = 'partialParticleEnergy'
self.supported_calculations[arg] = computearguments[arg] != 'notSupported'
del computearguments[arg]
# partialVirial and partialParticleVirial are allocated if they are required by the model, or if
# they are optional and stress is requested.
arg = 'partialVirial'
self.supported_calculations[arg] = (
computearguments[arg] == 'required' or
(self.support_stress and computearguments[arg] == 'optional'))
del computearguments[arg]
arg = 'partialParticleVirial'
self.supported_calculations[arg] = (
computearguments[arg] == 'required' or
(self.support_stress and computearguments[arg] == 'optional'))
del computearguments[arg]
# Make sure that the model does not require something extra.
for a, s in computearguments.items():
if s == 'required' or s == 'requiredByAPI':
raise RuntimeError("OpenKIM model '{}' requires parameter '{}' (status: '{}')".format(
self.name, a, s))
# Inform the calculator about which quantities should be allocated and calculated.
for k,v in self.supported_calculations.items():
self.please_support(k, v)
# Find out which neighborlist type we got. Check sanity and if ImageAtoms should be used.
pbc = atoms.get_pbc()
if self.verbose:
print(f"Activating Image atoms (pbc = {str(pbc)})")
self._use_imageatoms()
_asap.OpenKIMcalculator.set_atoms(self, atoms, *args)
[docs]
def get_supported_elements(self, user=True):
"""Get a list of supported elements, as chemical symbols.
Parameters
----------
user : bool, optional
If ``True`` (the default), elements with the names ``'user01'`` to
``'user20'`` may also be reported as supported.
Returns
-------
list of str
The chemical symbols of the supported elements.
"""
supported = []
candidates = chemical_symbols[1:] # chemical_symbols[0] is 'X' and should be skipped.
if user:
candidates += [f'user{x:02d}' for x in range(1,21)]
for element in candidates:
try:
self.get_type_code(element)
except AsapError:
pass
else:
supported.append(element)
return supported
def get_name(self):
return "asap3.OpenKIMcalculator(" + self.kimname + ")"
def _get_parameters(self):
return OpenKIMparameters(self)
name = property(get_name, doc="Name of the potential")
parameters = property(_get_parameters,
doc="Published parameters of the OpenKIM model as a dict-like object")
[docs]
class OpenKIMparameters(MutableMapping):
"""Published parameters of the OpenKIM model.
A dictionary-like object containing the parameters of a OpenKIM model.
Model parameters can be read and set. If the parameter is a multidimensional
array then a flattened version of the array is used when accessing the
parameters through normal dictionary indexing (``params['paramname']``).
This object also has two helper functions that can be used to get and set
multidimensional arrays, ``.getparameter(name, shape=None)`` and
``.setparameter(name, value, shape=None)``. They both take an optional argument
specifying how an array of values should be interpreted. The following values
are supported for the ``shape`` parameter:
*a tuple*
Explicitly gives the shape of the array.
``'square'``
The array is a square array.
``'ut'``
The array is an upper-triangular square matrix.
``'lt'``
The array is a lower-triangular square matrix.
``None``
The value is a scalar or a 1D array.
"""
def __init__(self, model):
self.model = model
self.parameters = {}
self.descriptions = {}
for i, (name, typecode, size, description) in enumerate(model._get_parameter_names_types()):
self.parameters[name] = (i, typecode, size)
self.descriptions[name] = description
def __len__(self):
return len(self.parameters)
def __getitem__(self, key):
return self.model._get_parameter(*self.parameters[key])
def __setitem__(self, key, value):
index = self.parameters[key][0]
if isinstance(value, numbers.Number):
# A scalar
self.model._set_parameter(index, value)
else:
# An array - flatten it.
self.model._set_parameter(index, np.ravel(value))
def __delitem__(self, key):
raise RuntimeError("Cannot delete a parameter from an OpenKIM model.")
def __iter__(self):
yield from self.parameters
[docs]
def get_parameter(self, key, shape=None):
"""Get a model parameter
Parameters
----------
key : str
Name of the parameter.
shape : str or tuple, optional
How to interpret the shape of the array - see class documentation.
"""
p = self[key]
if shape is None:
return p
elif isinstance(shape, tuple):
return p.reshape(shape)
shape = shape.lower()
if shape == "square":
size = int(np.round(np.sqrt(len(p))))
if size*size == len(p):
return p.reshape((size,size))
else:
raise ValueError(f"Cannot return parameter as a square matrix, its length is {len(p)}")
if shape == "upper triangular":
shape = "ut"
elif shape == "lower triangular":
shape = "lt"
if shape == "ut" or shape == "lt":
size = int(np.floor(np.sqrt(2*len(p))))
if size * (size + 1) // 2 == len(p):
result = np.zeros((size, size), dtype=p.dtype)
if shape == "ut":
idx = np.triu_indices(size)
else:
idx = np.tril_indices(size)
result[idx] = p
return result
else:
raise ValueError(f"Cannot return parameter as a triangular matrix ({shape}), its length is {len(p)}")
else:
raise ValueError("Unknown shape "+shape)
[docs]
def set_parameter(self, key, value, shape=None):
"""Set a model parameter
Parameters
----------
key : str
Name of the parameter.
value: int or float or numpy.ndarray
The new value of the parameter.
shape : str or tuple, optional
How to interpret the shape of the array - see class documentation.
"""
if shape is None:
self[key] = value
return
if len(value.shape) != 2 or value.shape[0] != value.shape[1]:
raise ValueError("All supported shapes assume a square matrix")
shape = shape.lower()
if shape == "square":
self[key] = value # Flattened in __setitem__
elif shape == "ut" or shape == "upper triangular":
idx = np.triu_indices(len(value))
self[key] = value[idx]
elif shape == "lt" or shape == "lower triangular":
idx = np.tril_indices(len(value))
self[key] = value[idx]
def _get_openkim_model_string(verbose):
exename = "kim-api-collections-info"
args = ' portable_models'
sistername = 'kim-api-collections-management'
# First try if the program is on the path.
try:
modelstring = subprocess.check_output(exename+args, shell=True, stderr=subprocess.STDOUT).decode('utf-8')
return modelstring
except subprocess.CalledProcessError:
pass
# Then look for it using pkg-config
exepath = None
try:
prefix = subprocess.check_output("pkg-config libkim-api --variable=libexecdir", shell=True).decode('utf-8').strip()
except subprocess.CalledProcessError:
pass
else:
exepath = os.path.join(prefix, 'kim-api', exename)
if verbose:
print("Found using pkg-config:", exepath)
# Finally, try to look for where kim-api-collections-management and then check ../libexec
if not exepath and hasattr(shutil, 'which'):
management = shutil.which(sistername)
if management:
prefix = os.path.dirname(os.path.dirname(management))
exepath = os.path.join(prefix, 'libexec', 'kim-api', exename)
if verbose:
print("Found relative to", sistername, ":", exepath)
if not exepath:
raise RuntimeError("Cannot locate "+exename+" to generate list of KIM models.")
if not os.access(exepath, os.R_OK):
raise RuntimeError("Executable should be at "+exepath+" but is absent or not usable.")
try:
modelstring = subprocess.check_output(exepath+args, shell=True).decode('utf-8')
return modelstring
except subprocess.CalledProcessError:
raise RuntimeError("Running "+exepath+" failed.")
def OpenKIMavailable(verbose=0):
"""Get the list of installed OpenKIM models.
The list is obtained by locating and running the command line tool
``kim-api-collections-info``.
Parameters
----------
verbose : int, optional
Print information about how the command line tool is located. The
default is 0 (quiet).
Returns
-------
list of str
The names of the installed models.
"""
modelstring = _get_openkim_model_string(verbose)
models = []
for line in modelstring.split('\n'):
if line:
models.append(line.split()[1])
return models