"""Neighbor lists.
A periodic cutoff list is :func:`linkcell.pairs_within`: one row per
atom-image, the caller's shift ``S``, and a squared distance strictly
below the cutoff squared. :func:`neighbor_list` unique-indexes those
rows. An open axis stays on :class:`vesin.NeighborList`, which accepts
per-axis periodicity. ``knearest`` is the k-nearest list, not a cutoff
list. The *brute* flag is retained for API compatibility and does not
select another algorithm.
"""
from __future__ import annotations
from typing import List, Sequence, Union
import numpy as np
from vesin import NeighborList as VesinNeighborList
from eon.geometry.pbc import pbc
PeriodicSpec = Union[bool, Sequence[bool], np.ndarray]
# Structure-like: needs .r, .box, __len__, and optionally .free
StructureLike = object
def _positions_box(p: StructureLike):
r = np.asarray(p.r, dtype=float)
box = np.asarray(p.box, dtype=float)
if r.ndim != 2 or r.shape[1] != 3:
raise ValueError(f"positions must be (N, 3), got {r.shape}")
if box.shape != (3, 3):
raise ValueError(f"box must be (3, 3), got {box.shape}")
return r, box
def _pair_lists(n: int, i: np.ndarray, j: np.ndarray) -> List[List[int]]:
"""Convert vesin pair indices to eOn adjacency lists (plain Python ints).
Vesin may return the same neighbor index multiple times under different
periodic shifts when the cutoff reaches multiple images. eOn historically
stores unique atom indices only (no multi-image multiplicity).
"""
nl_sets = [set() for _ in range(n)]
for a, b in zip(i.tolist(), j.tolist()):
ai, bi = int(a), int(b)
if ai == bi:
continue
nl_sets[ai].add(bi)
return [sorted(s) for s in nl_sets]
def _periodic_flags(p: StructureLike, periodic: PeriodicSpec | None) -> PeriodicSpec:
"""Resolve ``periodic`` from the explicit argument or ``p.periodic``."""
if periodic is not None:
return periodic
flags = getattr(p, "periodic", None)
if flags is None:
flags = getattr(p, "pbc", None)
if flags is None:
return True
return flags
def _all_periodic(flags: PeriodicSpec) -> bool:
"""True when every axis wraps. ``pairs_within`` has no open axis."""
if isinstance(flags, (bool, np.bool_)):
return bool(flags)
arr = np.asarray(flags, dtype=bool).reshape(-1)
if arr.size == 1:
return bool(arr[0])
return arr.size == 3 and bool(arr.all())
def _linkcell_pairs(r: np.ndarray, box: np.ndarray, cutoff: float, *, half: bool = False):
"""``(i, j, S, dist2)`` from :func:`linkcell.pairs_within`, or ``None``."""
try:
import linkcell
except ImportError:
return None
pairs_within = getattr(linkcell, "pairs_within", None)
if pairs_within is None:
return None
xyz = np.ascontiguousarray(r, dtype=np.float64)
cell = np.ascontiguousarray(box, dtype=np.float64)
i, j, shift, dist2 = pairs_within(xyz, cell, float(cutoff), half=half)
return (
np.asarray(np.from_dlpack(i), dtype=np.int64),
np.asarray(np.from_dlpack(j), dtype=np.int64),
np.asarray(np.from_dlpack(shift), dtype=np.int32),
np.asarray(np.from_dlpack(dist2), dtype=np.float64),
)
[docs]
def neighbor_list(
p: StructureLike,
cutoff: float,
brute: bool = False, # noqa: ARG001 — API compat; vesin always used
periodic: PeriodicSpec | None = None,
) -> List[List[int]]:
"""Return neighbors within *cutoff* for each atom (PBC, full undirected list).
Parameters
----------
p
Structure with ``.r`` (N,3) and ``.box`` (3,3). Optional
``.periodic`` (bool or length-3 bools) is used when *periodic*
is omitted.
cutoff
Pair cutoff distance.
brute
Ignored; kept so callers using ``config.comp_brute_neighbors`` need no change.
periodic
A single bool applies to all axes; a length-3 sequence is
per-axis. Default is ``p.periodic`` or all-periodic. Fully
periodic cells use :func:`linkcell.pairs_within`. An open axis
uses vesin.
"""
r, box = _positions_box(p)
n = r.shape[0]
if n == 0:
return []
if cutoff <= 0:
return [[] for _ in range(n)]
flags = _periodic_flags(p, periodic)
if _all_periodic(flags):
packed = _linkcell_pairs(r, box, cutoff, half=False)
if packed is not None:
return _pair_lists(n, packed[0], packed[1])
calc = VesinNeighborList(cutoff=float(cutoff), full_list=True)
i, j = calc.compute(r, box, periodic=flags, quantities="ij")
return _pair_lists(n, np.asarray(i), np.asarray(j))
[docs]
def brute_neighbor_list(p: StructureLike, cutoff: float) -> List[List[int]]:
"""Alias of :func:`neighbor_list` (historical name)."""
return neighbor_list(p, cutoff, brute=True)
[docs]
def neighbor_list_vectors(
p: StructureLike,
cutoff: float,
brute: bool = False,
) -> List[List[np.ndarray]]:
"""Neighbor list with minimum-image vectors from center → neighbor.
Unique-index adjacency is the historical eOn contract. Vectors are
one MIC wrap of ``r[j] - r[center]`` per neighbour index, computed
in one :func:`eon.geometry.pbc` call (minimage ``wrap_many`` when
installed). Multi-image pair lists belong on
:func:`neighbor_list_pairs`.
"""
nl = neighbor_list(p, cutoff, brute=brute)
r, box = _positions_box(p)
ibox = np.linalg.inv(box)
pairs = [(center, j) for center, neighs in enumerate(nl) for j in neighs]
if not pairs:
return [[] for _ in nl]
diffs = np.empty((len(pairs), 3), dtype=float)
for k, (center, j) in enumerate(pairs):
diffs[k] = r[j] - r[center]
wrapped = np.atleast_2d(
pbc(diffs, box, ibox, periodic=_periodic_flags(p, None))
)
out: List[List[np.ndarray]] = [[] for _ in nl]
for k, (center, _j) in enumerate(pairs):
out[center].append(np.asarray(wrapped[k], dtype=float))
return out
[docs]
def neighbor_list_pairs(
p: StructureLike,
cutoff: float,
periodic: PeriodicSpec | None = None,
):
"""Cutoff pair list with cell shifts (ASE/tonari ``ijS``).
Returns ``(i, j, S)`` with one row per atom-image pair, ordered by
``(i, j, S)``. Unlike :func:`neighbor_list`, this does not
unique-index or apply a minimum-image reduction. Displacement is
``r[j] - r[i] + S @ box``. A fully periodic cell uses
:func:`linkcell.pairs_within`. An open axis uses vesin. A squared
distance on a linkcell row is strictly below the cutoff squared.
"""
r, box = _positions_box(p)
n = r.shape[0]
empty = (
np.zeros(0, dtype=np.int64),
np.zeros(0, dtype=np.int64),
np.zeros((0, 3), dtype=np.int32),
)
if n == 0 or cutoff <= 0:
return empty
flags = _periodic_flags(p, periodic)
if _all_periodic(flags):
packed = _linkcell_pairs(r, box, cutoff, half=False)
if packed is not None:
i, j, shift, _dist2 = packed
if i.size == 0:
return empty
order = np.lexsort((shift[:, 2], shift[:, 1], shift[:, 0], j, i))
return i[order], j[order], shift[order]
calc = VesinNeighborList(cutoff=float(cutoff), full_list=True)
i, j, shift = calc.compute(r, box, periodic=flags, quantities="ijS")
i = np.asarray(i)
j = np.asarray(j)
shift = np.asarray(shift)
if i.size == 0:
return empty
order = np.lexsort((shift[:, 2], shift[:, 1], shift[:, 0], j, i))
return i[order], j[order], shift[order]
[docs]
def neighbor_list_linkcell(
p: StructureLike,
cutoff: float,
) -> List[List[int]]:
"""Unique-index adjacency from :func:`linkcell.pairs_within`.
The cell is periodic. ``knearest`` is a different query and is not
called. An empty list is returned when linkcell has no
``pairs_within``.
"""
r, box = _positions_box(p)
n = r.shape[0]
if n == 0:
return []
if cutoff <= 0 or n == 1:
return [[] for _ in range(n)]
packed = _linkcell_pairs(r, box, cutoff, half=False)
if packed is None:
return [[] for _ in range(n)]
return _pair_lists(n, packed[0], packed[1])
[docs]
def coordination_numbers(
p: StructureLike, cutoff: float, brute: bool = False
) -> List[int]:
return [len(l) for l in neighbor_list(p, cutoff, brute)]
[docs]
def least_coordinated(
p: StructureLike, cutoff: float, brute: bool = False
) -> List[int]:
"""Indices of free atoms with the lowest coordination number."""
from eon.structure import as_atom_free
cn = coordination_numbers(p, cutoff, brute)
if not cn:
return []
maxcoord = max(cn)
mincoord = min(cn)
free = as_atom_free(getattr(p, "free", np.ones(len(cn))))
while mincoord <= maxcoord:
least = [i for i in range(len(cn)) if cn[i] <= mincoord and free[i]]
if least:
return least
mincoord += 1
return []