# 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.
"""JAX Ewald summation implementation.
Wraps the framework-agnostic Warp kernels from
``nvalchemiops.interactions.electrostatics.ewald_kernels`` with JAX bindings.
The Ewald method splits long-range Coulomb interactions into components:
.. math::
E_{\\text{total}} = E_{\\text{real}} + E_{\\text{reciprocal}} - E_{\\text{self}} - E_{\\text{background}}
"""
from __future__ import annotations
import functools
import math
import warnings
import jax
import jax.numpy as jnp
import warp as wp
from jax.interpreters import ad as jax_ad
from jax.scipy.special import erfc
from warp.jax_experimental import GraphMode, jax_callable
from nvalchemiops.interactions.electrostatics._factory_common import _DerivState
from nvalchemiops.interactions.electrostatics.ewald_kernels import (
BATCH_BLOCK_SIZE,
batch_ewald_reciprocal_space_fill_structure_factors,
should_tile_ewald_recip_fill,
)
from nvalchemiops.interactions.electrostatics.ewald_kernels import (
ewald_reciprocal_space_fill_structure_factors as _wp_ewald_recip_fill,
)
from nvalchemiops.interactions.electrostatics.ewald_real_factory import (
get_ewald_real_kernel,
)
from nvalchemiops.interactions.electrostatics.ewald_recip_factory import (
get_ewald_recip_component_kernel,
get_ewald_recip_kernel,
)
from nvalchemiops.jax.interactions.electrostatics._autograd import (
_cell_grad_from_strain_virial,
_inject_charge_grad,
)
from nvalchemiops.jax.interactions.electrostatics._lazy_jax_kernels import (
_make_jax_kernel_factory,
)
from nvalchemiops.jax.interactions.electrostatics._utils import (
_build_electrostatic_result,
_component_direct_output_deprecation_msg,
_direct_output_deprecation_msg,
_normalize_dtype,
_prepare_cell,
)
from nvalchemiops.jax.interactions.electrostatics.k_vectors import (
generate_k_vectors_ewald_summation,
)
from nvalchemiops.jax.interactions.electrostatics.parameters import (
estimate_ewald_parameters,
)
from nvalchemiops.jax.interactions.electrostatics.slab import (
_prepare_pbc_for_slab,
_slab_correction_energy_autodiff,
)
from nvalchemiops.jax.interactions.electrostatics.slab import (
compute_slab_correction as _compute_slab_correction,
)
__all__ = [
"ewald_real_space",
"ewald_reciprocal_space",
"ewald_summation",
]
PI = math.pi
# ``_make_jax_kernel_factory`` returns lazy dtype mappings whose entries
# materialize their ``jax_kernel`` wrappers on first ``__getitem__``. Module import
# is therefore free of FFI work; warp NVRTC compile defers to first launch.
def _jax_can_tile_ewald_recip() -> bool:
"""Return whether JAX reciprocal tiled callbacks should be used."""
# ``jax_callable`` + nested ``wp.launch_tiled`` is not stable under the
# current JAX/Warp stack for jitted reciprocal calls. Keep JAX on the
# existing ``jax_kernel`` path until Warp exposes tiled launch metadata
# through that wrapper.
return False
# ==============================================================================
# JAX Kernel Wrappers - Real Space
# ==============================================================================
@functools.cache
def _jax_ewald_real_forward(
batched: bool,
neighbor_input: str,
deriv_state: _DerivState,
cell_grad: bool,
):
"""Return the lazy JAX wrapper for a factory-backed Ewald real forward kernel."""
return _make_jax_kernel_factory(
lambda wp_dtype: get_ewald_real_kernel(
wp_dtype,
batched=batched,
neighbor_input=neighbor_input,
deriv_state=deriv_state,
cell_grad=cell_grad,
order="forward",
),
4,
["pair_energies", "atomic_forces", "charge_gradients", "virial"],
)
@functools.cache
def _jax_ewald_real_double_backward(
batched: bool,
neighbor_input: str,
deriv_state: _DerivState,
cell_grad: bool,
):
"""Return the lazy JAX wrapper for a factory-backed Ewald real HVP kernel."""
output_names = ["grad_grad_energy", "grad_positions", "grad_charges"]
if cell_grad:
output_names.append("grad_cell")
return _make_jax_kernel_factory(
lambda wp_dtype: get_ewald_real_kernel(
wp_dtype,
batched=batched,
neighbor_input=neighbor_input,
deriv_state=deriv_state,
cell_grad=cell_grad,
order="double_backward",
),
len(output_names),
output_names,
)
def _jax_ewald_recip_component(
component: str,
output_names: list[str],
*,
batched: bool = False,
):
"""Return a lazy JAX wrapper for a factory-backed Ewald reciprocal component."""
return _make_jax_kernel_factory(
lambda wp_dtype: get_ewald_recip_component_kernel(
wp_dtype,
component=component,
batched=batched,
),
len(output_names),
output_names,
)
# ==============================================================================
# JAX Kernel Wrappers - Reciprocal Space
# ==============================================================================
# --- Structure Factor Computation ---
_jax_ewald_reciprocal_fill_structure_factors = _jax_ewald_recip_component(
"fill",
[
"total_charge",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
)
_jax_batch_ewald_reciprocal_fill_structure_factors = _jax_ewald_recip_component(
"fill",
[
"total_charges",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
batched=True,
)
def _ewald_recip_fill_tiled_f32(
positions: wp.array(dtype=wp.vec3f),
charges: wp.array(dtype=wp.float32),
k_vectors: wp.array(dtype=wp.vec3f),
cell: wp.array(dtype=wp.mat33f),
alpha: wp.array(dtype=wp.float32),
total_charge: wp.array(dtype=wp.float64),
cos_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
sin_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
real_structure_factors: wp.array(dtype=wp.float64),
imag_structure_factors: wp.array(dtype=wp.float64),
) -> None:
_wp_ewald_recip_fill(
positions,
charges,
k_vectors,
cell,
alpha,
total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_structure_factors,
imag_structure_factors,
wp.float32,
str(positions.device),
)
def _ewald_recip_fill_tiled_f64(
positions: wp.array(dtype=wp.vec3d),
charges: wp.array(dtype=wp.float64),
k_vectors: wp.array(dtype=wp.vec3d),
cell: wp.array(dtype=wp.mat33d),
alpha: wp.array(dtype=wp.float64),
total_charge: wp.array(dtype=wp.float64),
cos_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
sin_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
real_structure_factors: wp.array(dtype=wp.float64),
imag_structure_factors: wp.array(dtype=wp.float64),
) -> None:
_wp_ewald_recip_fill(
positions,
charges,
k_vectors,
cell,
alpha,
total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_structure_factors,
imag_structure_factors,
wp.float64,
str(positions.device),
)
def _batch_ewald_recip_fill_tiled_f32(
positions: wp.array(dtype=wp.vec3f),
charges: wp.array(dtype=wp.float32),
k_vectors: wp.array(dtype=wp.vec3f, ndim=2),
cell: wp.array(dtype=wp.mat33f),
alpha: wp.array(dtype=wp.float32),
atom_start: wp.array(dtype=wp.int32),
atom_end: wp.array(dtype=wp.int32),
total_charges: wp.array(dtype=wp.float64),
cos_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
sin_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
real_structure_factors: wp.array(dtype=wp.float64, ndim=2),
imag_structure_factors: wp.array(dtype=wp.float64, ndim=2),
max_blocks_per_system: wp.int32,
) -> None:
batch_ewald_reciprocal_space_fill_structure_factors(
positions,
charges,
k_vectors,
cell,
alpha,
atom_start,
atom_end,
total_charges,
cos_k_dot_r,
sin_k_dot_r,
real_structure_factors,
imag_structure_factors,
int(k_vectors.shape[1]),
int(cell.shape[0]),
int(max_blocks_per_system),
wp.float32,
str(positions.device),
)
def _batch_ewald_recip_fill_tiled_f64(
positions: wp.array(dtype=wp.vec3d),
charges: wp.array(dtype=wp.float64),
k_vectors: wp.array(dtype=wp.vec3d, ndim=2),
cell: wp.array(dtype=wp.mat33d),
alpha: wp.array(dtype=wp.float64),
atom_start: wp.array(dtype=wp.int32),
atom_end: wp.array(dtype=wp.int32),
total_charges: wp.array(dtype=wp.float64),
cos_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
sin_k_dot_r: wp.array(dtype=wp.float64, ndim=2),
real_structure_factors: wp.array(dtype=wp.float64, ndim=2),
imag_structure_factors: wp.array(dtype=wp.float64, ndim=2),
max_blocks_per_system: wp.int32,
) -> None:
batch_ewald_reciprocal_space_fill_structure_factors(
positions,
charges,
k_vectors,
cell,
alpha,
atom_start,
atom_end,
total_charges,
cos_k_dot_r,
sin_k_dot_r,
real_structure_factors,
imag_structure_factors,
int(k_vectors.shape[1]),
int(cell.shape[0]),
int(max_blocks_per_system),
wp.float64,
str(positions.device),
)
_JAX_EWALD_RECIP_FILL_TILED = {
jnp.dtype(jnp.float32): jax_callable(
_ewald_recip_fill_tiled_f32,
num_outputs=5,
in_out_argnames=[
"total_charge",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
graph_mode=GraphMode.NONE,
),
jnp.dtype(jnp.float64): jax_callable(
_ewald_recip_fill_tiled_f64,
num_outputs=5,
in_out_argnames=[
"total_charge",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
graph_mode=GraphMode.NONE,
),
}
_JAX_BATCH_EWALD_RECIP_FILL_TILED = {
jnp.dtype(jnp.float32): jax_callable(
_batch_ewald_recip_fill_tiled_f32,
num_outputs=5,
in_out_argnames=[
"total_charges",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
graph_mode=GraphMode.NONE,
),
jnp.dtype(jnp.float64): jax_callable(
_batch_ewald_recip_fill_tiled_f64,
num_outputs=5,
in_out_argnames=[
"total_charges",
"cos_k_dot_r",
"sin_k_dot_r",
"real_structure_factors",
"imag_structure_factors",
],
graph_mode=GraphMode.NONE,
),
}
# --- Energy Computation ---
_jax_ewald_reciprocal_compute_energy = _jax_ewald_recip_component(
"compute_energy",
["reciprocal_energies"],
)
_jax_batch_ewald_reciprocal_compute_energy = _jax_ewald_recip_component(
"compute_energy",
["reciprocal_energies"],
batched=True,
)
# --- Energy + Forces ---
_jax_ewald_reciprocal_energy_forces = _jax_ewald_recip_component(
"compute_energy_forces",
["reciprocal_energies", "atomic_forces"],
)
_jax_batch_ewald_reciprocal_energy_forces = _jax_ewald_recip_component(
"compute_energy_forces",
["reciprocal_energies", "atomic_forces"],
batched=True,
)
# --- Energy + Forces + Charge Gradients ---
_jax_ewald_reciprocal_energy_forces_charge_grad = _jax_ewald_recip_component(
"compute_energy_forces_charge_grad",
["reciprocal_energies", "atomic_forces", "charge_gradients"],
)
_jax_batch_ewald_reciprocal_energy_forces_charge_grad = _jax_ewald_recip_component(
"compute_energy_forces_charge_grad",
["reciprocal_energies", "atomic_forces", "charge_gradients"],
batched=True,
)
# --- Self-Energy Correction ---
_jax_ewald_subtract_self_energy = _jax_ewald_recip_component(
"subtract_self",
["energy_out"],
)
_jax_batch_ewald_subtract_self_energy = _jax_ewald_recip_component(
"subtract_self",
["energy_out"],
batched=True,
)
# --- Reciprocal-Space Virial ---
_jax_ewald_reciprocal_virial = _jax_ewald_recip_component(
"virial",
["virial"],
)
_jax_batch_ewald_reciprocal_virial = _jax_ewald_recip_component(
"virial",
["virial"],
batched=True,
)
_jax_ewald_reciprocal_double_backward_reduce = _make_jax_kernel_factory(
lambda wp_dtype: (
get_ewald_recip_kernel(
wp_dtype,
batched=False,
deriv_state=_DerivState.E_F_dQ,
cell_grad=False,
order="double_backward",
).fill
),
7,
[
"gA",
"gB",
"gC",
"gD",
"gP",
"gQ",
"grad_grad_energy",
],
)
_jax_ewald_reciprocal_double_backward_compute = _make_jax_kernel_factory(
lambda wp_dtype: (
get_ewald_recip_kernel(
wp_dtype,
batched=False,
deriv_state=_DerivState.E_F_dQ,
cell_grad=False,
order="double_backward",
).compute
),
2,
["grad_positions", "grad_charges"],
)
_jax_batch_ewald_reciprocal_double_backward_reduce = _make_jax_kernel_factory(
lambda wp_dtype: (
get_ewald_recip_kernel(
wp_dtype,
batched=True,
deriv_state=_DerivState.E_F_dQ,
cell_grad=False,
order="double_backward",
).fill
),
7,
[
"gA",
"gB",
"gC",
"gD",
"gP",
"gQ",
"grad_grad_energy",
],
)
_jax_batch_ewald_reciprocal_double_backward_compute = _make_jax_kernel_factory(
lambda wp_dtype: (
get_ewald_recip_kernel(
wp_dtype,
batched=True,
deriv_state=_DerivState.E_F_dQ,
cell_grad=False,
order="double_backward",
).compute
),
2,
["grad_positions", "grad_charges"],
)
# ==============================================================================
# Helper Functions
# ==============================================================================
def _prepare_alpha_array(
alpha: float | jax.Array,
num_systems: int,
dtype: jnp.dtype = jnp.float64,
) -> jax.Array:
"""Convert alpha to a per-system array of shape (B,) or (1,).
Parameters
----------
alpha : float or jax.Array
Ewald splitting parameter.
num_systems : int
Number of systems.
dtype : jnp.dtype, optional
Data type for the output array. Defaults to jnp.float64.
Returns
-------
jax.Array
Alpha array of shape (B,) or (1,).
"""
if isinstance(alpha, (int, float)):
return jnp.full(num_systems, float(alpha), dtype=dtype)
elif isinstance(alpha, jax.Array):
# generate elements from scalar
if alpha.ndim == 0:
return jnp.full(num_systems, alpha, dtype=dtype)
elif len(alpha) != num_systems:
raise ValueError(
f"alpha has {alpha.shape[0]} values but there are {num_systems} systems"
)
else:
return alpha.astype(dtype)
else:
raise TypeError(f"alpha must be float or jax.Array, got {type(alpha)}")
def _launch_ewald_real_forward_factory(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
energies: jax.Array,
dtype,
*,
is_batched: bool,
use_matrix: bool,
batch_idx: jax.Array | None = None,
idx_j: jax.Array | None = None,
neighbor_ptr: jax.Array | None = None,
unit_shifts: jax.Array | None = None,
neighbor_matrix: jax.Array | None = None,
unit_shifts_matrix: jax.Array | None = None,
mask_value: int = 0,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> tuple[jax.Array, jax.Array | None, jax.Array | None, jax.Array | None]:
"""Launch the factory-backed JAX Ewald real forward kernel."""
num_atoms = positions.shape[0]
num_systems = cell.shape[0] if is_batched else 1
need_forces = compute_forces or compute_charge_gradients or compute_virial
if compute_charge_gradients:
deriv_state = _DerivState.E_F_dQ
elif need_forces:
deriv_state = _DerivState.E_F
else:
deriv_state = _DerivState.E
batch_arg = batch_idx.astype(jnp.int32) if batch_idx is not None else _empty_i32()
if use_matrix:
if neighbor_matrix is None or unit_shifts_matrix is None:
raise ValueError("neighbor_matrix and unit_shifts_matrix are required")
idx_arg = _empty_i32()
ptr_arg = _empty_i32()
shifts_arg = _empty_vec(jnp.int32)
matrix_arg = neighbor_matrix.astype(jnp.int32)
matrix_shifts_arg = unit_shifts_matrix.astype(jnp.int32)
neighbor_input = "matrix"
else:
if idx_j is None or neighbor_ptr is None or unit_shifts is None:
raise ValueError("idx_j, neighbor_ptr, and unit_shifts are required")
idx_arg = idx_j.astype(jnp.int32)
ptr_arg = neighbor_ptr.astype(jnp.int32)
shifts_arg = unit_shifts.astype(jnp.int32)
matrix_arg = _empty_matrix_i32()
matrix_shifts_arg = _empty_shift_matrix()
neighbor_input = "list"
forces_arg = (
jnp.zeros((num_atoms, 3), dtype=dtype) if need_forces else _empty_vec(dtype)
)
charge_arg = (
jnp.zeros(num_atoms, dtype=jnp.float64)
if compute_charge_gradients
else jnp.zeros((0,), dtype=jnp.float64)
)
virial_arg = (
jnp.zeros((num_systems, 3, 3), dtype=dtype)
if compute_virial
else _empty_mat(dtype)
)
kernel = _jax_ewald_real_forward(
is_batched,
neighbor_input,
deriv_state,
compute_virial,
)[dtype]
energies, forces, charge_grads, virial = kernel(
positions,
charges,
cell,
batch_arg,
idx_arg,
ptr_arg,
shifts_arg,
matrix_arg,
matrix_shifts_arg,
int(mask_value),
alpha,
energies,
forces_arg,
charge_arg,
virial_arg,
launch_dims=(num_atoms,),
)
return (
energies,
forces if need_forces else None,
charge_grads if compute_charge_gradients else None,
virial if compute_virial else None,
)
def _compute_total_charge(
charges: jax.Array, batch_idx: jax.Array | None, num_systems: int = 1
) -> jax.Array:
"""Compute total charge (per system if batched).
Parameters
----------
charges : jax.Array, shape (N,)
Atomic charges.
batch_idx : jax.Array | None, shape (N,)
Batch indices.
num_systems : int, optional
Number of systems in the batch. Only used when batch_idx is not None.
Default is 1.
Returns
-------
jax.Array
Total charge, shape (1,) for single system or (B,) for batch.
"""
if batch_idx is None:
return jnp.array([charges.sum()], dtype=jnp.float64)
else:
total_charges = jnp.zeros(num_systems, dtype=jnp.float64)
total_charges = total_charges.at[batch_idx].add(charges)
return total_charges
# ==============================================================================
# Public API
# ==============================================================================
def _ewald_real_space_impl(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array,
neighbor_list: jax.Array | None = None,
neighbor_ptr: jax.Array | None = None,
neighbor_shifts: jax.Array | None = None,
neighbor_matrix: jax.Array | None = None,
neighbor_matrix_shifts: jax.Array | None = None,
mask_value: int | None = None,
batch_idx: jax.Array | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute real-space Ewald energy and optionally forces, charge gradients, and virial.
Computes the damped Coulomb interactions for atom pairs within the real-space
cutoff. The complementary error function (erfc) damping ensures rapid
convergence in real space.
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (1, 3, 3) or (B, 3, 3)
Unit cell matrices.
alpha : float or jax.Array
Ewald splitting parameter. Can be a float or array of shape (1,) or (B,).
neighbor_list : jax.Array | None, shape (2, M)
Neighbor list in COO format.
neighbor_ptr : jax.Array | None, shape (N+1,)
CSR row pointers for neighbor list.
neighbor_shifts : jax.Array | None, shape (M, 3)
Periodic image shifts for neighbor list.
neighbor_matrix : jax.Array | None, shape (N, max_neighbors)
Dense neighbor matrix format.
neighbor_matrix_shifts : jax.Array | None, shape (N, max_neighbors, 3)
Periodic image shifts for neighbor_matrix.
mask_value : int | None, optional
Value indicating invalid entries in neighbor_matrix.
If None (default), uses num_atoms as the mask value.
batch_idx : jax.Array | None, shape (N,)
System index for each atom. 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
Whether to compute explicit forces.
compute_charge_gradients : bool, default=False
Whether to compute charge gradients.
compute_virial : bool, default=False
Whether to compute the virial tensor.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom real-space energy.
forces : jax.Array, shape (N, 3), optional
Forces (if compute_forces=True or compute_charge_gradients=True).
charge_gradients : jax.Array, shape (N,), optional
Charge gradients (if compute_charge_gradients=True).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor (if compute_virial=True). Always last in the return tuple.
"""
# Validate inputs
use_list = neighbor_list is not None and neighbor_shifts is not None
use_matrix = neighbor_matrix is not None and neighbor_matrix_shifts is not None
if not use_list and not use_matrix:
raise ValueError(
"Must provide either neighbor_list/neighbor_shifts or "
"neighbor_matrix/neighbor_matrix_shifts"
)
if use_list and use_matrix:
raise ValueError(
"Cannot provide both neighbor list and neighbor matrix formats"
)
# Store input dtype for kernel dispatch and outputs
dtype = _normalize_dtype(positions.dtype)
# Cast inputs to consistent dtype
positions_cast = positions.astype(dtype)
charges_cast = charges.astype(dtype)
cell_cast = cell.astype(dtype)
# Ensure cell is (B, 3, 3)
if cell_cast.ndim == 2:
cell_cast = cell_cast[jnp.newaxis, :, :]
num_atoms = positions_cast.shape[0]
is_batched = batch_idx is not None
# Default mask_value to num_atoms (matches cell_list fill_value convention)
if mask_value is None:
mask_value = num_atoms
# Prepare alpha
alpha_arr = _prepare_alpha_array(alpha, cell_cast.shape[0], dtype=dtype)
# Allocate outputs (energies always float64, forces match input dtype)
energies = jnp.zeros(num_atoms, dtype=jnp.float64)
if use_list:
if neighbor_ptr is None:
raise ValueError("neighbor_ptr is required when using neighbor_list format")
if neighbor_list is None or neighbor_shifts is None:
raise ValueError("neighbor_list and neighbor_shifts are required")
idx_j = neighbor_list[1]
matrix_arg = None
matrix_shifts_arg = None
else:
if neighbor_matrix is None or neighbor_matrix_shifts is None:
raise ValueError("neighbor_matrix and neighbor_matrix_shifts are required")
idx_j = None
matrix_arg = neighbor_matrix
matrix_shifts_arg = neighbor_matrix_shifts
energies, forces, charge_grads, virial = _launch_ewald_real_forward_factory(
positions_cast,
charges_cast,
cell_cast,
alpha_arr,
energies,
dtype,
is_batched=is_batched,
use_matrix=use_matrix,
batch_idx=batch_idx,
idx_j=idx_j,
neighbor_ptr=neighbor_ptr,
unit_shifts=neighbor_shifts,
neighbor_matrix=matrix_arg,
unit_shifts_matrix=matrix_shifts_arg,
mask_value=int(mask_value),
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
)
return _build_electrostatic_result(
energies,
forces,
charge_grads,
virial,
compute_forces,
compute_charge_gradients,
compute_virial,
)
[docs]
def ewald_real_space(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array,
neighbor_list: jax.Array | None = None,
neighbor_ptr: jax.Array | None = None,
neighbor_shifts: jax.Array | None = None,
neighbor_matrix: jax.Array | None = None,
neighbor_matrix_shifts: jax.Array | None = None,
mask_value: int | None = None,
batch_idx: jax.Array | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute real-space Ewald energy and optional direct derivative outputs.
Energy-only calls participate in JAX autodiff through a private custom-JVP
wrapper. ``compute_forces=True`` remains a forward/direct escape hatch for
no-autograd MD/inference loops; charge-gradient and virial direct outputs
are deprecated training-style outputs and warn.
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (3, 3) or (B, 3, 3)
Unit cell matrices. A 2-D input is promoted to (1, 3, 3) internally.
alpha : float or jax.Array
Ewald splitting parameter. A scalar float or array of shape (1,) or (B,).
neighbor_list : jax.Array or None, shape (2, M), optional
Neighbor pairs in COO format; row 0 is ``idx_i``, row 1 is ``idx_j``.
Provide either ``neighbor_list`` + ``neighbor_ptr`` + ``neighbor_shifts``
or ``neighbor_matrix`` + ``neighbor_matrix_shifts``.
neighbor_ptr : jax.Array or None, shape (N+1,), optional
CSR row pointers for ``neighbor_list``.
neighbor_shifts : jax.Array or None, shape (M, 3), optional
Integer periodic image shifts for each neighbor pair.
neighbor_matrix : jax.Array or None, shape (N, max_neighbors), optional
Dense neighbor matrix; each row lists neighbor indices for one atom.
neighbor_matrix_shifts : jax.Array or None, shape (N, max_neighbors, 3), optional
Integer periodic image shifts for each entry in ``neighbor_matrix``.
mask_value : int or None, optional
Sentinel indicating unused slots in ``neighbor_matrix``.
Defaults to ``N`` (number of atoms) when ``None``.
batch_idx : jax.Array or None, shape (N,), optional
System index per atom for batched mode. Atoms must be grouped
contiguously with IDs ``0..B-1``.
compute_forces : bool, default=False
Return explicit forces :math:`-\\partial E / \\partial \\mathbf{r}_i`.
For differentiable force computation prefer JAX autodiff.
compute_charge_gradients : bool, default=False
.. deprecated:: 0.4.0
Deprecated. Return explicit :math:`\\partial E / \\partial q_i`.
Raises ``DeprecationWarning`` when True.
compute_virial : bool, default=False
.. deprecated:: 0.4.0
Deprecated. Return explicit virial tensor. Raises
``DeprecationWarning`` when True.
Returns
-------
jax.Array, shape (N,)
Per-atom real-space Ewald energy when no derivative flags are set.
tuple[jax.Array, ...]
``(energies, forces)`` when ``compute_forces=True``;
``(energies, forces, charge_gradients)`` when
``compute_charge_gradients=True``; additionally appends the virial
tensor of shape (1, 3, 3) or (B, 3, 3) when ``compute_virial=True``.
See Also
--------
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_reciprocal_space` : Reciprocal-space Ewald contribution.
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_summation` : Complete Ewald summation combining both components.
"""
component_deprecated_flags = tuple(
name
for name, enabled in (
("compute_charge_gradients", compute_charge_gradients),
("compute_virial", compute_virial),
)
if enabled
)
if component_deprecated_flags:
warnings.warn(
_component_direct_output_deprecation_msg(
"ewald_real_space", component_deprecated_flags
),
DeprecationWarning,
stacklevel=2,
)
if compute_forces or compute_charge_gradients or compute_virial:
return _ewald_real_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
batch_idx=batch_idx,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
)
if mask_value is None:
mask_value = positions.shape[0]
use_matrix = neighbor_matrix is not None and neighbor_matrix_shifts is not None
return _ewald_real_space_energy_jvp(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
)
def _ewald_reciprocal_space_impl(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: float | jax.Array,
batch_idx: jax.Array | None = None,
max_atoms_per_system: int | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute reciprocal-space Ewald energy and optionally forces, charge gradients, and virial.
Computes the smooth long-range electrostatic contribution using structure
factors in reciprocal space. Includes self-energy and background corrections.
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (1, 3, 3) or (B, 3, 3)
Unit cell matrices.
k_vectors : jax.Array
Reciprocal lattice vectors. Shape (K, 3) for single system, (B, K, 3) for batch.
alpha : float or jax.Array
Ewald splitting parameter. Can be a float or array of shape (1,) or (B,).
batch_idx : jax.Array | None, shape (N,)
System index for each atom. When provided, atoms must be grouped by
system: ``batch_idx`` must be contiguous, nondecreasing, and use system
IDs ``0..B-1``.
max_atoms_per_system : int | None, optional
Maximum number of atoms in any single system in the batch.
Required when using ``jax.jit`` with batched inputs. If None,
inferred from data (fails under JIT).
compute_forces : bool, default=False
Whether to compute explicit forces.
compute_charge_gradients : bool, default=False
Whether to compute charge gradients.
compute_virial : bool, default=False
Whether to compute the virial tensor.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom reciprocal-space energy (with corrections applied).
forces : jax.Array, shape (N, 3), optional
Forces (if compute_forces=True or compute_charge_gradients=True).
charge_gradients : jax.Array, shape (N,), optional
Charge gradients (if compute_charge_gradients=True).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor (if compute_virial=True). Always last in the return tuple.
"""
# Store input dtype for kernel dispatch and outputs
dtype = _normalize_dtype(positions.dtype)
# Cast inputs to consistent dtype
positions_cast = positions.astype(dtype)
charges_cast = charges.astype(dtype)
cell_cast = cell.astype(dtype)
k_vectors_cast = k_vectors.astype(dtype)
# Ensure cell is (B, 3, 3)
if cell_cast.ndim == 2:
cell_cast = cell_cast[jnp.newaxis, :, :]
num_atoms = positions_cast.shape[0]
is_batched = batch_idx is not None
# Prepare alpha
alpha_arr = _prepare_alpha_array(alpha, cell_cast.shape[0], dtype=dtype)
# Compute total charge and Q/V correction factor (always float64).
# The Warp fill kernel also has a historical total_charge output slot; pass
# it a zero scratch buffer and use this explicit JAX value for corrections so
# the buffer cannot become Q + Q/V.
total_charge = _compute_total_charge(
charges_cast, batch_idx, num_systems=cell_cast.shape[0]
)
volumes_for_background = jnp.abs(jnp.linalg.det(cell_cast)).astype(jnp.float64)
total_charge_over_volume = total_charge / volumes_for_background
fill_total_charge = jnp.zeros_like(total_charge)
# Determine k-vector dimensions
if is_batched:
# k_vectors should be (B, K, 3); expand from (K, 3) if necessary
if k_vectors_cast.ndim == 2:
k_vectors_cast = jnp.tile(
k_vectors_cast[jnp.newaxis, :, :],
(cell_cast.shape[0], 1, 1),
)
num_k = k_vectors_cast.shape[1]
num_systems = k_vectors_cast.shape[0]
else:
# k_vectors: (K, 3)
num_k = k_vectors_cast.shape[0]
num_systems = 1
# Allocate intermediate arrays for structure factors (always float64)
if is_batched:
cos_k_dot_r = jnp.zeros((num_k, num_atoms), dtype=jnp.float64)
sin_k_dot_r = jnp.zeros((num_k, num_atoms), dtype=jnp.float64)
real_sf = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
imag_sf = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
else:
cos_k_dot_r = jnp.zeros((num_k, num_atoms), dtype=jnp.float64)
sin_k_dot_r = jnp.zeros((num_k, num_atoms), dtype=jnp.float64)
real_sf = jnp.zeros(num_k, dtype=jnp.float64)
imag_sf = jnp.zeros(num_k, dtype=jnp.float64)
# Allocate output arrays (energies always float64)
raw_energies = jnp.zeros(num_atoms, dtype=jnp.float64)
energies = jnp.zeros(num_atoms, dtype=jnp.float64)
# Step 1: Fill structure factors
if is_batched:
batch_idx_i32 = batch_idx.astype(jnp.int32)
# Compute atom_start, atom_end, and max_blocks_per_system for batch kernels
atom_counts = jnp.bincount(batch_idx_i32, length=num_systems)
atom_end = jnp.cumsum(atom_counts).astype(jnp.int32)
atom_start = jnp.concatenate([jnp.zeros(1, dtype=jnp.int32), atom_end[:-1]])
if max_atoms_per_system is None:
try:
max_atoms_per_system = int(atom_counts.max())
except (
jax.errors.ConcretizationTypeError,
jax.errors.TracerIntegerConversionError,
):
raise ValueError(
"Cannot infer max_atoms_per_system inside jax.jit. "
"Please provide max_atoms_per_system explicitly when "
"using jax.jit."
) from None
max_blocks_per_system = (
max_atoms_per_system + BATCH_BLOCK_SIZE - 1
) // BATCH_BLOCK_SIZE
if _jax_can_tile_ewald_recip() and should_tile_ewald_recip_fill(
int(max_atoms_per_system)
):
(_fill_total_charge, cos_k_dot_r, sin_k_dot_r, real_sf, imag_sf) = (
_JAX_BATCH_EWALD_RECIP_FILL_TILED[jnp.dtype(dtype)](
positions_cast,
charges_cast,
k_vectors_cast,
cell_cast,
alpha_arr,
atom_start,
atom_end,
fill_total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
max_blocks_per_system,
)
)
else:
(_fill_total_charge, cos_k_dot_r, sin_k_dot_r, real_sf, imag_sf) = (
_jax_batch_ewald_reciprocal_fill_structure_factors[dtype](
positions_cast,
charges_cast,
k_vectors_cast,
cell_cast,
alpha_arr,
atom_start,
atom_end,
fill_total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
launch_dims=(num_k, num_systems, max_blocks_per_system),
)
)
else:
if _jax_can_tile_ewald_recip() and should_tile_ewald_recip_fill(int(num_atoms)):
(_fill_total_charge, cos_k_dot_r, sin_k_dot_r, real_sf, imag_sf) = (
_JAX_EWALD_RECIP_FILL_TILED[jnp.dtype(dtype)](
positions_cast,
charges_cast,
k_vectors_cast,
cell_cast,
alpha_arr,
fill_total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
)
)
else:
(_fill_total_charge, cos_k_dot_r, sin_k_dot_r, real_sf, imag_sf) = (
_jax_ewald_reciprocal_fill_structure_factors[dtype](
positions_cast,
charges_cast,
k_vectors_cast,
cell_cast,
alpha_arr,
fill_total_charge,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
launch_dims=(num_k,),
)
)
# Step 2: Compute energy (and forces/charge_grads if requested)
if is_batched:
batch_idx_i32 = batch_idx.astype(jnp.int32)
if compute_charge_gradients:
forces = jnp.zeros((num_atoms, 3), dtype=dtype)
charge_grads = jnp.zeros(num_atoms, dtype=jnp.float64)
(raw_energies, forces, charge_grads) = (
_jax_batch_ewald_reciprocal_energy_forces_charge_grad[dtype](
charges_cast,
batch_idx_i32,
k_vectors_cast,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
forces,
charge_grads,
launch_dims=(num_atoms,),
)
)
elif compute_forces:
forces = jnp.zeros((num_atoms, 3), dtype=dtype)
(raw_energies, forces) = _jax_batch_ewald_reciprocal_energy_forces[dtype](
charges_cast,
batch_idx_i32,
k_vectors_cast,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
forces,
launch_dims=(num_atoms,),
)
else:
(raw_energies,) = _jax_batch_ewald_reciprocal_compute_energy[dtype](
charges_cast,
batch_idx_i32,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
launch_dims=(num_atoms,),
)
else:
if compute_charge_gradients:
forces = jnp.zeros((num_atoms, 3), dtype=dtype)
charge_grads = jnp.zeros(num_atoms, dtype=jnp.float64)
(raw_energies, forces, charge_grads) = (
_jax_ewald_reciprocal_energy_forces_charge_grad[dtype](
charges_cast,
k_vectors_cast,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
forces,
charge_grads,
launch_dims=(num_atoms,),
)
)
elif compute_forces:
forces = jnp.zeros((num_atoms, 3), dtype=dtype)
(raw_energies, forces) = _jax_ewald_reciprocal_energy_forces[dtype](
charges_cast,
k_vectors_cast,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
forces,
launch_dims=(num_atoms,),
)
else:
(raw_energies,) = _jax_ewald_reciprocal_compute_energy[dtype](
charges_cast,
cos_k_dot_r,
sin_k_dot_r,
real_sf,
imag_sf,
raw_energies,
launch_dims=(num_atoms,),
)
# Step 3: Apply self-energy and background corrections
if is_batched:
batch_idx_i32 = batch_idx.astype(jnp.int32)
(energies,) = _jax_batch_ewald_subtract_self_energy[dtype](
charges_cast,
batch_idx_i32,
alpha_arr,
total_charge_over_volume,
raw_energies,
energies,
launch_dims=(num_atoms,),
)
else:
(energies,) = _jax_ewald_subtract_self_energy[dtype](
charges_cast,
alpha_arr,
total_charge_over_volume,
raw_energies,
energies,
launch_dims=(num_atoms,),
)
# Step 4: Compute virial if requested
virial = None
if compute_virial:
volume = jnp.abs(jnp.linalg.det(cell_cast)).astype(jnp.float64)
if is_batched:
virial = jnp.zeros((num_systems, 3, 3), dtype=dtype)
(virial,) = _jax_batch_ewald_reciprocal_virial[dtype](
k_vectors_cast, # (B, K, 3)
alpha_arr,
volume,
real_sf, # (B, K)
imag_sf, # (B, K)
virial,
launch_dims=(num_k, num_systems),
)
total_charges_v = (
jnp.zeros(
num_systems,
dtype=dtype,
)
.at[batch_idx.astype(jnp.int32)]
.add(charges_cast)
)
volumes_v = jnp.abs(jnp.linalg.det(cell_cast)).astype(dtype)
alpha_v = alpha_arr.astype(dtype)
e_bg = PI * total_charges_v**2 / (2.0 * alpha_v**2 * volumes_v)
eye = jnp.eye(3, dtype=dtype)
virial = virial - e_bg[:, jnp.newaxis, jnp.newaxis] * eye
else:
virial = jnp.zeros((1, 3, 3), dtype=dtype)
(virial,) = _jax_ewald_reciprocal_virial[dtype](
k_vectors_cast, # (K, 3)
alpha_arr,
volume,
real_sf, # (K,)
imag_sf, # (K,)
virial,
launch_dims=(num_k,),
)
q_total = charges_cast.sum().astype(dtype)
vol_v = jnp.abs(jnp.linalg.det(cell_cast.squeeze(0))).astype(dtype)
alpha_val_v = alpha_arr.astype(dtype).squeeze()
e_bg = PI * q_total**2 / (2.0 * alpha_val_v**2 * vol_v)
eye = jnp.eye(3, dtype=dtype)
virial = virial - e_bg * eye
# Apply corrections to charge gradients if requested
if compute_charge_gradients:
# Self-energy gradient: 2 * alpha / sqrt(pi) * q
alpha_val = alpha_arr[0] if not is_batched else alpha_arr[batch_idx]
self_energy_grad = 2.0 * alpha_val / jnp.sqrt(PI) * charges_cast
if is_batched:
total_charge_over_volume_per_atom = total_charge_over_volume[batch_idx]
else:
total_charge_over_volume_per_atom = total_charge_over_volume[0]
background_grad = (
PI / (alpha_val * alpha_val) * total_charge_over_volume_per_atom
)
charge_grads = charge_grads - self_energy_grad - background_grad
# Initialize optional variables to None if not computed
if not (compute_forces or compute_charge_gradients):
forces = None
charge_grads = None
elif not compute_charge_gradients:
charge_grads = None
return _build_electrostatic_result(
energies,
forces,
charge_grads,
virial,
compute_forces,
compute_charge_gradients,
compute_virial,
)
[docs]
def ewald_reciprocal_space(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: float | jax.Array,
batch_idx: jax.Array | None = None,
max_atoms_per_system: int | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute reciprocal-space Ewald energy and optional direct outputs.
Includes self-energy and background (net-charge) corrections so the
returned energies are the full reciprocal contribution to the Ewald sum.
Energy-only calls participate in JAX autodiff through a private custom-JVP
wrapper. ``compute_forces=True`` remains a forward/direct escape hatch for
no-autograd MD/inference loops; charge-gradient and virial direct outputs
are deprecated training-style outputs and warn.
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (3, 3) or (B, 3, 3)
Unit cell matrices. A 2-D input is promoted to (1, 3, 3) internally.
k_vectors : jax.Array, shape (K, 3) or (B, K, 3)
Reciprocal-space lattice vectors. Gradients are stopped internally
so this argument must not depend on ``cell`` outside this function.
Use :func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_summation`
for cell-differentiable k-vectors.
alpha : float or jax.Array
Ewald splitting parameter. A scalar float or array of shape (1,) or (B,).
batch_idx : jax.Array or None, shape (N,), optional
System index per atom for batched mode. Atoms must be grouped
contiguously with IDs ``0..B-1``.
max_atoms_per_system : int or None, optional
Maximum number of atoms in any single system. Required under
``jax.jit`` with batched inputs; inferred from data otherwise.
compute_forces : bool, default=False
Return explicit forces :math:`-\\partial E / \\partial \\mathbf{r}_i`.
For differentiable force computation prefer JAX autodiff.
compute_charge_gradients : bool, default=False
Deprecated. Return explicit :math:`\\partial E / \\partial q_i`.
Raises ``DeprecationWarning`` when True.
compute_virial : bool, default=False
Deprecated. Return explicit virial tensor. Raises
``DeprecationWarning`` when True.
Returns
-------
jax.Array, shape (N,)
Per-atom reciprocal-space Ewald energy (with self and background
corrections) when no derivative flags are set.
tuple[jax.Array, ...]
``(energies, forces)`` when ``compute_forces=True``;
``(energies, forces, charge_gradients)`` when
``compute_charge_gradients=True``; additionally appends the virial
tensor of shape (1, 3, 3) or (B, 3, 3) when ``compute_virial=True``.
See Also
--------
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_real_space` : Real-space Ewald contribution.
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_summation` : Complete Ewald summation combining both components.
:func:`nvalchemiops.jax.interactions.electrostatics.k_vectors.generate_k_vectors_ewald_summation` : Generates ``k_vectors`` from a cell and cutoff.
"""
component_deprecated_flags = tuple(
name
for name, enabled in (
("compute_charge_gradients", compute_charge_gradients),
("compute_virial", compute_virial),
)
if enabled
)
if component_deprecated_flags:
warnings.warn(
_component_direct_output_deprecation_msg(
"ewald_reciprocal_space", component_deprecated_flags
),
DeprecationWarning,
stacklevel=2,
)
k_vectors = jax.lax.stop_gradient(k_vectors)
if compute_forces or compute_charge_gradients or compute_virial:
return _ewald_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
k_vectors=k_vectors,
alpha=alpha,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
)
return _ewald_reciprocal_space_energy_jvp(
positions,
charges,
cell,
k_vectors,
alpha,
batch_idx,
max_atoms_per_system,
)
def _tangent_or_zeros(tangent, primal: jax.Array, dtype=None) -> jax.Array:
"""Materialize a custom-JVP tangent, replacing symbolic zeros with arrays."""
out_dtype = primal.dtype if dtype is None else dtype
if _is_symbolic_zero(tangent):
return jnp.zeros(primal.shape, dtype=out_dtype)
return tangent.astype(out_dtype)
def _stop_optional(value: jax.Array | None) -> jax.Array | None:
"""Stop gradients through an optional residual."""
if value is None:
return None
return jax.lax.stop_gradient(value)
def _is_symbolic_zero(tangent) -> bool:
"""Return whether a custom-JVP tangent is JAX's symbolic zero sentinel."""
return (
tangent is None
or isinstance(tangent, jax_ad.Zero)
or tangent.__class__.__name__ == "SymbolicZero"
)
def _cell_tangent_system_values(
grad_cell: jax.Array,
tangent_cell,
) -> jax.Array:
"""Contract a cell cotangent with a cell tangent per system."""
tcell = _tangent_or_zeros(tangent_cell, grad_cell, dtype=jnp.float64)
values = grad_cell.astype(jnp.float64) * tcell.astype(jnp.float64)
if values.ndim == 2:
return jnp.array([values.sum()], dtype=jnp.float64)
return values.sum(axis=(1, 2))
def _kvector_tangent_from_cell(
k_vectors: jax.Array,
cell: jax.Array,
tangent_cell: jax.Array,
) -> jax.Array:
"""Differentiate reciprocal vectors under a cell perturbation."""
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
tcell_3d = (
tangent_cell if tangent_cell.ndim == 3 else tangent_cell[jnp.newaxis, :, :]
)
k_3d = k_vectors if k_vectors.ndim == 3 else k_vectors[jnp.newaxis, :, :]
inv_cell_t = jnp.linalg.inv(jnp.swapaxes(cell_3d, -2, -1))
tangent = -jnp.matmul(
jnp.matmul(k_3d, jnp.swapaxes(tcell_3d, -2, -1)),
inv_cell_t,
)
if k_vectors.ndim == 2:
return tangent[0]
return tangent
def _per_system_atom_counts(
batch_idx: jax.Array | None,
num_systems: int,
num_atoms: int,
) -> jax.Array:
"""Return per-system atom counts as float64 for tangent redistribution."""
if batch_idx is None:
return jnp.full((num_systems,), float(num_atoms), dtype=jnp.float64)
bidx = batch_idx.astype(jnp.int32)
return (
jnp.zeros((num_systems,), dtype=jnp.float64)
.at[bidx]
.add(jnp.ones((num_atoms,), dtype=jnp.float64))
)
def _system_sum_from_atoms(
values: jax.Array,
batch_idx: jax.Array | None,
num_systems: int,
) -> jax.Array:
"""Sum per-atom scalar values into one scalar per system."""
if batch_idx is None:
return values.sum(keepdims=True)
return (
jnp.zeros((num_systems,), dtype=values.dtype)
.at[batch_idx.astype(jnp.int32)]
.add(values)
)
def _distribute_system_values(
system_values: jax.Array,
batch_idx: jax.Array | None,
num_atoms: int,
) -> jax.Array:
"""Distribute per-system values uniformly over each system's atoms."""
if batch_idx is None:
if num_atoms == 0:
return jnp.zeros((0,), dtype=system_values.dtype)
return jnp.full(
(num_atoms,), system_values[0] / num_atoms, dtype=system_values.dtype
)
bidx = batch_idx.astype(jnp.int32)
counts = _per_system_atom_counts(batch_idx, system_values.shape[0], num_atoms)
return (system_values / jnp.maximum(counts, 1.0))[bidx]
def _real_space_energy_reference(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
neighbor_list: jax.Array | None,
neighbor_ptr: jax.Array | None,
neighbor_shifts: jax.Array | None,
neighbor_matrix: jax.Array | None,
neighbor_matrix_shifts: jax.Array | None,
mask_value: int,
use_matrix: bool,
) -> jax.Array:
"""Pure JAX real-space per-atom energies for transposed weighted losses."""
dtype = _normalize_dtype(positions.dtype)
positions = positions.astype(dtype)
charges = charges.astype(jnp.float64)
cell_3d = cell.astype(dtype)
if cell_3d.ndim == 2:
cell_3d = cell_3d[jnp.newaxis, :, :]
alpha_arr = _prepare_alpha_array(alpha, cell_3d.shape[0], dtype=jnp.float64)
num_atoms = positions.shape[0]
atom_system = (
jnp.zeros((num_atoms,), dtype=jnp.int32)
if batch_idx is None
else batch_idx.astype(jnp.int32)
)
def _pair_energy(atom_i, atom_j, shifts):
system = atom_system[atom_i]
shifted = jnp.einsum("...j,...jk->...k", shifts.astype(dtype), cell_3d[system])
rij = positions[atom_j] - positions[atom_i] + shifted
distance_sq = jnp.sum(
rij.astype(jnp.float64) * rij.astype(jnp.float64), axis=-1
)
active = distance_sq > 1e-16
safe_distance = jnp.sqrt(jnp.where(active, distance_sq, 1.0))
qi = charges[atom_i]
qj = charges[atom_j]
value = 0.5 * qi * qj * erfc(alpha_arr[system] * safe_distance) / safe_distance
return jnp.where(active, value, 0.0)
if use_matrix:
if neighbor_matrix is None or neighbor_matrix_shifts is None:
raise ValueError("neighbor_matrix and neighbor_matrix_shifts are required")
valid = neighbor_matrix != int(mask_value)
atom_i = jnp.broadcast_to(
jnp.arange(num_atoms, dtype=jnp.int32)[:, jnp.newaxis],
neighbor_matrix.shape,
)
atom_j = jnp.where(valid, neighbor_matrix.astype(jnp.int32), 0)
energies = _pair_energy(atom_i, atom_j, neighbor_matrix_shifts)
return jnp.where(valid, energies, 0.0).sum(axis=1)
if neighbor_list is None or neighbor_ptr is None or neighbor_shifts is None:
raise ValueError(
"neighbor_list, neighbor_ptr, and neighbor_shifts are required"
)
atom_j = neighbor_list[1].astype(jnp.int32)
counts = neighbor_ptr[1:] - neighbor_ptr[:-1]
atom_i = jnp.repeat(
jnp.arange(num_atoms, dtype=jnp.int32),
counts,
total_repeat_length=atom_j.shape[0],
)
edge_energies = _pair_energy(atom_i, atom_j, neighbor_shifts)
return jnp.zeros((num_atoms,), dtype=jnp.float64).at[atom_i].add(edge_energies)
def _reciprocal_space_energy_reference(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
) -> jax.Array:
"""Pure JAX reciprocal per-atom energies for transposed weighted losses."""
dtype = _normalize_dtype(positions.dtype)
positions = positions.astype(dtype)
charges = charges.astype(jnp.float64)
cell_3d = cell.astype(dtype)
if cell_3d.ndim == 2:
cell_3d = cell_3d[jnp.newaxis, :, :]
num_systems = cell_3d.shape[0]
alpha_arr = _prepare_alpha_array(alpha, num_systems, dtype=jnp.float64)
if k_vectors.ndim == 2:
kv = jnp.broadcast_to(k_vectors.astype(dtype), (num_systems,) + k_vectors.shape)
else:
kv = k_vectors.astype(dtype)
num_atoms = positions.shape[0]
atom_system = (
jnp.zeros((num_atoms,), dtype=jnp.int32)
if batch_idx is None
else batch_idx.astype(jnp.int32)
)
volumes = jnp.abs(jnp.linalg.det(cell_3d)).astype(jnp.float64)
atom_k_vectors = kv[atom_system].astype(jnp.float64)
phase = jnp.einsum("nkd,nd->nk", atom_k_vectors, positions.astype(jnp.float64))
cos_phase = jnp.cos(phase)
sin_phase = jnp.sin(phase)
k_sq = jnp.sum(kv.astype(jnp.float64) * kv.astype(jnp.float64), axis=-1)
active_k = k_sq > 1e-10
safe_k_sq = jnp.where(active_k, k_sq, 1.0)
green = (
jnp.exp(-safe_k_sq / (4.0 * alpha_arr[:, jnp.newaxis] ** 2))
* (8.0 * PI)
/ volumes[:, jnp.newaxis]
/ safe_k_sq
)
green = jnp.where(active_k, green, 0.0)
weighted_cos = charges[:, jnp.newaxis] * cos_phase
weighted_sin = charges[:, jnp.newaxis] * sin_phase
real_sf = green * jnp.zeros((num_systems, kv.shape[1]), dtype=jnp.float64).at[
atom_system
].add(weighted_cos)
imag_sf = green * jnp.zeros((num_systems, kv.shape[1]), dtype=jnp.float64).at[
atom_system
].add(weighted_sin)
raw = (
0.5
* charges
* jnp.sum(
real_sf[atom_system] * cos_phase + imag_sf[atom_system] * sin_phase,
axis=1,
)
)
total_charge_over_volume = (
jnp.zeros((num_systems,), dtype=jnp.float64).at[atom_system].add(charges)
/ volumes
)
atom_alpha = alpha_arr[atom_system]
self_energy = atom_alpha * charges * charges / jnp.sqrt(PI)
background = (
PI
* charges
* total_charge_over_volume[atom_system]
/ (2.0 * atom_alpha * atom_alpha)
)
return raw - self_energy - background
def _empty_i32() -> jax.Array:
"""Return a zero-size int32 sentinel for inactive Warp array slots."""
return jnp.zeros((0,), dtype=jnp.int32)
def _empty_vec(dtype) -> jax.Array:
"""Return a zero-size vec3 sentinel for inactive Warp vector slots."""
return jnp.zeros((0, 3), dtype=dtype)
def _empty_vec_matrix(dtype) -> jax.Array:
"""Return a zero-size vec3 matrix sentinel for inactive Warp vector2d slots."""
return jnp.zeros((0, 0, 3), dtype=dtype)
def _empty_mat(dtype) -> jax.Array:
"""Return a zero-size mat33 sentinel for inactive Warp matrix slots."""
return jnp.zeros((0, 3, 3), dtype=dtype)
def _empty_matrix_i32() -> jax.Array:
"""Return a zero-size int32 matrix sentinel for inactive neighbor matrices."""
return jnp.zeros((0, 0), dtype=jnp.int32)
def _empty_shift_matrix() -> jax.Array:
"""Return a zero-size vec3i matrix sentinel for inactive shift matrices."""
return jnp.zeros((0, 0, 3), dtype=jnp.int32)
@functools.partial(jax.custom_jvp, nondiff_argnums=(10, 11))
def _ewald_real_energy_derivatives(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
neighbor_list: jax.Array | None,
neighbor_ptr: jax.Array | None,
neighbor_shifts: jax.Array | None,
neighbor_matrix: jax.Array | None,
neighbor_matrix_shifts: jax.Array | None,
mask_value: int,
use_matrix: bool,
) -> tuple[jax.Array, jax.Array]:
"""Return real-space ``dE/dR`` and ``dE/dq`` with factory double-backward."""
energy, forces, charge_grads = _ewald_real_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
batch_idx=batch_idx,
compute_forces=True,
compute_charge_gradients=True,
compute_virial=False,
)
del energy
return jax.lax.stop_gradient(-forces), jax.lax.stop_gradient(charge_grads)
@_ewald_real_energy_derivatives.defjvp
def _ewald_real_energy_derivatives_jvp(
mask_value: int,
use_matrix: bool,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, jax.Array]]:
"""JVP rule for real-space first derivatives using real-space double-backward."""
deriv_state = (
_DerivState.E_F if _is_symbolic_zero(tangents[1]) else _DerivState.E_F_dQ
)
return _ewald_real_energy_derivatives_jvp_impl(
mask_value,
use_matrix,
primals,
tangents,
deriv_state,
)
def _ewald_real_energy_derivatives_jvp_impl(
mask_value: int,
use_matrix: bool,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
deriv_state: _DerivState,
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, jax.Array]]:
"""JVP rule body for real-space first derivatives with explicit state."""
(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
) = primals
(
v_pos,
v_charge,
_v_cell,
_v_alpha,
_v_batch_idx,
_v_neighbor_list,
_v_neighbor_ptr,
_v_neighbor_shifts,
_v_neighbor_matrix,
_v_neighbor_matrix_shifts,
) = tangents
primal_out = _ewald_real_energy_derivatives(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
)
dtype = _normalize_dtype(positions.dtype)
positions_cast = positions.astype(dtype)
charges_cast = charges.astype(dtype)
cell_cast = cell.astype(dtype)
if cell_cast.ndim == 2:
cell_cast = cell_cast[jnp.newaxis, :, :]
alpha_arr = _prepare_alpha_array(alpha, cell_cast.shape[0], dtype=dtype)
num_atoms = positions_cast.shape[0]
num_systems = cell_cast.shape[0]
v_pos_arr = _tangent_or_zeros(v_pos, positions_cast, dtype=dtype)
v_charge_arr = _tangent_or_zeros(v_charge, charges, dtype=jnp.float64)
batch_i32 = batch_idx.astype(jnp.int32) if batch_idx is not None else _empty_i32()
if use_matrix:
if neighbor_matrix is None or neighbor_matrix_shifts is None:
raise ValueError("neighbor_matrix and neighbor_matrix_shifts are required")
idx_j = _empty_i32()
neighbor_ptr_i32 = _empty_i32()
shifts_i32 = _empty_vec(jnp.int32)
matrix_i32 = neighbor_matrix.astype(jnp.int32)
matrix_shifts_i32 = neighbor_matrix_shifts.astype(jnp.int32)
kernel = _jax_ewald_real_double_backward(
batch_idx is not None,
"matrix",
deriv_state,
False,
)[dtype]
else:
if neighbor_list is None or neighbor_ptr is None or neighbor_shifts is None:
raise ValueError(
"neighbor_list, neighbor_ptr, and neighbor_shifts are required"
)
idx_j = neighbor_list[1].astype(jnp.int32)
neighbor_ptr_i32 = neighbor_ptr.astype(jnp.int32)
shifts_i32 = neighbor_shifts.astype(jnp.int32)
matrix_i32 = _empty_matrix_i32()
matrix_shifts_i32 = _empty_shift_matrix()
kernel = _jax_ewald_real_double_backward(
batch_idx is not None,
"list",
deriv_state,
False,
)[dtype]
grad_energy = jnp.ones((num_systems,), dtype=jnp.float64)
grad_grad_energy = jnp.zeros((num_systems,), dtype=jnp.float64)
grad_positions = jnp.zeros((num_atoms, 3), dtype=dtype)
grad_charges = jnp.zeros((num_atoms,), dtype=jnp.float64)
grad_grad_energy, grad_positions, grad_charges = kernel(
v_pos_arr,
v_charge_arr,
_empty_mat(dtype),
grad_energy,
positions_cast,
charges_cast,
cell_cast,
batch_i32,
idx_j,
neighbor_ptr_i32,
shifts_i32,
matrix_i32,
matrix_shifts_i32,
int(mask_value),
alpha_arr,
grad_grad_energy,
grad_positions,
grad_charges,
_empty_mat(dtype),
launch_dims=(num_atoms,),
)
del grad_grad_energy
return primal_out, (grad_positions, grad_charges)
@functools.partial(jax.custom_jvp, nondiff_argnums=(6,))
def _ewald_reciprocal_energy_derivatives(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
max_atoms_per_system: int | None,
) -> tuple[jax.Array, jax.Array]:
"""Return reciprocal ``dE/dR`` and ``dE/dq`` with factory double-backward."""
energy, forces, charge_grads = _ewald_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
k_vectors=k_vectors,
alpha=alpha,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
compute_forces=True,
compute_charge_gradients=True,
compute_virial=False,
)
del energy
return jax.lax.stop_gradient(-forces), jax.lax.stop_gradient(charge_grads)
@_ewald_reciprocal_energy_derivatives.defjvp
def _ewald_reciprocal_energy_derivatives_jvp(
max_atoms_per_system: int | None,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, jax.Array]]:
"""JVP rule for reciprocal first derivatives using reciprocal double-backward."""
positions, charges, cell, k_vectors, alpha, batch_idx = primals
(
v_pos,
v_charge,
_v_cell,
_v_k_vectors,
_v_alpha,
_v_batch_idx,
) = tangents
primal_out = _ewald_reciprocal_energy_derivatives(
positions,
charges,
cell,
k_vectors,
alpha,
batch_idx,
max_atoms_per_system,
)
dtype = _normalize_dtype(positions.dtype)
positions_cast = positions.astype(dtype)
charges_cast = charges.astype(dtype)
cell_cast = cell.astype(dtype)
if cell_cast.ndim == 2:
cell_cast = cell_cast[jnp.newaxis, :, :]
alpha_arr = _prepare_alpha_array(alpha, cell_cast.shape[0], dtype=dtype)
k_vectors_cast = k_vectors.astype(dtype)
num_atoms = positions_cast.shape[0]
num_systems = cell_cast.shape[0]
if batch_idx is not None and k_vectors_cast.ndim == 2:
k_vectors_2d = jnp.tile(k_vectors_cast[jnp.newaxis, :, :], (num_systems, 1, 1))
elif batch_idx is None and k_vectors_cast.ndim == 2:
k_vectors_2d = k_vectors_cast[jnp.newaxis, :, :]
else:
k_vectors_2d = k_vectors_cast
num_k = k_vectors_2d.shape[1]
v_pos_arr = _tangent_or_zeros(v_pos, positions_cast, dtype=dtype)
v_charge_arr = _tangent_or_zeros(v_charge, charges, dtype=jnp.float64)
deriv_dq = 0 if _is_symbolic_zero(v_charge) else 1
batch_i32 = batch_idx.astype(jnp.int32) if batch_idx is not None else _empty_i32()
if batch_idx is None:
atom_start = _empty_i32()
atom_end = _empty_i32()
else:
atom_counts = jnp.bincount(batch_i32, length=num_systems)
atom_end = jnp.cumsum(atom_counts).astype(jnp.int32)
atom_start = jnp.concatenate([jnp.zeros(1, dtype=jnp.int32), atom_end[:-1]])
grad_energy = jnp.ones((num_systems,), dtype=jnp.float64)
gA = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
gB = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
gC = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
gD = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
gP = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
gQ = jnp.zeros((num_systems, num_k), dtype=jnp.float64)
grad_grad_energy = jnp.zeros((num_systems,), dtype=jnp.float64)
reduce_kernel = (
_jax_batch_ewald_reciprocal_double_backward_reduce[dtype]
if batch_idx is not None
else _jax_ewald_reciprocal_double_backward_reduce[dtype]
)
gA, gB, gC, gD, gP, gQ, grad_grad_energy = reduce_kernel(
positions_cast,
charges_cast,
k_vectors_2d,
cell_cast,
alpha_arr,
batch_i32,
atom_start,
atom_end,
v_pos_arr,
v_charge_arr,
grad_energy,
deriv_dq,
gA,
gB,
gC,
gD,
gP,
gQ,
grad_grad_energy,
0,
jnp.zeros((0,), dtype=jnp.float64),
_empty_vec_matrix(dtype),
jnp.zeros((0,), dtype=jnp.float64),
jnp.zeros((0, 0), dtype=jnp.float64),
jnp.zeros((0, 0), dtype=jnp.float64),
_empty_vec_matrix(dtype),
jnp.zeros((0,), dtype=jnp.float64),
launch_dims=(num_k, num_systems) if batch_idx is not None else (num_k,),
)
del grad_grad_energy
grad_positions = jnp.zeros((num_atoms, 3), dtype=dtype)
grad_charges = jnp.zeros((num_atoms,), dtype=jnp.float64)
compute_kernel = (
_jax_batch_ewald_reciprocal_double_backward_compute[dtype]
if batch_idx is not None
else _jax_ewald_reciprocal_double_backward_compute[dtype]
)
grad_positions, grad_charges = compute_kernel(
positions_cast,
charges_cast,
k_vectors_2d,
batch_i32,
v_pos_arr,
v_charge_arr,
grad_energy,
deriv_dq,
gA,
gB,
gC,
gD,
gP,
gQ,
grad_positions,
grad_charges,
0,
alpha_arr,
jnp.zeros((0,), dtype=jnp.float64),
_empty_vec_matrix(dtype),
jnp.zeros((0,), dtype=jnp.float64),
jnp.zeros((0, 0), dtype=jnp.float64),
jnp.zeros((0, 0), dtype=jnp.float64),
launch_dims=(num_atoms,),
)
volumes = jnp.abs(jnp.linalg.det(cell_cast)).astype(jnp.float64)
alpha_per_atom = alpha_arr[0] if batch_idx is None else alpha_arr[batch_i32]
self_hess = 2.0 * alpha_per_atom / jnp.sqrt(PI)
if batch_idx is None:
bg_coeff = PI / (alpha_arr[0] * alpha_arr[0] * volumes[0])
bg_grad = jnp.full_like(
charges, bg_coeff * v_charge_arr.sum(), dtype=jnp.float64
)
else:
bg_coeff = PI / (alpha_arr * alpha_arr * volumes)
vq_sum = (
jnp.zeros((num_systems,), dtype=jnp.float64).at[batch_i32].add(v_charge_arr)
)
bg_grad = bg_coeff[batch_i32] * vq_sum[batch_i32]
grad_charges = grad_charges - self_hess.astype(jnp.float64) * v_charge_arr - bg_grad
return primal_out, (grad_positions, grad_charges)
_ewald_real_energy_derivatives_jvp_raw = _ewald_real_energy_derivatives_jvp
_ewald_reciprocal_energy_derivatives_jvp_raw = _ewald_reciprocal_energy_derivatives_jvp
def _ewald_real_hvp(
v_pos: jax.Array,
v_charge: jax.Array,
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
neighbor_list: jax.Array | None,
neighbor_ptr: jax.Array | None,
neighbor_shifts: jax.Array | None,
neighbor_matrix: jax.Array | None,
neighbor_matrix_shifts: jax.Array | None,
mask_value: int,
use_matrix: bool,
deriv_state: _DerivState,
) -> tuple[jax.Array, jax.Array]:
"""Linear real-space HVP with an explicit transpose rule."""
residuals = (
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
)
# The HVP is a symmetric linear map in (v_pos, v_charge); custom_vjp supplies
# its transpose (== itself) so reverse-mode over this JVP yields the Hessian.
@jax.custom_vjp
def _linear_hvp(lin_pos, lin_charge):
_primal, tangent = _ewald_real_energy_derivatives_jvp_impl(
mask_value,
use_matrix,
residuals,
(lin_pos, lin_charge, None, None, None, None, None, None, None, None),
deriv_state,
)
return (
tangent[0].astype(positions.dtype),
tangent[1].astype(charges.dtype),
)
def _linear_hvp_fwd(lin_pos, lin_charge):
return _linear_hvp(lin_pos, lin_charge), None
def _linear_hvp_bwd(_res, ct_out):
ct_pos, ct_charge = ct_out
return _linear_hvp(
_tangent_or_zeros(ct_pos, positions, dtype=positions.dtype),
_tangent_or_zeros(ct_charge, charges, dtype=charges.dtype),
)
_linear_hvp.defvjp(_linear_hvp_fwd, _linear_hvp_bwd)
return _linear_hvp(v_pos, v_charge)
def _ewald_reciprocal_hvp(
v_pos: jax.Array,
v_charge: jax.Array,
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
max_atoms_per_system: int | None,
) -> tuple[jax.Array, jax.Array]:
"""Linear reciprocal HVP with an explicit transpose rule."""
residuals = (positions, charges, cell, k_vectors, alpha, batch_idx)
# The HVP is a symmetric linear map in (v_pos, v_charge); custom_vjp supplies
# its transpose (== itself) so reverse-mode over this JVP yields the Hessian.
@jax.custom_vjp
def _linear_hvp(lin_pos, lin_charge):
_primal, tangent = _ewald_reciprocal_energy_derivatives_jvp_raw(
max_atoms_per_system,
residuals,
(lin_pos, lin_charge, None, None, None, None),
)
return (
tangent[0].astype(positions.dtype),
tangent[1].astype(charges.dtype),
)
def _linear_hvp_fwd(lin_pos, lin_charge):
return _linear_hvp(lin_pos, lin_charge), None
def _linear_hvp_bwd(_res, ct_out):
ct_pos, ct_charge = ct_out
return _linear_hvp(
_tangent_or_zeros(ct_pos, positions, dtype=positions.dtype),
_tangent_or_zeros(ct_charge, charges, dtype=charges.dtype),
)
_linear_hvp.defvjp(_linear_hvp_fwd, _linear_hvp_bwd)
return _linear_hvp(v_pos, v_charge)
def _ewald_real_energy_derivatives_jvp_wrapped(
mask_value: int,
use_matrix: bool,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, jax.Array]]:
"""JVP rule that routes real-space HVP transposes through a custom VJP."""
(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
) = primals
v_pos, v_charge = tangents[:2]
deriv_state = _DerivState.E_F if _is_symbolic_zero(v_charge) else _DerivState.E_F_dQ
primal_out = _ewald_real_energy_derivatives(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
)
tangent_out = _ewald_real_hvp(
_tangent_or_zeros(v_pos, positions, dtype=positions.dtype),
_tangent_or_zeros(v_charge, charges, dtype=charges.dtype),
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
deriv_state,
)
return primal_out, (
tangent_out[0].astype(primal_out[0].dtype),
tangent_out[1].astype(primal_out[1].dtype),
)
def _ewald_reciprocal_energy_derivatives_jvp_wrapped(
max_atoms_per_system: int | None,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, jax.Array]]:
"""JVP rule that routes reciprocal HVP transposes through a custom VJP."""
positions, charges, cell, k_vectors, alpha, batch_idx = primals
v_pos, v_charge = tangents[:2]
primal_out = _ewald_reciprocal_energy_derivatives(
positions,
charges,
cell,
k_vectors,
alpha,
batch_idx,
max_atoms_per_system,
)
tangent_out = _ewald_reciprocal_hvp(
_tangent_or_zeros(v_pos, positions, dtype=positions.dtype),
_tangent_or_zeros(v_charge, charges, dtype=charges.dtype),
positions,
charges,
cell,
k_vectors,
alpha,
batch_idx,
max_atoms_per_system,
)
return primal_out, (
tangent_out[0].astype(primal_out[0].dtype),
tangent_out[1].astype(primal_out[1].dtype),
)
_ewald_real_energy_derivatives.defjvp(
_ewald_real_energy_derivatives_jvp_wrapped,
symbolic_zeros=True,
)
_ewald_reciprocal_energy_derivatives.defjvp(
_ewald_reciprocal_energy_derivatives_jvp_wrapped,
symbolic_zeros=True,
)
@functools.partial(jax.custom_jvp, nondiff_argnums=(10, 11))
def _ewald_real_space_energy_jvp(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
neighbor_list: jax.Array | None,
neighbor_ptr: jax.Array | None,
neighbor_shifts: jax.Array | None,
neighbor_matrix: jax.Array | None,
neighbor_matrix_shifts: jax.Array | None,
mask_value: int,
use_matrix: bool,
) -> jax.Array:
"""Energy-only real-space Ewald wrapper with custom autodiff."""
energy = _ewald_real_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
batch_idx=batch_idx,
compute_forces=False,
compute_charge_gradients=False,
compute_virial=False,
)
return jax.lax.stop_gradient(energy)
def _ewald_real_space_energy_jvp_rule(
mask_value: int,
use_matrix: bool,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[jax.Array, jax.Array]:
"""JVP rule for the real-space per-atom energy vector."""
(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
) = primals
(
t_positions,
t_charges,
t_cell,
_t_alpha,
_t_batch_idx,
_t_neighbor_list,
_t_neighbor_ptr,
_t_neighbor_shifts,
_t_neighbor_matrix,
_t_neighbor_matrix_shifts,
) = tangents
primal_out = _ewald_real_space_energy_jvp(
positions,
charges,
cell,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
)
tpos = _tangent_or_zeros(t_positions, positions, dtype=positions.dtype)
tq = _tangent_or_zeros(t_charges, charges, dtype=charges.dtype)
if (
not _is_symbolic_zero(t_positions)
or not _is_symbolic_zero(t_charges)
or not _is_symbolic_zero(t_cell)
):
tcell = _tangent_or_zeros(t_cell, cell, dtype=cell.dtype)
charges_ref = charges.astype(jnp.float64)
tq_ref = tq.astype(jnp.float64)
_reference_out, tangent_out = jax.jvp(
lambda p, q, c: _real_space_energy_reference(
p,
q,
c,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
),
(positions, charges_ref, cell),
(tpos, tq_ref, tcell),
)
return primal_out, tangent_out.astype(primal_out.dtype)
dpos, dq = _ewald_real_energy_derivatives(
positions,
charges,
jax.lax.stop_gradient(cell),
jax.lax.stop_gradient(alpha),
_stop_optional(batch_idx),
_stop_optional(neighbor_list),
_stop_optional(neighbor_ptr),
_stop_optional(neighbor_shifts),
_stop_optional(neighbor_matrix),
_stop_optional(neighbor_matrix_shifts),
mask_value,
use_matrix,
)
atom_tangent = (dpos.astype(jnp.float64) * tpos.astype(jnp.float64)).sum(axis=1)
atom_tangent = atom_tangent + dq.astype(jnp.float64) * tq.astype(jnp.float64)
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
num_atoms = positions.shape[0]
num_systems = cell_3d.shape[0]
system_tangent = _system_sum_from_atoms(atom_tangent, batch_idx, num_systems)
if not _is_symbolic_zero(t_cell):
_energy, _forces, _charge_grads, virial = ewald_real_space(
positions=jax.lax.stop_gradient(positions),
charges=jax.lax.stop_gradient(charges),
cell=jax.lax.stop_gradient(cell),
alpha=alpha,
batch_idx=_stop_optional(batch_idx),
neighbor_list=_stop_optional(neighbor_list),
neighbor_ptr=_stop_optional(neighbor_ptr),
neighbor_shifts=_stop_optional(neighbor_shifts),
neighbor_matrix=_stop_optional(neighbor_matrix),
neighbor_matrix_shifts=_stop_optional(neighbor_matrix_shifts),
mask_value=mask_value,
compute_forces=True,
compute_charge_gradients=True,
compute_virial=True,
)
grad_cell = _cell_grad_from_strain_virial(
positions=positions,
cell=cell,
batch_idx=batch_idx,
grad_positions=dpos,
virial=jax.lax.stop_gradient(virial),
grad_system=jnp.ones((num_systems,), dtype=jnp.float64),
)
system_tangent = system_tangent + _cell_tangent_system_values(
jax.lax.stop_gradient(grad_cell),
t_cell,
)
tangent_out = _distribute_system_values(system_tangent, batch_idx, num_atoms)
return primal_out, tangent_out.astype(primal_out.dtype)
_ewald_real_space_energy_jvp.defjvp(
_ewald_real_space_energy_jvp_rule,
symbolic_zeros=True,
)
@functools.partial(jax.custom_jvp, nondiff_argnums=(6,))
def _ewald_reciprocal_space_energy_jvp(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
k_vectors: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None,
max_atoms_per_system: int | None,
) -> jax.Array:
"""Energy-only reciprocal Ewald wrapper with custom autodiff."""
energy = _ewald_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
k_vectors=k_vectors,
alpha=alpha,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
compute_forces=False,
compute_charge_gradients=False,
compute_virial=False,
)
return jax.lax.stop_gradient(energy)
def _ewald_reciprocal_space_energy_jvp_rule(
max_atoms_per_system: int | None,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[jax.Array, jax.Array]:
"""JVP rule for the reciprocal per-atom energy vector."""
positions, charges, cell, k_vectors, alpha, batch_idx = primals
(
t_positions,
t_charges,
t_cell,
_t_k_vectors,
_t_alpha,
_t_batch_idx,
) = tangents
primal_out = _ewald_reciprocal_space_energy_jvp(
positions,
charges,
cell,
k_vectors,
alpha,
batch_idx,
max_atoms_per_system,
)
tpos = _tangent_or_zeros(t_positions, positions, dtype=positions.dtype)
tq = _tangent_or_zeros(t_charges, charges, dtype=charges.dtype)
tcell = _tangent_or_zeros(t_cell, cell, dtype=cell.dtype)
tk = jnp.zeros_like(k_vectors)
charges_ref = charges.astype(jnp.float64)
tq_ref = tq.astype(jnp.float64)
_reference_out, tangent_out = jax.jvp(
lambda p, q, c, k: _reciprocal_space_energy_reference(
p,
q,
c,
k,
alpha,
batch_idx,
),
(positions, charges_ref, cell, k_vectors),
(tpos, tq_ref, tcell, tk),
)
return primal_out, tangent_out.astype(primal_out.dtype)
_ewald_reciprocal_space_energy_jvp.defjvp(
_ewald_reciprocal_space_energy_jvp_rule,
symbolic_zeros=True,
)
@functools.partial(jax.custom_jvp, nondiff_argnums=(11, 12, 13))
def _ewald_summation_energy_jvp(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
k_vectors: jax.Array,
batch_idx: jax.Array | None,
neighbor_list: jax.Array | None,
neighbor_ptr: jax.Array | None,
neighbor_shifts: jax.Array | None,
neighbor_matrix: jax.Array | None,
neighbor_matrix_shifts: jax.Array | None,
k_vectors_are_internal: bool,
max_atoms_per_system: int | None,
mask_value: int,
) -> jax.Array:
"""Energy-only full Ewald wrapper with a second-order-capable custom JVP."""
energy = _ewald_summation_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
k_vectors=k_vectors,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
compute_forces=False,
compute_charge_gradients=False,
compute_virial=False,
)
return jax.lax.stop_gradient(energy)
def _ewald_summation_energy_jvp_rule(
k_vectors_are_internal: bool,
max_atoms_per_system: int | None,
mask_value: int,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[jax.Array, jax.Array]:
"""JVP rule for the full Ewald per-atom energy vector."""
(
positions,
charges,
cell,
alpha,
k_vectors,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
) = primals
(
t_positions,
t_charges,
t_cell,
_t_alpha,
_t_k_vectors,
_t_batch_idx,
_t_neighbor_list,
_t_neighbor_ptr,
_t_neighbor_shifts,
_t_neighbor_matrix,
_t_neighbor_matrix_shifts,
) = tangents
primal_out = _ewald_summation_energy_jvp(
positions,
charges,
cell,
alpha,
k_vectors,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
k_vectors_are_internal,
max_atoms_per_system,
mask_value,
)
tpos = _tangent_or_zeros(t_positions, positions, dtype=positions.dtype)
tq = _tangent_or_zeros(t_charges, charges, dtype=charges.dtype)
tcell = _tangent_or_zeros(t_cell, cell, dtype=cell.dtype)
if not _is_symbolic_zero(t_cell):
if not k_vectors_are_internal:
tk = jnp.zeros_like(k_vectors)
else:
tk = _kvector_tangent_from_cell(k_vectors, cell, tcell)
else:
tk = jnp.zeros_like(k_vectors)
use_matrix = neighbor_matrix is not None and neighbor_matrix_shifts is not None
_component_out, tangent_out = jax.jvp(
lambda p, q, c, k: (
_ewald_real_space_energy_jvp(
p,
q,
c,
alpha,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
mask_value,
use_matrix,
)
+ _reciprocal_space_energy_reference(
p,
q,
c,
k,
alpha,
batch_idx,
)
),
(positions, charges, cell, k_vectors),
(tpos, tq, tcell, tk),
)
return primal_out, tangent_out.astype(primal_out.dtype)
_ewald_summation_energy_jvp.defjvp(
_ewald_summation_energy_jvp_rule,
symbolic_zeros=True,
)
def _resolve_ewald_summation_parameters(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None,
k_vectors: jax.Array | None,
k_cutoff: float | jax.Array | None,
miller_bounds: tuple[int, int, int] | None,
batch_idx: jax.Array | None,
accuracy: float,
) -> tuple[float | jax.Array, jax.Array]:
"""Resolve Ewald ``alpha`` and ``k_vectors`` once for forward/backward reuse."""
if alpha is None or k_cutoff is None:
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
params = estimate_ewald_parameters(
positions=positions,
cell=cell_3d,
batch_idx=batch_idx,
accuracy=accuracy,
)
if alpha is None:
alpha = params.alpha
if k_cutoff is None:
k_cutoff = params.reciprocal_space_cutoff
if k_vectors is None:
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
if k_cutoff is None:
raise ValueError("k_cutoff must be provided if k_vectors is None")
k_vectors = generate_k_vectors_ewald_summation(
cell=cell_3d,
k_cutoff=k_cutoff,
miller_bounds=miller_bounds,
)
return alpha, k_vectors
def _ewald_summation_impl(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_cutoff: float | jax.Array | None = None,
miller_bounds: tuple[int, int, int] | None = None,
batch_idx: jax.Array | None = None,
max_atoms_per_system: int | None = None,
neighbor_list: jax.Array | None = None,
neighbor_ptr: jax.Array | None = None,
neighbor_shifts: jax.Array | None = None,
neighbor_matrix: jax.Array | None = None,
neighbor_matrix_shifts: jax.Array | None = None,
mask_value: int | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
hybrid_forces: bool = False,
accuracy: float = 1e-6,
pbc: jax.Array | None = None,
slab_correction: bool = False,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute complete Ewald summation implementation.
The Ewald method splits long-range Coulomb into components:
.. math::
E_{\\text{total}} = E_{\\text{real}} + E_{\\text{reciprocal}} - E_{\\text{self}} - E_{\\text{background}}
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (1, 3, 3) or (B, 3, 3)
Unit cell matrices.
alpha : float or jax.Array or None
Ewald splitting parameter. If None, estimated automatically.
k_vectors : jax.Array | None
Reciprocal lattice vectors. If None, generated automatically.
Shape (K, 3) for single system, (B, K, 3) for batch.
When supplied, treated as static metadata that corresponds to the
current ``cell``.
k_cutoff : float | None
K-space cutoff. Used only if k_vectors is None.
miller_bounds : tuple[int, int, int] | None, optional
Precomputed maximum Miller indices (M_h, M_k, M_l). Forwarded to
:func:`generate_k_vectors_ewald_summation` when ``k_vectors`` is ``None``.
When provided, makes k-vector generation compatible with ``jax.jit``.
Use :func:`generate_miller_indices` to precompute. Ignored when
``k_vectors`` is explicitly provided.
batch_idx : jax.Array | None, shape (N,)
System index for each atom. When provided, atoms must be grouped by
system: ``batch_idx`` must be contiguous, nondecreasing, and use system
IDs ``0..B-1``.
max_atoms_per_system : int | None, optional
Maximum number of atoms in any single system in the batch.
Required when using ``jax.jit`` with batched inputs. If None,
inferred from data (fails under JIT).
neighbor_list : jax.Array | None, shape (2, M)
Neighbor list in COO format.
neighbor_ptr : jax.Array | None, shape (N+1,)
CSR row pointers for neighbor list.
neighbor_shifts : jax.Array | None, shape (M, 3)
Periodic image shifts for neighbor list.
neighbor_matrix : jax.Array | None, shape (N, max_neighbors)
Dense neighbor matrix format.
neighbor_matrix_shifts : jax.Array | None, shape (N, max_neighbors, 3)
Periodic image shifts for neighbor_matrix.
mask_value : int | None
Value indicating invalid entries in neighbor_matrix.
compute_forces : bool, default=False
Whether to compute forces.
compute_charge_gradients : bool, default=False
Whether to compute charge gradients :math:`\\partial E / \\partial q_i`.
compute_virial : bool, default=False
Whether to compute the virial tensor.
hybrid_forces : bool, default=False
Whether to detach the force path and inject analytical charge gradients.
accuracy : float, default=1e-6
Target accuracy for automatic parameter estimation.
pbc : jax.Array, shape (3,) or (B, 3), dtype=bool, optional
Per-system periodic boundary conditions. Required when
``slab_correction=True``. True marks periodic directions and False
marks the non-periodic slab direction.
slab_correction : bool, default=False
If True, add the Yeh-Berkowitz/Ballenegger slab correction to the
3D-periodic Ewald outputs.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom total Ewald energy.
forces : jax.Array, shape (N, 3), optional
Forces (if compute_forces=True).
charge_gradients : jax.Array, shape (N,), optional
Charge gradients (if compute_charge_gradients=True).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor (if compute_virial=True). Always last in the return tuple.
Examples
--------
>>> # Complete Ewald summation with automatic parameters
>>> energies, forces = ewald_summation(
... positions, charges, cell,
... neighbor_list=nl, neighbor_ptr=neighbor_ptr, neighbor_shifts=shifts,
... accuracy=1e-6,
... compute_forces=True,
... )
"""
cell, num_systems = _prepare_cell(cell)
if batch_idx is not None:
batch_idx = batch_idx.astype(jnp.int32)
if slab_correction:
pbc = _prepare_pbc_for_slab(pbc, num_systems)
explicit_k_vectors = k_vectors is not None
alpha, k_vectors = _resolve_ewald_summation_parameters(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
k_vectors=k_vectors,
k_cutoff=k_cutoff,
miller_bounds=miller_bounds,
batch_idx=batch_idx,
accuracy=accuracy,
)
if explicit_k_vectors:
k_vectors = jax.lax.stop_gradient(k_vectors)
charges_orig = charges
need_charge_gradients = compute_charge_gradients or hybrid_forces
num_systems = cell.shape[0] if cell.ndim == 3 else 1
if hybrid_forces:
positions = jax.lax.stop_gradient(positions)
charges = jax.lax.stop_gradient(charges)
cell = jax.lax.stop_gradient(cell)
alpha = jax.lax.stop_gradient(alpha)
k_vectors = jax.lax.stop_gradient(k_vectors)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"The component direct-output flag\(s\).*",
category=DeprecationWarning,
)
# Compute real-space component
real_result = ewald_real_space(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
batch_idx=batch_idx,
compute_forces=compute_forces,
compute_charge_gradients=need_charge_gradients,
compute_virial=compute_virial,
)
# Compute reciprocal-space component
recip_result = ewald_reciprocal_space(
positions=positions,
charges=charges,
cell=cell,
k_vectors=k_vectors,
alpha=alpha,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
compute_forces=compute_forces,
compute_charge_gradients=need_charge_gradients,
compute_virial=compute_virial,
)
slab_result = None
if slab_correction:
if compute_forces or need_charge_gradients or compute_virial:
slab_result = _compute_slab_correction(
positions,
charges,
cell,
pbc,
batch_idx=batch_idx,
compute_forces=compute_forces,
compute_charge_gradients=need_charge_gradients,
compute_virial=compute_virial,
)
else:
slab_result = _slab_correction_energy_autodiff(
positions,
charges,
cell,
pbc,
batch_idx=batch_idx,
)
# Sum contributions
component_tuples = [
real_result if isinstance(real_result, tuple) else (real_result,),
recip_result if isinstance(recip_result, tuple) else (recip_result,),
]
if slab_result is not None:
component_tuples.append(
slab_result if isinstance(slab_result, tuple) else (slab_result,)
)
def _sum_component(tuple_index: int) -> jax.Array:
total = component_tuples[0][tuple_index]
for component in component_tuples[1:]:
total = total + component[tuple_index]
return total
tuple_index = 0
total_energies = _sum_component(tuple_index)
tuple_index += 1
total_charge_grads = None
results: tuple[jax.Array, ...] = (total_energies,)
if compute_forces:
total_forces = _sum_component(tuple_index)
results += (total_forces,)
tuple_index += 1
if need_charge_gradients:
total_charge_grads = _sum_component(tuple_index)
tuple_index += 1
if compute_charge_gradients:
results += (total_charge_grads,)
if compute_virial:
total_virial = _sum_component(tuple_index)
results += (total_virial,)
if hybrid_forces and total_charge_grads is not None:
bidx_for_inject = (
batch_idx
if batch_idx is not None
else jnp.zeros(positions.shape[0], dtype=jnp.int32)
)
total_energies = _inject_charge_grad(
total_energies,
charges_orig,
total_charge_grads,
batch_idx is not None,
bidx_for_inject,
num_systems,
)
results = (total_energies, *results[1:])
if len(results) == 1:
return results[0]
return results
[docs]
def ewald_summation(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_cutoff: float | jax.Array | None = None,
batch_idx: jax.Array | None = None,
max_atoms_per_system: int | None = None,
neighbor_list: jax.Array | None = None,
neighbor_ptr: jax.Array | None = None,
neighbor_shifts: jax.Array | None = None,
neighbor_matrix: jax.Array | None = None,
neighbor_matrix_shifts: jax.Array | None = None,
mask_value: int | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
accuracy: float = 1e-6,
hybrid_forces: bool = False,
pbc: jax.Array | None = None,
slab_correction: bool = False,
*,
miller_bounds: tuple[int, int, int] | None = None,
) -> jax.Array | tuple[jax.Array, ...]:
"""Compute complete Ewald summation.
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges.
cell : jax.Array, shape (3, 3) or (B, 3, 3)
Unit cell matrices.
alpha : float, jax.Array, or None, default=None
Ewald splitting parameter. If ``None``, estimated automatically.
k_vectors : jax.Array or None, default=None
Reciprocal lattice vectors. Generated from ``cell`` when omitted.
k_cutoff : float, jax.Array, or None, default=None
K-space cutoff used when generating ``k_vectors``.
miller_bounds : tuple[int, int, int] or None, default=None, keyword-only
Static Miller-index bounds for JIT-compatible k-vector generation.
batch_idx : jax.Array or None, default=None
System index for each atom. When provided, atoms must be grouped by
system: ``batch_idx`` must be contiguous, nondecreasing, and use system
IDs ``0..B-1``.
max_atoms_per_system : int or None, default=None
Static batch shape control for reciprocal kernels under ``jax.jit``.
neighbor_list, neighbor_ptr, neighbor_shifts : jax.Array or None
CSR neighbor-list inputs for the real-space component.
neighbor_matrix, neighbor_matrix_shifts : jax.Array or None
Dense neighbor-matrix inputs for the real-space component.
mask_value : int or None, default=None
Sentinel value for invalid neighbor-matrix entries.
compute_forces : bool, default=False
.. deprecated:: 0.4.0
Deprecated direct-output flag. Compute energy and use JAX autodiff for
differentiable forces.
compute_charge_gradients : bool, default=False
.. deprecated:: 0.4.0
Deprecated direct-output flag. Compute energy and use JAX autodiff for
:math:`\\partial E / \\partial q_i`.
compute_virial : bool, default=False
.. deprecated:: 0.4.0
Deprecated direct-output flag for the virial tensor.
accuracy : float, default=1e-6
Target accuracy for automatic parameter estimation.
hybrid_forces : bool, default=False
Deprecated direct-output flag retained for transition compatibility.
pbc : jax.Array, optional
Per-system periodic boundary conditions for slab correction.
slab_correction : bool, default=False
If True, add the Yeh-Berkowitz/Ballenegger slab correction.
Returns
-------
jax.Array, shape (N,)
Per-atom total Ewald energy when no deprecated direct-output flags are
set. Gradients flow through positions, charges, and cell via the
registered custom-JVP rules.
tuple[jax.Array, ...]
When any deprecated flag is True: ``(energies,)`` extended by the
requested outputs in order — forces of shape (N, 3),
charge gradients of shape (N,), virial of shape (1, 3, 3) or
(B, 3, 3) — matching the ordering of
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_real_space`.
See Also
--------
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_real_space` : Real-space component.
:func:`nvalchemiops.jax.interactions.electrostatics.ewald.ewald_reciprocal_space` : Reciprocal-space component.
:func:`nvalchemiops.jax.interactions.electrostatics.parameters.estimate_ewald_parameters` : Automatic alpha and k-cutoff estimation.
:func:`nvalchemiops.jax.interactions.electrostatics.k_vectors.generate_k_vectors_ewald_summation` : Generates k-vectors from cell and cutoff.
"""
if compute_forces or compute_virial or compute_charge_gradients or hybrid_forces:
warnings.warn(
_direct_output_deprecation_msg("ewald_summation"),
DeprecationWarning,
stacklevel=2,
)
if slab_correction and not (
compute_forces or compute_charge_gradients or compute_virial or hybrid_forces
):
generated_k_vectors = k_vectors is None
cell_3d, num_systems = _prepare_cell(cell)
pbc_prepared = _prepare_pbc_for_slab(pbc, num_systems)
alpha_resolved, k_vectors_resolved = _resolve_ewald_summation_parameters(
positions=positions,
charges=charges,
cell=cell_3d,
alpha=alpha,
k_vectors=k_vectors,
k_cutoff=k_cutoff,
miller_bounds=miller_bounds,
batch_idx=batch_idx,
accuracy=accuracy,
)
dtype = _normalize_dtype(positions.dtype)
alpha_arr = _prepare_alpha_array(alpha_resolved, cell_3d.shape[0], dtype=dtype)
if mask_value is None:
mask_value = positions.shape[0]
base_energy = _ewald_summation_energy_jvp(
positions,
charges,
cell_3d,
alpha_arr,
k_vectors_resolved,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
generated_k_vectors,
max_atoms_per_system,
mask_value,
)
slab_energy = _slab_correction_energy_autodiff(
positions,
charges,
cell_3d,
pbc_prepared,
batch_idx=batch_idx,
)
return base_energy + slab_energy
if compute_forces or compute_charge_gradients or compute_virial or hybrid_forces:
return _ewald_summation_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
k_vectors=k_vectors,
k_cutoff=k_cutoff,
miller_bounds=miller_bounds,
batch_idx=batch_idx,
max_atoms_per_system=max_atoms_per_system,
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
mask_value=mask_value,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
accuracy=accuracy,
hybrid_forces=hybrid_forces,
pbc=pbc,
slab_correction=slab_correction,
)
generated_k_vectors = k_vectors is None
alpha_resolved, k_vectors_resolved = _resolve_ewald_summation_parameters(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
k_vectors=k_vectors,
k_cutoff=k_cutoff,
miller_bounds=miller_bounds,
batch_idx=batch_idx,
accuracy=accuracy,
)
dtype = _normalize_dtype(positions.dtype)
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
alpha_arr = _prepare_alpha_array(alpha_resolved, cell_3d.shape[0], dtype=dtype)
if mask_value is None:
mask_value = positions.shape[0]
return _ewald_summation_energy_jvp(
positions,
charges,
cell,
alpha_arr,
k_vectors_resolved,
batch_idx,
neighbor_list,
neighbor_ptr,
neighbor_shifts,
neighbor_matrix,
neighbor_matrix_shifts,
generated_k_vectors,
max_atoms_per_system,
mask_value,
)