"""Module for setting up and running the simulation
Classes for the simulation box, minimizer and integrator."""
import logging
import warnings
from collections import defaultdict
from itertools import count, filterfalse, product
from typing import TYPE_CHECKING, Tuple, Union
import numpy as np
from statsmodels.tsa.stattools import kpss
from verbosemanager import VerboseManager
from MDMC.common import units
from MDMC.common.decorators import (
mod_docstring,
repr_decorator,
unit_decorator,
unit_decorator_getter,
)
from MDMC.MD.container import AtomContainer
from MDMC.MD.engine_facades.facade_factory import MDEngineFacadeFactory
from MDMC.MD.force_fields.force_field_factory import ForceFieldFactory
from MDMC.MD.interactions import Coulombic, Dispersion
from MDMC.MD.parameters import Parameters
from MDMC.MD.solvents.solvents import get_solvent_config, get_solvent_names
from MDMC.MD.structures import Structure
from MDMC.trajectory_analysis.trajectory import Configuration
if TYPE_CHECKING:
from MDMC.MD.interactions import Interaction
from MDMC.MD.structures import Atom, Molecule
from MDMC.trajectory_analysis.compact_trajectory import CompactTrajectory
LOGGER = logging.getLogger(__name__)
_FF_DOCSTRING = {'DYNAMIC_FORCE_FIELD_LIST':
', '.join(ForceFieldFactory.get_force_field_names())}
# pylint: disable=too-few-public-methods
# as many classes here are small MD engine compatibility
[docs]
@repr_decorator('dimensions', 'kspace_solver', 'electrostatic_solver',
'dispersive_solver', 'constraint_algorithm', 'parameters')
@mod_docstring(_FF_DOCSTRING)
class Universe(AtomContainer):
"""
Class where the configuration and topology are defined
Parameters
----------
dimensions : numpy.ndarray, list, float
Dimensions of the ``Universe``, in units of ``Ang``. A `float` can be
used for a cubic universe.
force_fields : ForceField, optional
A force field to apply to the Universe. The force fields available are:
DYNAMIC_FORCE_FIELD_LIST. Default is None.
structures : list, optional
``Structure`` objects contained in the ``Universe``. Default is None.
**settings
``kspace_solver`` (`KSpaceSolver`)
The k-space solver to be used for both electrostatic and dispersive
interactions. If this is passed then no ``electrostatic_solver`` or
``dispersive_solver`` may be passed.
``electrostatic_solver`` (`KSpaceSolver`)
The k-space solver to be used for electrostatic interactions.
``dispersive_solver`` (`KSpaceSolver`)
The k-space solver to be used for dispersive interactions.
``constraint_algorithm`` (`ConstraintAlgorithm`)
The constraint algorithm which will be applied to constrained
``BondedInteractions``.
``verbose`` (`str`)
If the output of the class instantiation should be reported, default to True.
Attributes
----------
dimensions : numpy.ndarray, list, float
Dimensions of the ``Universe`` in units of ``Ang``.
configuration : Configuration
Stores the content, i.e. configuration of atoms etc within the universe
force_fields : ForceField or None
Force field applied to apply to the Universe
kspace_solver : KSpaceSolver
The k-space solver to be used for both electrostatic and dispersive
interactions.
electrostatic_solver : KSpaceSolver
The k-space solver to be used for electrostatic interactions.
dispersive_solver : KSpaceSolver
The k-space solver to be used for dispersive interactions.
constraint_algorithm : ConstraintAlgorithm
The constraint algorithm which will be applied to constrained
``BondedInteractions``.
interactions
bonded_interactions
nonbonded_interactions
bonded_interaction_pairs
n_bonded
n_nonbonded
n_interactions
parameters
volume
element_list
element_dict
element_lookup
atoms
n_atoms
molecule_list
n_molecules
structure_list
top_level_structure_list
equivalent_top_level_structures_dict
force_fields
atom_types
atom_type_interactions
density
solvent_density
nbis_by_atom_type_pairs
"""
def __init__(self, dimensions, force_field=None, structures=None, **settings):
self.dimensions = dimensions
self._atom_types = defaultdict(list)
self._atom_type_interactions = {}
if structures:
self.configuration = Configuration(structures)
else:
self.configuration = Configuration(universe=self)
self._solvent_density = 0.
self._bonded_interaction_pairs = set()
self._nonbonded_interactions = set()
if force_field:
self._force_fields = ForceFieldFactory.create_force_field(
force_field)
else:
self._force_fields = None
self.kspace_solver = settings.get('kspace_solver')
self.electrostatic_solver = settings.get('electrostatic_solver')
self.dispersive_solver = settings.get('dispersive_solver')
self.verbose = settings.get('verbose', True)
# kspace_solver is mutually exclusive with the other two solver
# attributes
if self.kspace_solver and (self.electrostatic_solver or
self.dispersive_solver):
msg = 'No other solver may be passed if kspace_solver is passed.'
LOGGER.error('%s has kspace_solver and %s. %s',
self.__class__,
'electrostatic_solver' if self.electrostatic_solver
else 'dispersive_solver',
msg)
raise ValueError(msg)
self.constraint_algorithm = settings.get('constraint_algorithm')
LOGGER.info(r'%s created: {dimensions:%s}',
self.__class__,
self.dimensions)
if self.verbose:
setup_frame = "Universe created with:"
if self.dimensions is not None:
setup_frame += f"\nDimensions {np.round(self.dimensions, 2)}"
if force_field is not None:
setup_frame += f"\nForce field {force_field}"
if self.n_atoms > 0:
setup_frame += f"\nNumber of atoms {self.n_atoms}"
print(setup_frame)
def __str__(self) -> str:
return (f'Universe with {self.n_atoms} atoms, {self.n_bonded} bonded interactions, '
f'{self.n_nonbonded} nonbonded interactions, and dimensions of {self.dimensions}')
def __eq__(self, other) -> bool:
if id(other) == id(self):
return True
if isinstance(other, self.__class__):
for k, v in self.__dict__.items():
try:
iter(v)
if any(v != getattr(other, k)):
return False
except TypeError:
if v != getattr(other, k):
return False
return True
return False
# Unit decorator on getter due to operations in setter
@property
@unit_decorator_getter(unit=units.LENGTH)
def dimensions(self) -> np.ndarray:
"""
Get or set the dimensions of the ``Universe``
Raises
------
TypeError
If a `float`, `list`, or ``array`` are not passed
ValueError
If a `list` or ``array`` is not 1d with 3 elements
Returns
-------
numpy.ndarray
The dimensions of the ``Universe``
"""
return self._dimensions
@dimensions.setter
def dimensions(self, dimensions: Union[float, list, tuple, np.ndarray]) -> None:
if isinstance(dimensions, float):
if dimensions <= 0:
msg = 'Only positive values for the Universe dimensions are currently supported.'
LOGGER.error('%s: {dimensions: %s} %s',
self.__class__,
dimensions,
msg)
raise ValueError(msg)
self._dimensions = np.array([dimensions] * 3)
elif isinstance(dimensions, (list, tuple, np.ndarray)):
if len(dimensions) == 3:
if any(dim <= 0 for dim in np.array(dimensions)):
msg = ('Only positive values for the Universe dimensions '
'are currently supported.')
LOGGER.error('%s: {dimensions: %s} %s',
self.__class__,
dimensions,
msg)
raise ValueError(msg)
self._dimensions = np.array(dimensions)
else:
msg = '3 dimensions must be specified'
LOGGER.error('%s: {dimensions: %s} %s',
self.__class__,
dimensions,
msg)
raise ValueError(msg)
else:
msg = 'dimensions must be a float or 3 element list of floats.'
LOGGER.error('%s: {dimensions: %s} %s',
self.__class__,
dimensions,
msg)
raise TypeError(msg)
@property
def interactions(self) -> list:
"""
Get the interactions in the ``Universe``
Returns
-------
list
The interactions in the ``Universe``
"""
return self.bonded_interactions + self.nonbonded_interactions
@property
def bonded_interactions(self) -> list:
"""
Get the bonded interactions in the ``Universe``
Returns
-------
list
The bonded interactions in the ``Universe``
"""
return [pair[0] for pair in self.bonded_interaction_pairs]
@property
def nonbonded_interactions(self) -> list:
"""
Get the nonbonded interactions in the ``Universe``
Returns
-------
list
The nonbonded interactions in the ``Universe``
"""
return list(self._nonbonded_interactions)
@property
def bonded_interaction_pairs(self) -> list:
"""
Get the bonded interactions and the atoms they apply to
Returns
-------
list
The (``interaction``, ``atoms``) pairs in the ``Universe``, where
``atoms`` is a `tuple` of all ``Atom`` objects for that specific
``interaction``
Example
-------
For an O Atom with two bonds, one to H1 and one to H2::
>>> print(O.bonded_interaction_pairs)
[(Bond, (H1, O)), (Bond, (H2, O))]
"""
# bonded_interaction_pairs is a set to avoid double counting of
# interactions
return list(self._bonded_interaction_pairs)
@property
def n_bonded(self) -> int:
"""
Get the number of bonded interactions in the ``Universe``
Returns
-------
int
The number of bonded interactions in the ``Universe``
"""
return len(self.bonded_interactions)
@property
def n_nonbonded(self) -> int:
"""
Get the number of nonbonded interactions in the ``Universe``
Returns
-------
int
The number of nonbonded interactions in the ``Universe``
"""
return len(self.nonbonded_interactions)
@property
def n_interactions(self) -> int:
"""
Get the number of interactions in the ``Universe``
Returns
-------
int
The number of interactions in the ``Universe``
"""
return self.n_bonded + self.n_nonbonded
@property
def parameters(self) -> Parameters:
"""
Get the parameters of the interactions that exist within the
``Universe``
Returns
-------
Parameters
The ``Parameters`` objects defined within ``Universe``
"""
return Parameters([parameter for interaction in self.interactions
for parameter in interaction.parameters.as_array])
@property
@unit_decorator_getter(unit=units.LENGTH ** 3)
def volume(self) -> float:
"""
Get the volume of the ``Universe``
Returns
-------
float
Volume in ``Ang^3``
"""
return np.prod(self.dimensions)
@property
def element_list(self) -> 'list[str]':
"""
The elements of the atoms in the ``Universe``
Returns
-------
list
The elements in the ``Universe``
"""
return [atom.element.symbol for atom in self.atoms]
@property
def element_dict(self) -> 'dict[str, Atom]':
"""
Get the elements in the ``Universe`` and example ``Atom`` objects for
each element
This is required for MD engines which assign the same potential
parameters for all identical element types.
Returns
-------
dict
``element:atom pairs``, where ``atom`` is a single ``Atom`` of the
specified ``element``.
"""
return {atom.element.symbol: atom for atom in self.atoms}
@property
def element_lookup(self) -> 'dict[str, Atom]':
"""
Get the elements by ID in the ``Universe``
This is required for MD engines which assign the same potential
parameters for all identical element names.
Returns
-------
dict
``element:atom pairs``, where ``atom`` is a single ``Atom`` of the
specified ``element``.
"""
return {atom.atom_type: atom.element.symbol for atom in self.atoms}
@property
def atoms(self) -> 'list[Atom]':
"""
Get a list of the atoms in the ``Universe``
Returns
-------
list
The atoms in the ``Universe``
"""
return self.configuration.atoms
@property
def n_atoms(self) -> int:
"""
Get the number of atoms in the ``Universe``
Returns
-------
int
The number of atoms in the ``Universe``
"""
return len(self.atoms)
@property
def molecule_list(self) -> 'list[Molecule]':
"""
Get a list of the ``Molecule`` objects in the ``Universe``
Returns
-------
list
The ``Molecule`` objects in the ``Universe``
"""
return self.configuration.molecule_list
@property
def n_molecules(self) -> int:
"""
Get the number of molecules in the ``Universe``
Returns
-------
int
The number of molecules in the ``Universe``
"""
return len(self.molecule_list)
@property
def structure_list(self) -> None:
"""
Get a `list` of all ``Structure`` objects that exist in the
``Universe``. This includes all ``Structure`` that are a subunit
of another structure belonging to the ``Universe``.
Returns
-------
list
The ``Structure`` objects in the ``Universe``
"""
def add_all_parents(unit):
current = unit
parent = unit.parent
parents = [parent]
while parent is not parent.top_level_structure:
parent = current.parent
parents.append(parent)
current = parent
return parents
structures = {parent for atom in self.atoms for parent in add_all_parents(atom)}
structures.update(self.atoms)
return list(structures)
@property
def top_level_structure_list(self) -> 'list[Structure]':
"""
Get a `list` of the top level ``Structure`` objects that exist in the
``Universe``. This does not include any ``Structure`` that are a subunit
of another structure belonging to the ``Universe``.
Returns
-------
list
The top level ``Structure`` objects in the ``Universe``
"""
# Remove duplicate entries from multiple atoms belonging to the same molecule
structures = {atom.top_level_structure for atom in self.atoms}
# and sort by ID for consistency
# Sorted converts to list
top_level_structures = sorted(structures, key=lambda x: x.ID)
return top_level_structures
@property
def equivalent_top_level_structures_dict(self) -> 'dict[Structure, int]':
"""
Get a `dict` of equivalent top level ``Structure`` objects that
exist in the ``Universe`` as keys, and the number of ``Structure``
that are equivalent to the key as a value. This does not include any
``Structure`` that are a subunit of another structure belonging
to the ``Universe``.
Returns
-------
dict
The top level ``Structure`` objects in the ``Universe`` as
keys, and the number of each as a value
"""
equivalent_dict = {}
for structure in self.top_level_structure_list:
match = False
for key in equivalent_dict:
if structure.is_equivalent(key):
equivalent_dict[key] += 1
match = True
break
if not match:
equivalent_dict[structure] = 1
return equivalent_dict
@property
@mod_docstring(_FF_DOCSTRING)
def force_fields(self) -> None:
"""
Get the ``ForceField`` acting on the ``Universe``
The available force fields are:
DYNAMIC_FORCE_FIELD_LIST
Returns
-------
list
``ForceField`` that applies to the ``Universe``
"""
return self._force_fields
@property
def atom_types(self) -> 'list[Atom]':
"""
Get the atom types of atoms in the ``Universe``
Returns
-------
list
The atom types in the ``Universe``
"""
return self._atom_types
@property
def atom_type_interactions(self) -> 'dict[int, list[Interaction]]':
"""
Get the atom types and the interactions for each atom type
Returns
-------
dict
``atom_type:interactions`` pairs where ``atom_type`` is a `int`
specifying the atom type and ``interactions`` is a `list` of
``Interaction`` objects acting on that ``atom_type``
"""
return self._atom_type_interactions
@property
@unit_decorator_getter(unit=units.MASS / units.LENGTH ** 3)
def density(self) -> float:
"""
Get the mass density of the ``Universe``
Returns
-------
float
The mass density of all of the atoms of the ``Universe``
"""
return sum(atom.mass for atom in self.atoms) / self.volume
@property
@unit_decorator_getter(unit=units.MASS / units.LENGTH ** 3)
def solvent_density(self) -> float:
"""
Get the mass density of a solvent added using ``solvate()``
Returns
-------
float
The mass density of a solvent added using sovlate
"""
return self._solvent_density
def _update_atom_types(self, atom: 'Atom') -> None:
"""
Adds the atom to ``atom_types`` `dict`
``atom_type`` is decided on the following criteria:
- If an ``Atom`` has the same name and element as an ``Atom`` already
in the ``Universe``, it will be assigned the same ``atom_type``.
- If an ``Atom`` does not have a name (e.g. ``Atom.name == None``),
but has the same element and the same ``Interaction`` objects as an
``Atom`` already in the ``Universe``, it will be assigned the same
``atom_type``.
- If an ``Atom`` does not fulfil either of the other criteria, it will
be assigned the lowest integer which is not already an ``atom_type``
in the ``Universe``
Parameters
----------
atom : Atom
An ``Atom`` to add to the ``atom_types`` `dict`
"""
if atom.name:
inter_key = (atom.element.symbol, atom.name)
else:
# Sorting is just to ensure consistent order. As interactions will have
# different types, sort by id
inter_key = (atom.element.symbol, ) + tuple(sorted(atom.interactions,
k=id))
if atom.atom_type:
atom_type = atom.atom_type
if atom_type not in self.atom_types:
self._update_atom_type_interactions(inter_key, atom_type)
else:
try:
atom_type = self.atom_type_interactions[inter_key]
except KeyError:
# Get lowest missing interger in self.atom_type_interactions
atom_type = next(filterfalse(set(
self.atom_type_interactions.values()).__contains__,
count(1)))
self._update_atom_type_interactions(inter_key, atom_type)
atom.atom_type = atom_type
self._atom_types[atom_type].append(atom)
def _update_atom_type_interactions(self, key: tuple, atom_type: int) -> None:
"""
Adds a new ``key:atom_type`` to ``atom_type_interactions``, if the
``key`` does not already exist
Parameters
----------
key : tuple
``(element, *interactions)``, where ``element`` is a `str`
specifying the atomic element, and ``*interactions`` is one or more
``Interaction`` objects
atom_type : int
The atom type
Raises
------
TypeError
If an assignment is made to an ``atom_type`` which already has
associated interactions
"""
if key not in self.atom_type_interactions:
self.atom_type_interactions[key] = atom_type
else:
msg = ('assignments cannot be made to atom_type_interactions keys'
'which already possess values.')
LOGGER.error('%s: {key: %s, atom_type_interactions: %s} %s',
self.__class__,
key,
self.atom_type_interactions,
msg)
raise TypeError(msg)
[docs]
@mod_docstring(_FF_DOCSTRING)
def add_structure(self, structure: Union[Structure, int],
force_field: str = None,
center: bool = False) -> None:
"""
Adds a single ``Structure`` to the ``Universe``, with optional
``ForceField`` applying only to that ``Structure``
Parameters
----------
structure : Structure or int
The ``Structure`` (or its ``ID`` as an `int`) to be added to the ``Universe``
force_field : str, optional
The force field to be applied to the structure. The available
``ForceField`` are:
DYNAMIC_FORCE_FIELD_LIST
center : bool, optional
Whether to center `structure` within the Universe as it is
added
"""
if center:
structure.position = self.dimensions / 2.
structure.universe = self
self.configuration.add_structure(structure)
for atom in structure.atoms:
self.add_bonded_interaction_pairs(*atom.bonded_interaction_pairs)
self.add_nonbonded_interaction(*atom.nonbonded_interactions)
self._update_atom_types(atom)
if force_field:
self.add_force_field(force_field, *structure.interactions)
[docs]
@mod_docstring(_FF_DOCSTRING)
def fill(self, structures: Structure, force_field: str = None,
num_density: float = None, num_struc_units: int = None) -> None:
"""
A liquid-like filling of the ``Universe`` independent of existing atoms
Adds copies of ``structures`` to existing configuration until
``Universe`` is full. As exclusion region is defined by the size of a
bounding sphere, this method is most suitable for atoms or molecules
with approximately equal dimensions.
.. note:: CURRENT APPROACH RESULTS IN NUMBER DENSITY DIFFERENT TO WHAT
IS SPECIFIED DEPENDING ON HOW CLOSE CUBE ROOT OF N_MOLECULES
IS TO AN `int`.
.. note:: CURRENT IMPLEMENTATION SHOULD NOT BE USED WITH NON-CUBIC
UNIVERSES AS THE DENSITY MAY OR MAY NOT BE ISOTROPIC
DEPENDING ON THE DIMENSIONS AND NUMBER OF UNITS.
Parameters
----------
structures : Structure or int
The ``Structure`` with which to fill the ``Universe``
force_field : str
Applies a ``ForceField`` to the ``Universe``. The available
``ForceField`` are:
DYNAMIC_FORCE_FIELD_LIST
num_density: float
Non-negative `float` specifying the number density of the
``Structure``, in units of ``Structure / Ang ^ -3``
num_struc_units: int
Non-negative `int` specifying the number of passed
``Structure`` objects that the universe should be filled
with, regardless of ``Universe.dimensions``.
Raises
------
ValueError
If both ``num_density`` and ``num_struc_units`` are passed
ValueError
If neither ``num_density`` or ``num_struc_units`` are passed
"""
if num_density is None and num_struc_units is not None:
num_density = num_struc_units / np.prod(self.dimensions)
elif num_density is not None and num_struc_units is not None:
msg = ('Cannot pass both num_density and num_struc_units to'
' fill the universe with.')
LOGGER.error('%s: {num_density: %s, num_struc_units: %s}'
' %s',
self.__class__,
num_density,
num_struc_units,
msg)
raise ValueError(msg)
elif num_density is None and num_struc_units is None:
msg = ('The fill method takes either num_density or'
' num_struc_units as a parameter.')
LOGGER.error('%s %s',
self.__class__,
msg)
raise ValueError(msg)
n_units_xyz = self.dimensions * (num_density ** (1. / 3.))
n_units_xyz = n_units_xyz.astype(int)
# Determine the upper and lower bounds for structural unit with its
# position (CoM) and its bounding box
bounds = structures.bounding_box
mn = np.array((0., 0., 0.)) - (bounds.min - structures.position)
mx = self.dimensions - (bounds.min - structures.position)
positions = [np.linspace(mi, ma, n_units, endpoint=False)
for mi, ma, n_units in zip(mn, mx, n_units_xyz)]
positions = sorted(product(*positions))
# Add the first structural unit and force field (if specified) before
# copying the structural unit to fill the universe
self.add_structure(structures, force_field)
structures.position = positions.pop(0)
for position in positions:
new_unit = structures.copy(position)
self.add_structure(new_unit)
[docs]
@mod_docstring(_FF_DOCSTRING)
def add_force_field(self, force_field: str,
*interactions: 'Interaction',
**settings: dict) -> None:
"""
Adds a force field to the specified ``interactions``. If no
``interactions`` are passed, the force field is applied to all
interactions in the ``Universe``.
Parameters
----------
force_field : str
The ``ForceField`` to parameterize ``interactions`` (if provided),
or all the ``interactions`` in the ``Universe``. The available
``ForceField`` are:
DYNAMIC_FORCE_FIELD_LIST
*interactions
``Interaction`` objects to parameterize with the ``ForceField``
**settings
``add_dispersions`` (`bool` or `list` of ``Atoms``)
If `True`, a ``Dispersion`` interaction will be added to all
atoms in the ``Universe``. If a list of ``Atom`` objects is
provided, the ``Dispersion`` will be added to these instead. Any
added ``Dispersion`` interactions (and any previously defined)
will then be parametrized by the ``ForceField``. The
``Dispersion`` interactions added will only be like-like. By
default, no ``Dispersion`` interactions are added.
"""
self._force_fields = ForceFieldFactory.create_force_field(force_field)
add_dispersions = settings.get('add_dispersions', False)
if add_dispersions:
if isinstance(add_dispersions, list):
atoms = add_dispersions
elif isinstance(add_dispersions, bool):
atoms = self.atoms
else:
msg = ('add_dispersions parameter of add_force_field method'
'must be a list of Atoms or a bool.')
LOGGER.error('%s: {add_dispersions: %s}',
self.__class__,
add_dispersions)
raise TypeError(msg)
# Get unique atom types and add dispersions for each of these
atom_types = {atom.atom_type for atom in atoms}
dispersions = [Dispersion(self, (atom_type,) * 2) for atom_type in atom_types]
if not interactions:
self.force_fields.parameterize_interactions(set(self.interactions))
else:
if add_dispersions:
interactions = interactions + tuple(dispersions)
self.force_fields.parameterize_interactions(set(interactions))
# FileForceFields also contain mass definitions for atoms, so set these
try:
for atom in self.atoms:
self.force_fields.set_atom_mass(atom)
except AttributeError:
pass
[docs]
def add_bonded_interaction_pairs(self,
*bonded_interaction_pairs: 'tuple[Interaction, Atom]') -> None:
"""
Adds one or more interaction pairs to the ``Universe``
Parameters
----------
*bonded_interaction_pairs
one or more (``interaction``, ``atoms``) pairs, where ``atoms`` is a
`tuple` of all atoms for that specific bonded ``interaction``
"""
self._bonded_interaction_pairs.update(bonded_interaction_pairs)
[docs]
def add_nonbonded_interaction(self,
*nonbonded_interactions: 'tuple[Interaction, Atom]') -> None:
# ignore line too long linting as it is necessary for URL formatting
# pylint: disable=line-too-long
"""
Adds one or more nonbonded interactions to the ``Universe``
Parameters
----------
*nonbonded_interactions
Nonbonded interactions to be added to the ``Universe``.
Can take any number of non-bonded interactions:
``Dispersion()``:
either Lennard-Jones or Buckingham dispersion
``Coulombic()``:
normal or modified Coulomb interaction
with appropriate parameters for the interaction.
See http://mdmcproject.org/how-to/use-MDMC/notebooks/defining-molecule-interactions.ipynb
for more details on non-bonded interactions.
"""
# Since self._nonbonded_interactions is a set the update method only adds new interactions
self._nonbonded_interactions.update(nonbonded_interactions)
@property
def nbis_by_atom_type_pairs(self) -> 'dict[tuple[int], list[Interaction]]':
"""
Generates a `dict` of all nonbonded interactions possessed by all
combinations of ``atom_type`` pairs in the ``Universe``.
Returns
-------
dict
A `dict` of ``{pair: interactions}`` pairs, where:
- ``pair`` is a `tuple` for a pair of ``atom_types`` in the
``Universe``
- ``interactions`` is a list of nonbonded interactions that
exist for this ``pair`` of ``atom_types``
Any ``Dispersions`` in ``interactions`` are ones that exist
explicity for this ``pair``, whereas any ``Coulombics`` in
``interactions`` are ones that exist for either of the
``atom_types`` in ``pair``.
"""
pairs_interactions = {}
# Find pairwise combinations of N atom_types in the universe
# Where each pair (i, j) has 0 < i, j <= N and i <= j
atom_type_pairs = ((i, j) for i, j in product(self.atom_types,
repeat=2)
if i <= j)
# Create dict of interactions for each atom type pair
for pair in atom_type_pairs:
pairs_interactions[pair] = [inter for inter in self.interactions
if ((isinstance(inter, Dispersion)
and pair in inter.atom_types)) or
(isinstance(inter, Coulombic)
and any(elem in inter.atom_types for elem in pair))]
return pairs_interactions
def _check_out_of_bounds(self, position: Union[list, np.ndarray]) -> bool:
"""
Checks whether a ``position`` lies outside the bounds of the
``Universe``
Parameters
----------
position : list, array
The position to be checked against bounds of the ``Universe``.
Returns
-------
bool
`True` if the ``position`` passed falls outside the ``Universe``,
`False` otherwise.
"""
return any(position > self.dimensions) or any(position < [0, 0, 0])
[docs]
@mod_docstring({'DYNAMIC_SOLVENT_LIST': ', '.join(get_solvent_names())})
def solvate(self, density: float,
tolerance: float = 1., solvent: str = 'SPCE',
max_iterations: int = 100, **settings: dict) -> None:
"""
Fills the ``Universe`` with solvent molecules according to pre-defined
coordinates.
Parameters
----------
density : float
The desired density of the ``Solvent`` that solvates the
``Universe``, in units of ``amu Ang ^ -3``
tolerance : float, optional
The +/- percentage tolerance of the density to be achieved.
The default is 1 %. Tolerances of less than 1 % are at risk
of not converging.
solvent : str, optional
A `str` specifying an inbuilt ``Solvent`` from the following:
DYNAMIC_SOLVENT_LIST.
The default is 'SPCE'.
max_iterations: int, optional
The maximum number of times to try to solvate the universe to
within the required density before stopping. Defaults to 100.
**settings
``constraint_algorithm`` (`ConstraintAlgorithm`)
A ``ConstraintAlgorithm`` which is applied to the ``Universe``.
If an inbuilt ``Solvent`` is selected (e.g. 'SPCE') and
``constraint_algorithm`` is not passed, the
``ConstraintAlgorithm`` will default to ``Shake(1e-4, 100)``.
Raises
------
ValueError
If the ``Universe`` has already been solvated with a different
density.
"""
solvent_config = get_solvent_config(solvent)
# Calculate useful properties from the original box
solvent_mass = solvent_config.mass
orig_box_dimensions = solvent_config.box_dimensions
# density is adjusted to account for density of solvent already in box
density = density - self.solvent_density
# If this is already within the specified tolerance then return, as
# calling solvate is redundant. Otherwise, raise an error, as solvate is
# not designed to be applied multiple times to change the
# solvent_density of a Universe.
if abs(density * 100) <= abs(tolerance):
return
if self.solvent_density != 0.:
msg = ('The universe has already been solvated. The density of a'
' previously added solvent cannot be changed.')
LOGGER.error('%s: {solvent_density: %s, density: %s}. %s',
self.__class__,
self.solvent_density,
density, msg)
raise ValueError(msg)
# Get the prelim scaling of the orig box required to achieve density
dim_scaling = np.array([(solvent_config.density / density) ** (1. / 3.)]
* 3)
scale_factor = 0.
counter = 0
# Offset the atom_types of the solvent_config by the maximum atom_type
# in the Universe.
max_atom_type = max(self.atom_types.keys(), default=0)
solvent_config.offset_atom_types(max_atom_type)
difference = float('inf')
while abs(difference * 100) >= abs(tolerance):
counter += 1
dim_scaling *= 1 + scale_factor
box_dimensions = orig_box_dimensions * dim_scaling
num_tiles = np.array(self.dimensions / box_dimensions)
# Binary list for axes along which whole num of tiles are used.
wrap = np.array([1 if direc.is_integer() else 0
for direc in num_tiles])
num_tiles = np.ceil(num_tiles).astype(int)
mols = []
for trans_vect in product(range(0, num_tiles[0]),
range(0, num_tiles[1]),
range(0, num_tiles[2])):
solvent_config.reset_molecules()
for mol_key, mol in tuple(solvent_config.molecules.items()):
atom_positions = mol.values()
CoM = solvent_config.molec_from_dict(mol).position
remove = False
for pos in atom_positions:
pos += (dim_scaling
* (CoM + trans_vect * orig_box_dimensions)
- CoM)
# Create binary list indicating the axes along
# which the atom is out of bounds.
axes = np.array([1 if i > j else 0
for i, j in zip(pos, self.dimensions)])
# Translates position if wrapping required.
pos -= wrap * axes * num_tiles * box_dimensions
remove = self._check_out_of_bounds(pos)
# Check for overlap with solute molecules.
if not remove:
for solute in self.molecule_list:
cond1 = all(pos > solute.bounding_box.min)
cond2 = all(pos < solute.bounding_box.max)
if cond1 and cond2:
remove = True
if remove:
del solvent_config.molecules[mol_key]
mols += solvent_config.molecules_from_coords(
solvent_config.molecules,
universe=self)
# Check the density
actual = (len(mols) * solvent_mass) / self.volume
difference = (actual - density) / density
scale_factor = difference / counter
if counter > max_iterations:
msg = ('100 Iterations have been tried but the universe is not solvated'
' to within tolerance. Try increasing the tolerance or Universe size')
LOGGER.error(msg)
raise ValueError(msg)
# Once the correct density is achieved, add molecules to universe
# and get all bonded interactions
# Also determine the total density of the solvent
bonded_interactions = []
for molecule in mols:
self.add_structure(molecule)
bonded_interactions += molecule.interactions
# Get nonbonded interactions from atom types
# Add interaction if any of its atom types are in atom_types
nonbonded_interactions = []
for interaction in self.nonbonded_interactions:
inter_atom_types = np.array(interaction.atom_types).flatten()
if len(set(inter_atom_types).intersection(
solvent_config.atom_types.values())) >= 1:
nonbonded_interactions.append(interaction)
# Apply the force field of the solvent to the Universe
try:
self.add_force_field(solvent, *set(bonded_interactions
+ nonbonded_interactions))
# If BondedInteractions are constrained, apply a constrain algorithm
if solvent_config.constrained:
self.constraint_algorithm = settings.get('constraint_algorithm',
Shake(1e-4, 100))
if self.verbose:
print(f'Force field created by solvent {solvent}')
except ImportError:
pass
self._solvent_density += len(mols) * solvent_mass / self.volume
[docs]
class KSpaceSolver:
"""
Class describing the k-space solver that is applied to electrostatic and/or
dispersion interactions
Different ``MDEngine`` require different parameters to be specified for a
k-space solver to be used. These parameters are specified in settings.
Parameters
----------
**settings
``accuracy`` (`float`)
The relative RMS error in per-atom forces
Attributes
----------
accuracy : float
The relative RMS error in per-atom forces
"""
def __init__(self, **settings: dict):
self.accuracy = settings.get('accuracy')
@property
def name(self):
"""
Get the name of the class
Returns
-------
str
The name of the class
"""
return self.__class__.__name__
[docs]
class Ewald(KSpaceSolver):
"""
Holds the parameters that are required for the Ewald solver to be applied to
both/either the electrostatic and/or dispersion interactions
Parameters
----------
**settings
``accuracy`` (`float`)
The relative RMS error in per-atom forces
"""
[docs]
class PPPM(KSpaceSolver):
"""
Holds the parameters that are required for the PPPM solver to be applied to
both/either the electrostatic and/or dispersion interactions
Parameters
----------
**settings
``accuracy`` (`float`)
The relative RMS error in per-atom forces
"""
def __eq__(self, other) -> bool:
"""
Two KSpaceSolvers are equal if their __dict__ are equal
"""
if not isinstance(other, self.__class__):
return False
return all(v == getattr(other, k)
for k, v in self.__dict__.items())
def __ne__(self, other) -> bool:
return not self.__eq__(other)
[docs]
class ConstraintAlgorithm:
"""
Class describing the algorithm and parameters which are applied to
constrain ``BondedInteraction`` objects
Parameters
----------
accuracy : float
The accuracy (tolerance) of the applied constraints
max_iterations : int
The maximum number of iterations that can be used when calculating the
additional force that is required to constrain the atoms to satisfy the
constraints on the bonded interactions
Attributes
----------
accuracy : float
The accuracy (tolerance) of the applied constraints
"""
def __init__(self, accuracy: float, max_iterations: int):
self.accuracy = accuracy
self.max_iterations = max_iterations
@property
def name(self) -> str:
"""
Get the name of the class
Returns
-------
str
The name of the class
"""
return self.__class__.__name__
@property
def max_iterations(self) -> int:
"""
Get or set the maximum number of iterations that can be used when
calculating the additional force that is required to constrain the atoms
to satisfy the constraints on the bonded interactions
Returns
-------
int
The maximum number of iterations
"""
return self._max_iterations
@max_iterations.setter
def max_iterations(self, value: int) -> None:
self._max_iterations = int(value)
[docs]
class Shake(ConstraintAlgorithm):
"""
Holds the parameters which are required for the SHAKE algorithm to be
applied to the constrained interactions
Parameters
----------
accuracy : float
The accuracy (tolerance) of the applied constraints
max_iterations : int
The maximum number of iterations that can be used when calculating the
additional force that is required to constrain the atoms to satisfy the
constraints on the bonded interactions
"""
[docs]
class Rattle(ConstraintAlgorithm):
"""
Holds the parameters which are required for the RATTLE algorithm to be
applied to the constrained interactions
Parameters
----------
accuracy : float
The accuracy (tolerance) of the applied constraints
max_iterations : int
The maximum number of iterations that can be used when calculating the
additional force that is required to constrain the atoms to satisfy the
constraints on the bonded interactions
"""
[docs]
@repr_decorator('universe', 'engine', 'settings')
class Simulation:
"""
Sets up the molecular dynamics engine and parameters for how it should run
An ensemble is defined by whether a thermostat or barostat are present
Parameters
----------
universe : Universe
The ``Universe`` on which the simulation is performed.
traj_step : int
How many steps the simulation should take between dumping each
``CompactTrajectory`` frame. Along with ``time_step`` determines
the time separation of calculated variables such as energy.
time_step : float, optional
Simulation timestep in ``fs``. Default is 1.
engine : str, optional
The ``MDEngine`` used for the simulation. Default is ``'lammps'``.
**settings
``temperature`` (`float`)
Simulation temperature in ``K``. Note that velocities of atoms in
the MD engine are set based on the ``temperature``. If all atoms in
the ``universe`` have 0 velocity, then velocities for the MD engine
will be randomly chosen from a uniform distribution and then scaled
to give the correct temperature. If one or more velocities are set,
then these will be scaled to give the correct temperature. In
either case, only the velocities on the ``engine``, not the
``universe``, are affected.
``integrator`` (`str`)
Simulation time integrator.
``lj_options`` (`dict`)
``option:value`` pairs for Lennard-Jones interactions.
``es_options`` (`dict`)
``option:value`` pairs for electrostatic interactions.
``thermostat`` (`str`)
The name of the thermostat e.g. Nose-Hoover.
``barostat`` (`str`)
The name of the barostat e.g. Nose-Hoover.
``pressure`` (`float`)
Simulation pressure in ``Pa``. This is required if a barostat is
passed.
``verbose`` (`str`)
If the output of the class instantiation should be reported, default to True.
Attributes
----------
universe : Universe
The ``Universe`` on which the simulation is performed.
traj_step : int
How many steps the simulation should take between dumping each
``CompactTrajectory`` frame. Along with ``time_step`` determines
the time separation of calculated variables such as energy.
engine : MDEngine, optional
A subclass of ``MDEngine`` which provides the interface to the MD
library. Default is ``'lammps'``.
settings : dict
The settings passed to the ``Simulation``. See the Parameters section
for details.
time_step
trajectory
"""
# TODO: Potentially separate out universe and simulation setup
def __init__(self, universe: Universe, traj_step: int,
time_step: float = 1., engine: str = "lammps", **settings):
self.universe = universe
self.traj_step = traj_step
self.time_step = time_step
self.settings = settings
self.engine = MDEngineFacadeFactory.create_facade(engine)
self.engine.parent_simulation = self
self.verbose = settings.get('verbose', True)
self._setup()
self.setup_msg = f'Simulation created with {engine} engine'
if self.settings:
settings_strings = ''.join([f'{key}: {value} {units.SYSTEM.get(key.upper(),"")} \n'
for key, value in self.settings.items()])
self.setup_msg += f' and settings:\n{str(settings_strings)}\n'
if self.verbose:
print(self.setup_msg)
@property
def time_step(self) -> float:
"""
Get or set the simulation time step in ``fs``
Returns
-------
`float`
Simulation time step in ``fs``
"""
return self._time_step
@time_step.setter
@unit_decorator(unit=units.TIME)
def time_step(self, value: float) -> None:
self._time_step = value
@property
def traj_step(self) -> int:
"""
Get or set the number of simulation steps between saving the
``CompactTrajectory``
Returns
-------
`int`
Number of simulation steps that elapse between the
``CompactTrajectory`` being stored
"""
return self._traj_step
@traj_step.setter
def traj_step(self, value: int) -> None:
self._traj_step = value
def _setup(self) -> None:
"""
Creates a universe within the ``MDEngine`` with the equivalent
configuration and topology to ``self.universe`` and defines the
simulation conditions
"""
self.engine.setup_universe(self.universe, **self.settings)
self.engine.setup_simulation(**self.settings)
[docs]
def minimize(self, n_steps: int,
minimize_every: int = 10,
verbose: bool = False, output_log: str = None,
work_dir: str = None, **settings: dict) -> None:
"""
Performs an MD run intertwined with periodic structure relaxation.
This way after a local minimum is found, the system is taken
out of the minimum to explore a larger volume of the parameter
space.
Parameters
----------
n_steps : int
Total number of the MD run steps
minimize_every: int, optional
Number of MD steps between two consecutive minimizations
verbose: bool, optional
Whether to print statements when the minimization has been started and completed
(including the number of minimization steps and time taken). Default is `False`.
output_log: str, optional
Log file for the MD engine to write to. Default is `None`.
work_dir: str, optional
Working directory for the MD engine to write to. Default is `None`.
**settings
``etol`` (`float`)
If the energy change between iterations is less than ``etol``,
minimization is stopped. Default depends on engine used.
``ftol`` (`float`)
If the magnitude of the global force is less than ``ftol``,
minimization is stopped. Default depends on engine used.
``maxiter`` (`int`)
Maximum number of iterations of a single structure
relaxation procedure. Default depends on engine used.
``maxeval`` (`int`)
Maximum number of force evaluations to perform. Default depends
on engine used.
"""
verbose_manager = VerboseManager.instance()
# to match legacy use of verbose on this function (where verbose was bool) we use bool
# and convert to int, corresponding to verbose levels 0 or 1; there is only one verbose
# step in this function so verbose levels 2 or 3 would not provide extra information
verbose_manager.start(1, verbose=int(verbose))
verbose_manager.step(f"Running minimization every {minimize_every} steps "
f"in an MD run with {n_steps} steps")
self.engine.minimize(n_steps, minimize_every, output_log=output_log, work_dir=work_dir,
**settings)
verbose_manager.finish("Minimization")
[docs]
def run(self, n_steps: int, equilibration: bool = False, verbose: bool = False,
output_log: str = None, work_dir: str = None, **settings: dict) -> None:
"""
Runs the MD simulation for the specified number of steps. Trajectories
for the simulation are only saved when ``equilibration`` is `False`.
Additionally running equilibration for an NVE system (neither barostat
nor thermostat set) will temporarily apply a Berendsen thermostat (it
is removed from the simulation after the run is completed).
Parameters
----------
n_steps : int
Number of simulation steps to run
equilibration : bool, optional
If the run is for equilibration (`True`) or production (`False`).
Default is `False`.
verbose: bool, optional
Whether to print statements upon starting and completing the run.
Default is `False`.
output_log: str, optional
Log file for the MD engine to write to. Default is `None`.
work_dir: str, optional
Working directory for the MD engine to write to. Default is `None`.
"""
process = 'equilibration' if equilibration else 'simulation'
verbose_manager = VerboseManager.instance()
# to match legacy use of verbose on this function (where verbose was bool) we use bool
# and convert to int, corresponding to verbose levels 0 or 1; there is only one verbose
# step in this function so verbose levels 2 or 3 would not provide extra information
verbose_manager.start(1, verbose=int(verbose))
verbose_manager.step(f"Running {process} for {n_steps} steps")
if n_steps < self.traj_step:
warnings.warn(f"Run length ({n_steps}) less than dump frequency ({self.traj_step}), "
"run may not produce usable output.")
self.engine.run(n_steps=n_steps, equilibration=equilibration, output_log=output_log,
work_dir=work_dir, **self.settings)
verbose_manager.finish(f"{process.capitalize()}")
# pylint: disable=dangerous-default-value
# this is flagged up by variables having a list as default value
# but we don't mutate the list anywhere in the function, so
# this is safe and has better readability
[docs]
def auto_equilibrate(self,
variables: list[str] = ('temp', 'pe'),
eq_step: int = 10,
window_size: int = 100,
tolerance: float = 0.01) -> Tuple[int, dict]:
"""
Equilibrate until the specified list of variables have stabilised.
Uses the KPSS stationarity test to determine
whether the variables are stationary in a given window.
Parameters
----------
variables: list[str], default ['temp', 'pe']
The MD engine variables we use to monitor stability.
eq_step: int, default 10
The number of equilibration steps between each stability check.
window_size: int, default 100
The size of the rolling window of stability checks. The simulation
is considered stable if it has been stationary for eq_step*window_size
equilibration steps.
tolerance: float, 0.01
The p-value used for the KPSS test.
Returns
-------
int
The number of equilibration steps performed.
vals_dict
The value of each variable per step, for graphing if desired.
"""
vals_dict = {var: [] for var in variables}
def auto_step() -> None:
"""
Performs one step of an equilibration and records variable values.
"""
self.run(eq_step, equilibration=True)
for var in vals_dict:
vals_dict[var].append(self.engine.eval(var))
def window_is_stationary(var: str) -> bool:
"""
Checks stability for a variable by running the KPSS test on a window.
"""
variable_values = vals_dict[var]
window = variable_values[-window_size:]
results = kpss(window, regression='c')
# results[1] is the p-value from the test
# we base our tolerance on the p-value, where the altenrative hypothesis
# for KPSS is "NOT stationary" - statsmodels also never gives a p above
# 0.1 as it doesn't hold critical values above that point. so for 0.05
# tolerance, we are asking that p be greater than 0.95
return results[1] > 0.1 - tolerance
# we perform an initial run of window_size*eq_step steps to create a window.
for _ in range(window_size):
auto_step()
# we consider the simulation stable if the rolling window
# is stationary for all variables (by KPSS)
while not all(window_is_stationary(var) for var in vals_dict):
auto_step()
total_steps = len(list(vals_dict.values())[0]) * eq_step
print("Auto-equilibration has detected stability "
f"after {total_steps} equilibration steps.")
return total_steps, vals_dict
@property
def trajectory(self) -> Union['CompactTrajectory', None]:
"""
The ``CompactTrajectory`` produced by the most recent production run of the
``Simulation``.
Returns
-------
CompactTrajectory
Most recent production run ``CompactTrajectory``, or `None` if no
production run has been performed
"""
try:
return self.engine.convert_trajectory()
except AttributeError:
return None