"""Module in which all interactions between structural units are defined.
``Interaction`` is the abstract base class from which all interactions have to be derived.."""
from typing import NoReturn, Set, Union, TYPE_CHECKING
import weakref
from abc import ABC, abstractmethod
from itertools import permutations
from types import MethodType
import logging
import numpy as np
from MDMC.utilities.structures import is_atom
from MDMC.MD.interaction_functions import Coulomb
from MDMC.common import units
from MDMC.common.decorators import repr_decorator, unit_decorator
if TYPE_CHECKING:
from MDMC.MD.parameters import Parameters
from MDMC.MD.interaction_functions import InteractionFunction
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)
try:
self._add_to_universe(self.universe, tpl)
except AttributeError:
pass
@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)