Source code for eon.geometry.pbc

"""Periodic boundary conditions and distance helpers."""

from __future__ import annotations

from typing import Sequence, Union

import numpy as np

PeriodicSpec = Union[bool, Sequence[bool], np.ndarray]


def _periodic_mask(periodic: PeriodicSpec | None) -> np.ndarray | None:
    """None means wrap all axes. Otherwise a length-3 bool mask."""
    if periodic is None or periodic is True:
        return None
    if periodic is False:
        return np.zeros(3, dtype=bool)
    flags = np.asarray(periodic, dtype=bool)
    if flags.shape == ():
        return None if bool(flags) else np.zeros(3, dtype=bool)
    if flags.shape != (3,):
        raise ValueError(f"periodic must be bool or length-3, got {flags.shape}")
    if bool(flags.all()):
        return None
    return flags


def _pbc_numpy(
    r, box, ibox: np.ndarray | None, periodic: PeriodicSpec | None = None
) -> np.ndarray:
    if ibox is None:
        ibox = np.linalg.inv(box)
    vdir = np.dot(r, ibox)
    wrapped = (vdir % 1.0 + 1.5) % 1.0 - 0.5
    flags = _periodic_mask(periodic)
    if flags is not None:
        wrapped = np.where(flags, wrapped, vdir)
    return np.dot(wrapped, box)


[docs] def pbc( r, box, ibox: np.ndarray | None = None, periodic: PeriodicSpec | None = None, ) -> np.ndarray: """Minimum-image convention for displacement(s). The kernel is :mod:`minimage` (same wrap linkcell uses). Packed ``(N, 3)`` rows go through ``Cell.wrap_many`` when that method exists. The numpy path remains as :func:`pbc_eon_legacy` for the eOn/GROMACS/LAMMPS agreement tests. Parameters ---------- r : (3,) or (N, 3) Displacement vector(s). box : (3, 3) Cell matrix (rows = lattice vectors). ibox : (3, 3), optional Inverse of *box*; computed if omitted. periodic : bool or (3,) bool, optional Axes to wrap. True / omitted wraps all three. False wraps none. A length-3 mask wraps only the True axes. Partial masks skip minimage and use the numpy wrap so a free axis is left alone. """ r = np.asarray(r, dtype=float) box = np.asarray(box, dtype=float) flags = _periodic_mask(periodic) if flags is not None: return _pbc_numpy(r, box, ibox, periodic) try: import minimage cell = minimage.Cell.from_vesin(box.tolist()) if r.ndim == 1: wrap = getattr(cell, "wrap", None) if wrap is not None: return np.asarray(wrap(r.tolist()), dtype=float) return np.asarray( cell.displacement([0.0, 0.0, 0.0], r.tolist()), dtype=float ) rows = np.atleast_2d(r) wrap_many = getattr(cell, "wrap_many", None) if wrap_many is not None: return np.asarray(wrap_many(rows), dtype=float) out = np.empty_like(rows, dtype=float) zero = [0.0, 0.0, 0.0] for i, row in enumerate(rows): out[i] = cell.displacement(zero, row.tolist()) return out except ImportError: return _pbc_numpy(r, box, ibox)
[docs] def pbc_eon_legacy( r, box, ibox: np.ndarray | None = None, periodic: PeriodicSpec | None = None ) -> np.ndarray: """eOn numpy wrap. Kept so tests can compare it to minimage.""" r = np.asarray(r, dtype=float) box = np.asarray(box, dtype=float) return _pbc_numpy(r, box, ibox, periodic)
[docs] def per_atom_norm( v, box, ibox: np.ndarray | None = None, periodic: PeriodicSpec | None = None, ) -> np.ndarray: """Per-row Euclidean norm after PBC (shape ``(N,)``).""" diff = pbc(v, box, ibox, periodic) return np.sqrt(np.sum(diff**2.0, axis=1))
[docs] def per_atom_norm_gen( v, box, ibox: np.ndarray | None = None, periodic: PeriodicSpec | None = None, ): """Yield per-row norms after PBC (legacy generator API).""" diff = pbc(v, box, ibox, periodic) for d in diff: yield np.linalg.norm(d)