Source code for structuretoolkit.build.geometry
"""Utilities that operate purely geometric aspects of structures."""
import numpy as np
from ase.atoms import Atoms
from structuretoolkit.analyse import get_neighbors
[docs]
def repulse(
structure: Atoms,
min_dist: float = 1.5,
step_size: float = 0.2,
axis: int | None = None,
iterations: int = 100,
inplace: bool = False,
) -> Atoms:
"""Iteratively displace atoms apart until all interatomic distances exceed a minimum threshold.
For each pair of atoms closer than ``min_dist``, the atom is displaced away from its nearest
neighbour by up to ``step_size`` along the direction of the interatomic vector. The loop
repeats until all nearest-neighbour distances satisfy the minimum criterion or the iteration
limit is reached.
Args:
structure (:class:`ase.Atoms`):
Structure to modify.
min_dist (float):
Minimum interatomic distance (in Å) to enforce between every pair of atoms.
Defaults to 1.5.
step_size (float):
Maximum displacement (in Å) applied to a single atom per iteration.
Smaller values give smoother convergence but require more iterations.
Defaults to 0.2.
axis (int or None):
Cartesian axis index (0, 1, or 2) along which displacements are restricted.
When *None* (default) displacements are applied in all three directions.
iterations (int):
Maximum number of displacement steps before raising a :class:`RuntimeError`.
Defaults to 100.
inplace (bool):
If *True*, the positions of ``structure`` are modified directly.
If *False* (default), a copy is made and the original is left unchanged.
Returns:
:class:`ase.Atoms`: The structure with adjusted atomic positions. This is the
same object as ``structure`` when ``inplace=True``, or a new copy otherwise.
Raises:
RuntimeError: If the minimum distance criterion is not satisfied within
``iterations`` steps.
"""
if not inplace:
structure = structure.copy()
ax: int | slice = axis if axis is not None else slice(None)
for _ in range(iterations):
neigh = get_neighbors(structure, num_neighbors=1)
dd = neigh.distances[:, 0]
if dd.min() >= min_dist:
break
I = dd < min_dist
dd_I = dd[I]
vv = neigh.vecs[I, 0, :].copy()
# Avoid division by zero for coincident atoms (distance == 0).
# Assign opposite fallback directions based on atom-index ordering so
# the two coincident atoms separate rather than move together.
atom_indices = np.where(I)[0]
zero_mask = dd_I == 0
if np.any(zero_mask):
neighbor_indices = neigh.indices[atom_indices[zero_mask], 0]
sign = np.where(atom_indices[zero_mask] < neighbor_indices, 1.0, -1.0)
vv[zero_mask] = sign[:, None] * np.array([1.0, 0.0, 0.0])
safe_dd = np.where(dd_I > 0, dd_I, 1.0)
vv /= safe_dd[:, None]
disp = np.clip(min_dist - dd[I], 0, step_size)
displacement = disp[:, None] * vv # (N_close, 3)
structure.positions[I, ax] -= displacement[:, ax]
else:
raise RuntimeError(f"repulse did not converge within {iterations} iterations")
return structure
[docs]
def merge(structure: Atoms, cutoff: float = 1.8, iterations: int = 10) -> Atoms:
"""Merge pairs of atoms that are closer than ``cutoff`` by collapsing each
pair to their midpoint and deleting one of the two atoms.
The operation is applied repeatedly (up to ``iterations`` times) to handle
cases where a merge creates new close contacts.
.. note::
The structure is modified **in place**. Pass a copy if you need the
original to remain unchanged.
Args:
structure (:class:`ase.Atoms`):
Structure to modify.
cutoff (float):
Distance threshold in Ångström below which two atoms are
considered clashing and will be merged. Defaults to ``1.8``.
iterations (int):
Maximum number of recursive merge passes. Defaults to ``10``.
Returns:
:class:`ase.Atoms`: The modified structure with clashing atom pairs
replaced by single atoms at their midpoints.
"""
neigh = get_neighbors(structure, 1)
clashing = np.argwhere(neigh.distances[:, 0] < cutoff).ravel()
if len(clashing) == 0:
return structure
moving = []
deleting = []
for c in clashing:
if c in deleting:
continue
moving.append(c)
deleting.append(neigh.indices[c, 0])
structure.positions[moving] += neigh.vecs[moving, 0] / 2
del structure[deleting]
if iterations > 0:
return merge(structure, cutoff=cutoff, iterations=iterations - 1)
return structure
__all__ = [
"merge",
"repulse",
]