Source code for nvalchemi.dynamics.hooks.periodic

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Periodic boundary condition hook for coordinate wrapping.

Provides :class:`WrapPeriodicHook`, which wraps atomic positions back
into the unit cell under periodic boundary conditions.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from nvalchemi.dynamics.base import HookStageEnum
from nvalchemi.dynamics.hooks._utils import wrap_positions_into_cell

if TYPE_CHECKING:
    from nvalchemi.data import Batch
    from nvalchemi.dynamics.base import BaseDynamics

__all__ = ["WrapPeriodicHook"]


[docs] class WrapPeriodicHook: """Wrap atomic positions back into the simulation cell under PBC. During long molecular dynamics trajectories, atomic positions drift away from the unit cell as the integrator applies unbounded displacements. While physically valid (forces are invariant under lattice translations), large coordinates can cause problems: * **Neighbor list overflow** — distance calculations may exceed the numerical range of the cell-shift representation, leading to missed interactions or incorrect forces. * **Precision loss** — large coordinate magnitudes reduce the effective floating-point precision available for inter-atomic distances. * **Visualization artifacts** — trajectories with unwrapped coordinates are difficult to analyze and visualize. This hook wraps positions back into the unit cell by computing fractional coordinates, taking their modulo, and converting back to Cartesian:: frac = positions @ inv(cell) frac = frac % 1.0 positions = frac @ cell The wrapping is applied in-place to ``batch.positions`` and respects per-system periodicity flags in ``batch.pbc``: * If ``batch.pbc`` is ``[True, True, True]``, all three dimensions are wrapped. * If ``batch.pbc`` is ``[True, True, False]`` (e.g. a slab), only the *x* and *y* coordinates are wrapped; the *z* coordinate is left unwrapped to allow vacuum gaps. * If ``batch.pbc`` is ``[False, False, False]`` (non-periodic), the hook is a no-op for that system. The hook fires at :attr:`~HookStageEnum.AFTER_POST_UPDATE`, after velocities have been updated but before the next step begins. This ensures that the neighbor list built at the start of the next step sees wrapped coordinates. Parameters ---------- frequency : int, optional Wrap positions every ``frequency`` steps. Default ``1`` (every step). For simulations with moderate drift, wrapping every 10--100 steps is sufficient and reduces overhead. Attributes ---------- frequency : int Wrapping frequency in steps. stage : HookStageEnum Fixed to ``AFTER_POST_UPDATE``. Examples -------- >>> from nvalchemi.dynamics.hooks import WrapPeriodicHook >>> hook = WrapPeriodicHook(frequency=10) >>> dynamics = DemoDynamics(model=model, n_steps=10_000, dt=0.5, hooks=[hook]) >>> dynamics.run(batch) Notes ----- * Wrapping does **not** modify velocities, momenta, or forces — only positions. This is correct because forces depend on relative distances (invariant under translation) and velocities are already in Cartesian space. * For triclinic (non-orthorhombic) cells, the fractional-coordinate approach naturally handles skewed lattice vectors. * This hook assumes ``batch.cell`` has shape ``(B, 3, 3)`` with lattice vectors as **rows** (consistent with ASE convention). * In batched simulations, wrapping is applied per-graph using ``batch.batch`` to associate each atom with its cell. """ stage: HookStageEnum = HookStageEnum.AFTER_POST_UPDATE
[docs] def __init__(self, frequency: int = 1) -> None: self.frequency = frequency
def __call__(self, batch: Batch, dynamics: BaseDynamics) -> None: """Wrap positions into the unit cell in-place. Parameters ---------- batch : Batch The current batch of atomic data. ``batch.positions`` is modified in-place. dynamics : BaseDynamics The dynamics engine instance. """ cell = batch.cell pbc = batch.pbc # System-level tensors may have a leading singleton dim: (B, 1, 3, 3) -> (B, 3, 3) if cell.dim() == 4: cell = cell.squeeze(1) if pbc.dim() == 3: pbc = pbc.squeeze(1) wrap_positions_into_cell(batch.positions, cell, pbc, batch.batch)