Skip to content

Add repulse() to push close atoms apart#480

Open
pmrv wants to merge 7 commits intomainfrom
repulse
Open

Add repulse() to push close atoms apart#480
pmrv wants to merge 7 commits intomainfrom
repulse

Conversation

@pmrv
Copy link
Copy Markdown
Contributor

@pmrv pmrv commented Apr 7, 2026

Adds repulse() to structuretoolkit.build.geometry — an iterative utility that displaces atoms until all nearest-neighbour distances meet a configurable minimum threshold.

Key design points

  • Displacement: each atom closer than min_dist to its nearest neighbour is pushed away along the interatomic vector by up to step_size per iteration
  • Coincident atoms (distance == 0): fallback direction is assigned using neigh.indices — atom with lower index gets +x, higher index gets -x, so the pair separates rather than translating together
  • Convergence: loop breaks on dd.min() >= min_dist (strict > would false-fail when a distance lands exactly on the threshold)
  • axis: optionally restrict displacements to a single Cartesian axis (0/1/2)
  • inplace: modify the input structure directly or return a copy (default)
  • Raises RuntimeError if not converged within iterations steps
from structuretoolkit.build.geometry import repulse
from ase.build import bulk

atoms = bulk("Cu", cubic=True).repeat(3)
atoms.rattle(0.5, seed=0)

# Push all atoms apart until nearest-neighbour distance >= 1.5 Å
result = repulse(atoms, min_dist=1.5, step_size=0.1)

# Restrict motion to z-axis, modify in place
repulse(atoms, min_dist=1.5, axis=2, inplace=True)

pmrv and others added 2 commits April 7, 2026 11:53
@pmrv pmrv requested a review from samwaseda April 7, 2026 17:39
@pmrv pmrv requested a review from jan-janssen as a code owner April 7, 2026 17:39
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

A new geometry utility module introduces the repulse function, which iteratively displaces atomic positions to enforce minimum interatomic distances. The function supports in-place modification, coordinate axis constraints, configurable step sizes, and iteration limits. Comprehensive test coverage validates convergence behavior, inplace handling, axis constraints, and distance threshold enforcement.

Changes

Cohort / File(s) Summary
Geometry Utility
src/structuretoolkit/build/geometry.py
New repulse function that iteratively enforces minimum interatomic distances by displacing atoms along neighbor directions with bounded step sizes. Supports in-place modification, axis constraints, and raises RuntimeError on non-convergence.
Test Coverage
tests/test_geometry.py
Comprehensive test suite for repulse covering five scenarios: unchanged positions when no violations exist, inplace parameter behavior, convergence failure handling, axis-constraint preservation, and post-execution distance validation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

Atoms repel with graceful force,
No neighbors crowding their course,
A rabbit hops through vectors true,
Displacing atoms, clean and new,
Geometry dances, distances bloom! 🐰✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add repulse() to push close atoms apart' accurately describes the main change: introducing a new repulse() function that displaces atoms to enforce minimum interatomic distances.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch repulse

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 7, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.90%. Comparing base (94ec44a) to head (9c69fdf).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #480      +/-   ##
==========================================
+ Coverage   82.69%   82.90%   +0.20%     
==========================================
  Files          25       26       +1     
  Lines        1832     1854      +22     
==========================================
+ Hits         1515     1537      +22     
  Misses        317      317              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/structuretoolkit/build/geometry.py (2)

16-27: Docstring is missing axis and inplace parameters.

The docstring documents structure, min_dist, step_size, and iterations, but omits axis and inplace.

📝 Proposed docstring additions
     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
+        axis (int, list, or None):
+            Coordinate axis/axes along which atoms may be displaced.
+            If None, all axes are used.
+        inplace (bool):
+            If True, modify structure in place; otherwise return a copy.
         iterations (int):
             Maximum number of displacements made before giving up
+
+    Returns:
+        :class:`ase.Atoms`: The modified structure (same object if inplace=True).
+
+    Raises:
+        RuntimeError: If minimum distance is not achieved within the iteration limit.
     """
🤖 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 16 - 27, The docstring
for the displace function is missing descriptions for the axis and inplace
parameters; update the docstring in src/structuretoolkit/build/geometry.py (the
function that begins with "Displace atoms to avoid minimum overlap") to include
brief entries explaining axis (which axis or axes to constrain displacement to,
allowed values and default) and inplace (whether the operation modifies the
provided ase.Atoms in place or returns a new object and its default), matching
the style and parameter order used for structure, min_dist, step_size, and
iterations so callers understand behaviour and defaults.

38-38: Rename ambiguous variable I to a descriptive name.

Per Ruff E741, single uppercase I is easily confused with 1 or l. A descriptive name like too_close or close_mask improves readability.

♻️ Proposed fix
-        I = dd < min_dist
+        too_close = dd < min_dist

-        vv = neigh.vecs[I, 0, :]
-        vv /= dd[I, None]
+        vv = neigh.vecs[too_close, 0, :]
+        vv /= dd[too_close, None]

-        disp = np.clip(min_dist - dd[I], 0, step_size)
+        disp = np.clip(min_dist - dd[too_close], 0, step_size)

         displacement = disp[:, None] * vv  # (N_close, 3)
-        structure.positions[I, axis] -= displacement[:, axis]
+        structure.positions[too_close, axis] -= displacement[:, axis]
🤖 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 38, Rename the ambiguous
single-letter variable I used in the distance comparison to a descriptive name
(e.g., too_close or close_mask) inside the function that computes dd < min_dist
so it's clear this is a boolean mask of points closer than min_dist; update all
subsequent uses of I accordingly (search for the symbol I in this
module/function and replace with the chosen name) and run tests/linters to
ensure the Ruff E741 warning is resolved.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/structuretoolkit/build/geometry.py`:
- Around line 40-41: The code divides vv (from neigh.vecs[I,0,:]) by dd[I,None]
which can be zero if atoms coincide; add a guard before the in-place division
that detects dd[I] == 0 and either raises a clear ValueError (e.g. "Structure
contains coincident atoms") or skips/handles those vectors to avoid inf/nan.
Locate the normalization code using the symbols vv, neigh.vecs, dd, and I and
implement a check such as if dd[I] == 0: raise ValueError(...) (or set vv[...] =
0 and continue) to prevent division-by-zero; you can also add a precondition
using get_neighbors(...).distances to validate input earlier.

---

Nitpick comments:
In `@src/structuretoolkit/build/geometry.py`:
- Around line 16-27: The docstring for the displace function is missing
descriptions for the axis and inplace parameters; update the docstring in
src/structuretoolkit/build/geometry.py (the function that begins with "Displace
atoms to avoid minimum overlap") to include brief entries explaining axis (which
axis or axes to constrain displacement to, allowed values and default) and
inplace (whether the operation modifies the provided ase.Atoms in place or
returns a new object and its default), matching the style and parameter order
used for structure, min_dist, step_size, and iterations so callers understand
behaviour and defaults.
- Line 38: Rename the ambiguous single-letter variable I used in the distance
comparison to a descriptive name (e.g., too_close or close_mask) inside the
function that computes dd < min_dist so it's clear this is a boolean mask of
points closer than min_dist; update all subsequent uses of I accordingly (search
for the symbol I in this module/function and replace with the chosen name) and
run tests/linters to ensure the Ruff E741 warning is resolved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f970238a-6b86-4064-b990-185197356d42

📥 Commits

Reviewing files that changed from the base of the PR and between b82eef9 and 43853dc.

📒 Files selected for processing (2)
  • src/structuretoolkit/build/geometry.py
  • tests/test_geometry.py

@samwaseda
Copy link
Copy Markdown
Member

@copilot Complete the docstring of repulse

Copy link
Copy Markdown

Copilot AI commented Apr 7, 2026

@copilot Complete the docstring of repulse

Done in 26b9b08. The docstring now documents all parameters (axis, inplace), adds a Returns section describing the returned ase.Atoms object (and whether it's the same instance or a copy), and a Raises section for the RuntimeError that fires when the iteration limit is exceeded.

@samwaseda
Copy link
Copy Markdown
Member

@copilot Add type hints

Agent-Logs-Url: https://github.com/pyiron/structuretoolkit/sessions/dc485354-9225-480f-9de6-7924e42d047d

Co-authored-by: samwaseda <37879103+samwaseda@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI commented Apr 7, 2026

@copilot Add type hints

Done in 9c69fdf. All parameters now have type hints (structure: Atoms, min_dist: float, step_size: float, axis: int | None, iterations: int, inplace: bool) and the return type is annotated as -> Atoms.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a new geometry utility (repulse) in structuretoolkit.build to iteratively displace atoms so that all nearest-neighbor distances meet a configurable minimum distance threshold, along with unit tests validating expected behaviors.

Changes:

  • Added repulse() to iteratively separate atoms that are closer than min_dist, with optional axis-restricted motion and in-place vs copy semantics.
  • Added TestRepulse unit tests covering no-op behavior, in-place behavior, iteration exhaustion, axis restriction, and min_dist enforcement.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/structuretoolkit/build/geometry.py Implements the new repulse() geometric relaxation utility and exports it via __all__.
tests/test_geometry.py Adds unit tests exercising core repulse() behavior and edge cases.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +64 to +66
vv = neigh.vecs[I, 0, :]
vv /= dd[I, None]

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.

vv /= dd[I, None] will divide by zero if two atoms occupy the same position (distance 0), producing inf/NaN displacements and corrupting positions. Please handle dd == 0 explicitly (e.g., raise a clear error or choose a fallback displacement direction with an epsilon).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot apply changes based on this feedback

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Done in f0f24e0. Coincident atoms (distance == 0) now get a fallback displacement direction ([1.0, 0.0, 0.0]) instead of dividing by zero, and the convergence check was also fixed to use >= so exact equality is treated as converged.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@copilot This makes no sense, because both of the atoms would be moved by the same vector. Give the direction depending on the index of the neighboring atom (which is stored in neigh.indices)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 992f343. For coincident atoms, the fallback now uses neigh.indices to determine direction: if atom_index < neighbor_index, the atom gets +x; otherwise -x. This guarantees the two coincident atoms are pushed in opposite directions and separate correctly.

``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.
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.
for _ in range(iterations):
neigh = get_neighbors(structure, num_neighbors=1)
dd = neigh.distances[:, 0]
if dd.min() > min_dist:
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
if dd.min() > min_dist:
if dd.min() >= min_dist:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Applied in f0f24e0.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants