Skip to content

kups.core.neighborlist.verlet

Verlet neighbor list with a skin: the margin accounting.

A Verlet-skin scheme builds one conservative neighbor list at an enlarged radius r_build ≈ cutoff + skin, stores its edges, and reuses them over many steps via RefineCutoffNeighborList, amortizing the expensive build over the rebuild window. This module holds the pure geometry underneath such a scheme: skin_margin decides how long the stored list remains complete, and effective_build_radii keeps the build radius inside the single-image regime that edge reuse requires.

SkinMargin

Per-system completeness accounting of a stored skin list.

Attributes:

Name Type Description
consumed Array

Worst-case distance (Å) by which atom motion and cell deformation since the build can have pulled a non-listed pair inward, (n_sys,).

budget Array

Distance (Å) such a pair had to spare at build time — the effective skin r_build - cutoff, (n_sys,).

Source code in src/kups/core/neighborlist/verlet.py
@dataclass
class SkinMargin:
    """Per-system completeness accounting of a stored skin list.

    Attributes:
        consumed: Worst-case distance (Å) by which atom motion and cell
            deformation since the build can have pulled a non-listed pair
            inward, ``(n_sys,)``.
        budget: Distance (Å) such a pair had to spare at build time — the
            effective skin ``r_build - cutoff``, ``(n_sys,)``.
    """

    consumed: Array
    budget: Array

    @property
    def headroom(self) -> Array:
        """``budget - consumed``; the stored list is complete while ``>= 0``."""
        return self.budget - self.consumed

headroom property

budget - consumed; the stored list is complete while >= 0.

SkinReference

Geometry snapshot taken when the skin list was built.

skin_margin measures the drift of the current geometry relative to this snapshot. The arrays must not alias the live position/cell buffers (donated jitted steps would then receive the same buffer twice).

Attributes:

Name Type Description
positions Array

Cartesian positions at the build, (N, 3).

cell Cell[AnyPeriodicity]

(n_sys,)-batched cell at the build.

Source code in src/kups/core/neighborlist/verlet.py
@dataclass
class SkinReference:
    """Geometry snapshot taken when the skin list was built.

    [`skin_margin`][kups.core.neighborlist.verlet.skin_margin] measures the
    drift of the current geometry relative to this snapshot. The arrays must
    not alias the live position/cell buffers (donated jitted steps would then
    receive the same buffer twice).

    Attributes:
        positions: Cartesian positions at the build, ``(N, 3)``.
        cell: ``(n_sys,)``-batched cell at the build.
    """

    positions: Array
    cell: Cell[AnyPeriodicity]

effective_build_radii(cutoffs, skin, cell)

Per-system build radius: cutoff + skin, clamped to a single image.

Reusing stored edges keeps exactly one periodic image per pair, so the build radius must stay below half the cell's smallest perpendicular length on every periodic axis — beyond that, second images enter the build sphere and the reuse path would drop them. A cell that compresses mid-run thus degrades to a thinner effective skin (more frequent rebuilds) instead of an incomplete list. No clamp applies in vacuum.

Parameters:

Name Type Description Default
cutoffs Array

True cutoffs (Å), (n_sys,).

required
skin ArrayLike

Requested skin width (Å).

required
cell Cell[AnyPeriodicity]

(n_sys,)-batched cell the build runs in.

required

Returns:

Type Description
Array

Build radii (Å), (n_sys,). radii - cutoffs is the effective skin.

Source code in src/kups/core/neighborlist/verlet.py
def effective_build_radii(
    cutoffs: Array, skin: ArrayLike, cell: Cell[AnyPeriodicity]
) -> Array:
    """Per-system build radius: ``cutoff + skin``, clamped to a single image.

    Reusing stored edges keeps exactly one periodic image per pair, so the
    build radius must stay below half the cell's smallest perpendicular length
    on every periodic axis — beyond that, second images enter the build sphere
    and the reuse path would drop them. A cell that compresses mid-run thus
    degrades to a thinner effective skin (more frequent rebuilds) instead of an
    incomplete list. No clamp applies in vacuum.

    Args:
        cutoffs: True cutoffs (Å), ``(n_sys,)``.
        skin: Requested skin width (Å).
        cell: ``(n_sys,)``-batched cell the build runs in.

    Returns:
        Build radii (Å), ``(n_sys,)``. ``radii - cutoffs`` is the effective skin.
    """
    perp = cell.perpendicular_lengths
    limit = 0.5 * jnp.min(jnp.where(jnp.array(cell.periodic), perp, jnp.inf), axis=-1)
    return jnp.minimum(cutoffs + skin, limit)

skin_margin(particles, systems, reference, cutoffs, skin)

How much of the skin list's safety margin the geometry has used up.

A skin list built at radius r_build stays complete for the true cutoff as long as no pair that was outside r_build at build time has come inside cutoff since. Two things move pairs inward:

  1. Cell deformation. Between the build and now the cell changed by the linear map F = h_ref⁻¹ h_now (row-vector convention), which maps every build-time pair vector d — including those to periodic images — to d @ F. A linear map cannot shrink any vector by more than its smallest singular value: |d @ F| >= σ_min(F) |d| for all d. So the affine part of the motion leaves every non-listed pair at distance at least σ_min(F) r_build, an inward move of at most r_build (1 - σ_min(F)) — and none at all if the cell only expanded (σ_min >= 1). Because σ_min sees the whole map, pure shear counts like any other strain, unlike per-axis length ratios.
  2. Atom motion on top of the deformation. Each atom's non-affine displacement is u_i = x_i - x_i_ref @ F — what remains after riding the cell — minimum-image wrapped in the current cell so that a boundary crossing (even along a sheared lattice vector) is undone exactly (a genuine non-affine drift beyond half a cell would be under-measured, but rebuilds fire at skin scale long before that). A pair distance changes by at most the two endpoint displacements, 2 max|u|.

The stored list is therefore complete while, per system,

consumed := 2 max|u| + r_build max(0, 1 - σ_min(F))  <=  r_build - cutoff =: budget

i.e. while the worst-case inward motion of a non-listed pair (consumed) has not eaten the extra radius the build added on top of the cutoff (budget). Pairs never span systems, so the accounting is fully per system: one hot system neither charges nor rebuilds the others.

Parameters:

Name Type Description Default
particles Table[ParticleId, HasPositionsAndSystemIndex]

Current particle table (positions and system index).

required
systems Table[SystemId, NeighborListSystems]

Current system table (cells).

required
reference SkinReference

Positions and cell snapshot taken at the last build.

required
cutoffs Table[SystemId, Array]

True cutoffs (Å) per system.

required
skin ArrayLike

Requested skin width (Å) the list was built with.

required

Returns:

Type Description
Table[SystemId, SkinMargin]

Per-system SkinMargin

Table[SystemId, SkinMargin]

table (consumed and budget, both in Å).

Source code in src/kups/core/neighborlist/verlet.py
def skin_margin(
    particles: Table[ParticleId, HasPositionsAndSystemIndex],
    systems: Table[SystemId, NeighborListSystems],
    reference: SkinReference,
    cutoffs: Table[SystemId, Array],
    skin: ArrayLike,
) -> Table[SystemId, SkinMargin]:
    """How much of the skin list's safety margin the geometry has used up.

    A skin list built at radius ``r_build`` stays complete for the true
    ``cutoff`` as long as no pair that was *outside* ``r_build`` at build time
    has come *inside* ``cutoff`` since. Two things move pairs inward:

    1. **Cell deformation.** Between the build and now the cell changed by the
       linear map ``F = h_ref⁻¹ h_now`` (row-vector convention), which maps
       every build-time pair vector ``d`` — including those to periodic images —
       to ``d @ F``. A linear map cannot shrink any vector by more than its
       smallest singular value: ``|d @ F| >= σ_min(F) |d|`` for all ``d``. So
       the affine part of the motion leaves every non-listed pair at distance
       at least ``σ_min(F) r_build``, an inward move of at most
       ``r_build (1 - σ_min(F))`` — and none at all if the cell only expanded
       (``σ_min >= 1``). Because ``σ_min`` sees the whole map, pure shear
       counts like any other strain, unlike per-axis length ratios.
    2. **Atom motion on top of the deformation.** Each atom's *non-affine*
       displacement is ``u_i = x_i - x_i_ref @ F`` — what remains after riding
       the cell — minimum-image wrapped in the current cell so that a boundary
       crossing (even along a sheared lattice vector) is undone exactly (a
       genuine non-affine drift beyond half a cell would be under-measured, but
       rebuilds fire at skin scale long before that). A pair distance changes
       by at most the two endpoint displacements, ``2 max|u|``.

    The stored list is therefore complete while, per system,

        consumed := 2 max|u| + r_build max(0, 1 - σ_min(F))  <=  r_build - cutoff =: budget

    i.e. while the worst-case inward motion of a non-listed pair (*consumed*)
    has not eaten the extra radius the build added on top of the cutoff
    (*budget*). Pairs never span systems, so the accounting is fully per
    system: one hot system neither charges nor rebuilds the others.

    Args:
        particles: Current particle table (positions and system index).
        systems: Current system table (cells).
        reference: Positions and cell snapshot taken at the last build.
        cutoffs: True cutoffs (Å) per system.
        skin: Requested skin width (Å) the list was built with.

    Returns:
        Per-system [`SkinMargin`][kups.core.neighborlist.verlet.SkinMargin]
        table (``consumed`` and ``budget``, both in Å).
    """
    cell_now = systems.data.cell
    system = particles.data.system.indices
    cutoff_values = Table.broadcast_to(cutoffs, systems).data
    deform = reference.cell.inverse_vectors @ cell_now.vectors  # d_now = d_ref @ F
    # u_i = x_i - x_i_ref @ F, min-image wrapped
    co_moved = jnp.einsum("ni,nij->nj", reference.positions, deform[system])
    residual = cell_now[system].wrap(particles.data.positions - co_moved)
    u_max = particles.data.system.max_over(jnp.linalg.norm(residual, axis=-1)).data
    u_max = jnp.maximum(u_max, 0.0)  # empty segments reduce to -inf
    # σ_min(F) from the smallest eigenvalue of the 3x3 Gram matrix F Fᵀ
    # (cheaper than an SVD; the clamp guards eigvalsh's tiny negative noise).
    gram = deform @ jnp.swapaxes(deform, -1, -2)
    sigma_min = jnp.sqrt(jnp.maximum(jnp.linalg.eigvalsh(gram)[..., 0], 0.0))
    r_build = effective_build_radii(cutoff_values, skin, reference.cell)
    consumed = 2.0 * u_max + r_build * jnp.maximum(0.0, 1.0 - sigma_min)
    return Table(systems.keys, SkinMargin(consumed, r_build - cutoff_values))