-
Notifications
You must be signed in to change notification settings - Fork 1
Add repulse() to push close atoms apart #480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
13d4655
3b2d46a
43853dc
26b9b08
9c69fdf
f0f24e0
992f343
41d549e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,54 @@ | ||||||
| """Utilities that operate purely geometric aspects of structures.""" | ||||||
|
|
||||||
| import numpy as np | ||||||
|
|
||||||
| from structuretoolkit.analyse import get_neighbors | ||||||
|
|
||||||
|
|
||||||
| def repulse( | ||||||
| structure, | ||||||
| min_dist=1.5, | ||||||
| step_size=0.2, | ||||||
| axis=None, | ||||||
| iterations: int = 100, | ||||||
| inplace=False, | ||||||
| ): | ||||||
| """Displace atoms to avoid minimum overlap. | ||||||
|
|
||||||
| Args: | ||||||
| structure (:class:`ase.Atoms`): | ||||||
| structure to modify | ||||||
| min_dist (float): | ||||||
| Minimum distance to enforce between atoms | ||||||
| step_size (float): | ||||||
| Maximum distance to displace atoms in one step | ||||||
| iterations (int): | ||||||
| Maximum number of displacements made before giving up | ||||||
| """ | ||||||
| if not inplace: | ||||||
| structure = structure.copy() | ||||||
| if axis is None: | ||||||
| axis = slice(None) | ||||||
|
Comment on lines
+54
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Axis-constrained mode can’t separate coincident atoms unless At Line 74, the zero-distance fallback direction is hard-coded to x. With 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] * fallbackAlso applies to: 69-75, 81-81 🤖 Prompt for AI Agents |
||||||
| for _ in range(iterations): | ||||||
| neigh = get_neighbors(structure, num_neighbors=1) | ||||||
| dd = neigh.distances[:, 0] | ||||||
| if dd.min() > min_dist: | ||||||
|
pmrv marked this conversation as resolved.
Outdated
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Applied in f0f24e0. |
||||||
| break | ||||||
|
|
||||||
| I = dd < min_dist | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify Ruff E741 for ambiguous variable names in this file
ruff check src/structuretoolkit/build/geometry.py --select E741Repository: pyiron/structuretoolkit Length of output: 432 🏁 Script executed: sed -n '50,75p' src/structuretoolkit/build/geometry.py | cat -nRepository: pyiron/structuretoolkit Length of output: 1252 🏁 Script executed: sed -n '40,85p' src/structuretoolkit/build/geometry.py | cat -nRepository: pyiron/structuretoolkit Length of output: 2164 Rename The variable 🧰 Tools🪛 Ruff (0.15.9)[error] 62-62: Ambiguous variable name: (E741) 🤖 Prompt for AI Agents |
||||||
|
|
||||||
| vv = neigh.vecs[I, 0, :] | ||||||
| vv /= dd[I, None] | ||||||
|
samwaseda marked this conversation as resolved.
Outdated
|
||||||
|
|
||||||
|
||||||
| 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"] | ||||||
|
||||||
| 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) | ||
|
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) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
axisis documented as 0/1/2, but invalid values currently surface as a NumPyIndexErrorat the assignment. It would be better to validateaxisup front and raise aValueErrorwith a clear message whenaxisis not None and not in {0,1,2}.