Skip to content
Open
89 changes: 89 additions & 0 deletions src/structuretoolkit/build/geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Utilities that operate purely geometric aspects of structures."""

import numpy as np
from ase.atoms import Atoms

from structuretoolkit.analyse import get_neighbors


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()
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

axis is documented as 0/1/2, but invalid values currently surface as a NumPy IndexError at the assignment. It would be better to validate axis up front and raise a ValueError with a clear message when axis is not None and not in {0,1,2}.

Suggested change
structure = structure.copy()
structure = structure.copy()
if axis is not None and axis not in {0, 1, 2}:
raise ValueError("axis must be None or one of {0, 1, 2}")

Copilot uses AI. Check for mistakes.
if axis is None:
axis = slice(None)
Comment on lines +54 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Axis-constrained mode can’t separate coincident atoms unless axis=0.

At Line 74, the zero-distance fallback direction is hard-coded to x. With axis=1 or axis=2, Line 81 applies only y/z components, so coincident atoms receive zero displacement and may never converge.

Proposed fix
 def repulse(
@@
 ) -> Atoms:
@@
-    if axis is None:
-        axis = slice(None)
+    axis_idx = axis
+    if axis is None:
+        axis = slice(None)
+    elif axis not in (0, 1, 2):
+        raise ValueError("axis must be 0, 1, 2, or None")
@@
         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])
+            if axis_idx is None:
+                fallback = np.array([1.0, 0.0, 0.0])
+            else:
+                fallback = np.zeros(3)
+                fallback[axis_idx] = 1.0
+            vv[zero_mask] = sign[:, None] * fallback

Also applies to: 69-75, 81-81

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/structuretoolkit/build/geometry.py` around lines 54 - 55, The code
currently hard-codes the zero-distance fallback direction to the x-axis, which
fails when an axis constraint (axis=1 or axis=2) is used; change the fallback
direction assignment so it respects the provided axis parameter: if axis is an
int 0/1/2, set the fallback unit vector to the corresponding basis vector (e.g.,
np.eye(3)[axis]) so coincident atoms are displaced along that constrained axis;
if axis is None or slice(None) preserve the original fallback (or use a full 3D
random/unit vector), and ensure the downstream displacement computation still
applies only the components allowed by the axis constraint (the same variables
around the zero-distance handling where the fallback is currently hard-coded to
x).

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Ruff E741 for ambiguous variable names in this file
ruff check src/structuretoolkit/build/geometry.py --select E741

Repository: pyiron/structuretoolkit

Length of output: 432


🏁 Script executed:

sed -n '50,75p' src/structuretoolkit/build/geometry.py | cat -n

Repository: pyiron/structuretoolkit

Length of output: 1252


🏁 Script executed:

sed -n '40,85p' src/structuretoolkit/build/geometry.py | cat -n

Repository: pyiron/structuretoolkit

Length of output: 2164


Rename I to a descriptive mask name (e.g., close_mask) to resolve Ruff E741.

The variable I at line 62 is flagged as ambiguous. Renaming it—along with its 5 usages at lines 64, 65, 70, 77, and 80—improves readability and resolves the lint error. The name close_mask accurately describes its role as a boolean mask of atoms closer than min_dist.

🧰 Tools
🪛 Ruff (0.15.9)

[error] 62-62: Ambiguous variable name: I

(E741)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/structuretoolkit/build/geometry.py` at line 62, The variable name I in
src/structuretoolkit/build/geometry.py is ambiguous and triggers Ruff E741;
rename it to a descriptive boolean mask like close_mask (used where I = dd <
min_dist) and update all its usages (the five places currently referencing I at
the subsequent lines) to close_mask so the logic and intent remain identical and
linting passes; ensure any conditionals, indexing or counts using I are replaced
with close_mask and run tests/linter to confirm no remaining references to I.


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, axis] -= displacement[:, axis]

else:
raise RuntimeError(f"repulse did not converge within {iterations} iterations")

return structure


__all__ = ["repulse"]
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If repulse is intended to be part of the public build API (similar to create_mesh, grainboundary, etc.), consider re-exporting it from structuretoolkit.build.__init__ (and possibly the top-level structuretoolkit.__init__) so users can import it consistently.

Copilot uses AI. Check for mistakes.
61 changes: 61 additions & 0 deletions tests/test_geometry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import unittest

import numpy as np
from ase.build import bulk

from structuretoolkit.analyse import get_neighbors
from structuretoolkit.build.geometry import repulse


class TestRepulse(unittest.TestCase):
def setUp(self):
self.atoms = bulk("Cu", cubic=True).repeat(5)
self.atoms.rattle(1)
Comment thread
samwaseda marked this conversation as resolved.

def test_noop(self):
"""If no atoms are violating min_dist, atoms should be unchanged."""
atoms = bulk("Cu", cubic=True).repeat(5) # perfect FCC, no rattle
original_positions = atoms.positions.copy()
# Cu nearest-neighbor ~2.55 Å, so min_dist=2.0 triggers no displacement
result = repulse(atoms, min_dist=2.0)
np.testing.assert_array_equal(result.positions, original_positions)

def test_inplace(self):
"""Input atoms should be copied depending on `inplace`."""
original_positions = self.atoms.positions.copy()

# inplace=False (default): original must be untouched, result is a copy
result = repulse(self.atoms, inplace=False)
np.testing.assert_array_equal(self.atoms.positions, original_positions)
self.assertIsNot(result, self.atoms)

# inplace=True: result is the same object as the input
result2 = repulse(self.atoms, inplace=True)
self.assertIs(result2, self.atoms)

def test_iterations(self):
"""Should raise error if iterations exhausted."""
# min_dist=5.0 is far larger than any achievable spacing; step_size tiny
# → convergence is impossible, so iterations will be exhausted
with self.assertRaises(RuntimeError):
repulse(self.atoms, min_dist=5.0, step_size=0.001, iterations=2)

def test_axis(self):
"""If axis given, the other axes should be exactly untouched."""
atoms = self.atoms.copy()
original_y = atoms.positions[:, 1].copy()
original_z = atoms.positions[:, 2].copy()
# Modify inplace so we can inspect positions even if convergence fails
try:
repulse(atoms, axis=0, inplace=True)
except RuntimeError:
pass # convergence irrelevant; we only care which axes were touched
np.testing.assert_array_equal(atoms.positions[:, 1], original_y)
np.testing.assert_array_equal(atoms.positions[:, 2], original_z)

def test_min_dist(self):
"""min_dist must be respected after a call to repulse."""
min_dist = 1.5
result = repulse(self.atoms, min_dist=min_dist)
neigh = get_neighbors(result, num_neighbors=1)
self.assertGreaterEqual(neigh.distances[:, 0].min(), min_dist)
Loading