Source code for nvalchemiops.torch.spline

# 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.

"""
B-Spline Interpolation PyTorch Bindings
=======================================

This module provides PyTorch bindings for B-spline interpolation functions
used in mesh-based calculations (e.g., Particle Mesh Ewald).

This module wraps the framework-agnostic Warp kernels from
``nvalchemiops.math.spline`` with PyTorch custom operators.

SUPPORTED ORDERS
================

- Order 1: Constant (Nearest Grid Point)
- Order 2: Linear
- Order 3: Quadratic
- Order 4: Cubic (recommended for PME)
- Order 5: Quartic
- Order 6: Quintic

OPERATIONS
==========

1. SPREAD: Scatter atom values to mesh grid
   mesh[g] += value[atom] * weight(atom, g)

2. GATHER: Collect mesh values at atom positions
   value[atom] = sum_g mesh[g] * weight(atom, g)

3. GATHER_VEC3: Collect 3D vector field values at atom positions
   vector[atom] = sum_g mesh[g] * weight(atom, g)

4. GATHER_GRADIENT: Collect mesh values with weight gradients (forces)
   grad[atom] = sum_g mesh[g] * grad_weight(atom, g)

5. SPREAD_CHANNELS: Scatter multi-channel values (e.g., multipoles) to mesh
   mesh[c, g] += values[atom, c] * weight(atom, g)

6. GATHER_CHANNELS: Collect multi-channel values from mesh
   values[atom, c] = sum_g mesh[c, g] * weight(atom, g)

7. DECONVOLUTION: Correct B-spline approximation in Fourier space
   Used in FFT-based methods to remove B-spline smoothing artifacts.

USAGE
=====

Single-system:
    from nvalchemiops.torch.spline import spline_spread, spline_gather, spline_gather_gradient

    # Spread charges to mesh
    mesh = spline_spread(positions, charges, cell, mesh_dims, spline_order=4)

    # Gather potential from mesh
    potentials = spline_gather(positions, potential_mesh, cell, spline_order=4)

    # Gather forces
    forces = spline_gather_gradient(positions, charges, potential_mesh, cell, spline_order=4)

Multi-channel (multipoles):
    from nvalchemiops.torch.spline import spline_spread_channels, spline_gather_channels

    # multipoles has shape (N, num_channels) e.g. (N, 9) for L_max=2
    mesh = spline_spread_channels(positions, multipoles, cell, mesh_dims, spline_order=4)

    # Gather multi-channel potential from mesh
    potentials = spline_gather_channels(positions, potential_mesh, cell, spline_order=4)

Batched (multiple systems):
    # Spread charges to batched mesh
    mesh = spline_spread(positions, charges, cell, mesh_dims, spline_order=4, batch_idx=batch_idx)

    # Gather potential from batched mesh
    potentials = spline_gather(positions, potential_mesh, cell, spline_order=4, batch_idx=batch_idx)

Deconvolution:
    from nvalchemiops.torch.spline import compute_bspline_deconvolution

    # Get deconvolution factors for mesh
    deconv = compute_bspline_deconvolution(mesh_dims, spline_order=4, device=device)

    # Apply in Fourier space: mesh_corrected_k = mesh_k * deconv
    mesh_fft = torch.fft.fftn(mesh)
    mesh_corrected_fft = mesh_fft * deconv
    mesh_corrected = torch.fft.ifftn(mesh_corrected_fft).real

REFERENCES
==========

- Essmann et al. (1995). J. Chem. Phys. 103, 8577 (PME B-splines)
"""

from __future__ import annotations

import math
from typing import Any

import torch
import warp as wp

from nvalchemiops.math.spline import (
    _PER_ORDER_BATCH_GATHER_WITH_FORCE_KERNELS,
    _PER_ORDER_BATCH_SPREAD_KERNELS,
    _PER_ORDER_GATHER_WITH_FORCE_KERNELS,
    _PER_ORDER_SPREAD_KERNELS,
    # Kernel overloads (needed for custom ops)
    _batch_bspline_gather_channels_kernel_overload,
    _batch_bspline_gather_vec3_kernel_overload,
    _batch_bspline_spread_channels_kernel_overload,
    _bspline_gather_channels_kernel_overload,
    _bspline_gather_vec3_kernel_overload,
    _bspline_gather_with_force_kernel_overload,
    _bspline_spread_channels_kernel_overload,
    _bspline_weight_kernel_overload,
)
from nvalchemiops.math.spline import (
    batch_spline_gather_gradient_position_hessian as _batch_spline_pos_hessian_launch,
)
from nvalchemiops.math.spline import (
    batch_spline_spread_gradient_weights as _batch_spline_spread_grad_weights_launch,
)
from nvalchemiops.math.spline import (
    spline_gather_gradient_position_hessian as _spline_pos_hessian_launch,
)
from nvalchemiops.math.spline import (
    spline_spread_gradient_weights as _spline_spread_grad_weights_launch,
)

# Import from the torch-level module (NOT the electrostatics package) to avoid a
# spline -> electrostatics -> pme -> spline import cycle.
from nvalchemiops.torch._warp_op_helpers import (
    register_warp_op_chain,
)
from nvalchemiops.torch.autograd import (
    OutputSpec,
    WarpAutogradContextManager,
    attach_for_backward,
    needs_grad,
    warp_custom_op,
    warp_from_torch,
)
from nvalchemiops.torch.types import get_wp_dtype, get_wp_mat_dtype, get_wp_vec_dtype

###########################################################################################
########################### Internal Custom Ops: _spline_* (Single-System) #################
###########################################################################################

# Custom-op registration names are internal dispatch keys. Legacy
# ``alchemiops::`` keys are retained for compatibility; new registrations use
# ``nvalchemiops::``.


@warp_custom_op(
    name="alchemiops::_spline_weight",
    outputs=[
        OutputSpec(
            "weights",
            wp.array(dtype=Any, ndim=1),
            lambda u, *_: (u.shape[0],),
        ),
    ],
    grad_arrays=[
        "weights",
        "u",
    ],
)
def _spline_weight(
    u: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Internal: B-spline weight calculation with dtype flexibility."""
    device = wp.device_from_torch(u.device)
    input_dtype = u.dtype
    wp_dtype = get_wp_dtype(input_dtype)

    num_points = u.shape[0]
    needs_grad_flag = needs_grad(u)

    wp_u = warp_from_torch(u, wp_dtype, requires_grad=needs_grad_flag)

    weights = torch.zeros_like(u)
    wp_weights = warp_from_torch(weights, wp_dtype, requires_grad=needs_grad_flag)

    kernel = _bspline_weight_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=num_points,
            inputs=[wp_u, wp.int32(spline_order)],
            outputs=[wp_weights],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            weights,
            tape=tape,
            weights=wp_weights,
            u=wp_u,
        )
    return weights


###########################################################################################
###### Explicit torch.library backward chain for single-system spread/gather ###############
###########################################################################################
# Explicit ``register_warp_op_chain + register_autograd`` wiring for
# ``_spline_spread`` and ``_spline_gather``. The two operations are
# mathematical adjoints, so the backward of one is the forward of the other:
#
#   spread:  mesh[i,j,k] = Σ_n q_n · B(x_n - r_ijk)
#   gather:  pot[n]      = Σ_{i,j,k} mesh[i,j,k] · B(x_n - r_ijk)
#
# Spread backward gives:
#   grad_values[n]   = Σ_{i,j,k} grad_mesh[i,j,k] · B(x_n - r_ijk)   (gather of grad_mesh)
#   grad_positions[n,a] = -force[n,a]  where force = gather_gradient(grad_mesh)
#   grad_cell_inv_t[a,b] = Σ_n positions[n,b] · (q_n · Σ grad_mesh · grad_frac[a] at atom n)
#                        = (qgf.T @ positions)  with qgf = -cell @ force
#
# The Cartesian "force" returned by gather_gradient is ``-q · cell_inv_t.T · qgf``,
# so we recover qgf as ``-(force @ cell.T)`` via a single matmul.


def _scoped_warp_stream(device: torch.device):
    """Bind Warp's current stream to PyTorch's current CUDA stream.

    Required for ``torch.cuda.graph`` capture: without this, warp kernel
    launches go to warp's default stream (separate from torch's capturing
    stream) and aren't recorded into the graph, so replay silently no-ops.
    """
    if device.type != "cuda":
        from contextlib import nullcontext

        return nullcontext()
    torch_stream = torch.cuda.current_stream(device)
    return wp.ScopedStream(wp.stream_from_torch(torch_stream))


def _wp_from_torch(tensor: torch.Tensor, dtype):
    """Wrap a torch tensor as a Warp array WITHOUT allocating a shadow
    gradient array.

    Default ``wp.from_torch`` inherits ``requires_grad`` from the torch
    tensor and, if True, calls ``wp_alloc_device_async`` to allocate a
    gradient buffer for Warp's tape autograd. That allocation is not
    permitted inside ``torch.cuda.graph`` capture and causes
    ``cudaErrorStreamCaptureInvalidated``. Since our autograd.Functions
    handle backward explicitly, we never need Warp's shadow gradient.
    """
    return wp.from_torch(tensor, dtype=dtype, requires_grad=False)


def _spread_forward_launch(
    positions: torch.Tensor,
    values: torch.Tensor,
    cell_inv_t: torch.Tensor,
    mesh_dims: tuple[int, int, int],
    spline_order: int,
) -> torch.Tensor:
    """Single-system spline spread forward launch. No autograd plumbing."""
    from nvalchemiops.math.spline import spline_spread as _spread_launch

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    mesh_nx, mesh_ny, mesh_nz = mesh_dims
    mesh = torch.zeros(
        (mesh_nx, mesh_ny, mesh_nz), device=positions.device, dtype=input_dtype
    )

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_values = _wp_from_torch(values.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh, dtype=wp_dtype)

    # Per-order specialized spread kernel: one-thread-per-atom layout
    # with fully-unrolled order^3 stencil and 1D weights in registers.
    per_order_kernel = _PER_ORDER_SPREAD_KERNELS[wp_dtype].get(spline_order)

    with _scoped_warp_stream(positions.device):
        if per_order_kernel is not None:
            wp.launch(
                per_order_kernel,
                dim=positions.shape[0],
                inputs=[wp_positions, wp_values, wp_cell_inv_t],
                outputs=[wp_mesh],
                device=device,
            )
        else:
            _spread_launch(
                wp_positions,
                wp_values,
                wp_cell_inv_t,
                spline_order,
                wp_mesh,
                wp_dtype=wp_dtype,
                device=device,
            )
    return mesh


def _gather_forward_launch(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Single-system spline gather forward launch. No autograd plumbing."""
    from nvalchemiops.math.spline import spline_gather as _gather_launch

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    output = torch.zeros(num_atoms, device=positions.device, dtype=input_dtype)

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_output = _wp_from_torch(output, dtype=wp_dtype)

    with _scoped_warp_stream(positions.device):
        _gather_launch(
            wp_positions,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_output,
            wp_dtype=wp_dtype,
            device=device,
        )
    return output


def _gather_gradient_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    r"""Single-system spline gather-gradient forward launch.

    Returns Cartesian "force" :math:`-q_n \cdot \sum_g \text{mesh}[g] \cdot \partial W / \partial \text{position}` per atom.
    """
    from nvalchemiops.math.spline import (
        spline_gather_gradient as _grad_launch,
    )

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    forces = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_charges = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_forces = _wp_from_torch(forces, dtype=wp_vec_dtype)

    with _scoped_warp_stream(positions.device):
        _grad_launch(
            wp_positions,
            wp_charges,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_forces,
            wp_dtype=wp_dtype,
            device=device,
        )
    return forces


def _spread_gradient_weights_launch(
    positions: torch.Tensor,
    per_atom_vec: torch.Tensor,
    cell_inv_t: torch.Tensor,
    mesh_dims: tuple[int, int, int],
    spline_order: int,
) -> torch.Tensor:
    """Single-system ``_bspline_spread_gradient_weights_kernel`` launch."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    mesh_nx, mesh_ny, mesh_nz = mesh_dims
    mesh = torch.zeros(
        (mesh_nx, mesh_ny, mesh_nz), device=positions.device, dtype=input_dtype
    )

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_vec = _wp_from_torch(per_atom_vec.contiguous(), dtype=wp_vec_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh, dtype=wp_dtype)

    with _scoped_warp_stream(positions.device):
        _spline_spread_grad_weights_launch(
            wp_positions,
            wp_vec,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_dtype=wp_dtype,
            device=device,
        )
    return mesh


def _pos_hessian_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    v_per_atom: torch.Tensor,
    cell_inv_t: torch.Tensor,
    mesh: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    r"""Single-system B-spline position-Hessian launch.

    Implements :math:`\text{grad\_pos}[n] = \sum_g -q[n] \cdot \text{mesh}[g] \cdot \nabla^2 W_\text{frac}(x_n, g)`
    used inside the gather_gradient / gather_with_force backward chains.
    """
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    grad_positions = torch.zeros_like(positions)
    wp_pos = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_v = _wp_from_torch(v_per_atom.contiguous(), dtype=wp_vec_dtype)
    wp_cit = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_grad_pos = _wp_from_torch(grad_positions, dtype=wp_vec_dtype)

    with _scoped_warp_stream(positions.device):
        _spline_pos_hessian_launch(
            wp_pos,
            wp_chg,
            wp_v,
            wp_cit,
            spline_order,
            wp_mesh,
            wp_grad_pos,
            wp_dtype=wp_dtype,
            device=device,
        )
    return grad_positions


# Register the two raw warp helpers used inside spline backward chains as
# forward-only custom_ops so that under torch.compile fullgraph=True, AOT
# autograd traces through gather_gradient.backward → these helpers cleanly.
# No register_autograd is registered: these helpers are forward-only custom ops
# for compiled second-order spline chains, not a supported third-order
# differentiation surface.
register_warp_op_chain(
    name="nvalchemiops::spline_spread_gradient_weights",
    forward=_spread_gradient_weights_launch,
    forward_fake=lambda positions, per_atom_vec, cell_inv_t, mesh_dims, spline_order: (
        torch.empty(
            (mesh_dims[0], mesh_dims[1], mesh_dims[2]),
            dtype=positions.dtype,
            device=positions.device,
        )
    ),
)
register_warp_op_chain(
    name="nvalchemiops::spline_pos_hessian",
    forward=_pos_hessian_forward_launch,
    # Output shape == positions shape, so the default ``empty_like(positions)``
    # fake is correct.
)


# Single-system gather_gradient: forward returns Cartesian "force" per atom.
# Backward chains position-Hessian + spread-gradient-weights launches;
# grad_cell_inv_t deferred since the cell chain flows through spread/gather.
register_warp_op_chain(
    name="nvalchemiops::spline_gather_gradient",
    forward=_gather_gradient_forward_launch,
    # No backward op registered — composed manually via register_autograd below.
)


def _spline_gather_gradient_setup_ctx(ctx, inputs, output):
    positions, charges, mesh, cell_inv_t, spline_order = inputs
    ctx.save_for_backward(positions, charges, mesh, cell_inv_t, output)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.needs_pos = positions.requires_grad
    ctx.needs_chg = charges.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _spline_gather_gradient_backward_chain(ctx, grad_force):
    positions, charges, mesh, cell_inv_t, saved_forces = ctx.saved_tensors
    order = ctx.spline_order

    if grad_force is None:
        return None, None, None, None, None
    grad_force_c = grad_force.contiguous()

    # grad_positions (and the cell_inv_t implicit term) via the B-spline
    # position-Hessian path. The Hessian output is reused by the cell slot,
    # so compute it whenever positions OR cell_inv_t need a gradient.
    if ctx.needs_pos or ctx.needs_cell:
        v_per_atom = torch.bmm(
            cell_inv_t[0].unsqueeze(0).expand(positions.shape[0], -1, -1),
            grad_force_c.unsqueeze(-1),
        ).squeeze(-1)
        grad_pos_hess = torch.ops.nvalchemiops.spline_pos_hessian(
            positions,
            charges,
            v_per_atom,
            cell_inv_t,
            mesh,
            order,
        )
    else:
        grad_pos_hess = None
    grad_positions = grad_pos_hess if ctx.needs_pos else None

    # grad_charges via recursive call (q=1 path).
    if ctx.needs_chg:
        ones = torch.ones_like(charges, dtype=positions.dtype)
        force_per_unit_q = torch.ops.nvalchemiops.spline_gather_gradient(
            positions,
            ones,
            mesh,
            cell_inv_t,
            order,
        )
        grad_charges = (grad_force_c * force_per_unit_q).sum(dim=-1)
    else:
        grad_charges = None

    # grad_mesh via spread-with-gradient-weights.
    if ctx.needs_mesh:
        v = grad_force_c @ cell_inv_t[0].transpose(-1, -2)
        per_atom_vec = -(charges.to(positions.dtype).unsqueeze(-1) * v)
        grad_mesh = torch.ops.nvalchemiops.spline_spread_gradient_weights(
            positions,
            per_atom_vec,
            cell_inv_t,
            ctx.mesh_dims,
            order,
        )
    else:
        grad_mesh = None

    # grad_cell_inv_t: vjp of the Cartesian force w.r.t. cell_inv_t.
    # force[n,a] = Σ_k cell_inv_t[k,a] · force_frac_k(frac_n), frac = cell_inv_t @ pos.
    #   explicit (prefactor) term:  ff.T @ grad_force   (ff = cell @ force = -qgf)
    #   implicit (stencil-Hessian): HV.T @ positions    (HV = cell @ grad_pos_hess)
    if ctx.needs_cell:
        cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))  # (1, 3, 3)
        ff = saved_forces @ cell[0].transpose(-1, -2)
        term_explicit = ff.transpose(-1, -2) @ grad_force_c
        hv = grad_pos_hess @ cell[0].transpose(-1, -2)
        term_implicit = hv.transpose(-1, -2) @ positions
        grad_cell_inv_t = (term_explicit + term_implicit).unsqueeze(0)
    else:
        grad_cell_inv_t = None

    return grad_positions, grad_charges, grad_mesh, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::spline_gather_gradient",
    _spline_gather_gradient_backward_chain,
    setup_context=_spline_gather_gradient_setup_ctx,
)


def _cell_inv_t_grad_from_force(
    forces: torch.Tensor,
    positions: torch.Tensor,
    cell_inv_t: torch.Tensor,
) -> torch.Tensor:
    r"""Compute ``grad_cell_inv_t`` (shape ``(1, 3, 3)``) as a differentiable
    Torch expression.

    With ``cell = inv(cell_inv_t.T)`` and the Cartesian gather "force"
    ``force = cell_inv_t.T @ force_frac``, the q-weighted gather-force outer
    positions is :math:`\text{grad\_cell\_inv\_t}[a, b] = \sum_n \text{qgf}[n, a] \cdot \text{positions}[n, b]`
    with ``qgf = -(cell @ force) = -force_frac`` (the ``cell_inv_t`` Cartesian
    transform cancels). Expressed in Torch so the cell second order flows through
    ordinary autograd: ``forces`` carries the differentiable ``cell_inv_t``
    dependence via the ``spline_gather_gradient`` chain, and ``inv`` is
    Torch-native.
    """
    cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))  # (1, 3, 3)
    qgf = -(forces @ cell[0].transpose(-1, -2))
    return (qgf.transpose(-1, -2) @ positions).unsqueeze(0)


def _expand_shared_cell(cell: torch.Tensor, num_systems: int) -> torch.Tensor:
    """Expand a shared 2-D cell to a batched cell without reading ``batch_idx``."""
    if cell.dim() == 2:
        return cell.unsqueeze(0).expand(num_systems, -1, -1).contiguous()
    return cell


# Single-system spread + gather. These are mathematical adjoints, so each
# one's backward composes the OTHER's forward. We register both as forward-
# only custom_ops, then wire register_autograd manually with the composed
# backward chains (routed via torch.ops.* so they're compile-traceable).
register_warp_op_chain(
    name="nvalchemiops::spline_spread",
    forward=_spread_forward_launch,
    forward_fake=lambda positions, values, cell_inv_t, mesh_dims, spline_order: (
        torch.empty(
            (mesh_dims[0], mesh_dims[1], mesh_dims[2]),
            dtype=positions.dtype,
            device=positions.device,
        )
    ),
)


def _spline_spread_setup_ctx(ctx, inputs, output):
    positions, values, cell_inv_t, mesh_dims, spline_order = inputs
    ctx.save_for_backward(positions, values, cell_inv_t)
    ctx.spline_order = spline_order
    ctx.mesh_dims = tuple(mesh_dims)
    ctx.needs_pos = positions.requires_grad
    ctx.needs_val = values.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _spline_spread_backward_chain(ctx, grad_mesh):
    positions, values, cell_inv_t = ctx.saved_tensors
    order = ctx.spline_order
    grad_mesh_c = grad_mesh.contiguous()

    grad_values = (
        torch.ops.nvalchemiops.spline_gather(
            positions,
            grad_mesh_c,
            cell_inv_t,
            order,
        )
        if ctx.needs_val
        else None
    )

    if ctx.needs_pos or ctx.needs_cell:
        forces = torch.ops.nvalchemiops.spline_gather_gradient(
            positions,
            values,
            grad_mesh_c,
            cell_inv_t,
            order,
        )
        grad_positions = -forces if ctx.needs_pos else None
        grad_cell_inv_t = (
            _cell_inv_t_grad_from_force(forces, positions, cell_inv_t)
            if ctx.needs_cell
            else None
        )
    else:
        grad_positions = None
        grad_cell_inv_t = None

    return grad_positions, grad_values, grad_cell_inv_t, None, None


torch.library.register_autograd(
    "nvalchemiops::spline_spread",
    _spline_spread_backward_chain,
    setup_context=_spline_spread_setup_ctx,
)


register_warp_op_chain(
    name="nvalchemiops::spline_gather",
    forward=_gather_forward_launch,
    forward_fake=lambda positions, mesh, cell_inv_t, spline_order: torch.empty(
        positions.shape[0],
        dtype=positions.dtype,
        device=positions.device,
    ),
)


def _spline_gather_setup_ctx(ctx, inputs, output):
    positions, mesh, cell_inv_t, spline_order = inputs
    ctx.save_for_backward(positions, mesh, cell_inv_t)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.needs_pos = positions.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _spline_gather_backward_chain(ctx, grad_potentials):
    positions, mesh, cell_inv_t = ctx.saved_tensors
    order = ctx.spline_order
    grad_pot_c = grad_potentials.contiguous()

    grad_mesh = (
        torch.ops.nvalchemiops.spline_spread(
            positions,
            grad_pot_c,
            cell_inv_t,
            ctx.mesh_dims,
            order,
        )
        if ctx.needs_mesh
        else None
    )

    if ctx.needs_pos or ctx.needs_cell:
        forces = torch.ops.nvalchemiops.spline_gather_gradient(
            positions,
            grad_pot_c,
            mesh,
            cell_inv_t,
            order,
        )
        grad_positions = -forces if ctx.needs_pos else None
        grad_cell_inv_t = (
            _cell_inv_t_grad_from_force(forces, positions, cell_inv_t)
            if ctx.needs_cell
            else None
        )
    else:
        grad_positions = None
        grad_cell_inv_t = None

    return grad_positions, grad_mesh, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::spline_gather",
    _spline_gather_backward_chain,
    setup_context=_spline_gather_setup_ctx,
)


def _spline_spread(
    positions: torch.Tensor,
    values: torch.Tensor,
    cell: torch.Tensor,
    mesh_nx: int,
    mesh_ny: int,
    mesh_nz: int,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: single-system spline spread (registered custom op)."""
    if cell.dim() == 2:
        cell = cell.unsqueeze(0)
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.spline_spread(
        positions,
        values.to(positions.dtype),
        cell_inv_t,
        [mesh_nx, mesh_ny, mesh_nz],
        spline_order,
    )


def _spline_gather(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: single-system spline gather (registered custom op)."""
    if cell.dim() == 2:
        cell = cell.unsqueeze(0)
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.spline_gather(
        positions,
        mesh.to(positions.dtype),
        cell_inv_t,
        spline_order,
    )


@warp_custom_op(
    name="alchemiops::_spline_gather_vec3",
    outputs=[
        OutputSpec(
            "values", wp.array(dtype=Any, ndim=2), lambda pos, *_: (pos.shape[0], 3)
        ),
    ],
    grad_arrays=[
        "values",
        "positions",
        "charges",
        "mesh",
        "cell_inv_t",
    ],
)
def _spline_gather_vec3(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: Single-system vec3 spline gather with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, mesh, cell)

    if cell.dim() == 2:
        cell = cell.unsqueeze(0)

    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_charges = warp_from_torch(
        charges.to(input_dtype), wp_dtype, requires_grad=needs_grad_flag
    )
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )
    wp_mesh = warp_from_torch(
        mesh.to(input_dtype), wp_vec_dtype, requires_grad=needs_grad_flag
    )

    values = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)
    wp_values = warp_from_torch(values, wp_vec_dtype, requires_grad=needs_grad_flag)

    kernel = _bspline_gather_vec3_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[
                wp_positions,
                wp_charges,
                wp_cell_inv_t,
                wp.int32(spline_order),
                wp_mesh,
            ],
            outputs=[wp_values],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            values,
            tape=tape,
            values=wp_values,
            positions=wp_positions,
            charges=wp_charges,
            cell_inv_t=wp_cell_inv_t,
            mesh=wp_mesh,
        )
    return values


def _spline_gather_gradient(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: single-system spline gather-gradient (registered custom op)."""
    if cell.dim() == 2:
        cell = cell.unsqueeze(0)
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv(cell)
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.spline_gather_gradient(
        positions,
        charges.to(positions.dtype),
        mesh.to(positions.dtype),
        cell_inv_t,
        spline_order,
    )


def _gather_with_force_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Single-system fused gather + gather-gradient forward launch.

    Selects the per-order specialized kernel for orders 2-6 when available;
    falls back to the generic kernel otherwise.
    """
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_points = spline_order**3
    potential = torch.zeros(num_atoms, device=positions.device, dtype=input_dtype)
    forces = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)

    wp_pos = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cit = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_pot = _wp_from_torch(potential, dtype=wp_dtype)
    wp_forces = _wp_from_torch(forces, dtype=wp_vec_dtype)

    per_order_kernel = _PER_ORDER_GATHER_WITH_FORCE_KERNELS[wp_dtype].get(spline_order)

    with _scoped_warp_stream(positions.device):
        if per_order_kernel is not None:
            wp.launch(
                per_order_kernel,
                dim=num_atoms,
                inputs=[wp_pos, wp_chg, wp_cit, wp_mesh],
                outputs=[wp_pot, wp_forces],
                device=device,
            )
        else:
            kernel = _bspline_gather_with_force_kernel_overload[wp_dtype]
            wp.launch(
                kernel,
                dim=(num_atoms, num_points),
                inputs=[wp_pos, wp_chg, wp_cit, wp.int32(spline_order), wp_mesh],
                outputs=[wp_pot, wp_forces],
                device=device,
            )
    return potential, forces


# Single-system fused gather + force. Backward has two chains: grad_potential
# flows through the gather chain (spread + gather_gradient + cell_inv_t_grad)
# and grad_forces flows through the gather_gradient chain (position-Hessian,
# per-unit-q gather_gradient, spread-with-gradient-weights). Forward returns
# (potential, forces) — arity 2.
register_warp_op_chain(
    name="nvalchemiops::spline_gather_with_force",
    forward=_gather_with_force_forward_launch,
    forward_return_arity=2,
    forward_fake=lambda pos, *_: (
        torch.empty(pos.shape[0], dtype=pos.dtype, device=pos.device),
        torch.empty((pos.shape[0], 3), dtype=pos.dtype, device=pos.device),
    ),
)


def _spline_gather_with_force_setup_ctx(ctx, inputs, output):
    positions, charges, mesh, cell_inv_t, spline_order = inputs
    _potential, forces = output
    ctx.save_for_backward(positions, charges, mesh, cell_inv_t, forces)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.needs_pos = positions.requires_grad
    ctx.needs_chg = charges.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _spline_gather_with_force_backward_chain(ctx, grad_potential, grad_forces):
    positions, charges, mesh, cell_inv_t, saved_forces = ctx.saved_tensors
    order = ctx.spline_order
    grad_pos = grad_chg = grad_mesh = grad_cell_inv_t = None

    def _add(target, contrib):
        return contrib if target is None else target + contrib

    # gather chain (grad_potential → grads)
    if grad_potential is not None:
        gp = grad_potential.contiguous()
        if ctx.needs_pos or ctx.needs_cell:
            forces_g = torch.ops.nvalchemiops.spline_gather_gradient(
                positions,
                gp,
                mesh,
                cell_inv_t,
                order,
            )
            if ctx.needs_pos:
                grad_pos = _add(grad_pos, -forces_g)
            if ctx.needs_cell:
                grad_cell_inv_t = _add(
                    grad_cell_inv_t,
                    _cell_inv_t_grad_from_force(forces_g, positions, cell_inv_t),
                )
        if ctx.needs_mesh:
            grad_mesh = _add(
                grad_mesh,
                torch.ops.nvalchemiops.spline_spread(
                    positions,
                    gp,
                    cell_inv_t,
                    ctx.mesh_dims,
                    order,
                ),
            )

    # gather_gradient chain (grad_forces → grads)
    if grad_forces is not None:
        gf = grad_forces.contiguous()

        if ctx.needs_chg:
            ones = torch.ones_like(charges, dtype=positions.dtype)
            force_per_unit_q = torch.ops.nvalchemiops.spline_gather_gradient(
                positions,
                ones,
                mesh,
                cell_inv_t,
                order,
            )
            grad_chg = _add(grad_chg, (gf * force_per_unit_q).sum(dim=-1))

        if ctx.needs_mesh:
            v = gf @ cell_inv_t[0].transpose(-1, -2)
            per_atom_vec = -(charges.to(positions.dtype).unsqueeze(-1) * v)
            grad_mesh = _add(
                grad_mesh,
                torch.ops.nvalchemiops.spline_spread_gradient_weights(
                    positions,
                    per_atom_vec,
                    cell_inv_t,
                    ctx.mesh_dims,
                    order,
                ),
            )

        if ctx.needs_pos or ctx.needs_cell:
            v_per_atom = torch.bmm(
                cell_inv_t[0].unsqueeze(0).expand(positions.shape[0], -1, -1),
                gf.unsqueeze(-1),
            ).squeeze(-1)
            pos_hess = torch.ops.nvalchemiops.spline_pos_hessian(
                positions,
                charges,
                v_per_atom,
                cell_inv_t,
                mesh,
                order,
            )
            if ctx.needs_pos:
                grad_pos = _add(grad_pos, pos_hess)
            if ctx.needs_cell:
                cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))
                ff = saved_forces @ cell[0].transpose(-1, -2)
                term_explicit = ff.transpose(-1, -2) @ gf
                hv = pos_hess @ cell[0].transpose(-1, -2)
                term_implicit = hv.transpose(-1, -2) @ positions
                grad_cell_inv_t = _add(
                    grad_cell_inv_t,
                    (term_explicit + term_implicit).unsqueeze(0),
                )

    return grad_pos, grad_chg, grad_mesh, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::spline_gather_with_force",
    _spline_gather_with_force_backward_chain,
    setup_context=_spline_gather_with_force_setup_ctx,
)


def _spline_gather_with_force(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
    r"""Internal: single-system fused gather + gather-gradient (registered op).

    Returns ``(potential, forces)``:
      - ``potential[atom]`` = :math:`\sum_g \text{mesh}[g] \cdot w(\text{atom}, g)` (raw potential)
      - ``forces[atom]`` = :math:`-q_\text{atom} \sum_g \text{mesh}[g] \cdot C^{-T} \nabla w` (Cartesian force)
    """
    if cell.dim() == 2:
        cell = cell.unsqueeze(0)
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.spline_gather_with_force(
        positions,
        charges.to(positions.dtype),
        mesh.to(positions.dtype),
        cell_inv_t,
        spline_order,
    )


###########################################################################################
########################### Internal Custom Ops: _batch_spline_* (Batch) ###################
###########################################################################################


###########################################################################################
###### Explicit torch.library backward chain for batched spread/gather #####################
###########################################################################################
# Same adjoint structure as the single-system case above, with batch_idx
# threading the per-system cell_inv_t through positions and forces. The
# cell_inv_t gradient is accumulated per system via index_add_.


def _batch_spread_forward_launch(
    positions: torch.Tensor,
    values: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    num_systems: int,
    mesh_dims: tuple[int, int, int],
    spline_order: int,
) -> torch.Tensor:
    """Batched spline spread forward launch. No autograd plumbing."""
    from nvalchemiops.math.spline import batch_spline_spread as _spread_launch

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    mesh_nx, mesh_ny, mesh_nz = mesh_dims
    mesh = torch.zeros(
        (num_systems, mesh_nx, mesh_ny, mesh_nz),
        device=positions.device,
        dtype=input_dtype,
    )

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_values = _wp_from_torch(values.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh, dtype=wp_dtype)

    # Per-order specialized batch spread kernel.
    per_order_kernel = _PER_ORDER_BATCH_SPREAD_KERNELS[wp_dtype].get(spline_order)

    with _scoped_warp_stream(positions.device):
        if per_order_kernel is not None:
            wp.launch(
                per_order_kernel,
                dim=positions.shape[0],
                inputs=[wp_positions, wp_values, wp_batch_idx, wp_cell_inv_t],
                outputs=[wp_mesh],
                device=device,
            )
        else:
            _spread_launch(
                wp_positions,
                wp_values,
                wp_batch_idx,
                wp_cell_inv_t,
                spline_order,
                wp_mesh,
                wp_dtype=wp_dtype,
                device=device,
            )
    return mesh


def _batch_gather_forward_launch(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Batched spline gather forward launch. No autograd plumbing."""
    from nvalchemiops.math.spline import batch_spline_gather as _gather_launch

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    output = torch.zeros(num_atoms, device=positions.device, dtype=input_dtype)

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_output = _wp_from_torch(output, dtype=wp_dtype)

    with _scoped_warp_stream(positions.device):
        _gather_launch(
            wp_positions,
            wp_batch_idx,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_output,
            wp_dtype=wp_dtype,
            device=device,
        )
    return output


def _batch_gather_gradient_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Batched spline gather-gradient forward launch.

    Returns Cartesian "force" per atom, with per-system cell_inv_t applied
    according to batch_idx.
    """
    from nvalchemiops.math.spline import (
        batch_spline_gather_gradient as _grad_launch,
    )

    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    forces = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_charges = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_forces = _wp_from_torch(forces, dtype=wp_vec_dtype)

    with _scoped_warp_stream(positions.device):
        _grad_launch(
            wp_positions,
            wp_charges,
            wp_batch_idx,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_forces,
            wp_dtype=wp_dtype,
            device=device,
        )
    return forces


def _batch_spread_gradient_weights_launch(
    positions: torch.Tensor,
    per_atom_vec: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    num_systems: int,
    mesh_dims: tuple[int, int, int],
    spline_order: int,
) -> torch.Tensor:
    """Batched spread-with-gradient-weights launcher."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    mesh_nx, mesh_ny, mesh_nz = mesh_dims
    mesh = torch.zeros(
        (num_systems, mesh_nx, mesh_ny, mesh_nz),
        device=positions.device,
        dtype=input_dtype,
    )

    wp_positions = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_vec = _wp_from_torch(per_atom_vec.contiguous(), dtype=wp_vec_dtype)
    wp_batch_idx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32)
    wp_cell_inv_t = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh, dtype=wp_dtype)

    with _scoped_warp_stream(positions.device):
        _batch_spline_spread_grad_weights_launch(
            wp_positions,
            wp_vec,
            wp_batch_idx,
            wp_cell_inv_t,
            spline_order,
            wp_mesh,
            wp_dtype=wp_dtype,
            device=device,
        )
    return mesh


def _batch_pos_hessian_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    v_per_atom: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    mesh: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Batched B-spline position-Hessian launch (mirror of the single-
    system variant). Per-system cell_inv_t is indexed via ``batch_idx``."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    grad_positions = torch.zeros_like(positions)
    wp_pos = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_v = _wp_from_torch(v_per_atom.contiguous(), dtype=wp_vec_dtype)
    wp_bidx = _wp_from_torch(
        batch_idx.to(torch.int32).contiguous(),
        dtype=wp.int32,
    )
    wp_cit = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_grad_pos = _wp_from_torch(grad_positions, dtype=wp_vec_dtype)

    with _scoped_warp_stream(positions.device):
        _batch_spline_pos_hessian_launch(
            wp_pos,
            wp_chg,
            wp_v,
            wp_bidx,
            wp_cit,
            spline_order,
            wp_mesh,
            wp_grad_pos,
            wp_dtype=wp_dtype,
            device=device,
        )
    return grad_positions


# Forward-only custom_ops for the two raw warp helpers used inside the
# batched spline backward chains (mirrors the single-system registrations
# above).
register_warp_op_chain(
    name="nvalchemiops::batch_spline_spread_gradient_weights",
    forward=_batch_spread_gradient_weights_launch,
    forward_fake=lambda positions,
    per_atom_vec,
    batch_idx,
    cell_inv_t,
    num_systems,
    mesh_dims,
    spline_order: torch.empty(
        (num_systems, mesh_dims[0], mesh_dims[1], mesh_dims[2]),
        dtype=positions.dtype,
        device=positions.device,
    ),
)
register_warp_op_chain(
    name="nvalchemiops::batch_spline_pos_hessian",
    forward=_batch_pos_hessian_forward_launch,
    # Output shape == positions shape — default ``empty_like(positions)`` is right.
)


# Batched gather_gradient — same shape as single-system but with batch_idx
# (non-differentiable) in the input list and per-system cell_inv_t indexed
# via batch_idx in the position-Hessian path.
register_warp_op_chain(
    name="nvalchemiops::batch_spline_gather_gradient",
    forward=_batch_gather_gradient_forward_launch,
)


def _batch_spline_gather_gradient_setup_ctx(ctx, inputs, output):
    positions, charges, mesh, batch_idx, cell_inv_t, spline_order = inputs
    ctx.save_for_backward(positions, charges, mesh, batch_idx, cell_inv_t, output)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.num_systems = mesh.shape[0]
    ctx.needs_pos = positions.requires_grad
    ctx.needs_chg = charges.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _batch_spline_gather_gradient_backward_chain(ctx, grad_force):
    positions, charges, mesh, batch_idx, cell_inv_t, saved_forces = ctx.saved_tensors
    order = ctx.spline_order
    if grad_force is None:
        return None, None, None, None, None, None
    grad_force_c = grad_force.contiguous()

    idx = batch_idx.to(torch.int64)
    cell_inv_t_per_atom = cell_inv_t[idx]
    v_per_atom = torch.bmm(
        cell_inv_t_per_atom,
        grad_force_c.unsqueeze(-1),
    ).squeeze(-1)

    # Position-Hessian: reused by both grad_positions and the cell_inv_t
    # implicit term, so compute when positions OR cell_inv_t need a gradient.
    if ctx.needs_pos or ctx.needs_cell:
        grad_pos_hess = torch.ops.nvalchemiops.batch_spline_pos_hessian(
            positions,
            charges,
            v_per_atom,
            batch_idx,
            cell_inv_t,
            mesh,
            order,
        )
    else:
        grad_pos_hess = None
    grad_positions = grad_pos_hess if ctx.needs_pos else None

    if ctx.needs_chg:
        ones = torch.ones_like(charges, dtype=positions.dtype)
        force_per_unit_q = torch.ops.nvalchemiops.batch_spline_gather_gradient(
            positions,
            ones,
            mesh,
            batch_idx,
            cell_inv_t,
            order,
        )
        grad_charges = (grad_force_c * force_per_unit_q).sum(dim=-1)
    else:
        grad_charges = None

    if ctx.needs_mesh:
        per_atom_vec = -(charges.to(positions.dtype).unsqueeze(-1) * v_per_atom)
        grad_mesh = torch.ops.nvalchemiops.batch_spline_spread_gradient_weights(
            positions,
            per_atom_vec,
            batch_idx,
            cell_inv_t,
            ctx.num_systems,
            ctx.mesh_dims,
            order,
        )
    else:
        grad_mesh = None

    # grad_cell_inv_t: per-system vjp of force w.r.t. cell_inv_t (explicit
    # prefactor + implicit stencil-Hessian terms), reduced per system.
    if ctx.needs_cell:
        cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))  # (B, 3, 3)
        cell_per_atom = cell[idx]  # (N, 3, 3)
        ff = torch.bmm(cell_per_atom, saved_forces.unsqueeze(-1)).squeeze(
            -1
        )  # cell @ force
        hv = torch.bmm(cell_per_atom, grad_pos_hess.unsqueeze(-1)).squeeze(-1)
        contrib = ff.unsqueeze(-1) * grad_force_c.unsqueeze(-2)  # explicit
        contrib = contrib + hv.unsqueeze(-1) * positions.unsqueeze(-2)  # implicit
        grad_cell_inv_t = positions.new_zeros(cell.shape)
        grad_cell_inv_t.index_add_(0, idx, contrib)
    else:
        grad_cell_inv_t = None

    return grad_positions, grad_charges, grad_mesh, None, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::batch_spline_gather_gradient",
    _batch_spline_gather_gradient_backward_chain,
    setup_context=_batch_spline_gather_gradient_setup_ctx,
)


def _batch_cell_inv_t_grad_from_force(
    forces: torch.Tensor,
    positions: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
) -> torch.Tensor:
    r"""Batched ``grad_cell_inv_t`` as a differentiable Torch expression.

    Per-system analog of :func:`_cell_inv_t_grad_from_force`:
    ``qgf[n] = -(cell[s] @ force[n])`` with ``s = batch_idx[n]``, and
    :math:`\text{grad\_cell\_inv\_t}[s, a, b] = \sum_{n:\, \text{batch\_idx}[n]=s} \text{qgf}[n, a] \cdot \text{positions}[n, b]`.
    Reduced over atoms with ``index_add_`` so the cell second order flows through
    autograd.
    """
    cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))  # (B, 3, 3)
    idx = batch_idx.to(torch.int64)
    cell_per_atom = cell[idx]  # (N, 3, 3)
    qgf = -torch.bmm(cell_per_atom, forces.unsqueeze(-1)).squeeze(-1)  # (N, 3)
    contrib = qgf.unsqueeze(-1) * positions.unsqueeze(-2)  # (N, 3, 3): qgf[a]·pos[b]
    grad_cell_inv_t = positions.new_zeros(cell.shape)
    grad_cell_inv_t.index_add_(0, idx, contrib)
    return grad_cell_inv_t


# Batched spread + gather — same adjoint pattern as single-system, with
# ``batch_idx`` (non-differentiable) at position 2. The batched spread
# additionally carries an explicit ``num_systems: int`` arg at position 4
# (used to size the output mesh).
register_warp_op_chain(
    name="nvalchemiops::batch_spline_spread",
    forward=_batch_spread_forward_launch,
    forward_fake=lambda positions,
    values,
    batch_idx,
    cell_inv_t,
    num_systems,
    mesh_dims,
    spline_order: torch.empty(
        (num_systems, mesh_dims[0], mesh_dims[1], mesh_dims[2]),
        dtype=positions.dtype,
        device=positions.device,
    ),
)


def _batch_spline_spread_setup_ctx(ctx, inputs, output):
    (positions, values, batch_idx, cell_inv_t, num_systems, mesh_dims, spline_order) = (
        inputs
    )
    ctx.save_for_backward(positions, values, batch_idx, cell_inv_t)
    ctx.spline_order = spline_order
    ctx.num_systems = num_systems
    ctx.mesh_dims = tuple(mesh_dims)
    ctx.needs_pos = positions.requires_grad
    ctx.needs_val = values.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _batch_spline_spread_backward_chain(ctx, grad_mesh):
    positions, values, batch_idx, cell_inv_t = ctx.saved_tensors
    order = ctx.spline_order
    grad_mesh_c = grad_mesh.contiguous()

    grad_values = (
        torch.ops.nvalchemiops.batch_spline_gather(
            positions,
            grad_mesh_c,
            batch_idx,
            cell_inv_t,
            order,
        )
        if ctx.needs_val
        else None
    )

    if ctx.needs_pos or ctx.needs_cell:
        forces = torch.ops.nvalchemiops.batch_spline_gather_gradient(
            positions,
            values,
            grad_mesh_c,
            batch_idx,
            cell_inv_t,
            order,
        )
        grad_positions = -forces if ctx.needs_pos else None
        grad_cell_inv_t = (
            _batch_cell_inv_t_grad_from_force(
                forces,
                positions,
                batch_idx,
                cell_inv_t,
            )
            if ctx.needs_cell
            else None
        )
    else:
        grad_positions = None
        grad_cell_inv_t = None

    # 7 inputs total: positions, values, batch_idx, cell_inv_t, num_systems,
    # mesh_dims, spline_order. batch_idx (2), num_systems (4), mesh_dims (5),
    # spline_order (6) are non-differentiable.
    return grad_positions, grad_values, None, grad_cell_inv_t, None, None, None


torch.library.register_autograd(
    "nvalchemiops::batch_spline_spread",
    _batch_spline_spread_backward_chain,
    setup_context=_batch_spline_spread_setup_ctx,
)


register_warp_op_chain(
    name="nvalchemiops::batch_spline_gather",
    forward=_batch_gather_forward_launch,
    forward_fake=lambda positions,
    mesh,
    batch_idx,
    cell_inv_t,
    spline_order: torch.empty(
        positions.shape[0],
        dtype=positions.dtype,
        device=positions.device,
    ),
)


def _batch_spline_gather_setup_ctx(ctx, inputs, output):
    positions, mesh, batch_idx, cell_inv_t, spline_order = inputs
    ctx.save_for_backward(positions, mesh, batch_idx, cell_inv_t)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.num_systems = mesh.shape[0]
    ctx.needs_pos = positions.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _batch_spline_gather_backward_chain(ctx, grad_potentials):
    positions, mesh, batch_idx, cell_inv_t = ctx.saved_tensors
    order = ctx.spline_order
    grad_pot_c = grad_potentials.contiguous()

    grad_mesh = (
        torch.ops.nvalchemiops.batch_spline_spread(
            positions,
            grad_pot_c,
            batch_idx,
            cell_inv_t,
            ctx.num_systems,
            ctx.mesh_dims,
            order,
        )
        if ctx.needs_mesh
        else None
    )

    if ctx.needs_pos or ctx.needs_cell:
        forces = torch.ops.nvalchemiops.batch_spline_gather_gradient(
            positions,
            grad_pot_c,
            mesh,
            batch_idx,
            cell_inv_t,
            order,
        )
        grad_positions = -forces if ctx.needs_pos else None
        grad_cell_inv_t = (
            _batch_cell_inv_t_grad_from_force(
                forces,
                positions,
                batch_idx,
                cell_inv_t,
            )
            if ctx.needs_cell
            else None
        )
    else:
        grad_positions = None
        grad_cell_inv_t = None

    # 5 inputs: positions, mesh, batch_idx, cell_inv_t, spline_order.
    return grad_positions, grad_mesh, None, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::batch_spline_gather",
    _batch_spline_gather_backward_chain,
    setup_context=_batch_spline_gather_setup_ctx,
)


def _batch_spline_spread(
    positions: torch.Tensor,
    values: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    num_systems: int,
    mesh_nx: int,
    mesh_ny: int,
    mesh_nz: int,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: batch spline spread (registered custom op)."""
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.batch_spline_spread(
        positions,
        values.to(positions.dtype),
        batch_idx,
        cell_inv_t,
        num_systems,
        [mesh_nx, mesh_ny, mesh_nz],
        spline_order,
    )


def _batch_spline_gather(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: batch spline gather (registered custom op)."""
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.batch_spline_gather(
        positions,
        mesh.to(positions.dtype),
        batch_idx,
        cell_inv_t,
        spline_order,
    )


@warp_custom_op(
    name="alchemiops::_batch_spline_gather_vec3",
    outputs=[
        OutputSpec(
            "values", wp.array(dtype=Any, ndim=2), lambda pos, *_: (pos.shape[0], 3)
        ),
    ],
    grad_arrays=[
        "values",
        "positions",
        "charges",
        "mesh",
        "cell_inv_t",
    ],
)
def _batch_spline_gather_vec3(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: Batch vec3 spline gather with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, mesh, cell)

    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_charges = warp_from_torch(
        charges.to(input_dtype), wp_dtype, requires_grad=needs_grad_flag
    )
    wp_batch_idx = warp_from_torch(batch_idx, wp.int32)
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )
    wp_mesh = warp_from_torch(
        mesh.to(input_dtype), wp_vec_dtype, requires_grad=needs_grad_flag
    )

    values = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)
    wp_values = warp_from_torch(values, wp_vec_dtype, requires_grad=needs_grad_flag)

    kernel = _batch_bspline_gather_vec3_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[
                wp_positions,
                wp_charges,
                wp_batch_idx,
                wp_cell_inv_t,
                wp.int32(spline_order),
                wp_mesh,
            ],
            outputs=[wp_values],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            values,
            tape=tape,
            values=wp_values,
            positions=wp_positions,
            charges=wp_charges,
            cell_inv_t=wp_cell_inv_t,
            mesh=wp_mesh,
        )
    return values


def _batch_spline_gather_gradient(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> torch.Tensor:
    """Internal: batched spline gather-gradient (registered custom op)."""
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv(cell)
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.batch_spline_gather_gradient(
        positions,
        charges.to(positions.dtype),
        mesh.to(positions.dtype),
        batch_idx,
        cell_inv_t,
        spline_order,
    )


def _batch_gather_with_force_forward_launch(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell_inv_t: torch.Tensor,
    spline_order: int,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Batched fused gather + gather-gradient forward launch."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    potential = torch.zeros(num_atoms, device=positions.device, dtype=input_dtype)
    forces = torch.zeros((num_atoms, 3), device=positions.device, dtype=input_dtype)

    per_order_kernel = _PER_ORDER_BATCH_GATHER_WITH_FORCE_KERNELS[wp_dtype].get(
        spline_order
    )
    if per_order_kernel is None:
        raise NotImplementedError(
            f"Batch fused gather is only specialized for spline_order in "
            f"{tuple(_PER_ORDER_BATCH_GATHER_WITH_FORCE_KERNELS[wp_dtype])}; "
            f"got {spline_order}. The public ``spline_gather_with_force`` "
            "wrapper falls back to ``spline_gather`` + "
            "``spline_gather_gradient`` for unsupported orders."
        )

    wp_pos = _wp_from_torch(positions.contiguous(), dtype=wp_vec_dtype)
    wp_chg = _wp_from_torch(charges.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_bidx = _wp_from_torch(batch_idx.contiguous(), dtype=wp.int32)
    wp_cit = _wp_from_torch(cell_inv_t.contiguous(), dtype=wp_mat_dtype)
    wp_mesh = _wp_from_torch(mesh.to(input_dtype).contiguous(), dtype=wp_dtype)
    wp_pot = _wp_from_torch(potential, dtype=wp_dtype)
    wp_forces = _wp_from_torch(forces, dtype=wp_vec_dtype)

    with _scoped_warp_stream(positions.device):
        wp.launch(
            per_order_kernel,
            dim=num_atoms,
            inputs=[wp_pos, wp_chg, wp_bidx, wp_cit, wp_mesh],
            outputs=[wp_pot, wp_forces],
            device=device,
        )
    return potential, forces


# Batched fused gather + force. Dual-chain backward (gather chain for
# grad_potential, gather_gradient chain for grad_forces), indexed by
# batch_idx.
register_warp_op_chain(
    name="nvalchemiops::batch_spline_gather_with_force",
    forward=_batch_gather_with_force_forward_launch,
    forward_return_arity=2,
    forward_fake=lambda pos, *_: (
        torch.empty(pos.shape[0], dtype=pos.dtype, device=pos.device),
        torch.empty((pos.shape[0], 3), dtype=pos.dtype, device=pos.device),
    ),
)


def _batch_spline_gather_with_force_setup_ctx(ctx, inputs, output):
    positions, charges, mesh, batch_idx, cell_inv_t, spline_order = inputs
    _potential, forces = output
    ctx.save_for_backward(positions, charges, mesh, batch_idx, cell_inv_t, forces)
    ctx.spline_order = spline_order
    ctx.mesh_dims = (mesh.shape[-3], mesh.shape[-2], mesh.shape[-1])
    ctx.num_systems = mesh.shape[0]
    ctx.needs_pos = positions.requires_grad
    ctx.needs_chg = charges.requires_grad
    ctx.needs_mesh = mesh.requires_grad
    ctx.needs_cell = cell_inv_t.requires_grad


def _batch_spline_gather_with_force_backward_chain(ctx, grad_potential, grad_forces):
    positions, charges, mesh, batch_idx, cell_inv_t, saved_forces = ctx.saved_tensors
    order = ctx.spline_order
    grad_pos = grad_chg = grad_mesh = grad_cell_inv_t = None

    def _add(target, contrib):
        return contrib if target is None else target + contrib

    # gather chain (grad_potential → grads)
    if grad_potential is not None:
        gp = grad_potential.contiguous()
        if ctx.needs_pos or ctx.needs_cell:
            forces_g = torch.ops.nvalchemiops.batch_spline_gather_gradient(
                positions,
                gp,
                mesh,
                batch_idx,
                cell_inv_t,
                order,
            )
            if ctx.needs_pos:
                grad_pos = _add(grad_pos, -forces_g)
            if ctx.needs_cell:
                grad_cell_inv_t = _add(
                    grad_cell_inv_t,
                    _batch_cell_inv_t_grad_from_force(
                        forces_g,
                        positions,
                        batch_idx,
                        cell_inv_t,
                    ),
                )
        if ctx.needs_mesh:
            grad_mesh = _add(
                grad_mesh,
                torch.ops.nvalchemiops.batch_spline_spread(
                    positions,
                    gp,
                    batch_idx,
                    cell_inv_t,
                    ctx.num_systems,
                    ctx.mesh_dims,
                    order,
                ),
            )

    # gather_gradient chain (grad_forces → grads)
    if grad_forces is not None:
        gf = grad_forces.contiguous()
        cell_inv_t_per_atom = cell_inv_t[batch_idx.to(torch.int64)]
        v_per_atom = torch.bmm(
            cell_inv_t_per_atom,
            gf.unsqueeze(-1),
        ).squeeze(-1)

        if ctx.needs_chg:
            ones = torch.ones_like(charges, dtype=positions.dtype)
            force_per_unit_q = torch.ops.nvalchemiops.batch_spline_gather_gradient(
                positions,
                ones,
                mesh,
                batch_idx,
                cell_inv_t,
                order,
            )
            grad_chg = _add(grad_chg, (gf * force_per_unit_q).sum(dim=-1))

        if ctx.needs_mesh:
            per_atom_vec = -(charges.to(positions.dtype).unsqueeze(-1) * v_per_atom)
            grad_mesh = _add(
                grad_mesh,
                torch.ops.nvalchemiops.batch_spline_spread_gradient_weights(
                    positions,
                    per_atom_vec,
                    batch_idx,
                    cell_inv_t,
                    ctx.num_systems,
                    ctx.mesh_dims,
                    order,
                ),
            )

        if ctx.needs_pos or ctx.needs_cell:
            pos_hess = torch.ops.nvalchemiops.batch_spline_pos_hessian(
                positions,
                charges,
                v_per_atom,
                batch_idx,
                cell_inv_t,
                mesh,
                order,
            )
            if ctx.needs_pos:
                grad_pos = _add(grad_pos, pos_hess)
            if ctx.needs_cell:
                idx = batch_idx.to(torch.int64)
                cell = torch.linalg.inv(cell_inv_t.transpose(-1, -2))
                cell_per_atom = cell[idx]
                ff = torch.bmm(cell_per_atom, saved_forces.unsqueeze(-1)).squeeze(-1)
                hv = torch.bmm(cell_per_atom, pos_hess.unsqueeze(-1)).squeeze(-1)
                contrib = ff.unsqueeze(-1) * gf.unsqueeze(-2)
                contrib = contrib + hv.unsqueeze(-1) * positions.unsqueeze(-2)
                cell_term = positions.new_zeros(cell.shape)
                cell_term.index_add_(0, idx, contrib)
                grad_cell_inv_t = _add(grad_cell_inv_t, cell_term)

    # 6 inputs: positions, charges, mesh, batch_idx, cell_inv_t, spline_order
    return grad_pos, grad_chg, grad_mesh, None, grad_cell_inv_t, None


torch.library.register_autograd(
    "nvalchemiops::batch_spline_gather_with_force",
    _batch_spline_gather_with_force_backward_chain,
    setup_context=_batch_spline_gather_with_force_setup_ctx,
)


def _batch_spline_gather_with_force(
    positions: torch.Tensor,
    charges: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
    cell_inv_t: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
    """Internal: batched fused gather + gather-gradient (registered custom op)."""
    if cell_inv_t is None:
        cell_inv = torch.linalg.inv_ex(cell)[0]
        cell_inv_t = cell_inv.transpose(-1, -2).contiguous()
    return torch.ops.nvalchemiops.batch_spline_gather_with_force(
        positions,
        charges.to(positions.dtype),
        mesh.to(positions.dtype),
        batch_idx,
        cell_inv_t,
        spline_order,
    )


###########################################################################################
########################### Internal Custom Ops: Multi-Channel (Single-System) #############
###########################################################################################


@warp_custom_op(
    name="alchemiops::_spline_spread_channels",
    outputs=[
        OutputSpec(
            "mesh",
            wp.array(dtype=Any, ndim=4),
            lambda pos,
            values,
            cell,
            num_channels,
            mesh_nx,
            mesh_ny,
            mesh_nz,
            spline_order,
            *_: (
                num_channels,
                mesh_nx,
                mesh_ny,
                mesh_nz,
            ),
        ),
    ],
    grad_arrays=[
        "mesh",
        "positions",
        "values",
        "cell_inv_t",
    ],
)
def _spline_spread_channels(
    positions: torch.Tensor,
    values: torch.Tensor,
    cell: torch.Tensor,
    num_channels: int,
    mesh_nx: int,
    mesh_ny: int,
    mesh_nz: int,
    spline_order: int,
) -> torch.Tensor:
    """Internal: Single-system multi-channel spline spread with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, values, cell)

    if cell.dim() == 2:
        cell = cell.unsqueeze(0)

    cell_inv = torch.linalg.inv_ex(cell)[0]
    cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_values = warp_from_torch(
        values.to(input_dtype), wp_dtype, requires_grad=needs_grad_flag
    )
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )

    mesh = torch.zeros(
        (num_channels, mesh_nx, mesh_ny, mesh_nz),
        device=positions.device,
        dtype=input_dtype,
    )
    wp_mesh = warp_from_torch(mesh, wp_dtype, requires_grad=needs_grad_flag)

    kernel = _bspline_spread_channels_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[wp_positions, wp_values, wp_cell_inv_t, wp.int32(spline_order)],
            outputs=[wp_mesh],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            mesh,
            tape=tape,
            mesh=wp_mesh,
            positions=wp_positions,
            values=wp_values,
            cell_inv_t=wp_cell_inv_t,
        )
    return mesh


@warp_custom_op(
    name="alchemiops::_spline_gather_channels",
    outputs=[
        OutputSpec(
            "values",
            wp.array(dtype=Any, ndim=2),
            lambda pos, mesh, *_: (pos.shape[0], mesh.shape[0]),
        ),
    ],
    grad_arrays=[
        "values",
        "positions",
        "mesh",
        "cell_inv_t",
    ],
)
def _spline_gather_channels(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Internal: Single-system multi-channel spline gather with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_channels = mesh.shape[0]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, mesh, cell)

    if cell.dim() == 2:
        cell = cell.unsqueeze(0)

    cell_inv = torch.linalg.inv_ex(cell)[0]
    cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )
    wp_mesh = warp_from_torch(
        mesh.to(input_dtype), wp_dtype, requires_grad=needs_grad_flag
    )

    values = torch.zeros(
        (num_atoms, num_channels), device=positions.device, dtype=input_dtype
    )
    wp_values = warp_from_torch(values, wp_dtype, requires_grad=needs_grad_flag)

    kernel = _bspline_gather_channels_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[wp_positions, wp_cell_inv_t, wp.int32(spline_order), wp_mesh],
            outputs=[wp_values],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            values,
            tape=tape,
            values=wp_values,
            positions=wp_positions,
            cell_inv_t=wp_cell_inv_t,
            mesh=wp_mesh,
        )
    return values


###########################################################################################
########################### Internal Custom Ops: Multi-Channel (Batch) #####################
###########################################################################################


def _batch_spline_spread_channels_output_shape(
    position,
    values,
    batch_idx,
    cell,
    num_systems,
    num_channels,
    mesh_nx,
    mesh_ny,
    mesh_nz,
    spline_order,
):
    return (num_systems, num_channels, mesh_nx, mesh_ny, mesh_nz)


@warp_custom_op(
    name="alchemiops::_batch_spline_spread_channels",
    outputs=[
        OutputSpec(
            "mesh",
            wp.array(dtype=Any, ndim=4),
            _batch_spline_spread_channels_output_shape,
        ),
    ],
    grad_arrays=[
        "mesh",
        "positions",
        "values",
        "cell_inv_t",
    ],
)
def _batch_spline_spread_channels(
    positions: torch.Tensor,
    values: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    num_systems: int,
    num_channels: int,
    mesh_nx: int,
    mesh_ny: int,
    mesh_nz: int,
    spline_order: int,
) -> torch.Tensor:
    """Internal: Batch multi-channel spline spread with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, values, cell)

    cell_inv = torch.linalg.inv_ex(cell)[0]
    cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_values = warp_from_torch(
        values.to(input_dtype), wp_dtype, requires_grad=needs_grad_flag
    )
    wp_batch_idx = warp_from_torch(batch_idx, wp.int32)
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )

    # Create mesh with flattened (B*C, nx, ny, nz) format for Warp 4D limit
    mesh_flat = torch.zeros(
        (num_systems * num_channels, mesh_nx, mesh_ny, mesh_nz),
        device=positions.device,
        dtype=input_dtype,
    )
    wp_mesh = warp_from_torch(mesh_flat, wp_dtype, requires_grad=needs_grad_flag)

    kernel = _batch_bspline_spread_channels_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[
                wp_positions,
                wp_values,
                wp_batch_idx,
                wp_cell_inv_t,
                wp.int32(spline_order),
                wp.int32(num_channels),
            ],
            outputs=[wp_mesh],
            device=device,
        )

    # Reshape back to (B, C, nx, ny, nz) for output
    mesh = mesh_flat.view(num_systems, num_channels, mesh_nx, mesh_ny, mesh_nz)

    if needs_grad_flag:
        attach_for_backward(
            mesh,
            tape=tape,
            mesh=wp_mesh,
            positions=wp_positions,
            values=wp_values,
            cell_inv_t=wp_cell_inv_t,
        )
    return mesh


@warp_custom_op(
    name="alchemiops::_batch_spline_gather_channels",
    outputs=[
        OutputSpec(
            "values",
            wp.array(dtype=Any, ndim=2),
            lambda pos, mesh, *_: (pos.shape[0], mesh.shape[1]),
        ),
    ],
    grad_arrays=[
        "values",
        "positions",
        "mesh",
        "cell_inv_t",
    ],
)
def _batch_spline_gather_channels(
    positions: torch.Tensor,
    mesh: torch.Tensor,
    batch_idx: torch.Tensor,
    cell: torch.Tensor,
    spline_order: int,
) -> torch.Tensor:
    """Internal: Batch multi-channel spline gather with dtype flexibility."""
    device = wp.device_from_torch(positions.device)
    input_dtype = positions.dtype
    wp_dtype = get_wp_dtype(input_dtype)
    wp_vec_dtype = get_wp_vec_dtype(input_dtype)
    wp_mat_dtype = get_wp_mat_dtype(input_dtype)

    num_atoms = positions.shape[0]
    num_systems = mesh.shape[0]  # (B, C, nx, ny, nz)
    num_channels = mesh.shape[1]
    mesh_nx, mesh_ny, mesh_nz = mesh.shape[2], mesh.shape[3], mesh.shape[4]
    num_points = spline_order**3
    needs_grad_flag = needs_grad(positions, mesh, cell)

    cell_inv = torch.linalg.inv_ex(cell)[0]
    cell_inv_t = cell_inv.transpose(-1, -2).contiguous()

    wp_positions = warp_from_torch(
        positions, wp_vec_dtype, requires_grad=needs_grad_flag
    )
    wp_batch_idx = warp_from_torch(batch_idx, wp.int32)
    wp_cell_inv_t = warp_from_torch(
        cell_inv_t, wp_mat_dtype, requires_grad=needs_grad_flag
    )

    # Flatten mesh from (B, C, nx, ny, nz) to (B*C, nx, ny, nz) for Warp 4D limit
    mesh_flat = (
        mesh.to(input_dtype)
        .view(num_systems * num_channels, mesh_nx, mesh_ny, mesh_nz)
        .contiguous()
    )
    wp_mesh = warp_from_torch(mesh_flat, wp_dtype, requires_grad=needs_grad_flag)

    values = torch.zeros(
        (num_atoms, num_channels), device=positions.device, dtype=input_dtype
    )
    wp_values = warp_from_torch(values, wp_dtype, requires_grad=needs_grad_flag)

    kernel = _batch_bspline_gather_channels_kernel_overload[wp_dtype]

    with WarpAutogradContextManager(needs_grad_flag) as tape:
        wp.launch(
            kernel,
            dim=(num_atoms, num_points),
            inputs=[
                wp_positions,
                wp_batch_idx,
                wp_cell_inv_t,
                wp.int32(spline_order),
                wp.int32(num_channels),
                wp_mesh,
            ],
            outputs=[wp_values],
            device=device,
        )

    if needs_grad_flag:
        attach_for_backward(
            values,
            tape=tape,
            values=wp_values,
            positions=wp_positions,
            cell_inv_t=wp_cell_inv_t,
            mesh=wp_mesh,
        )
    return values


###########################################################################################
########################### Unified Public API #############################################
###########################################################################################


def bspline_weight(u: torch.Tensor, order: int) -> torch.Tensor:
    """Compute B-spline basis function M_n(u).

    Parameters
    ----------
    u : torch.Tensor
        Input values.
    order : int
        Spline order.

    Returns
    -------
    torch.Tensor
        Weights M_n(u).
    """
    return _spline_weight(u, order)


[docs] def spline_spread( positions: torch.Tensor, values: torch.Tensor, cell: torch.Tensor, mesh_dims: tuple[int, int, int], spline_order: int = 4, batch_idx: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, ) -> torch.Tensor: """Spread values from atoms to mesh grid using B-spline interpolation. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. values : torch.Tensor, shape (N,) Values to spread (e.g., charges). cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. For batched, shape should be (B, 3, 3). mesh_dims : tuple[int, int, int] Mesh dimensions (nx, ny, nz). spline_order : int, default=4 B-spline order (1-6, where 4=cubic). batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. cell_inv_t : torch.Tensor | None, default=None Precomputed transpose of cell inverse. If provided, skips inverse computation. Shape (1, 3, 3) for single-system or (B, 3, 3) for batch. Returns ------- mesh : torch.Tensor For single-system: shape (nx, ny, nz) For batch: shape (B, nx, ny, nz) """ mesh_nx, mesh_ny, mesh_nz = mesh_dims if batch_idx is None: return _spline_spread( positions, values, cell, mesh_nx, mesh_ny, mesh_nz, spline_order, cell_inv_t ) else: num_systems = cell.shape[0] if cell.dim() == 2: cell = cell.unsqueeze(0).expand(num_systems, -1, -1).contiguous() return _batch_spline_spread( positions, values, batch_idx, cell, num_systems, mesh_nx, mesh_ny, mesh_nz, spline_order, cell_inv_t, )
def spline_gather( positions: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, ) -> torch.Tensor: """Gather values from mesh to atoms using B-spline interpolation. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. mesh : torch.Tensor For single-system: shape (nx, ny, nz) For batch: shape (B, nx, ny, nz) cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. spline_order : int, default=4 B-spline order. batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. cell_inv_t : torch.Tensor | None, default=None Precomputed transpose of cell inverse. If provided, skips inverse computation. Shape (1, 3, 3) for single-system or (B, 3, 3) for batch. Returns ------- values : torch.Tensor, shape (N,) Interpolated values at atomic positions. """ if batch_idx is None: return _spline_gather(positions, mesh, cell, spline_order, cell_inv_t) else: # Ensure cell is 3D for batch operations cell = _expand_shared_cell(cell, mesh.shape[0]) return _batch_spline_gather( positions, mesh, batch_idx, cell, spline_order, cell_inv_t ) def spline_gather_vec3( positions: torch.Tensor, charges: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, ) -> torch.Tensor: """Gather 3D vector values from mesh to atoms using B-spline interpolation. This is useful for interpolating vector fields like electric fields. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. charges : torch.Tensor, shape (N,) Atomic charges (or other scalar weights). mesh : torch.Tensor For single-system: shape (nx, ny, nz, 3) For batch: shape (B, nx, ny, nz, 3) cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. spline_order : int, default=4 B-spline order. batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. cell_inv_t : torch.Tensor | None, default=None Precomputed transpose of cell inverse. If provided, skips inverse computation. Shape (1, 3, 3) for single-system or (B, 3, 3) for batch. Returns ------- vectors : torch.Tensor, shape (N, 3) Interpolated 3D vectors at atomic positions. """ if _gather_vec3_needs_autograd(positions, charges, mesh, cell): return _spline_gather_vec3_autograd( positions, charges, mesh, cell, spline_order, batch_idx, cell_inv_t ) if batch_idx is None: return _spline_gather_vec3( positions, charges, mesh, cell, spline_order, cell_inv_t ) else: # Ensure cell is 3D for batch operations cell = _expand_shared_cell(cell, mesh.shape[0]) return _batch_spline_gather_vec3( positions, charges, mesh, batch_idx, cell, spline_order, cell_inv_t ) def spline_gather_gradient( positions: torch.Tensor, charges: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, ) -> torch.Tensor: """Gather gradient from mesh to atoms using B-spline derivatives. Computes forces: .. math:: F_i = -q_i \\sum_g \\phi(g) \\nabla w(r_i, g) Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. charges : torch.Tensor, shape (N,) Atomic charges. mesh : torch.Tensor For single-system: shape (nx, ny, nz) For batch: shape (B, nx, ny, nz) cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. spline_order : int, default=4 B-spline order. batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. cell_inv_t : torch.Tensor | None, default=None Precomputed transpose of cell inverse. If provided, skips inverse computation. Shape (1, 3, 3) for single-system or (B, 3, 3) for batch. Returns ------- forces : torch.Tensor, shape (N, 3) Forces on atoms. """ if batch_idx is None: return _spline_gather_gradient( positions, charges, mesh, cell, spline_order, cell_inv_t ) else: # Ensure cell is 3D for batch operations cell = _expand_shared_cell(cell, mesh.shape[0]) return _batch_spline_gather_gradient( positions, charges, mesh, batch_idx, cell, spline_order, cell_inv_t ) def _gather_channels_needs_autograd( positions: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, ) -> bool: return torch.is_grad_enabled() and ( positions.requires_grad or mesh.requires_grad or cell.requires_grad ) def _spline_gather_channels_autograd( positions: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int, batch_idx: torch.Tensor | None, ) -> torch.Tensor: """Differentiable multi-channel gather using the single-channel backward chain.""" if batch_idx is None: channel_values = [ spline_gather(positions, mesh[channel], cell, spline_order=spline_order) for channel in range(mesh.shape[0]) ] else: channel_values = [ spline_gather( positions, mesh[:, channel], cell, spline_order=spline_order, batch_idx=batch_idx, ) for channel in range(mesh.shape[1]) ] return torch.stack(channel_values, dim=1) def _spread_channels_needs_autograd( positions: torch.Tensor, values: torch.Tensor, cell: torch.Tensor, ) -> bool: return torch.is_grad_enabled() and ( positions.requires_grad or values.requires_grad or cell.requires_grad ) def _spline_spread_channels_autograd( positions: torch.Tensor, values: torch.Tensor, cell: torch.Tensor, mesh_dims: tuple[int, int, int], spline_order: int, batch_idx: torch.Tensor | None, ) -> torch.Tensor: """Differentiable multi-channel spread using the single-channel backward chain.""" if batch_idx is None: channel_meshes = [ spline_spread( positions, values[:, channel], cell, mesh_dims, spline_order=spline_order, ) for channel in range(values.shape[1]) ] return torch.stack(channel_meshes, dim=0) channel_meshes = [ spline_spread( positions, values[:, channel], cell, mesh_dims, spline_order=spline_order, batch_idx=batch_idx, ) for channel in range(values.shape[1]) ] return torch.stack(channel_meshes, dim=1) def _gather_vec3_needs_autograd( positions: torch.Tensor, charges: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, ) -> bool: return torch.is_grad_enabled() and ( positions.requires_grad or charges.requires_grad or mesh.requires_grad or cell.requires_grad ) def _spline_gather_vec3_autograd( positions: torch.Tensor, charges: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int, batch_idx: torch.Tensor | None, cell_inv_t: torch.Tensor | None, ) -> torch.Tensor: """Differentiable vec3 gather using the scalar gather backward chain.""" component_values = [ spline_gather( positions, mesh[..., component], cell, spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) for component in range(3) ] return torch.stack(component_values, dim=1) * charges.to(positions.dtype).unsqueeze( 1 )
[docs] def spline_gather_with_force( positions: torch.Tensor, charges: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, cell_inv_t: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: r"""Fused gather of scalar potential AND derivative-based force from one mesh. Returns ``(output, forces)`` where: - ``output[atom]`` = :math:`\sum_g \text{mesh}[g] \cdot w(\text{atom}, g)` — raw potential per atom (the caller multiplies by charge in the PME corrections step). - ``forces[atom]`` = :math:`-q_\text{atom} \sum_g \text{mesh}[g] \cdot C^{-T} \nabla w` — Cartesian force. This replaces ``spline_gather(...)`` followed by ``spline_gather_gradient(...)`` on the same mesh: each thread reads its stencil cell ONCE and accumulates both outputs. Halves the mesh DRAM traffic and shares the per-thread weight derivative work across both channels. Parameters mirror ``spline_gather_gradient``. For ``spline_order`` in the set the per-order kernels cover (``{2, 3, 4, 5, 6}``), both single-system and batched inputs use the fused kernel directly. For unsupported orders, batched inputs fall back to the two-kernel sequence (``spline_gather`` + ``spline_gather_gradient``). """ if batch_idx is None: return _spline_gather_with_force( positions, charges, mesh, cell, spline_order, cell_inv_t ) # Batched path: use the per-order fused kernel when available, otherwise # fall back to the two-kernel sequence. wp_dtype = get_wp_dtype(positions.dtype) if spline_order in _PER_ORDER_BATCH_GATHER_WITH_FORCE_KERNELS[wp_dtype]: # Ensure cell is 3D for batched operations. cell = _expand_shared_cell(cell, mesh.shape[0]) return _batch_spline_gather_with_force( positions, charges, mesh, batch_idx, cell, spline_order, cell_inv_t ) potential = spline_gather( positions, mesh, cell, spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) forces = spline_gather_gradient( positions, charges, mesh, cell, spline_order=spline_order, batch_idx=batch_idx, cell_inv_t=cell_inv_t, ) return potential, forces
def spline_spread_channels( positions: torch.Tensor, values: torch.Tensor, cell: torch.Tensor, mesh_dims: tuple[int, int, int], spline_order: int = 4, batch_idx: torch.Tensor | None = None, ) -> torch.Tensor: """Spread multi-channel values from atoms to mesh grid using B-spline interpolation. This is useful for spreading multipole coefficients (e.g., 9 channels for L_max=2: 1 monopole + 3 dipoles + 5 quadrupoles). Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. values : torch.Tensor, shape (N, C) Multi-channel values to spread. C is the number of channels. cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. For batched, shape should be (B, 3, 3). mesh_dims : tuple[int, int, int] Mesh dimensions (nx, ny, nz). spline_order : int, default=4 B-spline order (1-6, where 4=cubic). batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. Returns ------- mesh : torch.Tensor For single-system: shape (C, nx, ny, nz) For batch: shape (B, C, nx, ny, nz) Example ------- >>> # Spread 9-channel multipole coefficients >>> multipoles = torch.randn(100, 9, dtype=torch.float64, device="cuda") >>> mesh = spline_spread_channels(positions, multipoles, cell, (16, 16, 16)) >>> print(mesh.shape) # (9, 16, 16, 16) """ mesh_nx, mesh_ny, mesh_nz = mesh_dims num_channels = values.shape[1] if batch_idx is not None and cell.dim() == 2: raise ValueError( "batched spline_spread_channels requires cell with shape (B, 3, 3)" ) if _spread_channels_needs_autograd(positions, values, cell): return _spline_spread_channels_autograd( positions, values, cell, mesh_dims, spline_order, batch_idx ) if batch_idx is None: return _spline_spread_channels( positions, values, cell, num_channels, mesh_nx, mesh_ny, mesh_nz, spline_order, ) else: num_systems = cell.shape[0] return _batch_spline_spread_channels( positions, values, batch_idx, cell, num_systems, num_channels, mesh_nx, mesh_ny, mesh_nz, spline_order, ) def spline_gather_channels( positions: torch.Tensor, mesh: torch.Tensor, cell: torch.Tensor, spline_order: int = 4, batch_idx: torch.Tensor | None = None, ) -> torch.Tensor: """Gather multi-channel values from mesh to atoms using B-spline interpolation. This is the inverse of spline_spread_channels. Parameters ---------- positions : torch.Tensor, shape (N, 3) Atomic positions. mesh : torch.Tensor For single-system: shape (C, nx, ny, nz) For batch: shape (B, C, nx, ny, nz) cell : torch.Tensor, shape (3, 3), (1, 3, 3), or (B, 3, 3) Unit cell matrix. spline_order : int, default=4 B-spline order. batch_idx : torch.Tensor | None, shape (N,), dtype=int32, default=None System index for each atom. If None, uses single-system kernel. Returns ------- values : torch.Tensor, shape (N, C) Interpolated multi-channel values at atomic positions. Example ------- >>> # Gather 9-channel potential from mesh >>> potential_mesh = torch.randn(9, 16, 16, 16, dtype=torch.float64, device="cuda") >>> potentials = spline_gather_channels(positions, potential_mesh, cell) >>> print(potentials.shape) # (100, 9) """ if _gather_channels_needs_autograd(positions, mesh, cell): return _spline_gather_channels_autograd( positions, mesh, cell, spline_order, batch_idx ) if batch_idx is None: return _spline_gather_channels(positions, mesh, cell, spline_order) else: # Ensure cell is 3D for batch operations cell = _expand_shared_cell(cell, mesh.shape[0]) return _batch_spline_gather_channels( positions, mesh, batch_idx, cell, spline_order ) ########################################################################################### ########################### Deconvolution Functions ####################################### ########################################################################################### def _bspline_modulus(k: torch.Tensor, n: int, order: int) -> torch.Tensor: """Compute the modulus of B-spline Fourier transform. The B-spline function M_n(u) has Fourier transform. For PME, we need the modulus of this for the cardinal B-spline interpolation. Parameters ---------- k : torch.Tensor Frequency indices (integers). n : int Grid dimension. order : int B-spline order. Returns ------- torch.Tensor |b(k)|^2 where b(k) is the B-spline Fourier coefficient. """ # Compute the exponential B-spline factors # Following Essmann et al. (1995) Eq. 4.7 pi = torch.tensor(math.pi, dtype=torch.float64, device=k.device) # Handle k=0 case specially (limit is 1) result = torch.ones_like(k, dtype=torch.float64) # For non-zero k, compute the product nonzero_mask = k != 0 # w = 2*pi * k / n w = 2.0 * pi * k.float() / n # The B-spline Fourier coefficient is: # b(k) = sum_{j=0}^{order-1} M_order(j+1) * exp(2*pi*i j k / n) # where M_order is the B-spline basis function # Compute M_order values at integer points 1, 2, ..., order m_values = _compute_bspline_coefficients(order, k.device) # Sum: b(k) = sum_j M_order(j+1) * exp(i w j) b_real = torch.zeros_like(k, dtype=torch.float64) b_imag = torch.zeros_like(k, dtype=torch.float64) for j in range(order): phase = w * j b_real = b_real + m_values[j] * torch.cos(phase) b_imag = b_imag + m_values[j] * torch.sin(phase) # |b(k)|^2 b_sq = b_real**2 + b_imag**2 # Handle k=0 case result = torch.where(nonzero_mask, b_sq, result) return result def _compute_bspline_coefficients(order: int, device) -> torch.Tensor: """Compute B-spline basis function values at integer points. For a B-spline of order n, we need M_n(1), M_n(2), ..., M_n(n). These are used in the Fourier transform computation. Parameters ---------- order : int B-spline order. device PyTorch device. Returns ------- torch.Tensor B-spline values [M_n(1), M_n(2), ..., M_n(n)]. """ if order == 1: return torch.tensor([1.0], dtype=torch.float64, device=device) elif order == 2: return torch.tensor([0.5, 0.5], dtype=torch.float64, device=device) elif order == 3: return torch.tensor([1 / 6, 4 / 6, 1 / 6], dtype=torch.float64, device=device) elif order == 4: return torch.tensor( [1 / 24, 11 / 24, 11 / 24, 1 / 24], dtype=torch.float64, device=device ) elif order == 5: return torch.tensor( [1 / 120, 26 / 120, 66 / 120, 26 / 120, 1 / 120], dtype=torch.float64, device=device, ) elif order == 6: return torch.tensor( [1 / 720, 57 / 720, 302 / 720, 302 / 720, 57 / 720, 1 / 720], dtype=torch.float64, device=device, ) else: # Use recursive definition for higher orders # M_n(u) = u/(n-1) * M_{n-1}(u) + (n-u)/(n-1) * M_{n-1}(u-1) coeffs = _compute_bspline_coefficients(order - 1, device) new_coeffs = torch.zeros(order, dtype=torch.float64, device=device) for j in range(order): u = float(j + 1) if j < order - 1: new_coeffs[j] += u / (order - 1) * coeffs[j] if j > 0: new_coeffs[j] += (order - u) / (order - 1) * coeffs[j - 1] return new_coeffs def compute_bspline_deconvolution( mesh_dims: tuple[int, int, int], spline_order: int = 4, device=None, ) -> torch.Tensor: """Compute B-spline deconvolution factors for Fourier space correction. In FFT-based methods (like PME), the B-spline interpolation introduces smoothing in the charge distribution. This function computes the deconvolution factors to correct for this smoothing in Fourier space. The correction is: mesh_corrected_k = mesh_k * deconv Parameters ---------- mesh_dims : tuple[int, int, int] Mesh dimensions (nx, ny, nz). spline_order : int, default=4 B-spline order. device : torch.device, optional Device for the output tensor. Default: CPU. Returns ------- deconv : torch.Tensor, shape (nx, ny, nz) Deconvolution factors. Multiply with FFT of mesh to correct. Example ------- >>> deconv = compute_bspline_deconvolution((16, 16, 16), spline_order=4) >>> mesh_fft = torch.fft.fftn(charge_mesh) >>> mesh_corrected_fft = mesh_fft * deconv >>> charge_mesh_corrected = torch.fft.ifftn(mesh_corrected_fft).real Notes ----- The deconvolution factor for a given k-vector is: D(k_x, k_y, k_z) = 1 / (|b(k_x)|^2 * |b(k_y)|^2 * |b(k_z)|^2) where b(k) is the Fourier transform of the 1D B-spline. For efficiency, this uses the separable property of the 3D B-spline. """ if device is None: device = torch.device("cpu") nx, ny, nz = mesh_dims # Create frequency indices for each dimension # For FFT, frequencies are arranged as [0, 1, ..., n//2, -(n//2-1), ..., -1] kx = torch.fft.fftfreq(nx, device=device) * nx # Integer frequencies ky = torch.fft.fftfreq(ny, device=device) * ny kz = torch.fft.fftfreq(nz, device=device) * nz # Compute |b(k)|^2 for each dimension bx_sq = _bspline_modulus(kx, nx, spline_order) by_sq = _bspline_modulus(ky, ny, spline_order) bz_sq = _bspline_modulus(kz, nz, spline_order) # The 3D deconvolution is the product of 1D factors # deconv = 1 / (bx^2 * by^2 * bz^2) # Use outer product for efficiency bx_sq = bx_sq.view(nx, 1, 1) by_sq = by_sq.view(1, ny, 1) bz_sq = bz_sq.view(1, 1, nz) b_sq_3d = bx_sq * by_sq * bz_sq # Avoid division by zero (should not happen for reasonable orders) b_sq_3d = torch.clamp(b_sq_3d, min=1e-15) deconv = 1.0 / b_sq_3d return deconv def compute_bspline_deconvolution_1d( n: int, spline_order: int = 4, device=None, ) -> torch.Tensor: """Compute 1D B-spline deconvolution factors. Useful for separable operations or debugging. Parameters ---------- n : int Grid dimension. spline_order : int, default=4 B-spline order. device : torch.device, optional Device for the output tensor. Returns ------- deconv_1d : torch.Tensor, shape (n,) 1D deconvolution factors. """ if device is None: device = torch.device("cpu") k = torch.fft.fftfreq(n, device=device) * n b_sq = _bspline_modulus(k, n, spline_order) b_sq = torch.clamp(b_sq, min=1e-15) return 1.0 / b_sq ########################################################################################### ########################### Module Exports ################################################# ########################################################################################### __all__ = [ # Unified PyTorch API (scalar) "bspline_weight", "spline_spread", "spline_gather", "spline_gather_vec3", "spline_gather_gradient", "spline_gather_with_force", # Unified PyTorch API (multi-channel) "spline_spread_channels", "spline_gather_channels", # Deconvolution "compute_bspline_deconvolution", "compute_bspline_deconvolution_1d", ]