Source code for nvalchemiops.interactions.lj

# SPDX-FileCopyrightText: Copyright (c) 2025 - 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.

"""
Lennard-Jones Potential
=======================

This module implements GPU-accelerated Lennard-Jones (LJ) energy and force
calculations using Warp kernels with neighbor lists for O(N) scaling.

Mathematical Formulation
------------------------

The Lennard-Jones potential describes the interaction between a pair of
neutral atoms or molecules:

.. math::

    V_{LJ}(r) = 4\\epsilon \\left[ \\left(\\frac{\\sigma}{r}\\right)^{12}
                - \\left(\\frac{\\sigma}{r}\\right)^{6} \\right]

where:
- :math:`\\epsilon` is the depth of the potential well (energy)
- :math:`\\sigma` is the distance at which the potential is zero
- :math:`r` is the interparticle distance

The force is the negative gradient:

.. math::

    F_{LJ}(r) = -\\frac{dV}{dr} = \\frac{24\\epsilon}{r}
                \\left[ 2\\left(\\frac{\\sigma}{r}\\right)^{12}
                - \\left(\\frac{\\sigma}{r}\\right)^{6} \\right]

For the virial tensor (needed for pressure calculations in NPT/NPH):

.. math::

    W_{\\alpha\\beta} = \\sum_{i<j} r_{ij,\\alpha} \\cdot F_{ij,\\beta}

.. note::
    This implementation assumes a **half neighbor list** where each pair (i, j)
    appears only once (i.e., only for i < j or only for i > j). Newton's third
    law is applied to accumulate forces on both atoms.

Neighbor Formats
----------------

This module supports two neighbor formats:

1. **Neighbor List (CSR format)**: `idx_j` contains target atom indices,
   `neighbor_ptr` contains CSR row pointers where neighbor_ptr[i] to
   neighbor_ptr[i+1] gives the range of neighbors for atom i.

2. **Neighbor Matrix**: `neighbor_matrix` is shape (N, max_neighbors) where
   each row contains neighbor indices for that atom.

API Structure
-------------

Public Wrappers:
    - `lj_energy()`: Compute energies only
    - `lj_forces()`: Compute forces only (convenience)
    - `lj_energy_forces()`: Compute both energies and forces
    - `lj_energy_forces_virial()`: Compute energies, forces, and virial tensor

References
----------
- Lennard-Jones, J. E. (1924). Proc. R. Soc. A, 106, 463-477
- Allen & Tildesley, "Computer Simulation of Liquids" (1987)

Examples
--------
>>> import warp as wp
>>> import numpy as np
>>> from nvalchemiops.interactions.lj import lj_energy_forces
>>>
>>> # Create simple FCC argon system
>>> positions = wp.array(pos_np, dtype=wp.vec3d, device="cuda:0")
>>> cell = wp.array(cell_np, dtype=wp.mat33d, device="cuda:0")
>>>
>>> # Compute LJ energy and forces
>>> energies, forces = lj_energy_forces(
...     positions=positions,
...     cell=cell,
...     neighbor_matrix=neighbor_matrix,
...     neighbor_matrix_shifts=neighbor_shifts,
...     num_neighbors=num_neighbors,
...     epsilon=0.0104,  # eV (argon)
...     sigma=3.40,      # Angstrom
...     cutoff=8.5,      # Angstrom (2.5*sigma)
...     fill_value=num_atoms,
... )
>>> print(f"Total energy: {energies.numpy().sum():.4f} eV")
"""

from __future__ import annotations

from typing import Any

import warp as wp

from nvalchemiops.interactions.switching import switch_c2

__all__ = [
    "lj_energy",
    "lj_forces",
    "lj_energy_forces",
    "lj_energy_forces_virial",
]


# ==============================================================================
# Helper Functions (dtype-flexible)
# ==============================================================================


@wp.func
def _lj_energy_pair(
    sigma_over_r: wp.float64,
    epsilon: wp.float64,
) -> wp.float64:
    """Compute LJ pair energy.

    Formula: V = 4 * epsilon * (s^12 - s^6) where s = sigma/r
    """
    s2 = sigma_over_r * sigma_over_r
    s6 = s2 * s2 * s2
    s12 = s6 * s6
    return wp.float64(4.0) * epsilon * (s12 - s6)


@wp.func
def _lj_force_over_r(
    sigma_over_r: wp.float64,
    epsilon: wp.float64,
    r_sq: wp.float64,
) -> wp.float64:
    """Compute LJ force magnitude divided by r.

    Formula: F/r = 24 * epsilon / r^2 * (2*s^12 - s^6) where s = sigma/r
    """
    s2 = sigma_over_r * sigma_over_r
    s6 = s2 * s2 * s2
    s12 = s6 * s6
    return wp.float64(24.0) * epsilon * (wp.float64(2.0) * s12 - s6) / r_sq


@wp.func
def _switch_params(
    cutoff: wp.float64,
    switch_width: wp.float64,
) -> tuple[wp.float64, wp.float64]:
    """Return (r_on, r_cut). If switch_width <= 0, returns (cutoff, cutoff)."""
    if switch_width <= wp.float64(0.0):
        return cutoff, cutoff
    r_on = cutoff - switch_width
    if r_on < wp.float64(0.0):
        r_on = wp.float64(0.0)
    return r_on, cutoff


# ==============================================================================
# Warp Kernels - Neighbor Matrix Format
# ==============================================================================


@wp.kernel
def _lj_energy_matrix_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    neighbor_matrix: wp.array2d(dtype=wp.int32),
    neighbor_matrix_shifts: wp.array2d(dtype=wp.vec3i),
    num_neighbors: wp.array(dtype=wp.int32),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    fill_value: wp.int32,
    atomic_energies: wp.array(dtype=Any),
):
    r"""Compute Lennard-Jones energies using neighbor matrix format.

    This kernel supports both **half** and **full** neighbor matrices via the
    `half_neighbor_list` flag:

    - **Half neighbor list**: each pair (i, j) appears once. The per-pair energy
      \(V_{ij}\) is split evenly across i and j (adds 0.5 * V to each).
    - **Full neighbor list**: each pair appears twice (once in i's row and once
      in j's row). The kernel adds 0.5 * V to the current atom only, so the
      total pair energy is still counted exactly once overall.

    Switching: if `switch_width > 0`, energy is multiplied by a C2 switching
    function between r_on = cutoff - switch_width and r_cut = cutoff.

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions (Cartesian, in the same length units as sigma/cutoff).
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix (used to convert integer shift vectors to Cartesian).
    neighbor_matrix : wp.array2d, shape (N, max_neighbors), dtype=wp.int32
        Neighbor indices for each atom; padded entries are expected to be
        `fill_value` (or >= N).
    neighbor_matrix_shifts : wp.array2d, shape (N, max_neighbors), dtype=wp.vec3i
        Integer lattice shifts corresponding to each neighbor entry.
    num_neighbors : wp.array, shape (N,), dtype=wp.int32
        Valid neighbor count per atom (for iterating only filled slots).
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element device arrays.
    half_neighbor_list : wp.bool
        True if the neighbor matrix contains each pair once; False if pairs are duplicated.
    fill_value : wp.int32
        Sentinel value used to pad `neighbor_matrix` rows.
    atomic_energies : wp.array, shape (N,), dtype=wp.float32 or wp.float64
        OUTPUT: per-atom energies accumulated atomically (matches input precision).

    Notes
    -----
    - Internal math is performed in float64 for stability; output is cast to match input dtype.
    - Pairs closer than ~1e-5 (r^2 < 1e-10) are skipped for safety.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]
    max_neighbors = neighbor_matrix.shape[1]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    n_neighbors = num_neighbors[atom_i]

    for neighbor_slot in range(n_neighbors):
        if neighbor_slot >= max_neighbors:
            break

        j = neighbor_matrix[atom_i, neighbor_slot]
        if j >= fill_value or j >= num_atoms:
            continue

        rj = positions[j]

        # Compute shift vector for periodic boundaries
        shift = neighbor_matrix_shifts[atom_i, neighbor_slot]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        # r_ij = r_i - r_j - shift (vector from j to i)
        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        # Energy: V = 4*eps*(s12 - s6), each pair counted once
        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
        else:
            pair_energy = pair_energy_raw
        # Energy accounting:
        # - If half neighbor list: each pair appears once; split 1/2 to i and 1/2 to j.
        # - If full neighbor list: each pair appears twice; add 1/2 to i only.
        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))

    # (energies accumulated per-pair)


@wp.kernel
def _lj_energy_forces_matrix_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    neighbor_matrix: wp.array2d(dtype=wp.int32),
    neighbor_matrix_shifts: wp.array2d(dtype=wp.vec3i),
    num_neighbors: wp.array(dtype=wp.int32),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    fill_value: wp.int32,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
):
    """Compute Lennard-Jones energies and forces using neighbor matrix format.

    Energy accounting matches `_lj_energy_matrix_kernel` (see its docstring).

    Forces are accumulated as:
    - **Half neighbor list**: applies Newton's 3rd law (updates both i and j).
    - **Full neighbor list**: updates only i (since j will process its own row).

    Switching: if `switch_width > 0`, both energy and force are smoothly switched
    to zero at cutoff using a C2 continuous switching function.

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions in Cartesian space.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix (for periodic shift conversion).
    neighbor_matrix : wp.array2d, shape (N, max_neighbors), dtype=wp.int32
        Neighbor indices for each atom.
    neighbor_matrix_shifts : wp.array2d, shape (N, max_neighbors), dtype=wp.vec3i
        Integer lattice shifts for each neighbor entry.
    num_neighbors : wp.array, shape (N,), dtype=wp.int32
        Valid neighbor count per atom.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True if each pair appears once; False if pairs appear twice.
    fill_value : wp.int32
        Sentinel value used to pad `neighbor_matrix`.
    atomic_energies : wp.array, shape (N,), dtype=wp.float32 or wp.float64
        OUTPUT: per-atom energies (matches input precision).
    atomic_forces : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces accumulated atomically (matches positions dtype).

    Notes
    -----
    - Internal math is performed in float64 for stability; output is cast to match input dtype.
    - The kernel accumulates force on i in registers and performs one atomic add at the end.
    - Pairs with r^2 >= cutoff^2 (or extremely small separation) are skipped.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]
    max_neighbors = neighbor_matrix.shape[1]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    n_neighbors = num_neighbors[atom_i]

    for neighbor_slot in range(n_neighbors):
        if neighbor_slot >= max_neighbors:
            break

        j = neighbor_matrix[atom_i, neighbor_slot]
        if j >= fill_value or j >= num_atoms:
            continue

        rj = positions[j]

        shift = neighbor_matrix_shifts[atom_i, neighbor_slot]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        # Raw energy/force
        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        # Optional C2 switching
        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Energies: see note in _lj_energy_matrix_kernel
        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))

        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        # Forces:
        # - half neighbor list: apply Newton's 3rd law (update j here)
        # - full neighbor list: j will be handled by its own row, so don't update j
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)
    wp.atomic_add(atomic_forces, atom_i, force_acc)


@wp.kernel
def _lj_energy_forces_virial_matrix_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    neighbor_matrix: wp.array2d(dtype=wp.int32),
    neighbor_matrix_shifts: wp.array2d(dtype=wp.vec3i),
    num_neighbors: wp.array(dtype=wp.int32),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    fill_value: wp.int32,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
    virial: wp.array(dtype=Any),
):
    r"""Compute Lennard-Jones energies, forces, and virial (neighbor matrix).

    Energy/force handling matches `_lj_energy_forces_matrix_kernel`.

    Virial tensor is accumulated as:

    \[
      W_{\\alpha\\beta} = -\\sum_{i<j} r_{ij,\\alpha} F_{ij,\\beta}
    \]

    The output uses a flattened 9-element layout:
    `[xx, xy, xz, yx, yy, yz, zx, zy, zz]`.

    For `half_neighbor_list=False` (full neighbor matrix), each pair appears twice,
    so the virial contribution is scaled by 0.5 per edge to avoid double-counting.

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions in Cartesian space.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix.
    neighbor_matrix : wp.array2d, shape (N, max_neighbors), dtype=wp.int32
        Neighbor indices.
    neighbor_matrix_shifts : wp.array2d, shape (N, max_neighbors), dtype=wp.vec3i
        Periodic image shifts (integer lattice vectors).
    num_neighbors : wp.array, shape (N,), dtype=wp.int32
        Valid neighbor count per atom.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True for half neighbor list; False for full neighbor list.
    fill_value : wp.int32
        Sentinel value used to pad `neighbor_matrix`.
    atomic_energies : wp.array, shape (N,), dtype=wp.float32 or wp.float64
        OUTPUT: per-atom energies (matches input precision).
    atomic_forces : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces.
    virial : wp.array, shape (9,), dtype=wp.float32 or wp.float64
        OUTPUT: global virial tensor (flattened 3x3, matches input precision).

    Notes
    -----
    - Internal accumulation uses float64 registers; output is cast to match input dtype.
    - Uses the same C2 switching as the energy/forces kernels when `switch_width > 0`.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]
    max_neighbors = neighbor_matrix.shape[1]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    # Local virial accumulator (9 components)
    vir_xx = wp.float64(0.0)
    vir_xy = wp.float64(0.0)
    vir_xz = wp.float64(0.0)
    vir_yx = wp.float64(0.0)
    vir_yy = wp.float64(0.0)
    vir_yz = wp.float64(0.0)
    vir_zx = wp.float64(0.0)
    vir_zy = wp.float64(0.0)
    vir_zz = wp.float64(0.0)

    n_neighbors = num_neighbors[atom_i]

    for neighbor_slot in range(n_neighbors):
        if neighbor_slot >= max_neighbors:
            break

        j = neighbor_matrix[atom_i, neighbor_slot]
        if j >= fill_value or j >= num_atoms:
            continue

        rj = positions[j]

        shift = neighbor_matrix_shifts[atom_i, neighbor_slot]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Energies: see note in _lj_energy_matrix_kernel
        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))

        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)

        # Virial: W_αβ = r_ij,α * F_ij,β (float64 for accuracy)
        r_ij_0 = wp.float64(r_ij[0])
        r_ij_1 = wp.float64(r_ij[1])
        r_ij_2 = wp.float64(r_ij[2])
        f_ij_0 = wp.float64(force_ij[0])
        f_ij_1 = wp.float64(force_ij[1])
        f_ij_2 = wp.float64(force_ij[2])

        # Virial scaling:
        # - half neighbor list: each pair once, keep full contribution
        # - full neighbor list: each pair twice, so take 1/2 per edge
        vir_scale = wp.float64(1.0) if half_neighbor_list else wp.float64(0.5)
        vir_xx += vir_scale * (r_ij_0 * f_ij_0)
        vir_xy += vir_scale * (r_ij_0 * f_ij_1)
        vir_xz += vir_scale * (r_ij_0 * f_ij_2)
        vir_yx += vir_scale * (r_ij_1 * f_ij_0)
        vir_yy += vir_scale * (r_ij_1 * f_ij_1)
        vir_yz += vir_scale * (r_ij_1 * f_ij_2)
        vir_zx += vir_scale * (r_ij_2 * f_ij_0)
        vir_zy += vir_scale * (r_ij_2 * f_ij_1)
        vir_zz += vir_scale * (r_ij_2 * f_ij_2)
    wp.atomic_add(atomic_forces, atom_i, force_acc)

    # Accumulate virial (W = Σ r ⊗ F = -dE/dε)
    # Cast from float64 accumulator back to output dtype

    wp.atomic_add(virial, 0, type(virial[0])(vir_xx))
    wp.atomic_add(virial, 1, type(virial[0])(vir_xy))
    wp.atomic_add(virial, 2, type(virial[0])(vir_xz))
    wp.atomic_add(virial, 3, type(virial[0])(vir_yx))
    wp.atomic_add(virial, 4, type(virial[0])(vir_yy))
    wp.atomic_add(virial, 5, type(virial[0])(vir_yz))
    wp.atomic_add(virial, 6, type(virial[0])(vir_zx))
    wp.atomic_add(virial, 7, type(virial[0])(vir_zy))
    wp.atomic_add(virial, 8, type(virial[0])(vir_zz))


# ==============================================================================
# Warp Kernels - Neighbor List (CSR) Format
# ==============================================================================


@wp.kernel
def _lj_energy_list_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    idx_j: wp.array(dtype=wp.int32),
    neighbor_ptr: wp.array(dtype=wp.int32),
    unit_shifts: wp.array(dtype=wp.vec3i),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    atomic_energies: wp.array(dtype=Any),
):
    """Compute Lennard-Jones energies using neighbor list (CSR) format.

    Neighbor list is provided in CSR form via (`neighbor_ptr`, `idx_j`, `unit_shifts`).
    Energy accounting follows the same convention as the neighbor matrix kernels:

    - **Half neighbor list**: adds 0.5 * V_ij to both i and j.
    - **Full neighbor list**: adds 0.5 * V_ij to i only (since the reverse edge exists).

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions in Cartesian space.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix.
    idx_j : wp.array, shape (M,), dtype=wp.int32
        Flattened neighbor indices (CSR adjacency list).
    neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32
        CSR row pointers. Neighbors for atom i are `idx_j[ptr[i]:ptr[i+1]]`.
    unit_shifts : wp.array, shape (M,), dtype=wp.vec3i
        Integer lattice shifts for each edge in CSR order.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True if each pair appears once; False if edges are duplicated.
    atomic_energies : wp.array, shape (N,), dtype=wp.float64
        OUTPUT: per-atom energies in float64.

    Notes
    -----
    - Uses float64 internally for energy; output is float64.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    j_start = neighbor_ptr[atom_i]
    j_end = neighbor_ptr[atom_i + 1]

    for edge_idx in range(j_start, j_end):
        j = idx_j[edge_idx]
        rj = positions[j]

        shift = unit_shifts[edge_idx]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
        else:
            pair_energy = pair_energy_raw

        # Energies: same convention as matrix kernels
        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))


@wp.kernel
def _lj_energy_forces_list_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    idx_j: wp.array(dtype=wp.int32),
    neighbor_ptr: wp.array(dtype=wp.int32),
    unit_shifts: wp.array(dtype=wp.vec3i),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
):
    """Compute Lennard-Jones energies and forces using neighbor list (CSR) format.

    Energy accounting matches `_lj_energy_list_kernel`.

    Forces are accumulated as:
    - **Half neighbor list**: applies Newton's 3rd law (updates i and j).
    - **Full neighbor list**: updates only i (j will process its own row).

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix.
    idx_j : wp.array, shape (M,), dtype=wp.int32
        Flattened neighbor indices.
    neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32
        CSR row pointers.
    unit_shifts : wp.array, shape (M,), dtype=wp.vec3i
        Integer lattice shifts for each edge.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True if each pair appears once; False if edges are duplicated.
    atomic_energies : wp.array, shape (N,), dtype=wp.float64
        OUTPUT: per-atom energies.
    atomic_forces : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces.

    Notes
    -----
    - Switching is applied identically to the neighbor matrix forces kernel.
    - Force on i is accumulated locally and written once to reduce atomic contention.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    j_start = neighbor_ptr[atom_i]
    j_end = neighbor_ptr[atom_i + 1]

    for edge_idx in range(j_start, j_end):
        j = idx_j[edge_idx]
        rj = positions[j]

        shift = unit_shifts[edge_idx]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))
        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)
    wp.atomic_add(atomic_forces, atom_i, force_acc)


@wp.kernel
def _lj_energy_forces_virial_list_kernel(
    positions: wp.array(dtype=Any),
    cell: wp.array(dtype=Any),
    idx_j: wp.array(dtype=wp.int32),
    neighbor_ptr: wp.array(dtype=wp.int32),
    unit_shifts: wp.array(dtype=wp.vec3i),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
    virial: wp.array(dtype=Any),
):
    r"""Compute Lennard-Jones energies, forces, and virial (CSR neighbor list).

    Energy/force handling matches `_lj_energy_forces_list_kernel`.

    Virial is accumulated into a global 9-vector (flattened 3x3):

    \[
      W = \\sum r \\otimes F = -\\partial E / \\partial \\varepsilon
    \]

    For full neighbor lists (`half_neighbor_list=False`), virial contributions are
    scaled by 0.5 per edge to avoid double-counting.

    Launch Grid
    -----------
    dim = [num_atoms]

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atom positions.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix.
    idx_j : wp.array, shape (M,), dtype=wp.int32
        Flattened neighbor indices.
    neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32
        CSR row pointers.
    unit_shifts : wp.array, shape (M,), dtype=wp.vec3i
        Integer lattice shifts.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True for half neighbor list; False for full neighbor list.
    atomic_energies : wp.array, shape (N,), dtype=wp.float64
        OUTPUT: per-atom energies.
    atomic_forces : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces.
    virial : wp.array, shape (9,), dtype=wp.float64
        OUTPUT: global virial tensor, flattened as `[xx, xy, xz, yx, yy, yz, zx, zy, zz]`.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]

    if atom_i >= num_atoms:
        return

    ri = positions[atom_i]
    cell_t = wp.transpose(cell[0])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    vir_xx = wp.float64(0.0)
    vir_xy = wp.float64(0.0)
    vir_xz = wp.float64(0.0)
    vir_yx = wp.float64(0.0)
    vir_yy = wp.float64(0.0)
    vir_yz = wp.float64(0.0)
    vir_zx = wp.float64(0.0)
    vir_zy = wp.float64(0.0)
    vir_zz = wp.float64(0.0)

    j_start = neighbor_ptr[atom_i]
    j_end = neighbor_ptr[atom_i + 1]

    for edge_idx in range(j_start, j_end):
        j = idx_j[edge_idx]
        rj = positions[j]

        shift = unit_shifts[edge_idx]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))
        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)

        # Virial
        r_ij_0 = wp.float64(r_ij[0])
        r_ij_1 = wp.float64(r_ij[1])
        r_ij_2 = wp.float64(r_ij[2])
        f_ij_0 = wp.float64(force_ij[0])
        f_ij_1 = wp.float64(force_ij[1])
        f_ij_2 = wp.float64(force_ij[2])

        vir_scale = wp.float64(1.0) if half_neighbor_list else wp.float64(0.5)
        vir_xx += vir_scale * (r_ij_0 * f_ij_0)
        vir_xy += vir_scale * (r_ij_0 * f_ij_1)
        vir_xz += vir_scale * (r_ij_0 * f_ij_2)
        vir_yx += vir_scale * (r_ij_1 * f_ij_0)
        vir_yy += vir_scale * (r_ij_1 * f_ij_1)
        vir_yz += vir_scale * (r_ij_1 * f_ij_2)
        vir_zx += vir_scale * (r_ij_2 * f_ij_0)
        vir_zy += vir_scale * (r_ij_2 * f_ij_1)
        vir_zz += vir_scale * (r_ij_2 * f_ij_2)
    wp.atomic_add(atomic_forces, atom_i, force_acc)

    # Accumulate virial (W = Σ r ⊗ F = -dE/dε)
    # Cast from float64 accumulator back to output dtype
    wp.atomic_add(virial, 0, type(virial[0])(vir_xx))
    wp.atomic_add(virial, 1, type(virial[0])(vir_xy))
    wp.atomic_add(virial, 2, type(virial[0])(vir_xz))
    wp.atomic_add(virial, 3, type(virial[0])(vir_yx))
    wp.atomic_add(virial, 4, type(virial[0])(vir_yy))
    wp.atomic_add(virial, 5, type(virial[0])(vir_yz))
    wp.atomic_add(virial, 6, type(virial[0])(vir_zx))
    wp.atomic_add(virial, 7, type(virial[0])(vir_zy))
    wp.atomic_add(virial, 8, type(virial[0])(vir_zz))


# ==============================================================================
# Warp Kernels - Batched (Neighbor Matrix)
# ==============================================================================


@wp.kernel
def _batch_lj_energy_forces_matrix_kernel(
    positions: wp.array(dtype=Any),
    cells: wp.array(dtype=Any),
    neighbor_matrix: wp.array2d(dtype=wp.int32),
    neighbor_matrix_shifts: wp.array2d(dtype=wp.vec3i),
    num_neighbors: wp.array(dtype=wp.int32),
    batch_idx: wp.array(dtype=wp.int32),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    fill_value: wp.int32,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
):
    """Compute Lennard-Jones energies and forces for batched systems (neighbor matrix).

    This is the batched analogue of `_lj_energy_forces_matrix_kernel`, where each
    atom belongs to a system `system_id = batch_idx[atom_i]` and periodic shifts are
    converted using `cells[system_id]`.

    Launch Grid
    -----------
    dim = [num_atoms_total]

    Parameters
    ----------
    positions : wp.array, shape (N_total,), dtype=wp.vec3f or wp.vec3d
        Concatenated atom positions across all systems.
    cells : wp.array, shape (B,), dtype=wp.mat33f or wp.mat33d
        Unit cell per system.
    neighbor_matrix : wp.array2d, shape (N_total, max_neighbors), dtype=wp.int32
        Neighbor indices for each atom.
    neighbor_matrix_shifts : wp.array2d, shape (N_total, max_neighbors), dtype=wp.vec3i
        Integer lattice shifts for each neighbor entry.
    num_neighbors : wp.array, shape (N_total,), dtype=wp.int32
        Neighbor count per atom.
    batch_idx : wp.array, shape (N_total,), dtype=wp.int32
        System id for each atom (0..B-1).
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays (shared across systems).
    half_neighbor_list : wp.bool
        True for half neighbor list; False for full neighbor list.
    fill_value : wp.int32
        Padding sentinel for `neighbor_matrix`.
    atomic_energies : wp.array, shape (N_total,), dtype=wp.float64
        OUTPUT: per-atom energies.
    atomic_forces : wp.array, shape (N_total,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces.

    Notes
    -----
    - Energies are float64; forces match the positions dtype.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]
    max_neighbors = neighbor_matrix.shape[1]

    if atom_i >= num_atoms:
        return

    system_id = batch_idx[atom_i]
    ri = positions[atom_i]
    cell_t = wp.transpose(cells[system_id])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    n_neighbors = num_neighbors[atom_i]

    for neighbor_slot in range(n_neighbors):
        if neighbor_slot >= max_neighbors:
            break

        j = neighbor_matrix[atom_i, neighbor_slot]
        if j >= fill_value or j >= num_atoms:
            continue

        rj = positions[j]

        shift = neighbor_matrix_shifts[atom_i, neighbor_slot]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))
        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)
    wp.atomic_add(atomic_forces, atom_i, force_acc)


@wp.kernel
def _batch_lj_energy_forces_virial_matrix_kernel(
    positions: wp.array(dtype=Any),
    cells: wp.array(dtype=Any),
    neighbor_matrix: wp.array2d(dtype=wp.int32),
    neighbor_matrix_shifts: wp.array2d(dtype=wp.vec3i),
    num_neighbors: wp.array(dtype=wp.int32),
    batch_idx: wp.array(dtype=wp.int32),
    epsilon: wp.array(dtype=Any),
    sigma: wp.array(dtype=Any),
    cutoff: wp.array(dtype=Any),
    switch_width: wp.array(dtype=Any),
    half_neighbor_list: wp.bool,
    fill_value: wp.int32,
    atomic_energies: wp.array(dtype=Any),
    atomic_forces: wp.array(dtype=Any),
    virial: wp.array2d(dtype=Any),
):
    """Compute Lennard-Jones energies, forces, and virial for batched systems.

    Batched analogue of `_lj_energy_forces_virial_matrix_kernel`.

    Virial is accumulated per-system into `virial[system_id, :]` with shape (B, 9)
    using the flattened layout `[xx, xy, xz, yx, yy, yz, zx, zy, zz]`.

    Launch Grid
    -----------
    dim = [num_atoms_total]

    Parameters
    ----------
    positions : wp.array, shape (N_total,), dtype=wp.vec3f or wp.vec3d
        Concatenated atom positions.
    cells : wp.array, shape (B,), dtype=wp.mat33f or wp.mat33d
        Unit cell per system.
    neighbor_matrix, neighbor_matrix_shifts, num_neighbors : arrays
        Neighbor matrix data for all atoms (see `_batch_lj_energy_forces_matrix_kernel`).
    batch_idx : wp.array, shape (N_total,), dtype=wp.int32
        System id per atom.
    epsilon, sigma, cutoff, switch_width : wp.array, shape (1,), dtype=float32/float64
        Scalar LJ parameters packed as 1-element arrays.
    half_neighbor_list : wp.bool
        True for half neighbor list; False for full neighbor list.
    fill_value : wp.int32
        Padding sentinel.
    atomic_energies : wp.array, shape (N_total,), dtype=wp.float64
        OUTPUT: per-atom energies.
    atomic_forces : wp.array, shape (N_total,), dtype=wp.vec3f or wp.vec3d
        OUTPUT: per-atom forces.
    virial : wp.array2d, shape (B, 9), dtype=wp.float64
        OUTPUT: per-system virial tensor.

    Notes
    -----
    - For full neighbor matrices, virial contributions are scaled by 0.5 per edge.
    """
    atom_i = wp.tid()
    num_atoms = positions.shape[0]
    max_neighbors = neighbor_matrix.shape[1]

    if atom_i >= num_atoms:
        return

    system_id = batch_idx[atom_i]
    ri = positions[atom_i]
    cell_t = wp.transpose(cells[system_id])
    eps = wp.float64(epsilon[0])
    sig = wp.float64(sigma[0])
    cut = wp.float64(cutoff[0])
    sw = wp.float64(switch_width[0])
    cutoff_sq = cut * cut
    r_on, r_cut = _switch_params(cut, sw)

    force_acc = type(ri)(
        type(ri[0])(0.0),
        type(ri[0])(0.0),
        type(ri[0])(0.0),
    )

    vir_xx = wp.float64(0.0)
    vir_xy = wp.float64(0.0)
    vir_xz = wp.float64(0.0)
    vir_yx = wp.float64(0.0)
    vir_yy = wp.float64(0.0)
    vir_yz = wp.float64(0.0)
    vir_zx = wp.float64(0.0)
    vir_zy = wp.float64(0.0)
    vir_zz = wp.float64(0.0)

    n_neighbors = num_neighbors[atom_i]

    for neighbor_slot in range(n_neighbors):
        if neighbor_slot >= max_neighbors:
            break

        j = neighbor_matrix[atom_i, neighbor_slot]
        if j >= fill_value or j >= num_atoms:
            continue

        rj = positions[j]

        shift = neighbor_matrix_shifts[atom_i, neighbor_slot]
        shift_vec = cell_t * type(ri)(
            type(ri[0])(shift[0]),
            type(ri[0])(shift[1]),
            type(ri[0])(shift[2]),
        )

        r_ij = ri - rj - shift_vec
        r_sq = wp.float64(wp.dot(r_ij, r_ij))

        if r_sq >= cutoff_sq or r_sq < wp.float64(1e-10):
            continue

        r = wp.sqrt(r_sq)
        sigma_over_r = sig / r

        pair_energy_raw = _lj_energy_pair(sigma_over_r, eps)
        force_mag_over_r_raw = _lj_force_over_r(sigma_over_r, eps, r_sq)

        if sw > wp.float64(0.0) and r > r_on:
            s, ds_dr = switch_c2(r, r_on, r_cut)
            pair_energy = s * pair_energy_raw
            force_mag_over_r = s * force_mag_over_r_raw + (-pair_energy_raw * ds_dr) / r
        else:
            pair_energy = pair_energy_raw
            force_mag_over_r = force_mag_over_r_raw

        # Cast from float64 accumulator back to output dtype
        half_energy = wp.float64(0.5) * pair_energy
        wp.atomic_add(atomic_energies, atom_i, type(atomic_energies[0])(half_energy))
        if half_neighbor_list:
            wp.atomic_add(atomic_energies, j, type(atomic_energies[0])(half_energy))
        force_ij = type(ri)(
            type(ri[0])(force_mag_over_r) * r_ij[0],
            type(ri[0])(force_mag_over_r) * r_ij[1],
            type(ri[0])(force_mag_over_r) * r_ij[2],
        )

        force_acc += force_ij
        if half_neighbor_list:
            wp.atomic_sub(atomic_forces, j, force_ij)

        r_ij_0 = wp.float64(r_ij[0])
        r_ij_1 = wp.float64(r_ij[1])
        r_ij_2 = wp.float64(r_ij[2])
        f_ij_0 = wp.float64(force_ij[0])
        f_ij_1 = wp.float64(force_ij[1])
        f_ij_2 = wp.float64(force_ij[2])

        vir_scale = wp.float64(1.0) if half_neighbor_list else wp.float64(0.5)
        vir_xx += vir_scale * (r_ij_0 * f_ij_0)
        vir_xy += vir_scale * (r_ij_0 * f_ij_1)
        vir_xz += vir_scale * (r_ij_0 * f_ij_2)
        vir_yx += vir_scale * (r_ij_1 * f_ij_0)
        vir_yy += vir_scale * (r_ij_1 * f_ij_1)
        vir_yz += vir_scale * (r_ij_1 * f_ij_2)
        vir_zx += vir_scale * (r_ij_2 * f_ij_0)
        vir_zy += vir_scale * (r_ij_2 * f_ij_1)
        vir_zz += vir_scale * (r_ij_2 * f_ij_2)
    wp.atomic_add(atomic_forces, atom_i, force_acc)

    # Accumulate virial (W = Σ r ⊗ F = -dE/dε)
    # Cast from float64 accumulator back to output dtype
    wp.atomic_add(virial, system_id, 0, type(virial[0][0])(vir_xx))
    wp.atomic_add(virial, system_id, 1, type(virial[0][0])(vir_xy))
    wp.atomic_add(virial, system_id, 2, type(virial[0][0])(vir_xz))
    wp.atomic_add(virial, system_id, 3, type(virial[0][0])(vir_yx))
    wp.atomic_add(virial, system_id, 4, type(virial[0][0])(vir_yy))
    wp.atomic_add(virial, system_id, 5, type(virial[0][0])(vir_yz))
    wp.atomic_add(virial, system_id, 6, type(virial[0][0])(vir_zx))
    wp.atomic_add(virial, system_id, 7, type(virial[0][0])(vir_zy))
    wp.atomic_add(virial, system_id, 8, type(virial[0][0])(vir_zz))


# ==============================================================================
# Kernel Overloads (float32/float64)
# ==============================================================================

_T = [wp.float32, wp.float64]
_V = [wp.vec3f, wp.vec3d]
_M = [wp.mat33f, wp.mat33d]

# Overload dictionaries
_lj_energy_matrix_kernel_overload = {}
_lj_energy_forces_matrix_kernel_overload = {}
_lj_energy_forces_virial_matrix_kernel_overload = {}
_lj_energy_list_kernel_overload = {}
_lj_energy_forces_list_kernel_overload = {}
_lj_energy_forces_virial_list_kernel_overload = {}
_batch_lj_energy_forces_matrix_kernel_overload = {}
_batch_lj_energy_forces_virial_matrix_kernel_overload = {}

for t, v, m in zip(_T, _V, _M):
    # Neighbor matrix kernels
    _lj_energy_matrix_kernel_overload[t] = wp.overload(
        _lj_energy_matrix_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array2d(dtype=wp.int32),  # neighbor_matrix
            wp.array2d(dtype=wp.vec3i),  # neighbor_matrix_shifts
            wp.array(dtype=wp.int32),  # num_neighbors
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.int32,  # fill_value
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
        ],
    )

    _lj_energy_forces_matrix_kernel_overload[t] = wp.overload(
        _lj_energy_forces_matrix_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array2d(dtype=wp.int32),  # neighbor_matrix
            wp.array2d(dtype=wp.vec3i),  # neighbor_matrix_shifts
            wp.array(dtype=wp.int32),  # num_neighbors
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.int32,  # fill_value
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
        ],
    )

    _lj_energy_forces_virial_matrix_kernel_overload[t] = wp.overload(
        _lj_energy_forces_virial_matrix_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array2d(dtype=wp.int32),  # neighbor_matrix
            wp.array2d(dtype=wp.vec3i),  # neighbor_matrix_shifts
            wp.array(dtype=wp.int32),  # num_neighbors
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.int32,  # fill_value
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
            wp.array(dtype=t),  # virial (matches input dtype)
        ],
    )

    # Neighbor list (CSR) kernels
    _lj_energy_list_kernel_overload[t] = wp.overload(
        _lj_energy_list_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array(dtype=wp.int32),  # idx_j
            wp.array(dtype=wp.int32),  # neighbor_ptr
            wp.array(dtype=wp.vec3i),  # unit_shifts
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
        ],
    )

    _lj_energy_forces_list_kernel_overload[t] = wp.overload(
        _lj_energy_forces_list_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array(dtype=wp.int32),  # idx_j
            wp.array(dtype=wp.int32),  # neighbor_ptr
            wp.array(dtype=wp.vec3i),  # unit_shifts
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
        ],
    )

    _lj_energy_forces_virial_list_kernel_overload[t] = wp.overload(
        _lj_energy_forces_virial_list_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cell
            wp.array(dtype=wp.int32),  # idx_j
            wp.array(dtype=wp.int32),  # neighbor_ptr
            wp.array(dtype=wp.vec3i),  # unit_shifts
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
            wp.array(dtype=t),  # virial (matches input dtype)
        ],
    )

    # Batched kernels
    _batch_lj_energy_forces_matrix_kernel_overload[t] = wp.overload(
        _batch_lj_energy_forces_matrix_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cells
            wp.array2d(dtype=wp.int32),  # neighbor_matrix
            wp.array2d(dtype=wp.vec3i),  # neighbor_matrix_shifts
            wp.array(dtype=wp.int32),  # num_neighbors
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.int32,  # fill_value
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
        ],
    )

    _batch_lj_energy_forces_virial_matrix_kernel_overload[t] = wp.overload(
        _batch_lj_energy_forces_virial_matrix_kernel,
        [
            wp.array(dtype=v),  # positions
            wp.array(dtype=m),  # cells
            wp.array2d(dtype=wp.int32),  # neighbor_matrix
            wp.array2d(dtype=wp.vec3i),  # neighbor_matrix_shifts
            wp.array(dtype=wp.int32),  # num_neighbors
            wp.array(dtype=wp.int32),  # batch_idx
            wp.array(dtype=t),  # epsilon
            wp.array(dtype=t),  # sigma
            wp.array(dtype=t),  # cutoff
            wp.array(dtype=t),  # switch_width
            wp.bool,  # half_neighbor_list
            wp.int32,  # fill_value
            wp.array(dtype=t),  # atomic_energies (matches input dtype)
            wp.array(dtype=v),  # atomic_forces
            wp.array2d(dtype=t),  # virial (matches input dtype)
        ],
    )


# ==============================================================================
# Public API
# ==============================================================================


def lj_energy(
    positions: wp.array,
    cell: wp.array,
    epsilon: float,
    sigma: float,
    cutoff: float,
    neighbor_matrix: wp.array | None = None,
    neighbor_matrix_shifts: wp.array | None = None,
    num_neighbors: wp.array | None = None,
    fill_value: int | None = None,
    neighbor_list: wp.array | None = None,
    neighbor_ptr: wp.array | None = None,
    neighbor_shifts: wp.array | None = None,
    switch_width: float = 0.0,
    half_neighbor_list: bool = True,
    device: str | None = None,
) -> wp.array:
    """Compute Lennard-Jones energies.

    Parameters
    ----------
    positions : wp.array, dtype=wp.vec3f or wp.vec3d
        Atomic coordinates. Shape (N,).
    cell : wp.array, dtype=wp.mat33f or wp.mat33d
        Unit cell matrix. Shape (1,).
    epsilon : float
        LJ energy parameter (well depth).
    sigma : float
        LJ length parameter (zero-crossing distance).
    cutoff : float
        Cutoff distance for interactions.
    neighbor_matrix : wp.array, shape (N, max_neighbors), dtype=wp.int32, optional
        Neighbor indices in matrix format. Provide either this or `neighbor_list`.
    neighbor_matrix_shifts : wp.array, shape (N, max_neighbors), dtype=wp.vec3i, optional
        Periodic shifts for each entry in `neighbor_matrix`.
    num_neighbors : wp.array, shape (N,), dtype=wp.int32, optional
        Valid neighbor count per atom; required when using matrix format.
    fill_value : int, optional
        Sentinel value used to pad `neighbor_matrix` rows.
    neighbor_list : wp.array, shape (2, M) or (M,), dtype=wp.int32, optional
        Neighbor target indices in COO/CSR adjacency form; alternative to matrix format.
    neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32, optional
        CSR row pointers; required when `neighbor_list` is provided.
    neighbor_shifts : wp.array, shape (M,), dtype=wp.vec3i, optional
        Periodic shifts for each edge in neighbor list format.
    switch_width : float, optional
        Width of the C2 switching region applied before cutoff. A value of 0.0
        (default) disables switching.
    half_neighbor_list : bool, optional
        True (default) if the neighbor structure contains each pair once.
        Set to False for full neighbor lists where each pair appears twice.
    device : str, optional
        Warp device. If None, inferred from positions.

    Returns
    -------
    wp.array, shape (N,), dtype=wp.float32 or wp.float64
        Per-atom LJ energies (matches input positions dtype). Sum to get total energy.
    """
    if device is None:
        device = positions.device

    num_atoms = positions.shape[0]
    is_f32 = positions.dtype == wp.vec3f
    scalar_dtype = wp.float32 if is_f32 else wp.float64

    # Check which format is provided
    use_matrix = neighbor_matrix is not None
    use_list = neighbor_list is not None

    if not use_matrix and not use_list:
        raise ValueError("Must provide either neighbor_matrix or neighbor_list")

    # Allocate output (matches input dtype for flexibility)
    energies = wp.zeros(num_atoms, dtype=scalar_dtype, device=device)

    # Wrap scalar parameters as arrays
    wp_epsilon = wp.array([epsilon], dtype=scalar_dtype, device=device)
    wp_sigma = wp.array([sigma], dtype=scalar_dtype, device=device)
    wp_cutoff = wp.array([cutoff], dtype=scalar_dtype, device=device)
    wp_switch_width = wp.array([switch_width], dtype=scalar_dtype, device=device)
    wp_half = wp.bool(half_neighbor_list)

    if use_matrix:
        if fill_value is None:
            fill_value = num_atoms

        wp.launch(
            _lj_energy_matrix_kernel_overload[scalar_dtype],
            dim=num_atoms,
            inputs=[
                positions,
                cell,
                neighbor_matrix,
                neighbor_matrix_shifts,
                num_neighbors,
                wp_epsilon,
                wp_sigma,
                wp_cutoff,
                wp_switch_width,
                wp_half,
                wp.int32(fill_value),
                energies,
            ],
            device=device,
        )
    else:
        if neighbor_ptr is None:
            raise ValueError("neighbor_ptr required for neighbor_list format")
        idx_j = (
            neighbor_list[1].contiguous() if neighbor_list.ndim == 2 else neighbor_list
        )

        wp.launch(
            _lj_energy_list_kernel_overload[scalar_dtype],
            dim=num_atoms,
            inputs=[
                positions,
                cell,
                idx_j,
                neighbor_ptr,
                neighbor_shifts,
                wp_epsilon,
                wp_sigma,
                wp_cutoff,
                wp_switch_width,
                wp_half,
                energies,
            ],
            device=device,
        )

    return energies


def lj_forces(
    positions: wp.array,
    cell: wp.array,
    epsilon: float,
    sigma: float,
    cutoff: float,
    neighbor_matrix: wp.array | None = None,
    neighbor_matrix_shifts: wp.array | None = None,
    num_neighbors: wp.array | None = None,
    fill_value: int | None = None,
    neighbor_list: wp.array | None = None,
    neighbor_ptr: wp.array | None = None,
    neighbor_shifts: wp.array | None = None,
    switch_width: float = 0.0,
    half_neighbor_list: bool = True,
    device: str | None = None,
) -> wp.array:
    """Compute Lennard-Jones forces.

    Convenience wrapper around :func:`nvalchemiops.interactions.lj.lj_energy_forces`
    that discards the energies and returns only forces.

    Parameters
    ----------
    positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Atomic coordinates.
    cell : wp.array, shape (1,), dtype=wp.mat33f or wp.mat33d
        Unit cell matrix.
    epsilon : float
        LJ energy parameter (well depth).
    sigma : float
        LJ length parameter (zero-crossing distance).
    cutoff : float
        Cutoff distance for interactions.
    neighbor_matrix : wp.array, shape (N, max_neighbors), dtype=wp.int32, optional
        Neighbor indices in matrix format.
    neighbor_matrix_shifts : wp.array, shape (N, max_neighbors), dtype=wp.vec3i, optional
        Periodic shifts for neighbor matrix format.
    num_neighbors : wp.array, shape (N,), dtype=wp.int32, optional
        Valid neighbor count per atom (for matrix format).
    fill_value : int, optional
        Sentinel value used to pad `neighbor_matrix`.
    neighbor_list : wp.array, shape (2, M) or (M,), dtype=wp.int32, optional
        Neighbor pairs or target indices in COO/CSR adjacency form.
    neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32, optional
        CSR row pointers; required when `neighbor_list` is provided.
    neighbor_shifts : wp.array, shape (M,), dtype=wp.vec3i, optional
        Periodic shifts for neighbor list format.
    switch_width : float, optional
        Width of the C2 switching region. A value of 0.0 (default) disables switching.
    half_neighbor_list : bool, optional
        True (default) if the neighbor structure contains each pair once.
    device : str, optional
        Warp device. If None, inferred from positions.

    Returns
    -------
    wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d
        Forces on each atom (matches positions dtype).
    """
    _, forces = lj_energy_forces(
        positions=positions,
        cell=cell,
        epsilon=epsilon,
        sigma=sigma,
        cutoff=cutoff,
        neighbor_matrix=neighbor_matrix,
        neighbor_matrix_shifts=neighbor_matrix_shifts,
        num_neighbors=num_neighbors,
        fill_value=fill_value,
        neighbor_list=neighbor_list,
        neighbor_ptr=neighbor_ptr,
        neighbor_shifts=neighbor_shifts,
        switch_width=switch_width,
        half_neighbor_list=half_neighbor_list,
        device=device,
    )
    return forces


[docs] def lj_energy_forces( positions: wp.array, cell: wp.array, epsilon: float, sigma: float, cutoff: float, neighbor_matrix: wp.array | None = None, neighbor_matrix_shifts: wp.array | None = None, num_neighbors: wp.array | None = None, fill_value: int | None = None, neighbor_list: wp.array | None = None, neighbor_ptr: wp.array | None = None, neighbor_shifts: wp.array | None = None, batch_idx: wp.array | None = None, switch_width: float = 0.0, half_neighbor_list: bool = True, device: str | None = None, energies_out: wp.array | None = None, forces_out: wp.array | None = None, ) -> tuple[wp.array, wp.array]: """Compute Lennard-Jones energies and forces. Parameters ---------- positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d Atomic coordinates. cell : wp.array, shape (1,) or (B,), dtype=wp.mat33f or wp.mat33d Unit cell matrix. Use shape ``(B,)`` for batched mode. epsilon : float LJ energy parameter (well depth). sigma : float LJ length parameter (zero-crossing distance). cutoff : float Cutoff distance for interactions. neighbor_matrix : wp.array, shape (N, max_neighbors), dtype=wp.int32, optional Neighbor indices in matrix format. Provide either this or `neighbor_list`. neighbor_matrix_shifts : wp.array, shape (N, max_neighbors), dtype=wp.vec3i, optional Periodic shifts for each entry in `neighbor_matrix`. num_neighbors : wp.array, shape (N,), dtype=wp.int32, optional Valid neighbor count per atom; required when using matrix format. fill_value : int, optional Sentinel value used to pad `neighbor_matrix` rows. neighbor_list : wp.array, shape (2, M) or (M,), dtype=wp.int32, optional Neighbor target indices in COO/CSR adjacency form; alternative to matrix format. neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32, optional CSR row pointers; required when `neighbor_list` is provided. neighbor_shifts : wp.array, shape (M,), dtype=wp.vec3i, optional Periodic shifts for each edge in neighbor list format. batch_idx : wp.array, shape (N,), dtype=wp.int32, optional System index per atom (0..B-1). Pass None for single-system mode. switch_width : float, optional Width of the C2 switching region applied before cutoff. A value of 0.0 (default) disables switching. half_neighbor_list : bool, optional True (default) if the neighbor structure contains each pair once. Set to False for full neighbor lists where each pair appears twice. device : str, optional Warp device. If None, inferred from positions. energies_out : wp.array, shape (N,), dtype=wp.float32 or wp.float64, optional Pre-allocated output buffer for per-atom energies. Modified in-place (zeroed before use). If None, a new array is allocated. forces_out : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d, optional Pre-allocated output buffer for forces. Modified in-place (zeroed before use). If None, a new array is allocated. Returns ------- wp.array, shape (N,), dtype=wp.float32 or wp.float64 Per-atom LJ energies (matches input positions dtype). wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d Forces on each atom (matches positions dtype). """ if device is None: device = positions.device num_atoms = positions.shape[0] is_f32 = positions.dtype == wp.vec3f scalar_dtype = wp.float32 if is_f32 else wp.float64 vec_dtype = wp.vec3f if is_f32 else wp.vec3d use_matrix = neighbor_matrix is not None use_list = neighbor_list is not None is_batched = batch_idx is not None if not use_matrix and not use_list: raise ValueError("Must provide either neighbor_matrix or neighbor_list") if energies_out is None: energies = wp.zeros(num_atoms, dtype=scalar_dtype, device=device) else: energies = energies_out energies.zero_() if forces_out is None: forces = wp.zeros(num_atoms, dtype=vec_dtype, device=device) else: forces = forces_out forces.zero_() # Wrap scalar parameters wp_epsilon = wp.array([epsilon], dtype=scalar_dtype, device=device) wp_sigma = wp.array([sigma], dtype=scalar_dtype, device=device) wp_cutoff = wp.array([cutoff], dtype=scalar_dtype, device=device) wp_switch_width = wp.array([switch_width], dtype=scalar_dtype, device=device) wp_half = wp.bool(half_neighbor_list) if use_matrix: if fill_value is None: fill_value = num_atoms if is_batched: wp.launch( _batch_lj_energy_forces_matrix_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, batch_idx, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, wp.int32(fill_value), energies, forces, ], device=device, ) else: wp.launch( _lj_energy_forces_matrix_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, wp.int32(fill_value), energies, forces, ], device=device, ) else: if neighbor_ptr is None: raise ValueError("neighbor_ptr required for neighbor_list format") idx_j = ( neighbor_list[1].contiguous() if neighbor_list.ndim == 2 else neighbor_list ) wp.launch( _lj_energy_forces_list_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, idx_j, neighbor_ptr, neighbor_shifts, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, energies, forces, ], device=device, ) return energies, forces
[docs] def lj_energy_forces_virial( positions: wp.array, cell: wp.array, epsilon: float, sigma: float, cutoff: float, neighbor_matrix: wp.array | None = None, neighbor_matrix_shifts: wp.array | None = None, num_neighbors: wp.array | None = None, fill_value: int | None = None, neighbor_list: wp.array | None = None, neighbor_ptr: wp.array | None = None, neighbor_shifts: wp.array | None = None, batch_idx: wp.array | None = None, switch_width: float = 0.0, half_neighbor_list: bool = True, device: str | None = None, energies_out: wp.array | None = None, forces_out: wp.array | None = None, virial_out: wp.array | None = None, ) -> tuple[wp.array, wp.array, wp.array]: """Compute Lennard-Jones energies, forces, and virial tensor. The virial tensor is needed for pressure/stress calculations in NPT/NPH. Parameters ---------- positions : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d Atomic coordinates. cell : wp.array, shape (1,) or (B,), dtype=wp.mat33f or wp.mat33d Unit cell matrix. Use shape ``(B,)`` for batched mode. epsilon : float LJ energy parameter (well depth). sigma : float LJ length parameter (zero-crossing distance). cutoff : float Cutoff distance for interactions. neighbor_matrix : wp.array, shape (N, max_neighbors), dtype=wp.int32, optional Neighbor indices in matrix format. Provide either this or `neighbor_list`. neighbor_matrix_shifts : wp.array, shape (N, max_neighbors), dtype=wp.vec3i, optional Periodic shifts for each entry in `neighbor_matrix`. num_neighbors : wp.array, shape (N,), dtype=wp.int32, optional Valid neighbor count per atom; required when using matrix format. fill_value : int, optional Sentinel value used to pad `neighbor_matrix` rows. neighbor_list : wp.array, shape (2, M) or (M,), dtype=wp.int32, optional Neighbor target indices in COO/CSR adjacency form; alternative to matrix format. neighbor_ptr : wp.array, shape (N+1,), dtype=wp.int32, optional CSR row pointers; required when `neighbor_list` is provided. neighbor_shifts : wp.array, shape (M,), dtype=wp.vec3i, optional Periodic shifts for each edge in neighbor list format. batch_idx : wp.array, shape (N,), dtype=wp.int32, optional System index per atom (0..B-1). Pass None for single-system mode. switch_width : float, optional Width of the C2 switching region applied before cutoff. A value of 0.0 (default) disables switching. half_neighbor_list : bool, optional True (default) if the neighbor structure contains each pair once. Set to False for full neighbor lists where each pair appears twice. device : str, optional Warp device. If None, inferred from positions. energies_out : wp.array, shape (N,), dtype=wp.float32 or wp.float64, optional Pre-allocated output buffer for per-atom energies. Modified in-place (zeroed before use). If None, a new array is allocated. forces_out : wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d, optional Pre-allocated output buffer for forces. Modified in-place (zeroed before use). If None, a new array is allocated. virial_out : wp.array, shape (9,) or (B, 9), dtype=wp.float32 or wp.float64, optional Pre-allocated output buffer for the virial tensor. Modified in-place (zeroed before use). If None, a new array is allocated. Returns ------- wp.array, shape (N,), dtype=wp.float32 or wp.float64 Per-atom LJ energies (matches input positions dtype). wp.array, shape (N,), dtype=wp.vec3f or wp.vec3d Forces on each atom (matches positions dtype). wp.array, shape (9,) or (B, 9), dtype=wp.float32 or wp.float64 Global virial tensor flattened as ``[xx, xy, xz, yx, yy, yz, zx, zy, zz]`` (matches input dtype). Shape is ``(B, 9)`` in batched mode. """ if device is None: device = positions.device num_atoms = positions.shape[0] is_f32 = positions.dtype == wp.vec3f scalar_dtype = wp.float32 if is_f32 else wp.float64 vec_dtype = wp.vec3f if is_f32 else wp.vec3d use_matrix = neighbor_matrix is not None use_list = neighbor_list is not None is_batched = batch_idx is not None if cell.ndim == 2: cell = cell.unsqueeze(0) num_systems = cell.shape[0] if not use_matrix and not use_list: raise ValueError("Must provide either neighbor_matrix or neighbor_list") if energies_out is None: energies = wp.zeros(num_atoms, dtype=scalar_dtype, device=device) else: energies = energies_out energies.zero_() if forces_out is None: forces = wp.zeros(num_atoms, dtype=vec_dtype, device=device) else: forces = forces_out forces.zero_() if virial_out is None: if is_batched: virial = wp.zeros((num_systems, 9), dtype=scalar_dtype, device=device) else: virial = wp.zeros(9, dtype=scalar_dtype, device=device) else: virial = virial_out virial.zero_() # Wrap scalar parameters wp_epsilon = wp.array([epsilon], dtype=scalar_dtype, device=device) wp_sigma = wp.array([sigma], dtype=scalar_dtype, device=device) wp_cutoff = wp.array([cutoff], dtype=scalar_dtype, device=device) wp_switch_width = wp.array([switch_width], dtype=scalar_dtype, device=device) wp_half = wp.bool(half_neighbor_list) if use_matrix: if fill_value is None: fill_value = num_atoms if is_batched: wp.launch( _batch_lj_energy_forces_virial_matrix_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, batch_idx, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, wp.int32(fill_value), energies, forces, virial, ], device=device, ) else: wp.launch( _lj_energy_forces_virial_matrix_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, neighbor_matrix, neighbor_matrix_shifts, num_neighbors, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, wp.int32(fill_value), energies, forces, virial, ], device=device, ) else: if neighbor_ptr is None: raise ValueError("neighbor_ptr required for neighbor_list format") idx_j = ( neighbor_list[1].contiguous() if neighbor_list.ndim == 2 else neighbor_list ) wp.launch( _lj_energy_forces_virial_list_kernel_overload[scalar_dtype], dim=num_atoms, inputs=[ positions, cell, idx_j, neighbor_ptr, neighbor_shifts, wp_epsilon, wp_sigma, wp_cutoff, wp_switch_width, wp_half, energies, forces, virial, ], device=device, ) return energies, forces, virial