# 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.
"""PyTorch bindings for the Yeh-Berkowitz / Ballenegger slab correction."""
from __future__ import annotations
import torch
import warp as wp
from nvalchemiops.interactions.electrostatics.slab_kernels import (
_launch_slab_correction,
_slab_reduce_moments_kernel_overload,
slab_precompute_geometry,
)
from nvalchemiops.torch.autograd import (
needs_grad,
warp_from_torch,
)
from nvalchemiops.torch.interactions.electrostatics._registration import (
ensure_electrostatics_ops_registered,
)
from nvalchemiops.torch.interactions.electrostatics._slab_chain import (
slab_correction_energy as _slab_correction_energy_autograd,
)
from nvalchemiops.torch.interactions.electrostatics._util import (
_build_electrostatic_result,
)
from nvalchemiops.torch.types import get_wp_dtype, get_wp_mat_dtype, get_wp_vec_dtype
__all__ = ["compute_slab_correction"]
def _prepare_cell(cell: torch.Tensor) -> tuple[torch.Tensor, int]:
"""Ensure cell is 3D (B, 3, 3) and return number of systems."""
if cell.dim() == 2:
cell = cell.unsqueeze(0)
return cell, cell.shape[0]
def _prepare_pbc_for_slab(
pbc: torch.Tensor | None,
num_systems: int,
device: torch.device,
) -> torch.Tensor:
"""Validate and normalize the pbc tensor for slab correction.
Parameters
----------
pbc : torch.Tensor or None
Per-system periodic boundary conditions. Accepted shapes:
- None: raises (slab correction requires explicit pbc).
- (3,) bool: accepted only for a single system.
- (num_systems, 3) bool: used as-is.
Batched slab correction requires explicit per-system pbc because slab
geometry can differ across systems.
num_systems : int
Number of systems in the batch.
device : torch.device
Target device for the output tensor.
Returns
-------
torch.Tensor, shape (num_systems, 3), dtype=bool
Validated pbc tensor.
"""
if pbc is None:
raise ValueError(
"slab_correction=True requires an explicit `pbc` argument. "
"Pass a (3,) or (B, 3) bool tensor with True for periodic "
"directions and False for the non-periodic (slab) direction."
)
if pbc.dtype != torch.bool:
raise ValueError(f"`pbc` must be a bool tensor, got dtype={pbc.dtype}.")
if pbc.dim() == 1:
if pbc.shape[0] != 3:
raise ValueError(
f"`pbc` of shape (3,) expected, got shape {tuple(pbc.shape)}."
)
if num_systems != 1:
raise ValueError(
"Batched slab correction requires `pbc` shape (B, 3); got "
"shape (3,). Pass per-system pbc explicitly."
)
pbc = pbc.unsqueeze(0).contiguous()
elif pbc.dim() == 2:
if pbc.shape != (num_systems, 3):
raise ValueError(
f"`pbc` of shape ({num_systems}, 3) expected, got "
f"shape {tuple(pbc.shape)}."
)
else:
raise ValueError(
f"`pbc` must be 1D (3,) or 2D (B, 3), got {pbc.dim()}D tensor."
)
return pbc.to(device=device, dtype=torch.bool)
@torch.library.custom_op(
"nvalchemiops::_slab_correction_direct",
mutates_args=(
"slab_axis",
"slab_normal",
"slab_volume",
"slab_height_sq",
"mz",
"mz2",
"qtotal",
"energy_in",
"slab_energies",
"slab_forces",
"slab_charge_grads",
"slab_virial",
),
)
def _slab_correction_direct_op(
positions: torch.Tensor,
charges: torch.Tensor,
cell: torch.Tensor,
pbc: torch.Tensor,
batch_idx: torch.Tensor,
compute_forces: bool,
compute_charge_gradients: bool,
compute_virial: bool,
slab_axis: torch.Tensor,
slab_normal: torch.Tensor,
slab_volume: torch.Tensor,
slab_height_sq: torch.Tensor,
mz: torch.Tensor,
mz2: torch.Tensor,
qtotal: torch.Tensor,
energy_in: torch.Tensor,
slab_energies: torch.Tensor,
slab_forces: torch.Tensor,
slab_charge_grads: torch.Tensor,
slab_virial: torch.Tensor,
) -> None:
"""Fill slab direct-output tensors through a Dynamo-visible custom-op boundary."""
num_atoms = positions.shape[0]
input_dtype = positions.dtype
wp_scalar = get_wp_dtype(input_dtype)
wp_vec = get_wp_vec_dtype(input_dtype)
wp_mat = get_wp_mat_dtype(input_dtype)
device = wp.device_from_torch(positions.device)
wp_positions = warp_from_torch(positions, wp_vec, requires_grad=False)
wp_charges = warp_from_torch(charges, wp_scalar, requires_grad=False)
wp_batch_idx = warp_from_torch(batch_idx, wp.int32, requires_grad=False)
wp_pbc = warp_from_torch(pbc.contiguous(), wp.bool, requires_grad=False)
wp_cell = warp_from_torch(cell, wp_mat, requires_grad=False)
wp_slab_axis = warp_from_torch(slab_axis, wp.int32, requires_grad=False)
wp_slab_normal = warp_from_torch(slab_normal, wp.vec3d, requires_grad=False)
wp_slab_volume = warp_from_torch(slab_volume, wp.float64, requires_grad=False)
wp_slab_height_sq = warp_from_torch(slab_height_sq, wp.float64, requires_grad=False)
slab_axis.zero_()
slab_normal.zero_()
slab_volume.zero_()
slab_height_sq.zero_()
mz.zero_()
mz2.zero_()
qtotal.zero_()
energy_in.zero_()
slab_energies.zero_()
if compute_forces:
slab_forces.zero_()
if compute_charge_gradients:
slab_charge_grads.zero_()
if compute_virial:
slab_virial.zero_()
slab_precompute_geometry(
wp_pbc,
wp_cell,
wp_slab_axis,
wp_slab_normal,
wp_slab_volume,
wp_slab_height_sq,
wp_scalar,
device,
)
if num_atoms == 0:
return
wp_mz = warp_from_torch(mz, wp.float64, requires_grad=False)
wp_mz2 = warp_from_torch(mz2, wp.float64, requires_grad=False)
wp_qtotal = warp_from_torch(qtotal, wp.float64, requires_grad=False)
wp_energy_in = warp_from_torch(energy_in, wp.float64, requires_grad=False)
wp_slab_energies = warp_from_torch(slab_energies, wp.float64, requires_grad=False)
wp_slab_forces = (
warp_from_torch(slab_forces, wp_vec, requires_grad=False)
if compute_forces
else None
)
wp_slab_charge_grads = (
warp_from_torch(slab_charge_grads, wp.float64, requires_grad=False)
if compute_charge_gradients
else None
)
wp_slab_virial = (
warp_from_torch(slab_virial, wp_mat, requires_grad=False)
if compute_virial
else None
)
wp.launch(
_slab_reduce_moments_kernel_overload[wp_scalar],
dim=num_atoms,
inputs=[
wp_positions,
wp_charges,
wp_batch_idx,
wp_pbc,
wp_cell,
wp_mz,
wp_mz2,
wp_qtotal,
],
device=device,
)
_launch_slab_correction(
positions=wp_positions,
charges=wp_charges,
batch_idx=wp_batch_idx,
pbc=wp_pbc,
cell=wp_cell,
mz=wp_mz,
mz2=wp_mz2,
qtotal=wp_qtotal,
slab_axis=wp_slab_axis,
slab_normal=wp_slab_normal,
slab_volume=wp_slab_volume,
slab_height_sq=wp_slab_height_sq,
energy_in=wp_energy_in,
energy_out=wp_slab_energies,
wp_dtype=wp_scalar,
forces=wp_slab_forces,
charge_grads=wp_slab_charge_grads,
virial=wp_slab_virial,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
device=device,
)
@_slab_correction_direct_op.register_fake
def _(
positions: torch.Tensor,
charges: torch.Tensor,
cell: torch.Tensor,
pbc: torch.Tensor,
batch_idx: torch.Tensor,
compute_forces: bool,
compute_charge_gradients: bool,
compute_virial: bool,
slab_axis: torch.Tensor,
slab_normal: torch.Tensor,
slab_volume: torch.Tensor,
slab_height_sq: torch.Tensor,
mz: torch.Tensor,
mz2: torch.Tensor,
qtotal: torch.Tensor,
energy_in: torch.Tensor,
slab_energies: torch.Tensor,
slab_forces: torch.Tensor,
slab_charge_grads: torch.Tensor,
slab_virial: torch.Tensor,
) -> None:
return
def _run_slab_correction_op(
positions: torch.Tensor,
charges: torch.Tensor,
cell: torch.Tensor,
pbc: torch.Tensor,
batch_idx: torch.Tensor,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> tuple[torch.Tensor, ...]:
"""Run the Ewald-style slab custom-op implementation."""
num_atoms = positions.shape[0]
num_systems = cell.shape[0]
input_dtype = positions.dtype
slab_axis = torch.empty(num_systems, device=cell.device, dtype=torch.int32)
slab_normal = torch.empty(num_systems, 3, device=cell.device, dtype=torch.float64)
slab_volume = torch.empty(num_systems, device=cell.device, dtype=torch.float64)
slab_height_sq = torch.empty_like(slab_volume)
mz_t = torch.empty(num_systems, 3, device=positions.device, dtype=torch.float64)
mz2_t = torch.empty_like(mz_t)
qtotal_t = torch.empty(num_systems, device=positions.device, dtype=torch.float64)
energy_in_t = torch.empty(num_atoms, device=positions.device, dtype=torch.float64)
slab_energies = torch.zeros(num_atoms, device=positions.device, dtype=torch.float64)
output_tensors: list[torch.Tensor] = [slab_energies]
if compute_forces:
slab_forces = torch.zeros(
num_atoms, 3, device=positions.device, dtype=input_dtype
)
output_tensors.append(slab_forces)
else:
slab_forces = torch.empty(0, 3, device=positions.device, dtype=input_dtype)
if compute_charge_gradients:
slab_charge_grads = torch.zeros(
num_atoms, device=positions.device, dtype=torch.float64
)
output_tensors.append(slab_charge_grads)
else:
slab_charge_grads = torch.empty(0, device=positions.device, dtype=torch.float64)
if compute_virial:
slab_virial = torch.zeros(
num_systems, 3, 3, device=positions.device, dtype=input_dtype
)
output_tensors.append(slab_virial)
else:
slab_virial = torch.empty(0, 3, 3, device=positions.device, dtype=input_dtype)
_slab_correction_direct_op(
positions,
charges,
cell,
pbc,
batch_idx,
compute_forces,
compute_charge_gradients,
compute_virial,
slab_axis,
slab_normal,
slab_volume,
slab_height_sq,
mz_t,
mz2_t,
qtotal_t,
energy_in_t,
slab_energies,
slab_forces,
slab_charge_grads,
slab_virial,
)
return tuple(output_tensors)
[docs]
def compute_slab_correction(
positions: torch.Tensor,
charges: torch.Tensor,
cell: torch.Tensor,
pbc: torch.Tensor,
batch_idx: torch.Tensor | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> torch.Tensor | tuple[torch.Tensor, ...]:
"""Yeh-Berkowitz slab correction for 2D periodic electrostatics, with the
Ballenegger et al. (2009) Eq. 29 extension for non-neutral systems.
Returns the slab-correction contribution (per-atom energy and optionally
per-atom force, charge gradient, and per-system virial). The caller adds
these to the corresponding 3D Ewald/PME quantities; in normal usage the
correction is invoked through ``ewald_summation(..., slab_correction=True)``
or ``particle_mesh_ewald(..., slab_correction=True)``.
Background-charge convention
----------------------------
For systems with net charge :math:`Q \\ne 0`, the formula corresponds to a
uniform-volume neutralizing background (the same convention used by
standard 3D Ewald). This matches LAMMPS and Ballenegger et al. (2009)
Eq. 29.
Cell geometry
-------------
Orthorhombic and triclinic cells are supported. For triclinic slab
systems, the slab normal follows the plane spanned by the two periodic
cell vectors.
Parameters
----------
positions : torch.Tensor, shape (N, 3)
Atomic coordinates.
charges : torch.Tensor, shape (N,)
Atomic charges.
cell : torch.Tensor, shape (3, 3) or (B, 3, 3)
Unit cell matrices.
pbc : torch.Tensor, shape (3,) or (B, 3), dtype=bool
Per-system periodic boundary conditions. True for periodic directions,
False for the non-periodic (slab) direction. Systems whose pbc is not
slab-like (i.e., has anything other than exactly one False entry)
contribute zero. A (3,) tensor is accepted only for single-system
calls; batched calls require explicit (B, 3) per-system pbc.
batch_idx : torch.Tensor, shape (N,), dtype=int32, optional
System index for each atom. Defaults to all zeros (single system). When
provided, atoms must be grouped by system: ``batch_idx`` must be
contiguous, nondecreasing, and use system IDs ``0..B-1``.
compute_forces : bool, default=False
If True, return per-atom forces.
compute_charge_gradients : bool, default=False
If True, return per-atom charge gradients dE_slab/dq_i.
compute_virial : bool, default=False
If True, return per-system virial tensor using the normal-following
affine strain convention W = E_slab * (I - 2 n n^T).
Returns
-------
energies : torch.Tensor, shape (N,), dtype=float64
Per-atom slab correction energy.
forces : torch.Tensor, shape (N, 3), dtype matches positions, optional
Per-atom slab force (only returned if compute_forces=True).
charge_grads : torch.Tensor, shape (N,), dtype=float64, optional
Per-atom slab charge gradient (only if compute_charge_gradients=True).
virial : torch.Tensor, shape (B, 3, 3), dtype matches positions, optional
Per-system slab virial (only if compute_virial=True).
Examples
--------
Standalone correction for an orthorhombic slab with vacuum along z::
>>> pbc_slab = torch.tensor([[True, True, False]], device=positions.device)
>>> slab_energy, slab_forces = compute_slab_correction(
... positions, charges, cell, pbc_slab, compute_forces=True
... )
>>> corrected_energy = ewald_energy + slab_energy
Triclinic cells use the normal to the periodic plane::
>>> triclinic_energy, triclinic_forces = compute_slab_correction(
... positions, charges, triclinic_cell, pbc_slab, compute_forces=True
... )
"""
ensure_electrostatics_ops_registered()
cell, num_systems = _prepare_cell(cell)
pbc = _prepare_pbc_for_slab(pbc, num_systems, positions.device)
if batch_idx is None:
batch_idx = torch.zeros(
positions.shape[0], dtype=torch.int32, device=positions.device
)
grad_enabled = needs_grad(positions, charges, cell)
direct_positions = positions.detach() if grad_enabled else positions
direct_charges = charges.detach() if grad_enabled else charges
direct_cell = cell.detach() if grad_enabled else cell
if compute_forces or compute_charge_gradients or compute_virial:
direct_outputs = _run_slab_correction_op(
direct_positions,
direct_charges,
direct_cell,
pbc,
batch_idx,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
)
out_idx = 0
energies = direct_outputs[out_idx]
out_idx += 1
forces = direct_outputs[out_idx] if compute_forces else None
out_idx += int(compute_forces)
charge_grads = direct_outputs[out_idx] if compute_charge_gradients else None
out_idx += int(compute_charge_gradients)
virial = direct_outputs[out_idx] if compute_virial else None
if grad_enabled:
energies = _slab_correction_energy_autograd(
positions, charges, cell, pbc, batch_idx
)
return _build_electrostatic_result(
energies,
forces,
charge_grads,
virial,
compute_forces,
compute_charge_gradients,
compute_virial,
)
if not grad_enabled:
return _run_slab_correction_op(positions, charges, cell, pbc, batch_idx)[0]
return _slab_correction_energy_autograd(positions, charges, cell, pbc, batch_idx)