import os
import subprocess
import tempfile
from typing import Any, Dict, List, Literal, Optional, Tuple, Union, overload, Sequence, TYPE_CHECKING
from collections import Counter
import numpy as np
from scm.plams.core.errors import MoleculeError
from scm.plams.core.private import saferun
from scm.plams.interfaces.adfsuite.ams import AMSJob
from scm.plams.mol.molecule import Molecule
from scm.plams.tools.periodic_table import PeriodicTable
from scm.plams.tools.units import Units
from scm.plams.interfaces.molecule.rdkit import readpdb, writepdb
from scm.plams.core.functions import requires_optional_package, log
from scm.plams.core.settings import Settings
from scm.plams.core.jobmanager import JobManager
if TYPE_CHECKING:
try:
from scm.libbase import UnifiedChemicalSystem as ChemicalSystem
except ImportError:
pass
__all__ = [
"packmol",
"packmol_around",
"packmol_in_void",
"packmol_on_slab",
"packmol_microsolvation",
"PackMolError",
]
def tolist(x):
return x if isinstance(x, list) else [x]
class PackMolError(MoleculeError):
pass
class PackMolStructure:
def __init__(
self,
molecule: Molecule,
n_molecules: Optional[int] = None,
n_atoms: Optional[int] = None,
box_bounds: Optional[List[float]] = None,
density: Optional[float] = None,
fixed: bool = False,
sphere: bool = False,
):
"""
Class representing a packmol structure.
molecule: |Molecule|
The molecule
n_molecules: int
The number of molecules to insert
n_atoms: int
An approximate number of atoms to insert
box_bounds: list of float
[xmin, ymin, zmin, xmax, ymax, zmax] in angstrom. The min values should all be 0, i.e. [0., 0., 0., xmax, ymax, zmax]
density: float
Density in g/cm^3
fixed: bool
Whether the structure should be fixed at its original coordinates.
sphere: bool
Whether the molecules should be packed in a sphere. The radius is determined by getting the volume from the box bounds! Cannot be combined with ``fixed`` (``fixed`` takes precedence).
"""
self.molecule = molecule
if fixed:
assert n_molecules is None or n_molecules == 1
assert density is None
self.n_molecules = 1
if molecule.lattice and len(molecule.lattice) == 3:
vecs = np.array(molecule.lattice)
positive_mask = vecs >= 0
negative_mask = vecs <= 0
minxyz = np.sum(vecs * negative_mask, axis=0)
maxxyz = np.sum(vecs * positive_mask, axis=0)
self.box_bounds = minxyz.tolist() + maxxyz.tolist()
# self.box_bounds = [
# 0.0,
# 0.0,
# 0.0,
# molecule.lattice[0][0],
# molecule.lattice[1][1],
# molecule.lattice[2][2],
# ]
else:
self.box_bounds = None
self.fixed = True
self.sphere = False
else:
if box_bounds and density:
if n_molecules or n_atoms:
raise ValueError("Cannot set all n_molecules or n_atoms together with (box_bounds AND density)")
n_molecules = self._get_n_molecules_from_density_and_box_bounds(self.molecule, box_bounds, density)
assert n_molecules is not None or n_atoms is not None
if n_molecules is None:
assert n_atoms is not None
self.n_molecules = self._get_n_molecules(self.molecule, n_atoms)
else:
self.n_molecules = n_molecules
assert box_bounds or density
self.box_bounds = box_bounds or self._get_box_bounds(self.molecule, self.n_molecules, density)
self.fixed = False
self.sphere = sphere
def _get_n_molecules_from_density_and_box_bounds(
self, molecule: Molecule, box_bounds: List[float], density: float
) -> int:
"""density in g/cm^3"""
molecule_mass = molecule.get_mass(unit="g")
volume_ang3 = self.get_volume(box_bounds)
volume_cm3 = volume_ang3 * 1e-24
n_molecules = int(density * volume_cm3 / molecule_mass)
return n_molecules
def get_volume(self, box_bounds: Optional[Sequence[float]] = None) -> float:
bb = box_bounds or self.box_bounds
if bb is None:
raise ValueError("Cannot call get_volume when box_bounds is None.")
vol = (bb[3] - bb[0]) * (bb[4] - bb[1]) * (bb[5] - bb[2])
return vol
def _get_n_molecules(self, molecule: Molecule, n_atoms: int):
return n_atoms // len(molecule)
def _get_box_bounds(self, molecule: Molecule, n_molecules: int, density: float):
mass = n_molecules * molecule.get_mass(unit="g")
volume_cm3 = mass / density
volume_ang3 = volume_cm3 * 1e24
side_length = volume_ang3 ** (1 / 3.0)
return [0.0, 0.0, 0.0, side_length, side_length, side_length]
def get_input_block(self, fname, tolerance):
if self.n_molecules == 0 and not self.fixed:
return ""
if self.fixed:
ret = f"""
structure {fname}
number 1
fixed 0. 0. 0. 0. 0. 0.
avoid_overlap yes
end structure
"""
elif self.sphere:
vol = self.get_volume()
radius = np.cbrt(3 * vol / (4 * np.pi))
ret = f"""
structure {fname}
number {self.n_molecules}
inside sphere 0. 0. 0. {radius}
end structure
"""
else:
box_string = f"{self.box_bounds[0]+tolerance/2} {self.box_bounds[1]+tolerance/2} {self.box_bounds[2]+tolerance/2} {self.box_bounds[3]-tolerance/2} {self.box_bounds[4]-tolerance/2} {self.box_bounds[5]-tolerance/2}"
ret = f"""
structure {fname}
number {self.n_molecules}
inside box {box_string}
end structure
"""
return ret
class PackMol:
def __init__(
self,
tolerance=2.0,
structures: Optional[List[PackMolStructure]] = None,
filetype="xyz",
seed: int = -1,
executable=None,
):
"""
Class for setting up and running packmol.
tolerance: float
The packmol tolerance (approximate minimum interatomic distance)
structures: list of PackMolStructure
Structures to insert
filetype: str
One of 'xyz' or 'pdb'. Specifies the file format to use with packmol. 'pdb' requires rdkit.
executable: str
Path to the packmol executable. If not specified, $AMSBIN/packmol.exe will be used.
seed: int
Random seed. If -1, the current time is used as a random seed in packmol.
Note: users are not recommended to use this class directly, but
instead use the ``packmol``, ``packmol_on_slab`` and ``packmol_microsolvation``
functions.
"""
self.tolerance = tolerance
self.structures = structures or []
self.filetype = filetype
if seed == -1 and "SCM_PACKMOL_SEED" in os.environ:
self.seed = int(os.environ["SCM_PACKMOL_SEED"])
else:
self.seed = seed
self.executable = executable or os.path.join(os.path.expandvars("$AMSBIN"), "packmol.exe")
if not os.path.exists(self.executable):
raise RuntimeError("PackMol exectuable not found: " + self.executable)
def add_structure(self, structure: PackMolStructure):
self.structures.append(structure)
def _get_complete_box_bounds(self) -> Tuple[float, float, float, float, float, float]:
min_x = min(s.box_bounds[0] for s in self.structures if s.box_bounds is not None)
min_y = min(s.box_bounds[1] for s in self.structures if s.box_bounds is not None)
min_z = min(s.box_bounds[2] for s in self.structures if s.box_bounds is not None)
max_x = min(s.box_bounds[3] for s in self.structures if s.box_bounds is not None)
max_y = min(s.box_bounds[4] for s in self.structures if s.box_bounds is not None)
max_z = min(s.box_bounds[5] for s in self.structures if s.box_bounds is not None)
# return min_x, min_y, min_z, max_x+self.tolerance, max_y+self.tolerance, max_z+self.tolerance
return min_x, min_y, min_z, max_x, max_y, max_z
def _get_complete_lattice(self) -> List[List[float]]:
"""
returns a 3x3 list using the smallest and largest x/y/z box_bounds for all structures
"""
if any(s.sphere for s in self.structures):
return []
(
min_x,
min_y,
min_z,
max_x,
max_y,
max_z,
) = self._get_complete_box_bounds()
return [
[max_x - min_x, 0.0, 0.0],
[0.0, max_y - min_y, 0.0],
[0.0, 0.0, max_z - min_z],
]
def _get_complete_radius(self) -> float:
"""
Calculates radius of sphere with the same volume as the
cuboid from the box bounds
:return: Radius in angstrom
:rtype: float
"""
volume = self._get_complete_volume()
radius = np.cbrt(3 * volume / (4 * np.pi))
return radius
def _get_complete_volume(self) -> float:
"""Returns volume based on box bounds in ang^3
:return: Volume in ang^3
:rtype: float
"""
(
min_x,
min_y,
min_z,
max_x,
max_y,
max_z,
) = self._get_complete_box_bounds()
volume = (max_x - min_x) * (max_y - min_y) * (max_z - min_z)
return volume
def run(self):
"""
returns: a Molecule with the packed structures
"""
output_molecule = Molecule()
with tempfile.TemporaryDirectory() as tmpdir:
output_fname = os.path.join(tmpdir, "output.xyz")
input_fname = os.path.join(tmpdir, "input.inp")
with open(input_fname, "w") as input_file:
input_file.write(f"tolerance {self.tolerance}\n")
input_file.write(f"seed {self.seed}\n")
input_file.write(f"filetype {self.filetype}\n")
input_file.write(f"output {output_fname}\n")
for i, structure in enumerate(self.structures):
structure_fname = os.path.join(tmpdir, f"structure{i}.{self.filetype}")
if self.filetype == "pdb":
with open(structure_fname, "w") as f:
writepdb(structure.molecule, f)
else:
structure.molecule.write(structure_fname)
input_file.write(structure.get_input_block(structure_fname, tolerance=self.tolerance))
with open(input_fname) as my_input:
saferun(self.executable, stdin=my_input, stdout=subprocess.DEVNULL)
if not os.path.exists(output_fname):
raise PackMolError("Packmol failed. It may work if you try a lower density.")
if self.filetype == "pdb":
with open(output_fname, "r") as f:
output_molecule = readpdb(f)
else:
output_molecule = Molecule(output_fname) # without periodic boundary conditions
output_molecule.lattice = self._get_complete_lattice()
return output_molecule
def sum_of_atomic_volumes(molecule: Molecule) -> float:
"""Returns the sum of atomic volumes (calculated using vdW radii) in angstrom^3."""
return (4 / 3) * np.pi * sum(at.radius**3 for at in molecule)
def guess_density(molecules: Sequence[Molecule], coeffs: Sequence[Union[int, float]]) -> float:
"""Guess a density for a liquid of the given molecules and stoichiometric coefficients.
This density is most of the time lower than the experimental density and is NOT meant
to be accurate. Always equilibrate the density with NPT MD after creating a box with a
density guessed using this method.
Returns the guessed density in g/cm^3.
"""
if len(molecules) != len(coeffs):
raise ValueError(f"Incompatible lengths: {len(molecules)=}, {len(coeffs)=}")
bond_volume_decrease_factor, estimated_volume_multiplier = 0.15, 18
tot_estimated_volume = 0
sum_atomic_masses = 0
for mol, coeff in zip(molecules, coeffs):
sum_atomic_volumes = 0
estimated_volume = 0
bond_volume_decrease = 0
for at in mol:
radius = PeriodicTable.get_radius(at.symbol)
if PeriodicTable.get_metallic(at.symbol):
radius *= 0.5
bond_volume_decrease += (
coeff * bond_volume_decrease_factor * min(len(at.bonds), 0.3) ** 0.3 * (4 / 3) * np.pi * radius**3
)
sum_atomic_volumes += coeff * (4 / 3) * np.pi * radius**3 # ang^3
sum_atomic_masses += coeff * PeriodicTable.get_mass(at.symbol)
estimated_volume += sum_atomic_volumes - bond_volume_decrease
estimated_volume *= estimated_volume_multiplier + 10 / max(4, len(mol))
tot_estimated_volume += estimated_volume
return sum_atomic_masses * Units.conversion_ratio("amu", "g") / (tot_estimated_volume * 1e-24)
@overload
def packmol(
molecules: Union[List[Molecule], Molecule],
mole_fractions: Optional[List[float]] = ...,
density: Optional[float] = ...,
n_atoms: Optional[int] = ...,
box_bounds: Optional[List[float]] = ...,
n_molecules: Union[List[int], int, None] = ...,
sphere: bool = ...,
fix_first: bool = ...,
keep_bonds: bool = ...,
keep_atom_properties: bool = ...,
region_names: Union[List[str], str, None] = ...,
return_details: Literal[False] = ...,
tolerance: float = ...,
seed: int = ...,
executable: Optional[str] = ...,
_return_only_details: bool = ...,
) -> Molecule: ...
@overload
def packmol(
molecules: Union[List[Molecule], Molecule],
mole_fractions: Optional[List[float]] = ...,
density: Optional[float] = ...,
n_atoms: Optional[int] = ...,
box_bounds: Optional[List[float]] = ...,
n_molecules: Union[List[int], int, None] = ...,
sphere: bool = ...,
fix_first: bool = ...,
keep_bonds: bool = ...,
keep_atom_properties: bool = ...,
region_names: Union[List[str], str, None] = ...,
return_details: Literal[True] = ...,
tolerance: float = ...,
seed: int = ...,
executable: Optional[str] = ...,
_return_only_details: bool = ...,
) -> Tuple[Molecule, Dict[str, Any]]: ...
[docs]def packmol(
molecules: Union[List[Molecule], Molecule],
mole_fractions: Optional[List[float]] = None,
density: Optional[float] = None,
n_atoms: Optional[int] = None,
box_bounds: Optional[List[float]] = None,
n_molecules: Union[List[int], int, None] = None,
sphere: bool = False,
fix_first: bool = False,
keep_bonds: bool = True,
keep_atom_properties: bool = True,
region_names: Union[List[str], str, None] = None,
return_details: bool = False,
tolerance: float = 2.0,
seed: int = -1,
executable: Optional[str] = None,
_return_only_details: bool = False, # get values of n_molecules, n_atoms, and mole_fractions in the returned dictionary
) -> Union[Molecule, Tuple[Molecule, Dict[str, Any]]]:
"""
Create a fluid of the given ``molecules``. The function will use the
given input parameters and try to obtain good values for the others.
It is *strongly recommended* to specify ``density`` and/or ``box_bounds``. Otherwise you will
get a (very inaccurate) guessed density in a cubic box (experimental feature).
molecules : |Molecule| or list of Molecule
The molecules to pack
mole_fractions : list of float
The mole fractions (in the same order as ``molecules``). Cannot be combined with ``n_molecules``. If not given, an equal (molar) mixture of all components will be created.
density: float
The total density (in g/cm^3) of the fluid
n_atoms: int
The (approximate) number of atoms in the final mixture
box_bounds: list of float (length 6)
The box in which to pack the molecules. The box is orthorhombic and should be specified as [xmin, ymin, zmin, xmax, ymax, zmax]. The minimum values should all be set to 0, i.e. set box_bounds=[0., 0., 0., xmax, ymax, zmax]. If not specified, a cubic box of appropriate dimensions will be used.
n_molecules : int or list of int
The (exact) number of molecules for each component (in the same order as ``molecules``). Cannot be combined with ``mole_fractions``.
sphere: bool
Whether the molecules should be packed in a sphere. The radius is determined by getting the volume from the box bounds!
fix_first: bool
Whether to keep the first molecule fixed. This can only be used with ``n_molecules=[1, ..., ...]``. Defaults to False.
keep_bonds : bool
If True, the bonds from the constituent molecules will be kept in the returned Molecule
keep_atom_properties : bool
If True, the atom.properties (e.g. force-field atom types) of the constituent molecules will be kept in
the returned Molecule
region_names : str or list of str
Populate the region information for each atom. Should have the same length and order as ``molecules``.
By default the regions are named ``mol0``, ``mol1``, etc.
tolerance: float
The packmol tolerance (approximately the minimum intermolecular distance). When packing
a periodic box, half the tolerance will be excluded from each face of the box.
return_details : bool
Return a 2-tuple (Molecule, dict) where the dict has keys like 'n_molecules', 'mole_fractions', 'density',
etc. They contain the actual details of the returned molecule, which may differ slightly from
the requested quantities.
Returned keys:
* 'n_molecules': list of integer with actually added number of molecules
* 'mole_fractions': list of float with actually added mole fractions
* 'density': float, gives the density in g/cm^3
* 'n_atoms': int, the number of atoms in the returned molecule
* 'molecule_type_indices': list of int of length n_atoms. For each atom, give an integer index for which TYPE of molecule it belongs to.
* 'molecule_indices': list of int of length n_atoms. For each atom, give an integer index for which molecule it belongs to
* 'atom_indices_in_molecule': list of int of length n_atoms. For each atom, give an integer index for which position in the molecule it is.
* 'volume': float. The volume of the bounding box / packed sphere in ang^3.
executable : str
The path to the packmol executable. If not specified, ``$AMSBIN/packmol.exe`` will be used (which is the correct path for the Amsterdam Modeling Suite).
Useful combinations:
* ``mole_fractions``, ``density``, ``n_atoms``: Create a mixture with a given density and approximate number of atoms (a cubic box will be created)
* ``mole_fractions``, ``density``, ``box_bounds``: Create a mixture with a given density inside a given box (the number of molecules will approximately match the density and mole fractions)
* ``n_molecules``, ``density``: Create a mixture with the given number of molecules and density (a cubic box will be created)
* ``n_molecules``, ``box_bounds``: Create a mixture with the given number of molecules inside the given box
Example:
.. code-block:: python
packmol(molecules=[from_smiles('O'), from_smiles('C')],
mole_fractions=[0.8, 0.2],
density=0.8,
n_atoms=100)
Returns: a |Molecule| or tuple (Molecule, dict)
If return_details=False, return a Molecule. If return_details=True, return a tuple.
"""
# Input arguments allow for lots of combinations.
# Let's try to check that the specified combination makes sense ...
if n_atoms is None and n_molecules is None and density is None:
raise ValueError("Illegal combination of arguments: must specify either n_atoms, n_molecules or density")
if n_atoms is not None and n_molecules is not None:
raise ValueError("Illegal combination of arguments: n_atoms and n_molecules are mutually exclusive")
if n_atoms is not None and box_bounds is not None and density is not None:
raise ValueError("Illegal combination of arguments: n_atoms, box_bounds and density specified at the same time")
if n_molecules is not None and box_bounds is not None and density is not None:
raise ValueError(
"Illegal combination of arguments: n_molecules, box_bounds and density specified at the same time"
)
if mole_fractions is not None and n_molecules is not None:
raise ValueError("Illegal combination of arguments: mole_fractions and n_molecules are mutually exclusive")
if fix_first:
if n_molecules is None or np.isscalar(n_molecules) or n_molecules[0] != 1:
raise ValueError(
f"Illegal combination of arguments: fix_first requires that n_molecules is a list where the first element is 1. Received n_molecules={n_molecules}"
)
if isinstance(molecules, list):
if n_molecules is not None:
if not isinstance(n_molecules, list):
raise ValueError("Illegal combination of arguments: molecules is a list, but n_molecules is not")
if len(n_molecules) != len(molecules):
raise ValueError("Illegal combination of arguments: len(n_molecules) != len(molecules)")
if mole_fractions is not None:
if not isinstance(mole_fractions, list):
raise ValueError("Illegal combination of arguments: molecules is a list, but mole_fractions is not")
if len(mole_fractions) != len(molecules):
raise ValueError("Illegal combination of arguments: len(mole_fractions) != len(molecules)")
if region_names is not None:
if not isinstance(region_names, list):
raise ValueError("Illegal combination of arguments: molecules is a list, but region_names is not")
if len(region_names) != len(molecules):
raise ValueError("Illegal combination of arguments: len(region_names) != len(molecules)")
else:
if n_molecules is not None and isinstance(n_molecules, list):
raise ValueError("Illegal combination of arguments: n_molecules is a list, when molecules is not")
if mole_fractions is not None and isinstance(mole_fractions, list):
raise ValueError("Illegal combination of arguments: mole_fractions is a list, when molecules is not")
if region_names is not None and isinstance(region_names, list):
raise ValueError("Illegal combination of arguments: region_names is a list, when molecules is not")
if _return_only_details:
return_details = True
molecules = tolist(molecules)
if mole_fractions is None:
mole_fractions = [1.0 / len(molecules)] * len(molecules)
if n_molecules:
n_molecules = tolist(n_molecules)
sum_n_molecules = np.sum(n_molecules)
if np.isclose(sum_n_molecules, 0):
raise ValueError(
f"The sum of n_molecules is {sum_n_molecules}, which is very close to 0. "
f"Specify larger numbers. n_molecules specified: {n_molecules}"
)
if any(x < 0 for x in n_molecules):
raise ValueError(f"All n_molecules must be >= 0. " f"n_molecules specified: {n_molecules}")
xs = np.array(mole_fractions)
sum_xs = np.sum(xs)
if np.isclose(sum_xs, 0):
raise ValueError(
f"The sum of mole fractions is {sum_xs}, which is very close to 0. "
f"Specify larger numbers. Mole fractions specified: {xs}"
)
if np.any(xs < 0):
raise ValueError(f"All mole fractions must be >= 0. " f"Mole fractions specified: {mole_fractions}")
atoms_per_mol = np.array([len(a) for a in molecules])
masses = np.array([m.get_mass(unit="g") for m in molecules])
coeffs = None
if n_molecules:
coeffs = np.int_(n_molecules)
elif n_atoms:
coeff_0 = n_atoms / np.dot(xs, atoms_per_mol)
coeffs_floats = xs * coeff_0
coeffs = np.int_(np.round(coeffs_floats))
if (n_atoms or n_molecules) and not box_bounds:
mass = np.dot(coeffs, masses)
if density is not None:
volume_cm3 = mass / density
else:
volume_cm3 = mass / guess_density(molecules, coeffs)
volume_ang3 = volume_cm3 * 1e24
side_length = volume_ang3 ** (1 / 3.0)
box_bounds = [0.0, 0.0, 0.0, side_length, side_length, side_length]
elif box_bounds and density and not n_molecules:
volume_cm3 = (
(box_bounds[3] - box_bounds[0]) * (box_bounds[4] - box_bounds[1]) * (box_bounds[5] - box_bounds[2]) * 1e-24
)
mass_g = volume_cm3 * density
coeffs = mass_g / np.dot(xs, masses)
coeffs = xs * coeffs
coeffs = np.int_(np.round(coeffs))
if coeffs is None:
raise ValueError(
f"Illegal combination of arguments: n_atoms={n_atoms}, "
f"n_molecules={n_molecules}, box_bounds={box_bounds}, density={density}"
)
pm = PackMol(executable=executable, tolerance=tolerance, seed=seed)
if sphere and len(molecules) == 2 and n_molecules and n_molecules[0] == 1:
# Special case used by packmol_microsolvation
s1 = PackMolStructure(molecules[0], n_molecules[0], box_bounds=box_bounds, sphere=False, fixed=True)
s2 = PackMolStructure(molecules[1], n_molecules[1], box_bounds=box_bounds, sphere=True, fixed=False)
pm.add_structure(s1)
pm.add_structure(s2)
else:
for i, (mol, n_mol) in enumerate(zip(molecules, coeffs)):
if fix_first and i == 0:
s1 = PackMolStructure(mol, n_molecules=n_mol, box_bounds=box_bounds, sphere=False, fixed=True)
pm.add_structure(s1)
else:
s1 = PackMolStructure(mol, n_molecules=n_mol, box_bounds=box_bounds, sphere=sphere)
pm.add_structure(s1)
if _return_only_details:
ret = {
"n_molecules": coeffs.tolist(),
"mole_fractions": (coeffs / np.sum(coeffs)).tolist() if np.sum(coeffs) > 0 else [0.0] * len(coeffs),
"n_atoms": np.dot([len(x) for x in molecules], coeffs),
}
return None, ret
out = pm.run()
# packmol returns the molecules sorted
molecule_type_indices = [] # [0,0,0,...,1,1,1] # two different molecules with 3 and 5 atoms
molecule_indices = (
[]
) # [0,0,0,1,1,1,2,2,2,....,58,58,58,58,58,59,59,59,59,59] # two different molecules with 3 and 5 atoms
atom_indices_in_molecule = [] # [0,1,2,0,1,2,...,0,1,2,3,4,0,1,2,3,4]
current = 0
for i, (mol, n_mol) in enumerate(zip(molecules, coeffs)):
molecule_type_indices += [i] * n_mol * len(mol)
atom_indices_in_molecule += list(range(len(mol))) * n_mol
temp = list(range(current, current + n_mol))
molecule_indices += list(np.repeat(temp, len(mol)))
current += n_mol
assert len(molecule_type_indices) == len(out)
assert len(molecule_indices) == len(out)
assert len(atom_indices_in_molecule) == len(out)
try:
volume = out.unit_cell_volume(unit="angstrom")
density = out.get_density() * 1e-3 # g / cm^3
except (ValueError, ZeroDivisionError): # not periodic, presumably when sphere=True
volume = pm._get_complete_volume()
if volume == 0:
density = 0
else:
mass = out.get_mass(unit="g")
density = mass / (volume * 1e-24) # g / cm^3
details = {
"n_molecules": coeffs.tolist(),
"mole_fractions": (coeffs / np.sum(coeffs)).tolist() if np.sum(coeffs) > 0 else [0.0] * len(coeffs),
"n_atoms": len(out),
"molecule_type_indices": molecule_type_indices, # for each atom, indicate which type of molecule it belongs to by an integer index (starts with 0)
"molecule_indices": molecule_indices, # for each atoms, indicate which molecule it belongs to by an integer index (starts with 0)
"atom_indices_in_molecule": atom_indices_in_molecule,
"volume": volume,
"density": density,
}
if sphere:
details["radius"] = np.cbrt(volume * 3 / (4 * np.pi))
if keep_atom_properties:
for at, molecule_type_index, atom_index_in_molecule in zip(
out, molecule_type_indices, atom_indices_in_molecule
):
at.properties = molecules[molecule_type_index][atom_index_in_molecule + 1].properties.copy()
if keep_bonds:
out.delete_all_bonds()
for imol, mol in enumerate(molecules):
for b in mol.bonds:
i1, i2 = sorted(mol.index(b)) # 1-based
for iout, (molecule_type, atom_index_molecule) in enumerate(
zip(molecule_type_indices, atom_indices_in_molecule)
):
if molecule_type != imol:
continue
if i1 != atom_index_molecule + 1:
continue
new_i1 = iout + 1 # iout 0-based
new_i2 = iout + 1 + i2 - i1 # iout 0-based
out.add_bond(out[new_i1], out[new_i2], order=b.order)
if region_names:
region_names = tolist(region_names)
else:
region_names = [f"mol{i}" for i in range(len(molecules))]
for at, molindex in zip(out, molecule_type_indices):
AMSJob._add_region(at, region_names[molindex])
tot_charge = sum(int(mol.properties.get("charge", 0)) * c for mol, c in zip(molecules, coeffs))
if tot_charge != 0:
out.properties.charge = tot_charge
if return_details:
return out, details
return out
def get_packmol_solid_liquid_box_bounds(slab: Molecule):
slab_max_z = max(at.coords[2] for at in slab)
slab_min_z = min(at.coords[2] for at in slab)
liquid_min_z = slab_max_z
liquid_max_z = liquid_min_z + slab.lattice[2][2] - (slab_max_z - slab_min_z)
box_bounds = [
0.0,
0.0,
liquid_min_z + 1.5,
slab.lattice[0][0],
slab.lattice[1][1],
liquid_max_z - 1.5,
]
return box_bounds
[docs]def packmol_in_void(
host: Molecule,
molecules: Union[List[Molecule], Molecule],
n_molecules: Union[List[int], int],
keep_bonds: bool = True,
keep_atom_properties: bool = True,
region_names: Optional[List[str]] = None,
tolerance: float = 2.0,
return_details: bool = False,
executable: Optional[str] = None,
):
"""
Pack molecules inside voids in a crystal.
host: Molecule
The host molecule. Must be 3D-periodic and the cell must be orthorhombic (all angles 90 degrees) with the lattice vectors parallel to the cartesian axes (all off-diagonal components must be 0).
For the other arguments, see the ``packmol`` function.
Note: ``region_names`` needs to have one more element than the list of
``molecules``. For example ``region_names=['host', 'guest1',
'guest2']``.
"""
if len(host.lattice) != 3:
raise ValueError("host in packmol_in_void must be 3D periodic")
if host.cell_angles() != [90.0, 90.0, 90.0]:
raise ValueError("host in packmol_in_void must be have orthorhombic cell")
my_host = host.copy()
my_host.map_to_central_cell(around_origin=False)
box_bounds = [0.0, 0.0, 0.0, my_host.lattice[0][0], my_host.lattice[1][1], my_host.lattice[2][2]]
my_molecules = [my_host] + tolist(molecules)
my_n_molecules = [1] + tolist(n_molecules)
ret = packmol(
molecules=my_molecules,
n_molecules=my_n_molecules,
box_bounds=box_bounds,
keep_bonds=keep_bonds,
keep_atom_properties=keep_atom_properties,
region_names=region_names,
return_details=return_details,
fix_first=True,
tolerance=tolerance,
executable=executable,
)
return ret
def _run_uff_md(
ucs: "ChemicalSystem",
nsteps: int = 1000,
vectors=None,
fixed_atoms: Optional[Sequence[int]] = None,
) -> "ChemicalSystem":
"""
Runs UFF MD with SHAKE all bonds, keeps ``fixed_atoms`` (0-based atom indices) fixed,
if ``vectors`` is not None will transform into those vectors
Returns: The final system from the MD simulation.
Raises: PackmolError if something goes worng.
"""
from scm.plams import config
thermostatted_region = "PACKMOL_thermostatted"
md_ucs = ucs.copy()
md_ucs.set_atoms_in_region(
[x for x in range(len(md_ucs)) if fixed_atoms is None or x not in fixed_atoms], thermostatted_region
)
s = Settings()
s.input.ForceField.Type = "UFF"
s.input.ams.Task = "MolecularDynamics"
if fixed_atoms:
s.input.ams.Constraints.AtomList = " ".join(str(x + 1) for x in fixed_atoms)
s.input.ams.MolecularDynamics.NSteps = nsteps
s.input.ams.MolecularDynamics.TimeStep = 0.5
s.input.ams.MolecularDynamics.Shake.All = "bonds * *"
s.input.ams.MolecularDynamics.InitialVelocities.Type = "Zero"
# s.input.ams.MolecularDynamics.InitialVelocities.Temperature = 10
s.input.ams.MolecularDynamics.Thermostat.Temperature = 5
s.input.ams.MolecularDynamics.Thermostat.Region = thermostatted_region
s.input.ams.MolecularDynamics.Thermostat.Tau = 2
s.input.ams.MolecularDynamics.Thermostat.Type = "Berendsen"
if vectors is not None:
x = vectors
target_lattice_str = f"""
{x[0][0]} {x[0][1]} {x[0][2]}
{x[1][0]} {x[1][1]} {x[1][2]}
{x[2][0]} {x[2][1]} {x[2][2]}
"""
s.input.ams.MolecularDynamics.Deformation.StartStep = 1
s.input.ams.MolecularDynamics.Deformation.StopStep = (nsteps * 3) // 4
s.input.ams.MolecularDynamics.Deformation.TargetLattice._1 = target_lattice_str
previous_config = config.copy()
try:
config.job.pickle = False
config.log.stdout = 0
with tempfile.TemporaryDirectory() as tmp_dir:
job_manager = JobManager(config.jobmanager, path=tmp_dir)
job = AMSJob(settings=s, molecule=md_ucs, name="shakemd")
job.run(jobmanager=job_manager)
if not job.ok():
error_msg = job.results.get_errormsg()
job_manager._clean()
raise PackMolError(
f"Try a lower density or a less skewed cell! Original file in {job.path}. {error_msg}"
)
my_packed = job.results.get_main_system()
my_packed.remove_region("PACKMOL_thermostatted")
job_manager._clean()
finally:
config.job.pickle = previous_config.job.pickle
config.log.stdout = previous_config.log.stdout
return my_packed
[docs]@requires_optional_package("scm.libbase")
def packmol_around(
current: Union[Molecule, "ChemicalSystem"],
molecules: Union[List[Molecule], Molecule],
mole_fractions: Optional[List[float]] = None,
density: Optional[float] = None,
n_atoms: Optional[int] = None,
n_molecules: Union[List[int], int, None] = None,
keep_bonds: bool = True,
keep_atom_properties: bool = True,
region_names: Union[List[str], str, None] = None,
return_details: bool = False,
tolerance: float = 2.0,
seed: int = -1,
executable: Optional[str] = None,
) -> Union[Molecule, Tuple[Molecule, Dict[str, Any]]]:
"""Pack around the current molecule.
``current``: Molecule
Must have a 3D lattice
``density``: float
Density in g/cm^3 of the *added* molecules (excluding ``current``).
Example: To pack liquid water around a metal slab, set density to 1.0.
The density is *estimated* from the available free volume and may
be inaccurate.
``mole_fractions``: list of float
Mole fractions of the *added* molecules.
``n_atoms``: float
Approximate number of *added* molecules.
``region_names``: list of str
Region names for the *added* molecules.
In general, the arguments refer to the *added* molecules. For all other arguments, see the ``packmol`` function.
In the returned ``Molecule``, the system will be mapped to ``[0..1]``. It has the same lattice has ``current``.
.. important::
The results from this function are almost always approximate! The output
system will not exactly match your request.
.. important::
For non-orthorhombic cells, the results are always approximate. Typically,
the density will be lower than what you request.
"""
from scm.libbase import UnifiedChemicalSystem as ChemicalSystem
from scm.utils.conversions import plams_molecule_to_chemsys, chemsys_to_plams_molecule
if isinstance(current, Molecule):
original_ucs = plams_molecule_to_chemsys(current)
else:
original_ucs = current.copy()
assert isinstance(original_ucs, ChemicalSystem)
if original_ucs.lattice.num_vectors != 3:
raise ValueError(f"Input molecule `current` must have 3D lattice, got: {current.lattice}")
lattice_is_orthorhombic = all(
np.isclose(original_ucs.lattice.vectors[i][j], 0) for i in range(3) for j in range(3) if i != j
)
# step 1: store info about original system
original_ucs.map_atoms(0)
original_volume = original_ucs.lattice.get_volume()
# step 2, get remaining volume
def get_details_for_remaining_volume(original_ucs, molecules, **kwargs):
sum_r3 = np.sum(np.fromiter((at.element.radius**3 for at in original_ucs), dtype=np.float32))
current_atomic_volume = (4 / 3) * np.pi * sum_r3
current_atomic_volume /= 0.74 # use packing efficiency in ccp as example to take up more volume
remaining_volume = original_volume - current_atomic_volume
# temporary value to call the original packmol with
temp_L = np.cbrt(remaining_volume)
box_bounds_for_remaining_volume = [0.0, 0.0, 0.0, temp_L, temp_L, temp_L]
# it is unnecessary to actually pack the molecules, this is just used to get the "details"
# details will contain the correct number of molecules to pack in the combined system
_, details = packmol(
molecules=molecules,
return_details=True,
box_bounds=box_bounds_for_remaining_volume,
_return_only_details=True,
**kwargs,
)
details["current_atomic_volume"] = current_atomic_volume
return details
molecules = tolist(molecules)
details = get_details_for_remaining_volume(
original_ucs,
molecules,
mole_fractions=mole_fractions,
n_atoms=n_atoms,
n_molecules=n_molecules,
density=density,
executable=executable,
)
# find cuboid parallel along x/y/z that is guaranteed to encompass the original lattice
n_molecules = [1] + details["n_molecules"]
system_for_packing = original_ucs.copy()
if lattice_is_orthorhombic:
box_bounds = [0, 0, 0] + np.diag(original_ucs.lattice.vectors).tolist()
else:
positive_mask = original_ucs.lattice.vectors >= 0
negative_mask = original_ucs.lattice.vectors <= 0
minxyz = np.sum(original_ucs.lattice.vectors * negative_mask, axis=0)
maxxyz = np.sum(original_ucs.lattice.vectors * positive_mask, axis=0)
box_bounds = minxyz.tolist() + maxxyz.tolist()
v_occ = details["current_atomic_volume"]
v_orig_free = original_ucs.lattice.get_volume() - v_occ
v_new_free = np.prod(maxxyz - minxyz) - v_occ
volume_multiplier = v_new_free / v_orig_free
new_n_atoms = int(np.round(details["n_atoms"] * volume_multiplier))
new_details = get_details_for_remaining_volume(
system_for_packing,
molecules,
n_atoms=new_n_atoms,
mole_fractions=details["mole_fractions"],
executable=executable,
)
n_molecules = [1] + new_details["n_molecules"]
my_molecules = [chemsys_to_plams_molecule(system_for_packing)] + tolist(molecules)
if region_names is not None:
region_names = tolist(region_names)
if len(region_names) == len(my_molecules) - 1:
# insert a dummy region name, it will not be returned anyway
region_names = ["current"] + region_names
my_packed, details = packmol(
molecules=my_molecules,
n_molecules=n_molecules,
fix_first=True,
box_bounds=box_bounds,
return_details=True,
tolerance=tolerance,
executable=executable,
keep_bonds=keep_bonds,
keep_atom_properties=keep_atom_properties,
region_names=region_names,
seed=seed,
)
# remove the original substrate
my_packed = plams_molecule_to_chemsys(my_packed)
my_packed.remove_atoms(range(len(original_ucs)))
### start removing molecules outside the unit cell for non-orthorhombic cells
### for orthorhombic cells the fractional coordiantes are all in (0,1)
### so nothing will happen here
# packed_n_molecules = my_packed.num_molecules()
my_packed.lattice = original_ucs.lattice.copy()
fractional_coords = my_packed.get_fractional_coordinates()
# TODO: work out some reasonable margin depending on lattice vector lengths
mask = (fractional_coords < 0) | (fractional_coords >= 1)
row_indices = np.any(mask, axis=1).nonzero()[0]
my_packed.select_atoms(row_indices)
# TODO: select_molecule assumes that there are bonds defined - perhaps
# one should really go through the details dictionary to find the indices
# of added molecules regardless of connectivity
my_packed.select_molecule()
removed_atoms_from_my_packed = my_packed.get_selected_atoms()
my_packed.remove_atoms(my_packed.get_selected_atoms())
# removed_n_molecules = packed_n_molecules - my_packed.num_molecules()
mti = details["molecule_type_indices"]
removed_molecules_types = [mti[i + len(original_ucs)] for i in removed_atoms_from_my_packed]
counter = Counter(removed_molecules_types)
ret_details = dict(n_molecules=[])
for imol, nmol in enumerate(details["n_molecules"]):
if imol == 0: # skip "current"
continue
lenmol = len(my_molecules[imol])
if lenmol == 0:
new_nmol = nmol
else:
new_nmol = nmol - counter[imol] // lenmol
ret_details["n_molecules"].append(new_nmol)
sum_molecules = np.sum(ret_details["n_molecules"])
if sum_molecules == 0:
ret_details["mole_fractions"] = [0] * len(ret_details["n_molecules"])
else:
ret_details["mole_fractions"] = (np.array(ret_details["n_molecules"]) / sum_molecules).tolist()
ret = original_ucs + my_packed
ret = chemsys_to_plams_molecule(ret)
if return_details:
return ret, ret_details
return ret
[docs]def packmol_on_slab(
slab: Molecule,
molecules: Union[List[Molecule], Molecule],
mole_fractions: Optional[List[float]] = None,
density: float = 1.0,
keep_bonds: bool = True,
keep_atom_properties: bool = True,
region_names: Optional[List[str]] = None,
executable: Optional[str] = None,
):
"""
Creates a solid/liquid interface with an approximately correct density. The
density is calculated for the volume not occupied by the slab (+ 1.5
angstrom buffer at each side of the slab).
Returns: a |Molecule|
slab : |Molecule|
The system must have a 3D lattice (including a vacuum gap along z) and be orthorhombic. The vacuum gap will be filled with the liquid.
For the other arguments, see ``packmol``.
Example:
.. code-block:: python
packmol_on_slab(slab=slab_3d_with_vacuum_gap,
molecules=[from_smiles('O'), from_smiles('C')],
mole_fractions=[0.8, 0.2],
density=0.8)
"""
if len(slab.lattice) != 3:
raise ValueError("slab in packmol_on_slab must be 3D periodic: slab in xy-plane with vacuum gap along z-axis")
if not all(np.isclose(slab.cell_angles(), [90.0, 90.0, 90.0])):
raise ValueError("slab in packmol_on_slab must be have orthorhombic cell")
liquid = packmol(
molecules=molecules,
mole_fractions=mole_fractions,
density=density,
box_bounds=get_packmol_solid_liquid_box_bounds(slab),
keep_bonds=keep_bonds,
keep_atom_properties=keep_atom_properties,
region_names=region_names,
executable=executable,
)
# Map all liquid molecules to [0..1]
# NOTE: We need to be using the lattice of the slab for this!
# The lattice of the liquid is different ...
liquid.lattice = slab.lattice
# If the slab has cell-shifts for the bonds, the liquid also needs to have
# them. If would not have cell-shifts, they would not be updated in the
# map_to_central_cell call, even though they would become significant when
# combining with the slab that has them: minimum image convention is only
# assumed if no bond has cell-shifts.
if liquid.bonds and any(b.has_cell_shifts() for b in slab.bonds):
for b in liquid.bonds:
b.properties.suffix = "0 0 0"
liquid.map_to_central_cell(around_origin=False)
if liquid.bonds and any(b.has_cell_shifts() for b in slab.bonds):
for b in liquid.bonds:
if b.properties.suffix == "0 0 0":
del b.properties.suffix
# Shift liquid molecules (now in [0..1]) on top of the slab.
# The slab could be anywhere, e.g. [-0.5..0.5] ...
slab_center_x = (max(at.coords[0] for at in slab) + min(at.coords[0] for at in slab)) / 2
slab_center_y = (max(at.coords[1] for at in slab) + min(at.coords[1] for at in slab)) / 2
liquid_center_x = (max(at.coords[0] for at in liquid) + min(at.coords[0] for at in liquid)) / 2
liquid_center_y = (max(at.coords[1] for at in liquid) + min(at.coords[1] for at in liquid)) / 2
liquid.translate([-liquid_center_x + slab_center_x, -liquid_center_y + slab_center_y, 0.0])
out = slab.copy()
for at in out:
AMSJob._add_region(at, "slab")
out.add_molecule(liquid)
return out
def get_n_from_density_and_box_bounds(molecule, box_bounds, density):
molecule_mass = molecule.get_mass(unit="g")
volume_ang3 = (box_bounds[3] - box_bounds[0]) * (box_bounds[4] - box_bounds[1]) * (box_bounds[5] - box_bounds[2])
volume_cm3 = volume_ang3 * 1e-24
n_molecules = int(density * volume_cm3 / molecule_mass)
return n_molecules
[docs]def packmol_microsolvation(
solute: Molecule,
solvent: Molecule,
density: float = 1.0,
threshold: float = 3.0,
keep_bonds: bool = True,
keep_atom_properties: bool = True,
region_names: List[str] = ["solute", "solvent"],
executable: Optional[str] = None,
):
"""
Microsolvation of a ``solute`` with a ``solvent`` with an approximate ``density``.
solute: |Molecule|
The solute to be surrounded by solvent molecules
solvent: |Molecule|
The solvent molecule
density: float
Approximate density in g/cm^3
threshold: float
Distance in angstrom. Any solvent molecule for which at least 1 atom is within this threshold to the solute molecule will be kept
For the other arguments, see ``packmol``.
"""
solute_coords = solute.as_array()
com = np.mean(solute_coords, axis=0)
plams_solute = solute.copy()
plams_solute.translate(-com)
solute_coords = plams_solute.as_array()
box_bounds = [0.0, 0.0, 0.0] + list(np.max(solute_coords, axis=0) - np.min(solute_coords, axis=0) + 3 * threshold)
n_solvent = get_n_from_density_and_box_bounds(solvent, box_bounds, density=density)
plams_solvated = packmol(
[plams_solute, solvent],
n_molecules=[1, n_solvent],
box_bounds=box_bounds,
keep_bonds=keep_bonds,
keep_atom_properties=keep_atom_properties,
region_names=region_names,
sphere=True,
executable=executable,
)
solute_indices = [i for i, at in enumerate(plams_solvated, 1) if i <= len(solute)]
newmolecule = plams_solvated.get_complete_molecules_within_threshold(solute_indices, threshold=threshold)
return newmolecule
@requires_optional_package("scm.libbase")
def packmol_around_md(
current: Union[Molecule, "ChemicalSystem"],
molecules: Union[Molecule, List[Molecule]],
return_details: bool = False,
always_run_md: bool = False,
**kwargs,
) -> Molecule:
"""Pack around the current molecule, relax structure with MD.
Experimental feature.
``current``: Molecule
Must have a 3D lattice
``always_run_md``: bool
If True, will run UFF MD also for orthorhombic cells. For nonorthorhombic cells, MD is always run irrespective of this flag.
For all other arguments, see the ``packmol`` function.
In the returned ``Molecule``, the system will be mapped to ``[0..1]``. It has the same lattice has ``current``.
"""
from scm.libbase import (
UnifiedChemicalSystem as ChemicalSystem,
UnifiedLattice as Lattice,
)
from scm.utils.conversions import plams_molecule_to_chemsys, chemsys_to_plams_molecule
loglevel = 7
if isinstance(current, Molecule):
original_ucs = plams_molecule_to_chemsys(current)
else:
original_ucs = current.copy()
assert isinstance(original_ucs, ChemicalSystem)
original_ucs.map_atoms(0)
# step 1: store info about original system
if original_ucs.lattice.num_vectors != 3:
raise ValueError(f"Input molecule `current` must have 3D lattice, got: {current.lattice}")
original_frac_coords = original_ucs.get_fractional_coordinates()
original_volume = original_ucs.lattice.get_volume()
original_lattice = original_ucs.lattice.copy()
# step 2, get remaining volume
current_atomic_volume = (
(4 / 3) * np.pi * np.sum(np.fromiter((at.element.radius for at in original_ucs), dtype=np.float32))
)
current_atomic_volume /= 0.74 # use packing efficiency in ccp as example to take up more volume
remaining_volume = original_volume - current_atomic_volume
# temporary value to call the original packmol with
temp_L = np.cbrt(remaining_volume)
box_bounds_for_remaining_volume = [0.0, 0.0, 0.0, temp_L, temp_L, temp_L]
# it is unnecessary to actually pack the molecules, this is just used to get the "details"
# details will contain the correct number of molecules to pack in the combined system
# TODO: reorganize the packmol function so that one can get this info without calling packmol
log(f"Initial packing to determine number of molecules: {molecules}, {box_bounds_for_remaining_volume}", loglevel)
_, details = packmol(molecules=molecules, return_details=True, box_bounds=box_bounds_for_remaining_volume, **kwargs)
# find cuboid parallel along x/y/z that is guaranteed to encompass the original lattice
maxcomponents = np.max(original_ucs.lattice.vectors, axis=0) - np.min(original_ucs.lattice.vectors, axis=0)
box_bounds = [0.0, 0.0, 0.0] + list(maxcomponents)
will_run_uff_md = always_run_md or any(
(not np.isclose(original_lattice.vectors[i][j], 0) for i in range(3) for j in range(3) if i != j)
)
log(f"will_run_uff_md: {will_run_uff_md}", loglevel)
if will_run_uff_md:
if np.linalg.det(original_lattice.vectors) < 0:
raise PackMolError("packmol_around cannot handle lattice where the determinant of the vectors is negative.")
target_lattice = np.diag(maxcomponents)
system_for_packing_type = "supercell" # "supercell" or "distorted"
# system_for_packing_type = "distorted" # "supercell" or "distorted"
n_molecules = [1] + details["n_molecules"]
if system_for_packing_type == "supercell":
# Create a supercell that should encompass the target x/y/z lattice
# this is used for the initial packing of molecules to ensure there is no overlap
# with the original atoms
trafo = np.linalg.inv(original_ucs.lattice.vectors) @ np.array(target_lattice)
trafo = np.sign(trafo) * np.ceil(np.abs(trafo))
trafo = np.int_(trafo)
supercell = original_ucs.make_supercell_trafo(trafo)
supercell.map_atoms(0)
system_for_packing = supercell
tolerance = kwargs.get("tolerance", 1.5)
else:
# now distort the original system to the target lattice
distorted = original_ucs.copy()
distorted.lattice.vectors = np.diag(maxcomponents)
distorted.set_fractional_coordinates(original_frac_coords)
system_for_packing = distorted
# in general we need higher tolerance here since we may be expanding the original system,
# and we do not want the added molecules to enter in artificial "voids"
tolerance = kwargs.get("tolerance", 1.5) * 1.3 # should depend on distortion_vol_expansion_factor somehow
log(f"{system_for_packing_type=}", loglevel)
log(f"{n_molecules=}, {box_bounds=}, {tolerance=}", loglevel)
my_packed, details = packmol(
molecules=[chemsys_to_plams_molecule(system_for_packing)] + tolist(molecules),
n_molecules=n_molecules,
fix_first=True,
box_bounds=box_bounds,
return_details=True,
tolerance=tolerance,
)
# remove the original substrate
my_packed = plams_molecule_to_chemsys(my_packed)
my_packed.remove_atoms(range(len(system_for_packing)))
my_packed.map_atoms_continuous()
my_packed.lattice = Lattice() # so that we can add_other without having incompatible lattices
# now create a distorted system
distorted = original_ucs.copy()
distorted.lattice.vectors = np.diag(maxcomponents)
distorted.set_fractional_coordinates(original_frac_coords)
# distortion_vol_expansion_factor will be used to modify the tolerance when doing the packing
# distortion_vol_expansion_factor = (distorted.lattice.get_volume() / original_volume) ** (1 / 3.0)
# remove bonds to be able to do "shake all bonds * *" for the remaining molecules
if will_run_uff_md:
distorted.bonds.clear_bonds()
distorted.add_other(my_packed)
if will_run_uff_md:
log("Running UFF MD", loglevel)
distorted = _run_uff_md(
distorted,
nsteps=1500,
vectors=original_ucs.lattice.vectors,
fixed_atoms=list(range(len(original_ucs))),
)
distorted.remove_atoms(range(len(original_ucs)))
distorted.map_atoms_continuous()
distorted.lattice = Lattice() # so that we can add_other without having incompatible lattices
# this ensures that the original UCS is exactly preserved (including bonds etc.)
out_ucs = original_ucs.copy()
out_ucs.add_other(distorted)
out_ucs.map_atoms(0)
out_mol = chemsys_to_plams_molecule(out_ucs)
if return_details:
return out_mol, details
return out_mol