"""A context manager for temporary splitting (and reassembling) molecules.
Index
-----
.. currentmodule:: CAT.attachment.mol_split_cm
.. autosummary::
SplitMol
API
---
.. autoclass:: SplitMol
:members:
:private-members:
:special-members:
"""
import copy
import reprlib
from typing import Iterable, Union, Dict, Tuple, NoReturn, Any, Type
from contextlib import AbstractContextManager
from scm.plams import Molecule, Atom, PT, Bond, MoleculeError, PTError, rotation_matrix
from ..mol_utils import separate_mod # noqa: F401
__all__ = ['SplitMol']
[docs]class SplitMol(AbstractContextManager):
"""A context manager for temporary splitting a single molecule into multiple components.
The context manager splits the provided molecule into multiple components,
capping all broken bonds in the process.
The exact amount of fragments depends on the number of specified bonds.
These moleculair fragments are returned upon opening the context manager and merged back
into the initial molecule once the context manager is closed. While opened, the initial molecule
is cleared of all atoms and bonds, while the same hapens to the moleculair fragments upon
closing.
Examples
--------
.. code:: python
>>> from scm.plams import Molecule, Bond, from_smiles
>>> mol: Molecule = from_smiles('CC') # Ethane
>>> bond: Bond = mol[1, 2]
# A backup of all bonds and atoms
>>> bonds_backup = mol.bonds.copy()
>>> atoms_backup = mol.atoms.copy()
# The context manager is opened; the bond is removed and the molecule is fragmented
>>> with SplitMol(mol, bond) as fragment_tuple:
... for fragment in fragment_tuple:
... fancy_operation(fragment) # doctest: +SKIP
...
... print(
... mol.bonds == bonds_backup,
... mol.atoms == atoms_backup,
... bond in mol.bonds
... )
False False False
# The context manager is closed; all atoms and bonds have been restored
>>> print(
... mol.bonds == bonds_backup,
... mol.atoms == atoms_backup,
... bond in mol.bonds
... )
True True True
Parameters
----------
mol : |plams.Molecule|
A PLAMS molecule.
See :attr:`SplitMol.mol`.
bond_list : |plams.Bond| or |Iterable| [|plams.Bond|]
An iterable consisting of PLAMS bonds.
All bonds must be part of **mol**.
See :attr:`SplitMol.bonds`.
cap_type : :class:`str`, :class:`int` or |plams.Atom|
An atomic number or symbol of the atom type used for capping the to-be split molecule.
See :attr:`SplitMol.cap_type`.
Attributes
----------
mol : |plams.Molecule|
A PLAMS molecule.
bonds : :class:`set` [|plams.Bond|]
A set of PLAMS bonds.
cap_type : :class:`str`
An atomic symbol of the atom type used for capping the to-be split molecule.
_at_pairs : :class:`list` [:class:`dict` [|plams.Atom|, |plams.Atom|]], optional
A list of dictionaries.
Each dictionary contains two atoms as keys (see :attr:`SplitMol.bond_list`) and
their respective capping atom as values.
Used for reassembling :attr:`SplitMol.mol` once the context manager is closed.
Set internally by :meth:`SplitMol.__enter__`.
_vars_backup : :class:`dict` [:class:`str`, |Any|], optional
A backup of all instance variables of :attr:`SplitMol.mol`.
Set internally by :meth:`SplitMol.__enter__`.
_tmp_mol_list : :class:`tuple` [|plams.Molecule|], optional
A list of PLAMS molecules obtained by splitting :attr:`SplitMol.mol`.
Set internally by :meth:`SplitMol.__enter__`.
Raises
------
|MoleculeError|
Raised when one attempts to access or manipulate the instance variables of
:attr:`SplitMol.mol` when the context manager is opened.
"""
def __init__(self, mol: Molecule, bond_list: Union[Bond, Iterable[Bond]],
cap_type: Union[str, int, Atom] = 'H') -> None:
"""Initialize a :class:`SplitMol` instance."""
self.mol = mol
self.bonds = bond_list
self.cap_type = cap_type
# Private variables which are modified in __enter__ and __exit__
self._at_pairs = None
self._vars_backup = None
self._tmp_mol_list = None
"""####################################### Properties #######################################"""
@property
def bonds(self) -> Dict[Bond, None]:
"""Getter: Return :attr:`SplitMol.bonds`. Setter: Assign the provided value as a dictionary with bonds as keys and their matching index as value.""" # noqa
return self._bonds
@bonds.setter
def bonds(self, value: Union[Bond, Iterable[Bond]]) -> None:
if isinstance(value, Bond):
self._bonds = {value: None}
else:
self._bonds = {i: None for i in value}
@property
def cap_type(self) -> str:
"""Getter: Return :attr:`SplitMol.cap_type`. Setter: Assign the provided value after parsing the atom type (:class:`str`).""" # noqa
return self._cap_type
@cap_type.setter
def cap_type(self, value: Union[str, int, Atom]) -> None:
if isinstance(value, int):
self._cap_type = PT.get_symbol(value)
elif isinstance(value, Atom):
self._cap_type = value.symbol
elif isinstance(value, str):
if value.capitalize() not in PT.symtonum:
raise PTError(f"Trying to convert an invalid atomic symbol: {reprlib.repr(value)}")
self._cap_type = value
else:
raise TypeError(f"Invalid atom type: {repr(type(value))}")
"""Methods for manipulating the molecule."""
def cap_fragments(self, bond: Bond) -> Dict[Atom, Atom]:
"""Delete a bond from :attr:`SplitMol.mol` and cap the resulting fragments with :attr:`SplitMol.cap_type`.
Parameters
----------
bond : |plams.Bond|
A PLAMS bond.
Returns
-------
:class:`dict` [|plams.Atom|, |plams.Atom|]
A dictionary with the old atoms in **bond** as keys and their new capping atoms
as values.
""" # noqa
mol = self.mol
symbol = self.cap_type
# Construct the capping atoms
atom1, atom2 = bond.atom1, bond.atom2
atom1_cap = Atom(symbol=symbol, coords=atom2.coords)
atom2_cap = Atom(symbol=symbol, coords=atom1.coords)
mol.add_atom(atom1_cap, adjacent=[atom1])
mol.add_atom(atom2_cap, adjacent=[atom2])
# Resize the capping atom bond
length1 = atom1.radius + atom1_cap.radius
length2 = atom2.radius + atom2_cap.radius
mol.bonds[-2].resize(atom1_cap, length1)
mol.bonds[-1].resize(atom2_cap, length2)
# Delete the old bond and return a dictionary containg marking all new bonds
return {atom1: atom1_cap, atom2: atom2_cap}
def reassemble(self) -> None:
"""Reassemble :attr:`SplitMol.mol` from its consitituent components in :attr:`SplitMol._tmp_mol_list`.
All capping atoms (and their respective bonds) specified in :attr:`SplitMol._at_pairs` are
removed and the previously broken bonds (stored in :attr:`SplitMol.bonds`) are restored.
""" # noqa
mol = self.mol
mark = self._at_pairs
bonds = self.bonds
_iterator = enumerate(zip(mark, bonds), start=(1 - len(bonds)))
for i, (atom_dict, bond) in _iterator:
# Extract atoms
iterator = iter(atom_dict.items())
atom1, atom1_cap = next(iterator)
atom2, atom2_cap = next(iterator)
atom1_cap.bonds[0].resize(atom1_cap, atom1.radius + atom2.radius)
atom2_cap.bonds[0].resize(atom2_cap, atom1.radius + atom2.radius)
# Allign the molecules by rotation
vec1 = atom2.vector_to(atom2_cap)
vec2 = atom1_cap.vector_to(atom1)
rotmat = rotation_matrix(vec1, vec2)
atom2.mol.rotate(rotmat)
# Allign the molecules by translation
vec_trans = atom2.vector_to(atom1_cap)
atom2.mol.translate(vec_trans)
# Replace the capping atom bonds with the previously broken bond
atom1.bonds[-1] = bond
atom2.bonds[-1] = bond
# Don't bother transfering ownsership for the last fragment
if i:
_tmp_mol = atom1.mol
_tmp_mol.atoms += atom2.mol.atoms
_tmp_mol.bonds += atom2.mol.bonds
for at in atom2.mol.atoms:
at.mol = _tmp_mol
for bond in atom2.mol.bonds:
bond.mol = _tmp_mol
# Ensure all atoms and bonds belong to mol
for at in mol.atoms:
at.mol = mol
for bond in mol.bonds:
bond.mol = mol
# Resize bonds
for atom_dict, bond in zip(mark, bonds):
atom1, atom2 = atom_dict
length = atom1.radius + atom2.radius
bond.resize(atom1, length)
def lock_mol(self) -> None:
"""Lock :attr:`SplitMol.mol`, preventing any access to the instance."""
mol = self.mol
mol.atoms = mol.bonds = FrozenList()
def unlock_mol(self) -> None:
"""Unlock :attr:`SplitMol.mol`, restoring access to the instance."""
self.mol.__dict__ = self._vars_backup
@staticmethod
def reset_vars(obj: Any) -> None:
"""Replace all instance variables of **obj** with empty instances of their respective class.
A value will be substituted for ``None`` if a :exc:`TypeError` is encountered during
instance creation.
Parameters
----------
obj : |Any|
A class instance with the :attr:`__dict__` attribute.
"""
vars_dct = vars(obj)
for k, v in vars_dct.items():
cls = type(v)
try: # More foolproof than .__init__(), especially for empty class instances
vars_dct[k] = cls.__new__(cls)
except TypeError:
# For some classes even the .__new__ method fails (e.g. range or np.ndarray objects)
vars_dct[k] = None
"""############################# Context manager magic methods #############################"""
def __enter__(self) -> Tuple[Molecule]:
"""Enter the :class:`SplitMol` context manager; return a list of molecules."""
# Create a backup of mols' instance variables
mol = self.mol
self._vars_backup = vars(mol)
mol.__dict__ = {k: copy.copy(v) for k, v in vars(mol).items()}
# Add capping atoms along the to-be split bonds
self._at_pairs = [self.cap_fragments(bond) for bond in self.bonds]
# Actually delete the to-be split bonds and split the molecule
for bond in self.bonds:
mol.delete_bond(bond)
self._tmp_mol_list = mol.separate_mod()
# Lock al instance variables of mol
# Accessing mol will now raise a MoleculeError for as long as the context manager is open
self.lock_mol()
return self._tmp_mol_list
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""Exit the :class:`SplitMol` context manager."""
# Restore all instance variables of mol
self.unlock_mol()
# Delete all capping atoms recreate the previously broken bond(s)
self.reassemble()
# Delete all instance variables of the temporary molecules
# All variables are replaced with empty instance of their respective class
for mol_tmp in self._tmp_mol_list:
self.reset_vars(mol_tmp)
class FrozenList(list):
"""A list subclass that will raise a |MoleculeError| upon modification of an instance."""
def __init__(self, *args,
exc_type: Type[Exception] = MoleculeError,
exc_msg: str = ("'Molecule' objects are inaccessible while opened in "
"the 'SplitMol' context manager"),
**kwargs) -> None:
super().__init__(*args, **kwargs)
self.exc_type = exc_type
self.exc_msg = exc_msg
def raise_exception(self, *args, **kwargs) -> NoReturn: raise self.exc_type(self.exc_msg)
__delitem__ = __iadd__ = __setitem__ = raise_exception
append = clear = extend = insert = pop = remove = reverse = sort = raise_exception