"""Module in which all interactions between structural units are defined.
``Interaction`` is the abstract base class from which all interactions have to be derived.."""
import logging
import weakref
from abc import ABC, abstractmethod
from contextlib import suppress
from itertools import permutations
from types import MethodType
from typing import TYPE_CHECKING, NoReturn, Set, Union
import numpy as np
from MDMC.common import units
from MDMC.common.decorators import repr_decorator, unit_decorator
from MDMC.MD.interaction_functions import Coulomb
from MDMC.utilities.structures import is_atom
if TYPE_CHECKING:
from MDMC.MD.interaction_functions import InteractionFunction
from MDMC.MD.parameters import Parameters
from MDMC.MD.simulation import Universe
from MDMC.MD.structures import Atom
LOGGER = logging.getLogger(__name__)
[docs]
@repr_decorator('function')
class Interaction(ABC):
"""
Base class for interactions, both bonded, non-bonded and constraints
Each different type of interaction should have an ``Interaction`` object.
This object contains a `list` of the ``Atom`` (or ```Atom`` pairs, triplets
or quadruplets, depending on the type of interaction) for which this
``Interaction`` applies. For example, an oxygen ``Coulombic`` interaction
would contain a `list` of `tuple` where each `tuple` contains a different O
``Atom``, and a hydrogen-oxygen ``Bond`` interaction would contain a `list`
of `tuple` where each `tuple` contains a different H and O pair.
``Interaction`` objects can be sliced to return a sublist of the `tuple`.
When an ``Atom`` is passed to an ``Interaction``, the ``Interaction`` is
also added to the ``Atom``.
Parameters
----------
**settings
``function`` (`InteractionFunction`)
A class of interaction function (e.g. ``HarmonicPotential``)
"""
def __init__(self, **settings: dict):
"""
Arguments:
atom_tuples - One or more tuples consisting of one or more Atom objects.
Each tuple contains all of the atoms involved in a single interaction.
For example:
H1 = Atom('H')
H2 = Atom('H')
O = Atom('O')
For non-bonded interactions both of the following are equivalent
(applying a Dispersion interaction to both H atoms):
H_dispersive = Dispersion((H1, ), (H2, ))
H_dispersive = Dispersion(H1, H2)
For bonded interactions both of the following are equivalent:
HO_bond = Bond((H1, O))
HO_bond = Bond(H1, O)
However when multiple bonds are initialized within a single Bond object
(e.g. creating a Bond between each H and O for a single water molecule),
tuples must be used to separate the bonds:
HO_bond = Bond((H1, O), (H2,O))
whereas the following is not valid:
HO_bond = Bond(H1, O, H2, O)
TypeError: object of type 'Atom' has no len()
"""
self.function = settings.get('function', None)
self.name = self.__class__.__name__
def __deepcopy__(self, memo=None) -> NoReturn:
"""
Interactions cannot be copied
"""
raise AttributeError('Interactions cannot be deepcopied')
def __copy__(self) -> NoReturn:
"""
Interactions cannot be copied
"""
raise AttributeError('Interactions cannot be copied')
@property
@abstractmethod
def atoms(self) -> Union['Atom', 'list[Atom]']:
"""
Get the atoms on which the ``Interaction`` is applied
"""
raise NotImplementedError
@property
def parameters(self) -> 'Parameters':
"""
Get the ``Parameter`` objects belonging to the ``InteractionFunction``
belonging to the ``Interaction``
Returns
-------
Parameters
A ``Parameters`` object containing each ``Parameter``
"""
return self.function.parameters
@property
def function(self) -> 'InteractionFunction':
"""
Get or set the ``InteractionFunction`` of the ``Interaction``
Returns
-------
InteractionFunction
The interaction function of the ``Interaction``
"""
return self._function
@function.setter
def function(self, value: 'InteractionFunction'):
self._function = value
@property
def function_name(self) -> Union[str, None]:
"""
Get the name of the ``InteractionFunction`` belonging to the
``Interaction``
Returns
-------
str
The name of the ``InteractionFunction``, or `None` if no
``InteractionFunction`` has been set
"""
try:
return self.function.name
except AttributeError:
return None
@property
@abstractmethod
def universe(self) -> 'Universe':
"""
Get the ``Universe`` to which the ``Interaction`` belongs
Returns
-------
Universe
The ``Universe`` to which the ``Interaction`` belongs or `None`
"""
raise NotImplementedError
[docs]
@abstractmethod
def element_list(self) -> list:
"""
Get a `list` of the elements for which the ``Interaction`` applies
Returns
-------
list
The elements for which the ``Interaction`` applies
"""
raise NotImplementedError
[docs]
def sorted_element_list(self) -> list:
"""
Sort the list of elements for which the ``Interaction`` applies
Returns
-------
list
The elements for which the ``Interaction`` applies, sorted
alphabetically
"""
return sorted(self.element_list())
[docs]
def element_tuple(self) -> tuple:
"""
A `tuple` of elements for which the ``Interaction`` applies
Returns
-------
tuple
The elements for which the ``Interaction`` applies
"""
return tuple(self.element_list())
def _add_interaction_atoms(self, atoms: 'list[Atom]'):
"""
Add the ``Interaction`` to atoms for which the ``Interaction`` has been
applied
Parameters
----------
atoms : list
``Atom`` objects which have been added to the ``Interaction``
"""
for atom in atoms:
atom.add_interaction(self, from_interaction=True)
[docs]
@repr_decorator('function', 'atom_types', 'cutoff')
class NonBondedInteraction(Interaction):
"""
Base class for non-bonded interactions
Parameters
----------
universe : Universe
The ``Universe`` in which the ``NonBondedInteraction`` exists
*atom_types
`int` for each ``atom_type`` for which the ``NonBondedInteraction``
applies
**settings
``cutoff`` (`float`)
The distance in ``Ang`` at which the interaction potential is
truncated
"""
def __init__(self, universe, *atom_types, **settings):
self.universe = universe
if self.universe:
self.universe.add_nonbonded_interaction(self)
self.cutoff = settings.get('cutoff')
super().__init__(**settings)
LOGGER.info('%s created: {function:%s, atom_types:%s}',
self.__class__,
self.function,
self.atom_types)
@abstractmethod
def __eq__(self, other) -> bool:
raise NotImplementedError
def __ne__(self, other) -> bool:
return not self == other
def __hash__(self):
# Simplified version of immutable hash which Python3 produces
# (marginally less efficient but shouldn't matter)
return id(self) // 8
def __str__(self) -> str:
"""
Returns
-------
str
The ``type``, ``atom_types`` and ``cutoff`` of the
``NonBondedInteraction``
"""
return (f'{self.__class__.__name__} interaction atom_types: {self.atom_types} '
f' cutoff: {self.cutoff}')
@property
@abstractmethod
def atom_types(self) -> 'list[int]':
"""
Get the atom types for which the ``NonBondedInteraction`` applies
Returns
-------
list
A list of `int` for the ``atom_types``
"""
raise NotImplementedError
@property
def universe(self) -> 'Universe':
"""
Get or set the ``Universe`` to which the ``NonBondedInteraction``
belongs
Returns
-------
Universe
The ``Universe`` to which the ``NonBondedInteraction`` belongs or
`None`
"""
try:
return self._universe()
except TypeError:
return self._universe
@universe.setter
def universe(self, value: 'Universe') -> None:
try:
self._universe = weakref.ref(value)
except TypeError:
self._universe = None
@property
def cutoff(self) -> float:
"""
Get or set the distance in ``Ang`` at which the interaction potential is
truncated
Returns
-------
float
The distance in ``Ang`` of the ``cutoff``
"""
return self._cutoff
@cutoff.setter
@unit_decorator(unit=units.LENGTH)
def cutoff(self, value: float) -> None:
self._cutoff = value
[docs]
def is_equivalent(self, other) -> bool:
"""
Checks for equivalence between two ``NonBondedInteraction``s, specifically
if they apply to the same ``atom_types``, have the same ``cuttoff`` and
the same ``function`` describing the interaction.
Parameters
----------
other : NonBondedInteraction
The object to compare against.
Returns
-------
bool
"""
if id(other) == id(self):
return True
return (isinstance(other, type(self))
and (sorted(self.atom_types, key=id)
== sorted(other.atom_types, key=id))
and self.cutoff == other.cutoff
and self.function == other.function)
[docs]
class Dispersion(NonBondedInteraction):
"""
A non-bonded dispersive interaction - either LJ or Buckingham
Parameters
----------
universe : Universe
The ``Universe`` in which the ``NonBondedInteraction`` exists
*atom_types
`int` for each atom type for which the ``NonBondedInteraction`` applies
**settings
``cutoff`` (`float`)
The distance in ``Ang`` at which the interaction potential is
truncated
``vdw_tail_correction`` (`bool`)
Specifies if the tail correction to the energy and pressure should
be applied. This only affects the simulation dynamics if the
simulation is being performed with constant pressure.
Raises
------
TypeError
``atom_types`` must be iterable
ValueError
``Dispersion`` should only be specified as existing between pairs of
``atom_types``
TypeError
Each ``atom_type`` must be `int`
"""
# Python3 requires subclasses that overwrite __eq__ to explicity inherit
# __hash__
__hash__ = NonBondedInteraction.__hash__
def __init__(self, universe: 'Universe', *atom_types: int, **settings: dict):
# Ignore pylint warning for inner function docstring
# pylint: disable=missing-docstring
def validate_atom_type_pair(atom_type_pair):
try:
atom_type_pair = tuple(sorted(atom_type_pair))
except TypeError as err:
raise TypeError('Atom types must be an iterable') from err
if len(atom_type_pair) != 2:
raise ValueError('Dispersion interactions should only be'
' specified as existing between pairs of'
' atom types')
if not all(isinstance(atom_type, (int, np.integer)) for atom_type
in atom_type_pair):
raise TypeError('Each atom type must be int')
return atom_type_pair
# Remove duplicates
self._atom_types = tuple({validate_atom_type_pair(atp) for atp
in atom_types})
super().__init__(universe, **settings)
# Add interactions to all atoms
for atom_type_pair in self.atoms:
for atoms in atom_type_pair:
for atom in atoms:
atom.add_interaction(self)
self.vdw_tail_correction = settings.get('vdw_tail_correction', False)
def __eq__(self, other):
return other.atom_types == self.atom_types and isinstance(other,
type(self))
@property
def atom_types(self):
return tuple(sorted(self._atom_types))
@property
def atoms(self) -> 'list[tuple[list[Atom]]]':
"""
Get the atoms on which the ``Dispersion`` is applied
Returns
-------
list
A `list` of two `tuple`, where each `tuple` contains a `list` of
`Atom`. Every ``Atom`` in the first `tuple` has a dispersion
interaction with every ``Atom`` in the second `tuple` (excluding
self interactions). This is the complete list of possible dispersion
interactions, i.e. it is only exactly correct if no cutoff has been
specified.
"""
return [map(lambda x: self.universe.atom_types[x], tpl) for tpl
in self.atom_types]
[docs]
def element_list(self) -> list:
"""
Get a list of the elements for which the ``Interaction`` applies
Returns
-------
list
The elements for which the ``Interaction`` applies
"""
# Each value in universe.atom_types dictionary contain list of atoms
# with same elements, so use index 0
# This is determined for all atom types in Dispersion interaction
return [self.universe.atom_types[atom_type][0].element.symbol
for tpl in self.atom_types
for atom_type in tpl]
[docs]
def is_equivalent(self, other) -> bool:
"""
Checks for equivalence between two ``Dispersion``s, specifically
if they apply to the same ``atom_types``, have the same ``cuttoff``,
the same ``function`` describing the interaction and
``vdw_tail_correction`` setting.
Parameters
----------
other : Dispersion
The object to compare against.
Returns
-------
bool
"""
return (super().is_equivalent(other)
and self.vdw_tail_correction == other.vdw_tail_correction)
[docs]
class Coulombic(NonBondedInteraction):
"""
A non-bonded coulombic interaction - either normal or modified Coulomb
Parameters
----------
universe : Universe, optional
The ``Universe`` in which the ``Coulombic`` exists. Default is `None`.
Must be passed as a parameter if ``atom_types`` if passed.
**settings
``charge`` (`float`)
The charge parameter of the ``Coulombic`` interaction, in units of
``e``. If this argument is passed, the ``interaction_function`` of
this ``Coulombic`` is set to ``Coulomb`` with this `float` as its
``Parameter``. Passing ``charge`` will overwrite any other
``interaction_functions`` that are set, i.e. it makes ``function``
parameter redundant
``atoms`` (`list`)
``Atom`` objects to which the ``Coulombic`` applies. If specifying
the ``atoms``, ``universe`` does not need to be passed as a
parameter.
atom_types : list of int
int for each atom_type for which the NonBondedInteraction applies.
If specifying the atom_types, the universe must be passed as a
parameter and the atoms for which the atom_types are specified must
exist in Universe. See the example above in the 'charge' section.
Raises
------
TypeError
If one or more atom_types are passed but no universe is passed
TypeError
If neither atom_types or atoms have been passed
TypeError
If both atom_types and atoms have been passed
Examples
--------
Upon initializing an ``Atom`` object and adding it to a ``Universe``:
.. highlight:: python
.. code-block:: python
O = Atom('O', atom_type=1)
universe = Universe(10.0)
universe.add_structure('O')
The following initializations of Coulombic are equivalent:
.. highlight:: python
.. code-block:: python
O_coulombic = Coulombic(universe, atom_types=[O.atom_type],
charge=-0.84)
O_coulombic = Coulombic(universe, atom_types=[O.atom_type],
function=Coulomb(-0.84))
If ``atoms`` is passed then a ``Universe`` does not need to be passed:
.. highlight:: python
.. code-block:: python
O_coulombic = Coulombic(atoms=[O], charge=-0.84)
"""
# Python3 requires subclasses thay overwrite __eq__ to explicity inherit
# __hash__
__hash__ = NonBondedInteraction.__hash__
def __init__(self, universe: 'Universe' = None, **settings: dict):
# pylint: disable=not-callable
# as it raises a false positive on self.add_atoms
try:
atom_types = settings['atom_types']
if settings.get('atoms'):
raise TypeError('Cannot pass both atoms and atom_types '
'as parameters.')
if isinstance(atom_types, (int, np.integer)):
# Account for init argument atom_types=atom_type
# rather than atom_types=[atom_type]
atom_types = [atom_types]
if not universe:
raise TypeError('Coulombic requires a universe when '
'atom_types are passed')
self.add_atom_types = MethodType(_add_atom_types, self)
self._atom_types = atom_types
self._atoms = [atom for atom_type in self.atom_types
for atom in universe.atom_types[atom_type]]
super().__init__(universe, **settings)
# Add interaction to atoms
for atom in self.atoms:
atom.add_interaction(self)
except KeyError:
self.add_atoms = MethodType(_add_atoms, self)
try:
atoms = settings['atoms']
except KeyError as error:
raise TypeError('Coulombic takes either atom_types or atoms '
'as parameters') from error
# Account for init argument atoms=atom rather than atoms=[atom]
if is_atom(atoms):
atoms = [atoms]
self._atoms = []
self._atom_types = []
self.add_atoms(*atoms)
# Assumes all atoms are in the same universe (or None)
universe = self.atoms[0].universe
super().__init__(universe, **settings)
charge = settings.get('charge')
if charge is not None:
# Initializes a Coulomb interaction function with charge and units
# and assigns it to self.function
self.function = Coulomb(charge)
LOGGER.warning('%s: Coulombic interaction for the Atom object'
'initialized with the Coulomb interaction function.',
self.__class__)
def __len__(self) -> int:
"""
Returns
-------
int
The number of interactions of this type that have been set
"""
return len(self.atoms)
def __getitem__(self, key: int) -> tuple:
"""
Returns
-------
tuple
The `tuple` of atoms at the specified index in ``atoms``.
"""
return self.atoms[key]
def __eq__(self, other) -> bool:
return (isinstance(other, type(self))
and (sorted(self.atom_types, key=id)
== sorted(other.atom_types, key=id))
and sorted(self.atoms, key=id) == sorted(other.atoms, key=id))
@property
def atoms(self) -> 'list[Atom]':
"""
Get the atoms on which the ``Coulombic`` interaction is applied
Returns
-------
list
A `list` of ``Atom`` on which the ``Coulombic`` is applied
"""
return self._atoms
@property
def atom_types(self) -> list:
"""
Get the atom types for which the ``Coulombic`` applies
Returns
-------
list
All atom types to which the ``Coulombic`` applies. If the
interaction was initialized with ``atoms``, all ``atom_types``
of the ``atoms`` to which the ``Coulombic`` was applied are
returned; HOWEVER THE COULOMBIC INTERACTION IS NOT APPLIED TO ALL
ATOMS OF THESE ``atom_types``, ONLY THE ATOMS IN ``self.atoms``
"""
return self._atom_types
[docs]
def element_list(self) -> list:
"""
Get a list of the elements for which the ``Coulombic`` interaction applies
Returns
-------
list
The elements for which the ``Coulombic`` interaction applies
"""
return list(set([atom.element.symbol for atom in self._atoms]
+ [self.universe.atom_types[atom_type][0].element.symbol for
atom_type in self.atom_types]))
[docs]
@repr_decorator('function', 'n_atoms')
class BondedInteraction(Interaction):
"""
Base class for bonded interactions
Parameters
----------
atom_tuples : list
A `list` of `tuple`. Each `tuple` contains ``Atom`` objects which are
bonded together. For three or more ``Atom`` objects, the order of
the ``Atom`` objects within each `tuple` is important.
**settings
``n_atoms`` (`int`)
The number of atoms to which this ``BondedInteraction`` applies, for
example 2 for a ``Bond``.
Examples
--------
For a single bonded interactions which applies to ``H1``, ``O1``, and
``H2``:
.. highlight:: python
.. code-block:: python
BondedInteraction(H1, O1, H2)
For two bonded interactions of the same ``BondedInteraction`` type, one
applied to ``H1``, ``O1`` and ``H2`` ``Atom`` objects, and the other applied
to ``H3``, ``O2`` and ``H4`` ``Atom`` objects:
.. highlight:: python
.. code-block:: python
BondedInteraction((H1, O1, H2), (H3, O2, H4))
Whereas the above examples are both specifying a H-O-H ordered
``BondedInteraction``, the following specifies a H-H-O
``BondedInteraction``:
.. highlight:: python
.. code-block:: python
BondAngle(H1, H2, O)
"""
def __init__(self, *atom_tuples: 'list[tuple]', **settings: dict):
if atom_tuples and is_atom(atom_tuples[0]):
atom_tuples = (atom_tuples, )
if settings.get('n_atoms'):
# This ensures that BondedInteractions can also be __init__ with 0
# atoms
for tpl in atom_tuples:
self._validate_atoms(tpl, settings.get('n_atoms'))
self.atoms = list(atom_tuples)
super().__init__(**settings)
LOGGER.info('%s created: {function:%s, atom IDs:%s}',
self.__class__,
self.function,
[tuple(map(lambda a: a.ID, tpl)) for tpl in self.atoms])
def __len__(self) -> int:
"""
Returns
-------
int
The number of interactions of this type that have been set
"""
return len(self.atoms)
def __getitem__(self, key: int) -> tuple:
"""
Returns
-------
tuple
The `tuple` of ``Atom`` at the specified index. For a single index
(as opposed to a slice) this is a group of atoms which are bonded
together.
"""
return self.atoms[key]
def __str__(self) -> str:
"""
Returns
-------
str
The type, and number of atoms of the ``BondedInteraction``
"""
return f'{self.__class__.__name__} interaction applied to {len(self.atoms)} atom tuples'
[docs]
def is_equivalent(self, other) -> bool:
"""
Checks for equivalence between two ``BondedInteraction``s, specifically
if they apply to the same ``atom_types`` and the same ``function``
describing the interaction.
Parameters
----------
other : BondedInteraction
The object to compare against.
Returns
-------
bool
"""
return (id(other) == id(self) or
(isinstance(other, self.__class__)
and self.atom_types == other.atom_types
and self.function == other.function))
@property
def atoms(self) -> 'list[tuple[Atom]]':
"""
Get or set the atoms on which the ``Coulombic`` interaction is applied
Returns
-------
list
A `list` of `tuple` containing one or more ``Atom``. Each `tuple`
contains all of the atoms involved in one example of the
interaction. For example a ``BondAngle`` interaction each `tuple`
would contain 3 or 4 atoms.
Raises
------
TypeError
If a `list` of `tuple` is not set
"""
return self._atoms
@atoms.setter
def atoms(self, atom_tuples: 'list[tuple[Atom]]') -> None:
# Check for duplicate tuples in list
self._check_duplicates(atom_tuples)
# Check for duplicate atoms in each tuple
try:
for tpl in atom_tuples:
self._check_duplicates(tpl)
# try/except accounts for single atom passed rather than (atom,) tuple
# e.g. if atom_tuples = [atom] instead of atom_tuples = [(atom,)]
except TypeError as error:
if len(atom_tuples) == 1 and is_atom(atom_tuples[0]):
atom_tuples = [(atom_tuples[0],)]
else:
raise TypeError(
'atom_tuples must be [(atom, ...), ...]') from error
# Only assign interaction to atoms after these validation steps
self._atoms = []
for tpl in atom_tuples:
# Each tuple is appended individually so that it can be easily added
# to ._bonded_interaction_pairs for every atom in the tuple
self._atoms.append(tpl)
self._add_interaction_atoms(tpl)
# Add interaction to Universe (pass if no universe exists)
with suppress(AttributeError):
self._add_to_universe(self.universe, tpl)
@property
def atom_types(self) -> Set[Union[int, None]]:
"""
Get the `set` of all ``atom_type``s that this ``BondedInteraction``
corresponds to, including `None` if appropriate.
Returns
-------
Set[Union[int, NoneType]]
A set of all (unique) ``atom_type``s.
"""
return {atom.atom_type for atom_tuple in self.atoms for atom in atom_tuple}
@property
def universe(self) -> Union['Universe', None]:
"""
Get the ``Universe`` to which the ``BondedInteraction`` belongs
Returns
-------
Universe
The ``Universe`` to which the ``BondedInteraction`` belongs or
`None`
"""
try:
return self.atoms[0][0].universe
except IndexError:
return None
[docs]
def element_list(self) -> Union[list, None]:
"""
Get a `list` of the elements for which the ``BondedInteraction`` applies
Returns
-------
list
The elements for which the ``BondedInteraction`` applies
"""
try:
# Each tuple should contain the same elements, so first tuple's used
return [atom.element.symbol for atom in self.atoms[0]]
except (AttributeError, IndexError):
return None
@staticmethod
def _validate_atoms(atoms: list, n_atoms: int) -> None:
"""
Validates that the correct number of atoms have been passed to the
interaction
Parameters
----------
atoms : list
A `list` of ``Atom`` to validate
n_atoms : int
The expected number of ``Atom`` objects in ``atoms``
Raises
------
TypeError
If the number of ``Atom`` objects in ``atoms`` is not equal to
``n_atoms``
"""
if len(atoms) not in n_atoms:
raise TypeError(f"This interaction only accepts {n_atoms} atoms")
[docs]
def add_atoms(self, *atoms: 'Atom', **settings: dict) -> None:
"""
Add atoms which are all involved in one example of this interaction
Parameters
----------
*atoms
one or more ``Atom`` objects
**settings
``from_structure`` (`bool`)
If ``add_atoms`` has been called from a ``Structure``
Raises
------
ValueError
If this ``BondedInteraction`` has already been applied to one or
more of the ``atoms``
"""
self._check_duplicates(atoms)
if atoms in self.atoms:
raise ValueError('This interaction has already been applied to this'
' atom(s)')
self._atoms.append(atoms)
from_structure = settings.get('from_structure', False)
if not from_structure:
for atom in atoms:
atom.add_interaction(self, from_interaction=True)
if self.universe:
self._add_to_universe(self.universe, atoms)
def _check_duplicates(self, structs: list) -> None:
"""
Checks for duplicates ``Structure``
Parameters
----------
structs : list
A `list` of ``Structure``
err_msg : str
A `str` to provide as an error message if there is a duplicate
``Structure``
Raises
------
ValueError
If there is a duplicate ``Structure``
"""
err_msg = ('Each tuple in the list of atom tuples must be unique, and'
' each atom in a tuple must be unique')
# Check for duplicates (or reverse duplicates)
try:
equivalent_structs = self._get_equivalent_structures(structs)
except TypeError:
equivalent_structs = structs
if len(set(equivalent_structs)) != len(equivalent_structs):
raise ValueError(err_msg)
def _get_equivalent_structures(self, structs: list) -> 'list[tuple[Atom]]':
"""
Returns
-------
list
`list` of `tuple` of ``Atom`` orderings which are equivalent
"""
return structs + [tuple(reversed(atom_tuple)) for atom_tuple in structs]
def _add_to_universe(self, universe: 'Universe', atoms: 'tuple[Atom]') -> None:
"""
Adds interaction and atom tuple to ``universe``
Parameters
----------
universe : Universe
The ``Universe`` to which to add the ``Interaction`` and `tpl`
tpl : tuple
A `tuple` of ``Atom``
"""
universe.add_bonded_interaction_pairs((self, atoms))
[docs]
@repr_decorator('constrained')
class ConstrainableMixin:
# pylint: disable=too-few-public-methods
# as this is a mixin class
"""
A mixin class enabling classes inheriting from ``BondedInteraction`` to be
constrained
These constraints are then applied by a constraint algorithm (e.g. SHAKE),
which is specified in the ``Universe`` to which the ``BondedInteraction``
belongs.
Parameters
----------
atom_tuples : list
A `list` of `tuple`. Each `tuple` contains ``Atom`` objects which are
bonded together. For three or more ``Atom`` objects, the order of the
``Atom`` objects within each `tuple` is important.
**settings
Attributes
----------
constrained : bool
Specifying whether the object is constrained
"""
def __init__(self, *atom_tuples: tuple, **settings: dict):
self.constrained = settings.get('constrained', False)
super().__init__(*atom_tuples, **settings)
[docs]
@repr_decorator('function', 'constrained')
class Bond(ConstrainableMixin, BondedInteraction):
"""
A bond between any two atoms. Requires exactly two atoms in each
``atom_tuple``.
Parameters
----------
atom_tuples : list
A `list` of `tuple`. Each `tuple` contains ``Atom`` which are bonded
together.
**settings
"""
def __init__(self, *atom_tuples: tuple, **settings: dict):
settings['n_atoms'] = (2, )
super().__init__(*atom_tuples, **settings)
[docs]
def is_equivalent(self, other) -> bool:
"""
Checks for equivalence between two ``BondedInteraction``s, specifically
if they apply to the same ``atom_types``, have the same ``function``
describing the interaction, and have the same ``constrained`` setting.
Parameters
----------
other : BondedInteraction
The object to compare against.
Returns
-------
bool
"""
return super().is_equivalent(other) and self.constrained == other.constrained
[docs]
@repr_decorator('function', 'constrained')
class BondAngle(ConstrainableMixin, BondedInteraction):
"""
A bond angle between any two bonds
Requires three ``Atom`` objects (rotation around central atom) in each
``atom_tuple``. The atoms are ordered ``i``, ``j``, ``k``, where ``j`` is
the central atom. So:
.. highlight:: python
.. code-block:: python
BondAngle(i, j, k) == BondAngle(k, j, i)
Parameters
----------
atom_tuples : list
A `list` of `tuple`. Each `tuple` contains ``Atom`` which are bonded
together. For three or more ``Atom`` objects, the order of the ``Atom``
objects within each `tuple` is important.
**settings
"""
def __init__(self, *atom_tuples: tuple, **settings: dict):
settings['n_atoms'] = (3, )
super().__init__(*atom_tuples, **settings)
[docs]
def is_equivalent(self, other: BondedInteraction) -> bool:
"""
Checks for equivalence between two ``BondedInteraction``s, specifically
if they apply to the same ``atom_types``, have the same ``function``
describing the interaction, and have the same ``constrained`` setting.
Parameters
----------
other : BondedInteraction
The object to compare against.
Returns
-------
bool
"""
return super().is_equivalent(other) and self.constrained == other.constrained
[docs]
@repr_decorator('function', 'improper')
class DihedralAngle(BondedInteraction):
"""
A dihedral angle between any two sets of three atoms, ``ijk`` and ``jkl``.
Dihedral angles can be both proper and improper, where the angle between the
two planes of ``ijk`` and ``jkl`` is fixed for improper dihedrals.
The atoms of a proper ``DihedralAngle`` are ordered ``i``, ``j``, ``k``,
``l``, where ``j`` and ``k`` are the two central atoms. So:
.. highlight:: python
.. code-block:: python
DihedralAngle(i, j, k, l) == DihedralAngle(l, k, j, i)
The atoms of an improper ``DihedralAngle`` are ordered ``i``, ``j``, ``k``,
``l``, where ``i`` is the central atom to which ``j``, ``k``, and ``l`` are
all connected. So:
.. highlight:: python
.. code-block:: python
(DihedralAngle(i, j, k, l, improper=True)
== DihedralAngle(i, j, l, k, improper=True)
== DihedralAngle(i, l, k, j, improper=True)
== DihedralAngle(i, l, j, k, improper=True))
== DihedralAngle(i, k, j, l, improper=True))
== DihedralAngle(i, k, l, j, improper=True))
Parameters
----------
atom_tuples : list
A `list` of `tuple`. Each `tuple` contains four `Atom` objects which are
bonded together by the ``DihedralAngle``, in the order specified.
**settings
``improper`` (`bool`)
Whether the ``DihedralAngle`` is improper or not.
Attributes
----------
improper : bool
Whether the ``DihedralAngle`` is improper or not, which affects the
``InteractionFunction`` which can be set for this ``DihedralAngle``. By
default this is set to `False` i.e. the interaction is a proper
dihedral.
"""
def __init__(self, *atom_tuples: tuple, **settings: dict):
settings['n_atoms'] = (4, )
self.improper = settings.get('improper', False)
super().__init__(*atom_tuples, **settings)
def _get_equivalent_structures(self, structs: 'list[tuple[Atom]]') -> list:
"""
Parameters
----------
structs: list[tuple[Atom]]
A list of tuples of Atoms.
Returns
-------
list
`list` of `tuple` of ``Atom`` orderings which are equivalent
"""
# Improper dihedrals are equivalent if they have the same first
# (central) atom, and any permutation of the other three atoms
if self.improper:
equivalent = []
for atom_tuple in structs:
equivalent += [(atom_tuple[0], ) + permutation for permutation
in permutations(atom_tuple[1:])]
return equivalent
# Proper dihedrals are equivalent if they are reversed (as with Bond and
# BondAngle)
return super()._get_equivalent_structures(structs)
def _add_atom_types(self, *atom_types: 'list[int]') -> None:
"""
Function for dynamically creating an ``add_atom_types`` method in
``Coulombic``
Parameters
----------
atom_types : list
One or more `int` specifying ``atom_types`` that exist in ``universe``
of the ``Coulombic``
"""
self.atom_types.append(*atom_types)
def _add_atoms(self, *atoms: 'list[Atom]') -> None:
"""
Function for dynamically creating an ``add_atoms`` method in ``Coulombic``
Adds ``*atoms`` to ``Coulombic`` and adds ``Coulombic`` to
``atoms.nonbonded_interactions``
Parameters
----------
atoms : list
list of ``Atom``
"""
for atom in atoms:
# Add atom to interaction
self.atoms.append(atom)
# Add interaction to atom
atom.add_interaction(self, from_interaction=True)
# Add atom_type to interaction.atom_types
if atom.atom_type and atom.atom_type not in self.atom_types:
self.atom_types.append(atom.atom_type)