# 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 Particle Mesh Ewald (PME) implementation.
This module provides JAX bindings for PME long-range electrostatics calculations.
PME achieves :math:`O(N \\log N)` scaling through FFT-based reciprocal space
computation combined with real-space Ewald summation.
The implementation uses:
- JAX FFT operations (jnp.fft.rfftn/irfftn)
- B-spline interpolation from nvalchemiops.jax.spline
- Ewald real-space from nvalchemiops.jax.interactions.electrostatics.ewald
- Warp kernels for Green's function and energy corrections
Key Functions
-------------
particle_mesh_ewald : Complete PME calculation (real + reciprocal space)
pme_reciprocal_space : Reciprocal-space component only
pme_green_structure_factor : Green's function and structure factor
pme_energy_corrections : Self-energy and background corrections
See Also
--------
nvalchemiops.jax.interactions.electrostatics.ewald : Ewald real-space
nvalchemiops.jax.spline : B-spline interpolation
"""
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 warp.jax_experimental import GraphMode, jax_callable
from nvalchemiops.interactions.electrostatics.pme_factory import get_pme_kernel
from nvalchemiops.interactions.electrostatics.pme_kernels import (
_batch_pme_green_structure_factor_kernel_overload,
_pme_green_structure_factor_kernel_overload,
_pme_virial_bg_apply_kernel_overload,
_pme_virial_bg_reduce_kernel_overload,
)
from nvalchemiops.jax.interactions.electrostatics._autograd import (
_inject_charge_grad,
)
from nvalchemiops.jax.interactions.electrostatics._lazy_jax_kernels import (
_make_jax_kernel_factory,
_make_jax_kernels,
)
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.ewald import (
ewald_real_space,
)
from nvalchemiops.jax.interactions.electrostatics.k_vectors import (
generate_k_vectors_pme,
)
from nvalchemiops.jax.interactions.electrostatics.parameters import (
estimate_pme_mesh_dimensions,
estimate_pme_parameters,
mesh_spacing_to_dimensions,
)
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,
)
from nvalchemiops.jax.spline import (
_spline_gather_gradient_position_hessian,
_spline_gather_with_force,
_spline_spread_gradient_weights,
spline_gather,
spline_gather_gradient,
spline_spread,
)
__all__ = [
"particle_mesh_ewald",
"pme_reciprocal_space",
"pme_green_structure_factor",
"pme_energy_corrections",
"pme_energy_corrections_with_charge_grad",
"compute_bspline_moduli_1d",
]
# ==============================================================================
# Helper Function for JAX Kernel Creation
# ==============================================================================
# ``_make_jax_kernels`` returns a lazy dict (see _lazy_jax_kernels) that
# materializes its ``jax_kernel`` entries on first __getitem__. Prefer
# ``jax_kernel`` for single-launch ops; use ``jax_callable`` only when
# fusing multiple wp.launch calls into one FFI thunk
# (see :func:`_make_jax_pme_virial_bg_fused`).
def _jax_pme_factory_component(
component: str,
output_names: list[str],
*,
batched: bool = False,
charge_grad: bool = False,
):
"""Return a lazy JAX wrapper for a factory-backed PME component."""
return _make_jax_kernel_factory(
lambda wp_dtype: get_pme_kernel(
wp_dtype,
component=component,
batched=batched,
charge_grad=charge_grad,
),
len(output_names),
output_names,
)
# ==============================================================================
# JAX Kernel Wrappers
# ==============================================================================
# Single-system kernels
_jax_pme_green_sf = _make_jax_kernels(
_pme_green_structure_factor_kernel_overload,
2,
["green_function", "structure_factor_sq"],
)
_jax_pme_energy_corrections = _jax_pme_factory_component(
"pme_corrections",
["corrected_energies"],
)
_jax_pme_energy_corrections_charge_grad = _jax_pme_factory_component(
"pme_corrections",
["corrected_energies", "charge_gradients"],
charge_grad=True,
)
# Batch kernels
_jax_batch_pme_green_sf = _make_jax_kernels(
_batch_pme_green_structure_factor_kernel_overload,
2,
["green_function", "structure_factor_sq"],
)
_jax_batch_pme_energy_corrections = _jax_pme_factory_component(
"pme_corrections",
["corrected_energies"],
batched=True,
)
_jax_batch_pme_energy_corrections_charge_grad = _jax_pme_factory_component(
"pme_corrections",
["corrected_energies", "charge_gradients"],
batched=True,
charge_grad=True,
)
# Fused convolve — replaces the older Green's-function + multiply path with
# a single warp kernel that computes G(k), the B-spline structure factor
# correction C^2(k), and multiplies mesh_fft → convolved_mesh in one launch.
# (mirrors the fused-convolve path in the torch bindings.)
_jax_pme_convolve = _jax_pme_factory_component(
"pme_convolve",
["convolved_mesh"],
)
_jax_batch_pme_convolve = _jax_pme_factory_component(
"pme_convolve",
["convolved_mesh"],
batched=True,
)
# Two-pass virial background correction. Pass 1 reduces per-atom
# charges into per-system total charges (atomic_add). Pass 2 computes
# E_bg = π Q² / (2 α² V) per system and subtracts it from the three diagonal
# entries of ``virial_in``. Mirrors the torch path's ``pme_virial_bg_correction``.
_jax_pme_virial_bg_reduce = _make_jax_kernels(
_pme_virial_bg_reduce_kernel_overload,
1,
["total_charges"],
)
_jax_pme_virial_bg_apply = _make_jax_kernels(
_pme_virial_bg_apply_kernel_overload,
1,
["virial_out"],
)
# Fuse the two-pass virial bg correction (reduce + apply) into one XLA FFI
# call via jax_callable so JAX can CUDA-graph it with the surrounding warp
# ops + FFTs. Per-pass jax_kernel thunks above remain for direct test use.
def _make_jax_pme_virial_bg_fused(wp_dtype):
reduce_overload = _pme_virial_bg_reduce_kernel_overload[wp_dtype]
apply_overload = _pme_virial_bg_apply_kernel_overload[wp_dtype]
def _fn(
# inputs
charges: wp.array(dtype=wp_dtype),
batch_idx: wp.array(dtype=wp.int32),
cell: wp.array3d(dtype=wp_dtype),
alpha: wp.array(dtype=wp_dtype),
virial_in: wp.array3d(dtype=wp_dtype),
# in-out: zero-initialized by caller, scatter-add target for pass 1
total_charges: wp.array(dtype=wp_dtype),
# outputs
virial_out: wp.array3d(dtype=wp_dtype),
):
# Reference closure-captured names so they survive as
# ``__closure__`` cells. ``from __future__ import annotations``
# stringifies the annotations, so they don't on their own pull
# ``wp_dtype`` into the closure -- and warp's annotation eval
# then can't resolve it. Touching the names in the body fixes that.
_ = wp_dtype
wp.launch(
reduce_overload,
dim=charges.shape,
inputs=[charges, batch_idx],
outputs=[total_charges],
)
wp.launch(
apply_overload,
dim=total_charges.shape,
inputs=[total_charges, cell, alpha, virial_in],
outputs=[virial_out],
)
return jax_callable(
_fn,
num_outputs=2,
in_out_argnames=["total_charges"],
graph_mode=GraphMode.JAX,
)
_jax_pme_virial_bg_fused = {
jnp.float32: _make_jax_pme_virial_bg_fused(wp.float32),
jnp.float64: _make_jax_pme_virial_bg_fused(wp.float64),
}
# ==============================================================================
# Public API Functions
# ==============================================================================
[docs]
def compute_bspline_moduli_1d(
miller_indices: jax.Array,
mesh_N: int,
spline_order: int,
) -> jax.Array:
"""Precompute a 1D B-spline modulus LUT for one PME mesh axis.
Returns ``b[i] = sinc(m_i / N)^spline_order`` for each Miller index
``m_i`` (with ``sinc(x) = sin(pi*x)/(pi*x)``, ``sinc(0) = 1``). The
three-axis product ``b_x[i] * b_y[j] * b_z[k]`` is the B-spline
structure factor consumed by :func:`pme_fused_convolve`. Precomputing the
LUT lets the convolve kernel replace three sinc transcendentals + an
order-dependent power loop per (i, j, k) thread with three reads + two
multiplies.
Parameters
----------
miller_indices : jax.Array, shape (N,)
Integer Miller indices for one mesh axis, e.g. from
``jnp.fft.fftfreq(N, d=1.0/N)`` or ``jnp.fft.rfftfreq(N, d=1.0/N)``.
mesh_N : int
Number of mesh points along this axis.
spline_order : int
B-spline interpolation order (e.g. 4 for cubic B-splines).
Returns
-------
jax.Array, shape (N,)
Per-Miller-index B-spline modulus ``sinc(m/N)^spline_order``.
"""
# sinc(x) for x in [-0.5, 0.5] is bounded in [2/pi, 1], so s^spline_order
# (for orders 2-6) stays well within fp32 range. Stay in the input dtype
# to avoid an fp32 -> fp64 -> fp32 round-trip per call. jax.numpy.sinc uses
# the normalized convention sinc(pi*x)/(pi*x); matches torch.
arg = miller_indices / float(mesh_N)
s = jnp.sinc(arg)
return s**spline_order
def pme_fused_convolve(
mesh_fft: jax.Array,
k_squared: jax.Array,
moduli_x: jax.Array,
moduli_y: jax.Array,
moduli_z: jax.Array,
alpha: jax.Array,
volume: jax.Array,
is_batch: bool,
) -> jax.Array:
"""Fused Green's function + structure-factor multiply, single launch.
Replaces (compute G(k), compute C^2(k), divide mesh_fft by C^2, multiply
by G(k)) with a single warp kernel. ``moduli_x/y/z`` are precomputed
1D B-spline modulus LUTs (``sinc(m/N)^spline_order`` per axis); see
``compute_bspline_moduli_1d``.
Parameters
----------
mesh_fft : complex64 or complex128
FFT of the charge mesh. Shape (Nx, Ny, Nz_rfft) for single system
or (B, Nx, Ny, Nz_rfft) for batch.
k_squared : float32 or float64
|k|^2 at each grid point. Same leading shape as mesh_fft.
moduli_x, moduli_y, moduli_z : float32 or float64
Per-axis B-spline modulus LUTs.
alpha : float32 or float64
Ewald splitting parameter. Shape (1,) or (B,).
volume : float32 or float64
Cell volume. Shape (1,) or (B,).
is_batch : bool
Whether this is a batched call.
Returns
-------
convolved_mesh : complex64 or complex128, same shape as mesh_fft.
"""
real_dtype = jnp.float32 if mesh_fft.dtype == jnp.complex64 else jnp.float64
complex_dtype = mesh_fft.dtype
input_dtype = _normalize_dtype(real_dtype)
# generate_k_vectors_pme squeezes the batch dim when B=1 — restore it
# for the batch kernel which expects (B, nx, ny, nz_r).
squeeze_output = False
if is_batch and k_squared.ndim == 3:
k_squared = k_squared[jnp.newaxis, ...]
if is_batch and mesh_fft.ndim == 3:
mesh_fft = mesh_fft[jnp.newaxis, ...]
squeeze_output = True
# Reinterpret complex (N, ..., M) -> real (N, ..., M, 2) for the
# vec2-typed warp kernel. jax.lax.bitcast_convert_type doesn't accept
# complex→float, so use .view() (doubles the trailing dim) and then
# reshape to add the explicit vec2 axis.
mesh_fft_real = mesh_fft.view(real_dtype).reshape(*mesh_fft.shape, 2)
# Ensure alpha / volume are 1-D arrays of the right dtype.
alpha = alpha.astype(real_dtype)
volume = volume.astype(real_dtype)
if alpha.ndim == 0:
alpha = alpha.reshape(1)
if volume.ndim == 0:
volume = volume.reshape(1)
moduli_x = moduli_x.astype(real_dtype)
moduli_y = moduli_y.astype(real_dtype)
moduli_z = moduli_z.astype(real_dtype)
k_squared = k_squared.astype(real_dtype)
# Pre-allocate output (same shape as mesh_fft_real, treated as in-out
# by the warp kernel).
convolved_real = jnp.zeros_like(mesh_fft_real)
if is_batch:
kernel = _jax_batch_pme_convolve[input_dtype]
else:
kernel = _jax_pme_convolve[input_dtype]
# Launch dims match the spectrum shape (drop the trailing length-2 vec2 dim).
launch_dims = mesh_fft.shape
(convolved_real,) = kernel(
mesh_fft_real,
k_squared,
moduli_x,
moduli_y,
moduli_z,
alpha,
volume,
convolved_real,
launch_dims=launch_dims,
)
# Reverse the reshape+view: collapse the trailing vec2 dim, then view
# as complex (which halves the trailing dim back to the original).
convolved_flat = convolved_real.reshape(*mesh_fft.shape[:-1], -1)
convolved = convolved_flat.view(complex_dtype)
if squeeze_output:
convolved = convolved.squeeze(0)
return convolved
def pme_green_structure_factor(
k_squared: jax.Array,
mesh_dimensions: tuple[int, int, int],
alpha: jax.Array,
cell: jax.Array,
spline_order: int = 4,
batch_idx: jax.Array | None = None,
) -> tuple[jax.Array, jax.Array]:
r"""Compute Green's function and B-spline structure factor correction.
Computes the Coulomb Green's function with volume normalization and the
B-spline aliasing correction factor for PME.
Green's function (volume-normalized):
.. math::
G(\mathbf{k}) = \frac{2\pi}{V} \frac{\exp(-k^2 / (4\alpha^2))}{k^2}
Structure factor correction (for B-spline deconvolution):
.. math::
C^2(\mathbf{k}) = \left[\operatorname{sinc}(m_x/N_x) \cdot
\operatorname{sinc}(m_y/N_y) \cdot \operatorname{sinc}(m_z/N_z)\right]^{2p}
where :math:`p` is the spline order.
Parameters
----------
k_squared : jax.Array
:math:`|k|^2` values at each FFT grid point.
- Single-system: shape (Nx, Ny, Nz_rfft)
- Batch: shape (B, Nx, Ny, Nz_rfft)
mesh_dimensions : tuple[int, int, int]
Full mesh dimensions (Nx, Ny, Nz) before rfft.
alpha : jax.Array
Ewald splitting parameter.
- Single-system: shape (1,) or scalar
- Batch: shape (B,)
cell : jax.Array
Unit cell matrices.
- Single-system: shape (3, 3) or (1, 3, 3)
- Batch: shape (B, 3, 3)
spline_order : int, default=4
B-spline interpolation order (typically 4 for cubic B-splines).
batch_idx : jax.Array | None, default=None
If provided, dispatches to batch kernels.
Returns
-------
green_function : jax.Array
Volume-normalized Green's function G(k).
- Single-system: shape (Nx, Ny, Nz_rfft)
- Batch: shape (B, Nx, Ny, Nz_rfft)
structure_factor_sq : jax.Array
Squared structure factor :math:`C^2(k)` for B-spline deconvolution.
Shape (Nx, Ny, Nz_rfft), shared across batch.
Notes
-----
- G(k=0) is set to zero to avoid singularity
- The volume normalization in G(k) eliminates later divisions
- Structure factor is mesh-dependent only, so shared across batch
- This compatibility helper returns raw ``G(k)``. The fused convolve path
folds deconvolution internally as ``G(k) / C^2(k)``.
"""
mesh_nx, mesh_ny, mesh_nz = mesh_dimensions
input_dtype = _normalize_dtype(k_squared.dtype)
# Ensure cell is correct shape
if cell.ndim == 2:
cell = cell[jnp.newaxis, :, :]
volume = jnp.abs(jnp.linalg.det(cell)).astype(input_dtype)
# Generate Miller indices using JAX FFT frequency functions
# Use d=1.0/n to get integer Miller indices
miller_x = jnp.fft.fftfreq(mesh_nx, d=1.0 / mesh_nx).astype(input_dtype)
miller_y = jnp.fft.fftfreq(mesh_ny, d=1.0 / mesh_ny).astype(input_dtype)
miller_z = jnp.fft.rfftfreq(mesh_nz, d=1.0 / mesh_nz).astype(input_dtype)
# Ensure alpha is 1D array
if alpha.ndim == 0:
alpha = alpha.reshape(1)
alpha = alpha.astype(input_dtype)
# Get kernel for input dtype
if batch_idx is None:
# Single system
kernel = _jax_pme_green_sf[input_dtype]
# Allocate outputs
green_function = jnp.zeros(
(mesh_nx, mesh_ny, mesh_nz // 2 + 1), dtype=input_dtype
)
structure_factor_sq = jnp.zeros(
(mesh_nx, mesh_ny, mesh_nz // 2 + 1), dtype=input_dtype
)
# Launch kernel
green_out, sf_out = kernel(
k_squared.astype(input_dtype),
miller_x,
miller_y,
miller_z,
alpha,
volume,
int(mesh_nx),
int(mesh_ny),
int(mesh_nz),
int(spline_order),
green_function,
structure_factor_sq,
launch_dims=(mesh_nx, mesh_ny, mesh_nz // 2 + 1),
)
return green_out, sf_out
else:
# Batch
num_systems = cell.shape[0]
kernel = _jax_batch_pme_green_sf[input_dtype]
# Ensure k_squared has batch dimension for batch kernels
k_sq = k_squared.astype(input_dtype)
if k_sq.ndim == 3:
k_sq = jnp.broadcast_to(
k_sq[jnp.newaxis], (num_systems, mesh_nx, mesh_ny, mesh_nz // 2 + 1)
)
# Allocate outputs
green_function = jnp.zeros(
(num_systems, mesh_nx, mesh_ny, mesh_nz // 2 + 1), dtype=input_dtype
)
structure_factor_sq = jnp.zeros(
(mesh_nx, mesh_ny, mesh_nz // 2 + 1), dtype=input_dtype
)
# Launch kernel
green_out, sf_out = kernel(
k_sq,
miller_x,
miller_y,
miller_z,
alpha,
volume,
int(mesh_nx),
int(mesh_ny),
int(mesh_nz),
int(spline_order),
green_function,
structure_factor_sq,
launch_dims=(num_systems, mesh_nx, mesh_ny, mesh_nz // 2 + 1),
)
return green_out, sf_out
def pme_virial_bg_correction(
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
virial: jax.Array,
batch_idx: jax.Array | None = None,
volume: jax.Array | None = None,
) -> jax.Array:
r"""Apply non-neutral background virial correction in a single Warp launch.
Two-pass fused kernel:
1. Reduce per-atom ``charges`` into per-system totals (atomic_add).
2. Compute :math:`E_\text{bg} = \pi Q^2 / (2 \alpha^2 V)` per system and subtract it from
the three diagonal entries of ``virial`` (off-diagonal unchanged).
Single-system inputs are fanned out via ``batch_idx`` filled with zeros.
Parameters
----------
charges : jax.Array, shape (N,)
Per-atom charges.
cell : jax.Array, shape (3, 3) or (B, 3, 3)
Unit cell. Single-system 2D is promoted to (1, 3, 3).
alpha : jax.Array, shape () / (1,) / (B,)
Per-system Ewald splitting parameter.
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3)
Virial tensor to correct in place (functional return).
batch_idx : jax.Array | None, shape (N,), dtype=int32, optional
System index per atom. If None, every atom maps to system 0.
volume : jax.Array | None, optional
Precomputed per-system cell volume. Treated as static metadata when
supplied.
Returns
-------
virial_out : jax.Array, same shape as ``virial``
Background-corrected virial.
"""
input_dtype = _normalize_dtype(charges.dtype)
cell_w = cell.astype(input_dtype)
if cell_w.ndim == 2:
cell_w = cell_w[jnp.newaxis, :, :]
num_systems = cell_w.shape[0]
num_atoms = charges.shape[0]
alpha_w = alpha.astype(input_dtype)
if alpha_w.ndim == 0:
alpha_w = alpha_w.reshape(1)
if alpha_w.shape[0] == 1 and num_systems > 1:
alpha_w = jnp.broadcast_to(alpha_w, (num_systems,))
if batch_idx is None:
bidx = jnp.zeros(num_atoms, dtype=jnp.int32)
else:
bidx = batch_idx.astype(jnp.int32)
virial_w = virial.astype(input_dtype)
if virial_w.ndim == 2:
virial_w = virial_w[jnp.newaxis, :, :]
total_charges = jnp.zeros(num_systems, dtype=input_dtype)
if volume is not None:
total_charges = total_charges.at[bidx].add(charges.astype(input_dtype))
volume_w = volume.astype(input_dtype)
if volume_w.ndim == 0:
volume_w = volume_w.reshape(1)
if volume_w.shape[0] == 1 and num_systems > 1:
volume_w = jnp.broadcast_to(volume_w, (num_systems,))
bg_energy = (
jnp.pi
* total_charges
* total_charges
/ (2.0 * alpha_w * alpha_w * volume_w)
)
eye = jnp.eye(3, dtype=input_dtype)
diag_delta = bg_energy[:, None, None] * eye[jnp.newaxis, :, :]
return virial_w - diag_delta
# Fused two-pass via jax_callable: a single XLA FFI thunk that runs both
# the scatter-add reduce (pass 1) and the per-system E_bg apply (pass 2).
# This collapses what used to be 2 jax_kernel thunks into 1 and lets JAX
# CUDA-graph-capture both passes together.
fused = _jax_pme_virial_bg_fused[input_dtype]
_total_charges, virial_out = fused(
charges.astype(input_dtype),
bidx,
cell_w,
alpha_w,
virial_w,
total_charges, # in/out — zero-initialized scatter-add target
output_dims={"virial_out": virial_w.shape},
)
return virial_out
def pme_energy_corrections(
raw_energies: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None = None,
volume: jax.Array | None = None,
) -> jax.Array:
r"""Apply self-energy and background corrections to PME energies.
Converts raw interpolated potential to energy and subtracts corrections:
.. math::
E_i = q_i \varphi_i - E_{\text{self},i} - E_{\text{bg},i}
Self-energy correction (removes Gaussian self-interaction):
.. math::
E_{\text{self},i} = \frac{\alpha}{\sqrt{\pi}} q_i^2
Background correction (for non-neutral systems):
.. math::
E_{\text{bg},i} = \frac{\pi}{2\alpha^2 V} q_i Q_\text{total}
Parameters
----------
raw_energies : jax.Array, shape (N,) or (N_total,)
Raw potential values :math:`\varphi_i` from mesh interpolation.
charges : jax.Array, shape (N,) or (N_total,)
Atomic charges.
cell : jax.Array
Unit cell matrices.
- Single-system: shape (3, 3) or (1, 3, 3)
- Batch: shape (B, 3, 3)
alpha : jax.Array
Ewald splitting parameter.
- Single-system: shape (1,) or scalar
- Batch: shape (B,)
batch_idx : jax.Array | None, default=None
System index for each atom. If provided, uses batch kernels. Atoms must
be grouped by system: ``batch_idx`` must be contiguous, nondecreasing,
and use system IDs ``0..B-1``.
volume : jax.Array | None, optional
Precomputed per-system cell volume. Treated as static setup metadata
when supplied.
Returns
-------
corrected_energies : jax.Array, shape (N,) or (N_total,)
Final per-atom reciprocal-space energy with corrections applied.
Notes
-----
- For neutral systems, background correction is zero
- Supports both float32 and float64 dtypes
"""
input_dtype = _normalize_dtype(raw_energies.dtype)
num_atoms = raw_energies.shape[0]
# Ensure alpha is 1D array
if alpha.ndim == 0:
alpha = alpha.reshape(1)
alpha = alpha.astype(input_dtype)
if batch_idx is None:
# Single system
kernel = _jax_pme_energy_corrections[input_dtype]
# Ensure cell is correct shape
if cell.ndim == 2:
cell = cell[jnp.newaxis, :, :]
if volume is None:
volume = jnp.abs(jnp.linalg.det(cell)).astype(input_dtype)
else:
volume = volume.astype(input_dtype)
if volume.ndim == 0:
volume = volume.reshape(1)
total_charge = charges.sum().reshape(1).astype(input_dtype)
# Allocate output
corrected_energies = jnp.zeros(num_atoms, dtype=input_dtype)
batch_idx_dummy = jnp.zeros((num_atoms,), dtype=jnp.int32)
charge_gradients_dummy = jnp.zeros(num_atoms, dtype=input_dtype)
# Launch kernel
(corrected_out,) = kernel(
raw_energies.astype(input_dtype),
charges.astype(input_dtype),
batch_idx_dummy,
volume,
alpha,
total_charge,
corrected_energies,
charge_gradients_dummy,
launch_dims=(num_atoms,),
)
return corrected_out
else:
# Batch
kernel = _jax_batch_pme_energy_corrections[input_dtype]
num_systems = cell.shape[0] if cell.ndim == 3 else 1
if cell.ndim == 2:
cell = cell[jnp.newaxis, :, :]
if volume is None:
volumes = jnp.abs(jnp.linalg.det(cell)).astype(input_dtype)
else:
volumes = volume.astype(input_dtype)
if volumes.ndim == 0:
volumes = volumes.reshape(1)
if volumes.shape[0] == 1 and num_systems > 1:
volumes = jnp.broadcast_to(volumes, (num_systems,))
# Compute total charge per system
total_charges = jnp.zeros(num_systems, dtype=input_dtype)
total_charges = total_charges.at[batch_idx].add(charges.astype(input_dtype))
# Allocate output
corrected_energies = jnp.zeros(num_atoms, dtype=input_dtype)
charge_gradients_dummy = jnp.zeros(num_atoms, dtype=input_dtype)
# Launch kernel
(corrected_out,) = kernel(
raw_energies.astype(input_dtype),
charges.astype(input_dtype),
batch_idx.astype(jnp.int32),
volumes,
alpha,
total_charges,
corrected_energies,
charge_gradients_dummy,
launch_dims=(num_atoms,),
)
return corrected_out
def pme_energy_corrections_with_charge_grad(
raw_energies: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
batch_idx: jax.Array | None = None,
volume: jax.Array | None = None,
) -> tuple[jax.Array, jax.Array]:
r"""Apply energy corrections and compute charge gradients.
Same as pme_energy_corrections but also returns :math:`\partial E/\partial q`
for each atom.
Parameters
----------
raw_energies : jax.Array, shape (N,) or (N_total,)
Raw potential values :math:`\varphi_i` from mesh interpolation.
charges : jax.Array, shape (N,) or (N_total,)
Atomic charges.
cell : jax.Array
Unit cell matrices.
- Single-system: shape (3, 3) or (1, 3, 3)
- Batch: shape (B, 3, 3)
alpha : jax.Array
Ewald splitting parameter.
- Single-system: shape (1,) or scalar
- Batch: shape (B,)
batch_idx : jax.Array | None, default=None
System index for each atom. If provided, uses batch kernels. Atoms must
be grouped by system: ``batch_idx`` must be contiguous, nondecreasing,
and use system IDs ``0..B-1``.
volume : jax.Array | None, optional
Precomputed per-system cell volume. Treated as static setup metadata
when supplied.
Returns
-------
corrected_energies : jax.Array, shape (N,) or (N_total,)
Final per-atom reciprocal-space energy with corrections applied.
charge_gradients : jax.Array, shape (N,) or (N_total,)
Per-atom charge gradients :math:`\partial E/\partial q`.
Notes
-----
- Useful for training models that predict partial charges
- Supports both float32 and float64 dtypes
"""
input_dtype = _normalize_dtype(raw_energies.dtype)
num_atoms = raw_energies.shape[0]
# Ensure alpha is 1D array
if alpha.ndim == 0:
alpha = alpha.reshape(1)
alpha = alpha.astype(input_dtype)
if batch_idx is None:
# Single system
kernel = _jax_pme_energy_corrections_charge_grad[input_dtype]
# Ensure cell is correct shape
if cell.ndim == 2:
cell = cell[jnp.newaxis, :, :]
if volume is None:
volume = jnp.abs(jnp.linalg.det(cell)).astype(input_dtype)
else:
volume = volume.astype(input_dtype)
if volume.ndim == 0:
volume = volume.reshape(1)
total_charge = charges.sum().reshape(1).astype(input_dtype)
# Allocate outputs
corrected_energies = jnp.zeros(num_atoms, dtype=input_dtype)
charge_gradients = jnp.zeros(num_atoms, dtype=input_dtype)
batch_idx_dummy = jnp.zeros((num_atoms,), dtype=jnp.int32)
# Launch kernel
corrected_out, charge_grad_out = kernel(
raw_energies.astype(input_dtype),
charges.astype(input_dtype),
batch_idx_dummy,
volume,
alpha,
total_charge,
corrected_energies,
charge_gradients,
launch_dims=(num_atoms,),
)
return corrected_out, charge_grad_out
else:
# Batch
kernel = _jax_batch_pme_energy_corrections_charge_grad[input_dtype]
num_systems = cell.shape[0] if cell.ndim == 3 else 1
if cell.ndim == 2:
cell = cell[jnp.newaxis, :, :]
if volume is None:
volumes = jnp.abs(jnp.linalg.det(cell)).astype(input_dtype)
else:
volumes = volume.astype(input_dtype)
if volumes.ndim == 0:
volumes = volumes.reshape(1)
if volumes.shape[0] == 1 and num_systems > 1:
volumes = jnp.broadcast_to(volumes, (num_systems,))
# Compute total charge per system
total_charges = jnp.zeros(num_systems, dtype=input_dtype)
total_charges = total_charges.at[batch_idx].add(charges.astype(input_dtype))
# Allocate outputs
corrected_energies = jnp.zeros(num_atoms, dtype=input_dtype)
charge_gradients = jnp.zeros(num_atoms, dtype=input_dtype)
# Launch kernel
corrected_out, charge_grad_out = kernel(
raw_energies.astype(input_dtype),
charges.astype(input_dtype),
batch_idx.astype(jnp.int32),
volumes,
alpha,
total_charges,
corrected_energies,
charge_gradients,
launch_dims=(num_atoms,),
)
return corrected_out, charge_grad_out
def _compute_pme_reciprocal_virial(
mesh_fft_raw: jax.Array,
convolved_mesh: jax.Array,
k_vectors: jax.Array,
k_squared: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int],
is_batch: bool,
) -> jax.Array:
r"""Compute PME reciprocal-space virial tensor in k-space.
Uses the exact spectral pair from the pipeline (mesh_fft_raw before
deconvolution, and convolved_mesh after Green's function multiplication)
to compute the per-k energy density directly via Parseval's theorem.
The virial per k-point is W_ab(k) = E_k * sigma_ab(k) where:
- E_k = prefactor * weight(k) * Re(mesh_fft_raw(k) * convolved_mesh(k)*)
- sigma_ab(k) = delta_ab - 2*k_a*k_b/k^2 * (1 + k^2/(4*alpha^2))
(sign reflects :math:`W = -dE/d\varepsilon` convention)
Parameters
----------
mesh_fft_raw : jax.Array
Raw rfftn output before B-spline deconvolution.
Shape (nx, ny, nz//2+1) or (B, nx, ny, nz//2+1), complex.
convolved_mesh : jax.Array
Deconvolved mesh FFT multiplied by Green's function: (mesh_fft/B^2)*G.
Shape matching mesh_fft_raw.
k_vectors : jax.Array
k-vectors on the mesh. Shape (..., nx, ny, nz//2+1, 3).
k_squared : jax.Array
|k|^2. Shape (..., nx, ny, nz//2+1).
alpha : jax.Array
Ewald splitting parameter.
mesh_dimensions : tuple
(nx, ny, nz).
is_batch : bool
Whether this is a batched calculation.
Returns
-------
virial : jax.Array, shape (B, 3, 3) or (1, 3, 3)
Per-system virial tensor.
"""
mesh_nx, mesh_ny, mesh_nz = mesh_dimensions
# Determine accumulation dtype from k_squared (float32 or float64)
acc_dtype = _normalize_dtype(k_squared.dtype)
complex_dtype = jnp.complex64 if acc_dtype == jnp.float32 else jnp.complex128
# Per-k energy density from exact pipeline spectral pair.
# Re(mesh_fft_raw * convolved_mesh*) = |mesh_fft_raw|^2 * G / B^2
fft_raw_cast = mesh_fft_raw.astype(complex_dtype)
conv_cast = convolved_mesh.astype(complex_dtype)
energy_density = (fft_raw_cast * jnp.conj(conv_cast)).real
# Weight for rfft symmetry: 2 for interior k_z, 1 for boundary
weight = jnp.full_like(energy_density, 2.0)
weight = weight.at[..., 0].set(1.0) # k_z = 0
if mesh_nz % 2 == 0:
weight = weight.at[..., -1].set(1.0) # k_z = nz//2 (Nyquist)
# Weighted energy density
weighted_energy = weight * energy_density
# Virial W = -dE/dε, so sigma_ab = delta_ab - 2*k_a*k_b/k^2 * (1 + k^2/(4*alpha^2))
k_sq_acc = k_squared.astype(acc_dtype)
alpha_acc = alpha.astype(acc_dtype)
# generate_k_vectors_pme squeezes the batch dim when B=1; restore it so
# the batched einsum and sum_dims=(1,2,3) operate on the correct axes.
if is_batch and k_sq_acc.ndim == 3:
k_sq_acc = jnp.expand_dims(k_sq_acc, axis=0)
# Handle alpha broadcasting: alpha may be (B,) for batch
if is_batch and alpha_acc.ndim == 1:
alpha_view = alpha_acc.reshape(-1, 1, 1, 1)
else:
alpha_view = alpha_acc.reshape(-1) if alpha_acc.ndim == 0 else alpha_acc
exp_factor = 0.25 / (alpha_view**2)
# Avoid division by zero at k=0
safe_k_sq = jnp.maximum(k_sq_acc, 1e-30)
k_factor = 2.0 * (1.0 + k_sq_acc * exp_factor) / safe_k_sq
# Zero out k=0 contribution (no virial from k=0)
k_mask = k_sq_acc > 1e-10
# Six per-component weighted reductions instead of einsum: XLA lowers
# einsum(k,k,m) to a slow small-MN / large-K cuBLAS GEMM.
k_vecs_acc = k_vectors.astype(acc_dtype) # (..., nx, ny, nz//2+1, 3)
if is_batch and k_vecs_acc.ndim == 4:
k_vecs_acc = jnp.expand_dims(k_vecs_acc, axis=0)
masked_energy = weighted_energy * k_mask # (..., nx, ny, nz//2+1)
masked_energy_kf = masked_energy * k_factor # (..., nx, ny, nz//2+1)
if is_batch:
sum_dims = (1, 2, 3)
else:
sum_dims = (0, 1, 2)
trace_term = masked_energy.sum(axis=sum_dims) # scalar or (B,)
kx = k_vecs_acc[..., 0]
ky = k_vecs_acc[..., 1]
kz = k_vecs_acc[..., 2]
xx = (kx * kx * masked_energy_kf).sum(axis=sum_dims)
yy = (ky * ky * masked_energy_kf).sum(axis=sum_dims)
zz = (kz * kz * masked_energy_kf).sum(axis=sum_dims)
xy = (kx * ky * masked_energy_kf).sum(axis=sum_dims)
xz = (kx * kz * masked_energy_kf).sum(axis=sum_dims)
yz = (ky * kz * masked_energy_kf).sum(axis=sum_dims)
eye = jnp.eye(3, dtype=acc_dtype)
if is_batch:
kk_term = jnp.stack(
[
jnp.stack([xx, xy, xz], axis=-1),
jnp.stack([xy, yy, yz], axis=-1),
jnp.stack([xz, yz, zz], axis=-1),
],
axis=-2,
)
virial = eye * trace_term[:, jnp.newaxis, jnp.newaxis] - kk_term # (B, 3, 3)
else:
kk_term = jnp.stack(
[jnp.stack([xx, xy, xz]), jnp.stack([xy, yy, yz]), jnp.stack([xz, yz, zz])],
) # (3, 3)
virial = (eye * trace_term - kk_term)[jnp.newaxis, :, :] # (1, 3, 3)
return virial.astype(acc_dtype)
def _pme_reciprocal_space_impl(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None = None,
mesh_spacing: float | None = None,
spline_order: int = 4,
batch_idx: jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_squared: jax.Array | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
hybrid_forces: bool = False,
volume: jax.Array | None = None,
cell_inv_t: jax.Array | None = None,
moduli_x: jax.Array | None = None,
moduli_y: jax.Array | None = None,
moduli_z: jax.Array | None = None,
) -> (
jax.Array
| tuple[jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array, jax.Array]
):
r"""Compute PME reciprocal-space contribution implementation.
Implements the FFT-based long-range component of PME using B-spline
interpolation and convolution with the Green's function.
Pipeline:
1. Spread charges to mesh (spline_spread)
2. FFT -> frequency space
3. Compute Green's function and structure factor
4. Convolve: mesh_fft * G(k) / C^2(k)
5. IFFT -> potential mesh
6. Gather potential at atoms (spline_gather)
7. Apply self-energy and background corrections
8. (Optional) Compute forces via Fourier gradient
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 with lattice vectors as rows.
alpha : jax.Array
Ewald splitting parameter.
- Single-system: shape (1,) or scalar
- Batch: shape (B,)
mesh_dimensions : tuple[int, int, int], optional
FFT mesh dimensions (nx, ny, nz).
mesh_spacing : float, optional
Target mesh spacing. Used to compute mesh_dimensions if not provided.
spline_order : int, default=4
B-spline interpolation order (4 = cubic).
batch_idx : jax.Array | 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``.
k_vectors : jax.Array, optional
Precomputed k-vectors from generate_k_vectors_pme.
k_squared : jax.Array, optional
Precomputed :math:`k^2` values from generate_k_vectors_pme.
compute_forces : bool, default=False
If True, compute forces via Fourier gradient.
compute_charge_gradients : bool, default=False
If True, compute charge gradients :math:`\partial E/\partial q`.
compute_virial : bool, default=False
If True, compute the virial tensor ``W = -dE/d(displacement)`` for the
row-vector displacement recipe.
Stress = -virial / volume.
hybrid_forces : bool, default=False
If True, detach ``positions``/``charges``/``cell`` from the autograd
graph through the spline/FFT chain (forces and virial become
forward-only), and inject analytical :math:`\partial E/\partial q` via a custom-VJP straight-
through trick so ``jax.grad`` w.r.t. charges propagates correctly.
Forces ``compute_charge_gradients=True``.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom reciprocal-space energies.
forces : jax.Array, shape (N, 3), optional
Per-atom forces (only if compute_forces=True).
charge_gradients : jax.Array, shape (N,), optional
Per-atom charge gradients (only if compute_charge_gradients=True).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor (only if compute_virial=True). Always last in the return tuple.
Notes
-----
- Output dtype for energy/forces matches the input positions dtype
- FFT/convolution and spline operations all respect the input dtype
- Automatically determines mesh_dimensions if not provided
- Virial is computed in k-space and uses the same dtype as k_squared
- Energy-derived gradients are supported for positions, charges, and
strain-first virials. Reverse-mode higher-order reciprocal position and
charge losses use the private PME mesh HVP path.
"""
num_atoms = positions.shape[0]
input_dtype = _normalize_dtype(positions.dtype)
is_batch = batch_idx is not None
fft_dims = (1, 2, 3) if is_batch else (0, 1, 2)
reciprocal_metadata_is_supplied = k_vectors is not None and k_squared is not None
volume_is_supplied = volume is not None
# hybrid_forces: forward-only spline/FFT chain. We sever ∂/∂{positions,
# cell} (and the spline/FFT path through charges) via lax.stop_gradient,
# then re-attach the analytical ∂E/∂q at the end. Charges still need to
# be a tracer for the custom-VJP injector to see them, so save the
# original handle.
charges_orig = charges
need_charge_gradients = compute_charge_gradients or hybrid_forces
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)
if cell_inv_t is not None:
cell_inv_t = jax.lax.stop_gradient(cell_inv_t)
# Ensure cell is correct shape for num_systems calculation
if cell.ndim == 2:
num_systems = 1
else:
num_systems = cell.shape[0]
# Handle empty systems
if num_atoms == 0:
energies = jnp.zeros(num_atoms, dtype=input_dtype)
forces = (
jnp.zeros((num_atoms, 3), dtype=input_dtype) if compute_forces else None
)
charge_grads = (
jnp.zeros(num_atoms, dtype=input_dtype) if need_charge_gradients else None
)
virial = (
jnp.zeros((num_systems, 3, 3), dtype=input_dtype)
if compute_virial
else None
)
return _build_electrostatic_result(
energies,
forces,
charge_grads,
virial,
compute_forces,
compute_charge_gradients,
compute_virial,
)
_require_explicit_mesh_dimensions_in_tracing(
mesh_dimensions=mesh_dimensions,
cell=cell,
alpha=alpha,
batch_idx=batch_idx,
)
# Determine mesh dimensions
if mesh_dimensions is None:
if mesh_spacing is not None:
mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing)
else:
# Default estimation
mesh_dimensions = estimate_pme_mesh_dimensions(cell, alpha, accuracy=1e-6)
mesh_nx, mesh_ny, mesh_nz = mesh_dimensions
# cell_inv_t cache: MD callers can pass cell_inv_t in (NVT case)
# to skip the per-step linalg.inv + transpose. We compute it once here
# and forward to spline_spread / spline_gather(_with_force), so any
# work upstream of the kernel doesn't get duplicated.
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
if cell_inv_t is None:
cell_inv = jnp.linalg.inv(cell_3d)
cell_inv_t = jnp.transpose(cell_inv, (0, 2, 1)).astype(input_dtype)
else:
cell_inv_t = cell_inv_t.astype(input_dtype)
if cell_inv_t.ndim == 2:
cell_inv_t = cell_inv_t[jnp.newaxis, :, :]
cell_inv_t = jax.lax.stop_gradient(cell_inv_t)
cell_inv = jnp.transpose(cell_inv_t, (0, 2, 1))
# Step 1: Spread charges to mesh
mesh_grid = spline_spread(
positions,
charges,
cell,
mesh_dims=mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t,
)
# Step 2: FFT of charge mesh
mesh_fft = jnp.fft.rfftn(mesh_grid, axes=fft_dims, norm="backward")
# Step 3: Generate k-space grid and compute Green's function + structure factor.
# When cell_inv_t is supplied, derive reciprocal_cell = 2π · cell_inv from
# the cached transpose so generate_k_vectors_pme skips its own inv.
if k_vectors is None or k_squared is None:
reciprocal_cell = (2.0 * jnp.pi) * cell_inv
k_vectors, k_squared = generate_k_vectors_pme(
cell,
mesh_dimensions,
reciprocal_cell=reciprocal_cell,
)
if hybrid_forces or reciprocal_metadata_is_supplied:
k_vectors = jax.lax.stop_gradient(k_vectors)
k_squared = jax.lax.stop_gradient(k_squared)
# Step 4: Fused Green's function + B-spline deconvolution + multiply in a
# single warp kernel. Replaces the prior 2-pass path that
# called pme_green_structure_factor then divided/multiplied in JAX.
# Caller can supply moduli_x/y/z to skip the per-call fftfreq + sinc^p
# rebuild (they only depend on mesh + spline_order).
if moduli_x is None or moduli_y is None or moduli_z is None:
miller_x = jnp.fft.fftfreq(mesh_nx, d=1.0 / mesh_nx).astype(input_dtype)
miller_y = jnp.fft.fftfreq(mesh_ny, d=1.0 / mesh_ny).astype(input_dtype)
miller_z = jnp.fft.rfftfreq(mesh_nz, d=1.0 / mesh_nz).astype(input_dtype)
moduli_x = compute_bspline_moduli_1d(miller_x, mesh_nx, spline_order)
moduli_y = compute_bspline_moduli_1d(miller_y, mesh_ny, spline_order)
moduli_z = compute_bspline_moduli_1d(miller_z, mesh_nz, spline_order)
else:
moduli_x = jax.lax.stop_gradient(moduli_x)
moduli_y = jax.lax.stop_gradient(moduli_y)
moduli_z = jax.lax.stop_gradient(moduli_z)
# Caller-supplied volume= short-circuits the linalg.det.
if volume is None:
volume = jnp.abs(jnp.linalg.det(cell_3d)).astype(input_dtype)
else:
volume = volume.astype(input_dtype)
if volume.ndim == 0:
volume = volume.reshape(1)
if hybrid_forces or volume_is_supplied:
volume = jax.lax.stop_gradient(volume)
complex_dtype = jnp.complex64 if input_dtype == jnp.float32 else jnp.complex128
mesh_fft = mesh_fft.astype(complex_dtype)
# Raw FFT (before convolve) is what the virial path needs.
mesh_fft_raw = mesh_fft if compute_virial else None
convolved_mesh = pme_fused_convolve(
mesh_fft,
k_squared.astype(input_dtype),
moduli_x,
moduli_y,
moduli_z,
alpha,
volume,
is_batch,
)
# Step 5: Compute virial before forces to allow early release of mesh_fft_raw
# (virial needs mesh_fft_raw; forces only need convolved_mesh)
virial = None
if compute_virial:
virial = _compute_pme_reciprocal_virial(
mesh_fft_raw=mesh_fft_raw,
convolved_mesh=convolved_mesh,
k_vectors=k_vectors,
k_squared=k_squared,
alpha=alpha,
mesh_dimensions=mesh_dimensions,
is_batch=is_batch,
)
del mesh_fft_raw # Free before force field meshes are allocated
# Fused 2-pass warp kernel: reduce per-atom charges → per-system Q,
# then subtract E_bg = π Q² / (2 α² V) from the virial diagonal.
# Matches the torch ``pme_virial_bg_correction`` path. Single-system
# is fanned out internally via batch_idx=zeros.
virial = pme_virial_bg_correction(
charges=charges,
cell=cell,
alpha=alpha,
virial=virial,
batch_idx=batch_idx,
volume=volume,
)
# Step 6: Inverse FFT to get potential mesh
potential_mesh = jnp.fft.irfftn(
convolved_mesh, s=mesh_dimensions, axes=fft_dims, norm="forward"
)
# Step 6: Interpolate potential to atomic positions. With forces requested,
# use the fused gather kernel that walks the spline stencil
# ONCE per atom and emits both potential AND spline-derivative force,
# avoiding the 3 extra IFFTs + spline_gather_vec3 of the Fourier-gradient
# path. Matches the torch reciprocal-space path.
if compute_forces:
raw_energies, gathered_force = _spline_gather_with_force(
positions,
charges,
potential_mesh,
cell,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t,
)
else:
raw_energies = spline_gather(
positions,
potential_mesh,
cell,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t,
)
gathered_force = None
# Step 7: Apply corrections
if need_charge_gradients:
energies, charge_grads = pme_energy_corrections_with_charge_grad(
raw_energies, charges, cell, alpha, batch_idx, volume=volume
)
else:
energies = pme_energy_corrections(
raw_energies, charges, cell, alpha, batch_idx, volume=volume
)
charge_grads = None
# Step 8: Forces from the fused gather above. The 2× scaling absorbs the
# 1/2 pair-counting factor baked into the Green's function
# (G = 2π/(V k²) instead of 4π/(V k²)).
forces = 2.0 * gathered_force if compute_forces else None
# Hybrid-forces: route ∂E/∂q through the analytical kernel-computed
# ``charge_grads`` via a custom-VJP straight-through, using the original
# (non-detached) ``charges`` so jax.grad reaches them.
if hybrid_forces:
bidx_for_inject = (
batch_idx
if batch_idx is not None
else jnp.zeros(num_atoms, dtype=jnp.int32)
)
energies = _inject_charge_grad(
energies,
charges_orig,
charge_grads,
batch_idx is not None,
bidx_for_inject,
num_systems,
)
return _build_electrostatic_result(
energies,
forces,
charge_grads,
virial,
compute_forces,
compute_charge_gradients,
compute_virial,
)
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_traced_array(value) -> bool:
"""Return whether ``value`` is a JAX tracer inside transformations."""
return isinstance(value, jax.core.Tracer)
def _require_explicit_mesh_dimensions_in_tracing(
*,
mesh_dimensions: tuple[int, int, int] | None,
cell: jax.Array,
alpha: jax.Array | float | None,
batch_idx: jax.Array | None = None,
) -> None:
"""Reject auto PME mesh sizing under JAX tracing with a clear message."""
is_traced = (
_is_traced_array(cell) or _is_traced_array(alpha) or _is_traced_array(batch_idx)
)
if is_traced and alpha is None and mesh_dimensions is None:
raise ValueError(
"JAX PME requires explicit alpha and explicit mesh_dimensions inside "
"jax.jit or other JAX transformations. Compute PME parameters outside "
"the transformed function and pass alpha and mesh_dimensions=(nx, ny, nz) "
"explicitly."
)
if is_traced and alpha is None:
raise ValueError(
"JAX PME requires explicit alpha inside jax.jit or other JAX "
"transformations. Compute PME parameters outside the transformed "
"function and pass alpha explicitly."
)
if mesh_dimensions is not None:
return
if is_traced:
raise ValueError(
"JAX PME requires explicit mesh_dimensions inside jax.jit or other "
"JAX transformations. Compute mesh_spacing/accuracy-based mesh sizing "
"outside the transformed function and pass mesh_dimensions=(nx, ny, nz)."
)
def _tangent_or_zeros(tangent, primal: jax.Array, dtype=None) -> jax.Array:
"""Materialize a custom-JVP tangent, replacing symbolic zeros."""
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 _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 _bspline_weight_reference(u: jax.Array, order: int) -> jax.Array:
"""Pure-JAX cardinal B-spline basis for custom-JVP reference tangents."""
dtype = u.dtype
if order == 1:
return jnp.where((u >= 0.0) & (u < 1.0), 1.0, 0.0).astype(dtype)
result = jnp.zeros_like(u)
for j in range(order + 1):
coeff = (-1.0 if j % 2 else 1.0) * float(math.comb(order, j))
result = result + jnp.asarray(coeff, dtype=dtype) * jnp.maximum(
u - jnp.asarray(float(j), dtype=dtype), 0.0
) ** (order - 1)
return result / jnp.asarray(float(math.factorial(order - 1)), dtype=dtype)
def _reference_cell_inv_t(
cell: jax.Array,
cell_inv_t: jax.Array | None,
dtype,
) -> jax.Array:
"""Return cell inverse-transpose using supplied static metadata if present."""
if cell_inv_t is None:
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
return jnp.transpose(jnp.linalg.inv(cell_3d.astype(dtype)), (0, 2, 1))
cell_inv_t = jax.lax.stop_gradient(cell_inv_t).astype(dtype)
if cell_inv_t.ndim == 2:
cell_inv_t = cell_inv_t[jnp.newaxis, :, :]
return cell_inv_t
def _reference_atom_systems(
positions: jax.Array,
batch_idx: jax.Array | None,
) -> jax.Array:
"""Return an int32 system id per atom."""
if batch_idx is None:
return jnp.zeros((positions.shape[0],), dtype=jnp.int32)
return batch_idx.astype(jnp.int32)
def _spline_spread_reference(
positions: jax.Array,
values: jax.Array,
cell_inv_t: jax.Array,
mesh_dimensions: tuple[int, int, int],
spline_order: int,
batch_idx: jax.Array | None,
num_systems: int,
) -> jax.Array:
"""Pure-JAX charge spread with the production B-spline stencil."""
dtype = _normalize_dtype(positions.dtype)
nx, ny, nz = mesh_dimensions
atom_system = _reference_atom_systems(positions, batch_idx)
dims = jnp.asarray(mesh_dimensions, dtype=dtype)
frac = jnp.einsum(
"nij,nj->ni",
cell_inv_t[atom_system].astype(dtype),
positions.astype(dtype),
)
mesh_coords = frac * dims
base = jnp.floor(mesh_coords).astype(jnp.int32)
theta = mesh_coords - base.astype(dtype)
mesh_shape = (nx, ny, nz) if batch_idx is None else (num_systems, nx, ny, nz)
mesh = jnp.zeros(mesh_shape, dtype=dtype)
half_n_minus_1 = jnp.asarray(0.5 * float(spline_order - 2), dtype=dtype)
half_order = jnp.asarray(0.5 * float(spline_order), dtype=dtype)
starts = jnp.floor(theta - half_n_minus_1).astype(jnp.int32)
for ox in range(spline_order):
off_x = starts[:, 0] + ox
gx = jnp.mod(base[:, 0] + off_x, nx)
wx = _bspline_weight_reference(
half_order + theta[:, 0] - off_x.astype(dtype), spline_order
)
for oy in range(spline_order):
off_y = starts[:, 1] + oy
gy = jnp.mod(base[:, 1] + off_y, ny)
wy = _bspline_weight_reference(
half_order + theta[:, 1] - off_y.astype(dtype), spline_order
)
for oz in range(spline_order):
off_z = starts[:, 2] + oz
gz = jnp.mod(base[:, 2] + off_z, nz)
wz = _bspline_weight_reference(
half_order + theta[:, 2] - off_z.astype(dtype), spline_order
)
contrib = values.astype(dtype) * wx * wy * wz
if batch_idx is None:
mesh = mesh.at[gx, gy, gz].add(contrib)
else:
mesh = mesh.at[atom_system, gx, gy, gz].add(contrib)
return mesh
def _spline_gather_reference(
positions: jax.Array,
mesh: jax.Array,
cell_inv_t: jax.Array,
spline_order: int,
batch_idx: jax.Array | None,
) -> jax.Array:
"""Pure-JAX mesh gather with the production B-spline stencil."""
dtype = _normalize_dtype(positions.dtype)
nx, ny, nz = mesh.shape[-3:]
atom_system = _reference_atom_systems(positions, batch_idx)
dims = jnp.asarray((nx, ny, nz), dtype=dtype)
frac = jnp.einsum(
"nij,nj->ni",
cell_inv_t[atom_system].astype(dtype),
positions.astype(dtype),
)
mesh_coords = frac * dims
base = jnp.floor(mesh_coords).astype(jnp.int32)
theta = mesh_coords - base.astype(dtype)
output = jnp.zeros((positions.shape[0],), dtype=dtype)
half_n_minus_1 = jnp.asarray(0.5 * float(spline_order - 2), dtype=dtype)
half_order = jnp.asarray(0.5 * float(spline_order), dtype=dtype)
starts = jnp.floor(theta - half_n_minus_1).astype(jnp.int32)
for ox in range(spline_order):
off_x = starts[:, 0] + ox
gx = jnp.mod(base[:, 0] + off_x, nx)
wx = _bspline_weight_reference(
half_order + theta[:, 0] - off_x.astype(dtype), spline_order
)
for oy in range(spline_order):
off_y = starts[:, 1] + oy
gy = jnp.mod(base[:, 1] + off_y, ny)
wy = _bspline_weight_reference(
half_order + theta[:, 1] - off_y.astype(dtype), spline_order
)
for oz in range(spline_order):
off_z = starts[:, 2] + oz
gz = jnp.mod(base[:, 2] + off_z, nz)
wz = _bspline_weight_reference(
half_order + theta[:, 2] - off_z.astype(dtype), spline_order
)
if batch_idx is None:
mesh_values = mesh[gx, gy, gz]
else:
mesh_values = mesh[atom_system, gx, gy, gz]
output = output + mesh_values.astype(dtype) * wx * wy * wz
return output
def _pme_energy_corrections_reference(
raw_energies: jax.Array,
charges: jax.Array,
alpha: jax.Array,
volume: jax.Array,
batch_idx: jax.Array | None,
num_systems: int,
) -> jax.Array:
"""Pure-JAX PME self/background correction for reference tangents."""
dtype = _normalize_dtype(raw_energies.dtype)
charges = charges.astype(jnp.float64)
raw = raw_energies.astype(jnp.float64)
alpha_arr = _pme_alpha_array(alpha, jnp.float64, num_systems)
volume = volume.astype(jnp.float64)
if volume.ndim == 0:
volume = volume.reshape(1)
if volume.shape[0] == 1 and num_systems > 1:
volume = jnp.broadcast_to(volume, (num_systems,))
atom_system = _reference_atom_systems(raw_energies, batch_idx)
total_charges = _system_sum_from_atoms(charges, batch_idx, num_systems)
alpha_atom = alpha_arr[atom_system]
volume_atom = volume[atom_system]
total_atom = total_charges[atom_system]
self_energy = alpha_atom * charges * charges / jnp.sqrt(jnp.pi)
background = (
jnp.pi * charges * total_atom / (2.0 * alpha_atom * alpha_atom * volume_atom)
)
return (raw * charges - self_energy - background).astype(dtype)
def _pme_convolve_reference(
mesh_fft: jax.Array,
k_squared: jax.Array,
moduli_x: jax.Array,
moduli_y: jax.Array,
moduli_z: jax.Array,
alpha: jax.Array,
volume: jax.Array,
is_batch: bool,
) -> jax.Array:
"""Pure-JAX equivalent of the fused PME convolve multiplier."""
real_dtype = jnp.float32 if mesh_fft.dtype == jnp.complex64 else jnp.float64
alpha = _pme_alpha_array(alpha, real_dtype, volume.shape[0])
volume = volume.astype(real_dtype)
if volume.ndim == 0:
volume = volume.reshape(1)
k_sq = k_squared.astype(real_dtype)
if is_batch and k_sq.ndim == 3:
k_sq = k_sq[jnp.newaxis, ...]
safe_k_sq = jnp.where(k_sq > 1e-10, k_sq, 1.0)
if is_batch:
alpha_view = alpha.reshape(-1, 1, 1, 1)
volume_view = volume.reshape(-1, 1, 1, 1)
else:
alpha_view = alpha.reshape(-1)[0]
volume_view = volume.reshape(-1)[0]
green = (
2.0
* jnp.pi
* jnp.exp(-safe_k_sq / (4.0 * alpha_view * alpha_view))
/ (volume_view * safe_k_sq)
)
green = jnp.where(k_sq > 1e-10, green, 0.0)
sf = (
moduli_x.astype(real_dtype)[:, None, None]
* moduli_y.astype(real_dtype)[None, :, None]
* moduli_z.astype(real_dtype)[None, None, :]
)
sf_sq = jnp.maximum(sf * sf, jnp.asarray(1e-10, dtype=real_dtype))
return mesh_fft * (green / sf_sq).astype(mesh_fft.dtype)
def _pme_reciprocal_energy_reference(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> jax.Array:
"""Pure-JAX PME reciprocal per-atom energy for weighted-loss tangents."""
dtype = _normalize_dtype(positions.dtype)
cell_3d = cell.astype(dtype)
if cell_3d.ndim == 2:
cell_3d = cell_3d[jnp.newaxis, :, :]
num_systems = cell_3d.shape[0]
is_batch = batch_idx is not None
if mesh_dimensions is None:
if mesh_spacing is None:
mesh_dimensions = estimate_pme_mesh_dimensions(cell, alpha, accuracy=1e-6)
else:
mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing)
cell_inv_t_ref = _reference_cell_inv_t(cell, cell_inv_t, dtype)
cell_inv_ref = jnp.transpose(cell_inv_t_ref, (0, 2, 1))
mesh_grid = _spline_spread_reference(
positions,
charges,
cell_inv_t_ref,
mesh_dimensions,
spline_order,
batch_idx,
num_systems,
)
fft_dims = (1, 2, 3) if is_batch else (0, 1, 2)
complex_dtype = jnp.complex64 if dtype == jnp.float32 else jnp.complex128
mesh_fft = jnp.fft.rfftn(mesh_grid, axes=fft_dims, norm="backward").astype(
complex_dtype
)
if k_vectors is None or k_squared is None:
reciprocal_cell = (2.0 * jnp.pi) * cell_inv_ref
_k_vectors, k_squared = generate_k_vectors_pme(
cell,
mesh_dimensions,
reciprocal_cell=reciprocal_cell,
)
else:
k_squared = jax.lax.stop_gradient(k_squared)
nx, ny, nz = mesh_dimensions
if moduli_x is None or moduli_y is None or moduli_z is None:
miller_x = jnp.fft.fftfreq(nx, d=1.0 / nx).astype(dtype)
miller_y = jnp.fft.fftfreq(ny, d=1.0 / ny).astype(dtype)
miller_z = jnp.fft.rfftfreq(nz, d=1.0 / nz).astype(dtype)
moduli_x = compute_bspline_moduli_1d(miller_x, nx, spline_order)
moduli_y = compute_bspline_moduli_1d(miller_y, ny, spline_order)
moduli_z = compute_bspline_moduli_1d(miller_z, nz, spline_order)
else:
moduli_x = jax.lax.stop_gradient(moduli_x)
moduli_y = jax.lax.stop_gradient(moduli_y)
moduli_z = jax.lax.stop_gradient(moduli_z)
if volume is None:
volume_ref = jnp.abs(jnp.linalg.det(cell_3d)).astype(dtype)
else:
volume_ref = jax.lax.stop_gradient(volume).astype(dtype)
if volume_ref.ndim == 0:
volume_ref = volume_ref.reshape(1)
if volume_ref.shape[0] == 1 and num_systems > 1:
volume_ref = jnp.broadcast_to(volume_ref, (num_systems,))
convolved = _pme_convolve_reference(
mesh_fft,
k_squared,
moduli_x,
moduli_y,
moduli_z,
alpha,
volume_ref,
is_batch,
)
potential_mesh = jnp.fft.irfftn(
convolved, s=mesh_dimensions, axes=fft_dims, norm="forward"
).astype(dtype)
raw_energies = _spline_gather_reference(
positions,
potential_mesh,
cell_inv_t_ref,
spline_order,
batch_idx,
)
return _pme_energy_corrections_reference(
raw_energies,
charges,
alpha,
volume_ref,
batch_idx,
num_systems,
)
@jax.custom_jvp
def _unsupported_pme_cell_hvp_gradient(grad_cell: jax.Array) -> jax.Array:
"""Identity wrapper that rejects cell/stress higher-order PME derivatives."""
return grad_cell
@_unsupported_pme_cell_hvp_gradient.defjvp
def _unsupported_pme_cell_hvp_gradient_jvp(
primals: tuple[jax.Array],
tangents: tuple[jax.Array],
) -> tuple[jax.Array, jax.Array]:
"""Reject JVPs through the first-order PME cell-gradient adapter."""
del tangents
(grad_cell,) = primals
raise NotImplementedError(
"JAX PME stress/cell/strain HVPs are unsupported. Differentiate "
"position or charge losses, or use first-order cell/strain gradients only."
)
@jax.custom_jvp
def _unsupported_pme_cell_hvp_primal(cell: jax.Array) -> jax.Array:
"""Identity wrapper that marks PME cell JVP state as first-order only."""
return cell
@_unsupported_pme_cell_hvp_primal.defjvp
def _unsupported_pme_cell_hvp_primal_jvp(
primals: tuple[jax.Array],
tangents: tuple[jax.Array],
) -> tuple[jax.Array, jax.Array]:
"""Reject JVPs through a PME cell-gradient computation."""
del tangents
(cell,) = primals
raise NotImplementedError(
"JAX PME stress/cell/strain HVPs are unsupported. Differentiate "
"position or charge losses, or use first-order cell/strain gradients only."
)
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 _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)
return (
jnp.zeros((num_systems,), dtype=jnp.float64)
.at[batch_idx.astype(jnp.int32)]
.add(jnp.ones((num_atoms,), dtype=jnp.float64))
)
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
)
counts = _per_system_atom_counts(batch_idx, system_values.shape[0], num_atoms)
return (system_values / jnp.maximum(counts, 1.0))[batch_idx.astype(jnp.int32)]
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 _per_atom_cell_inv_t_matvec(
cell_inv_t: jax.Array,
vectors: jax.Array,
batch_idx: jax.Array | None,
) -> jax.Array:
"""Apply the per-system ``cell_inv_t`` matrix to per-atom vectors."""
if batch_idx is None:
return jnp.einsum("ij,nj->ni", cell_inv_t[0], vectors)
return jnp.einsum(
"nij,nj->ni",
cell_inv_t[batch_idx.astype(jnp.int32)],
vectors,
)
def _pme_alpha_array(
alpha: jax.Array,
dtype,
num_systems: int,
) -> jax.Array:
"""Return PME alpha as a length-``num_systems`` array."""
alpha_arr = alpha.astype(dtype)
if alpha_arr.ndim == 0:
alpha_arr = alpha_arr.reshape(1)
if alpha_arr.shape[0] == 1 and num_systems > 1:
alpha_arr = jnp.broadcast_to(alpha_arr, (num_systems,))
return alpha_arr
def _pme_charge_background_hvp(
h_charges: jax.Array,
alpha: jax.Array,
volume: jax.Array,
batch_idx: jax.Array | None,
num_systems: int,
) -> tuple[jax.Array, jax.Array]:
"""Return per-atom ``alpha`` and background charge-Hessian coefficient."""
d_qtotal = _system_sum_from_atoms(h_charges, batch_idx, num_systems)
if batch_idx is None:
alpha_atom = alpha[0]
bg_coeff = jnp.full_like(
h_charges, jnp.pi / (alpha_atom * alpha_atom * volume[0])
)
dqtotal_atom = jnp.full_like(h_charges, d_qtotal[0])
return alpha_atom, bg_coeff * dqtotal_atom
batch_i32 = batch_idx.astype(jnp.int32)
bg_coeff = jnp.pi / (alpha * alpha * volume)
return alpha[batch_i32], bg_coeff[batch_i32] * d_qtotal[batch_i32]
def _pme_reciprocal_hvp_state(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> tuple[jax.Array, ...]:
"""Build reusable PME reciprocal state for fixed-cell HVP evaluation."""
if mesh_dimensions is None:
if mesh_spacing is None:
raise ValueError("mesh_dimensions must be resolved before PME HVP")
mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing)
dtype = _normalize_dtype(positions.dtype)
positions_cast = positions.astype(dtype)
charges_cast = charges.astype(dtype)
cell_cast = cell.astype(dtype)
cell_3d = cell_cast if cell_cast.ndim == 3 else cell_cast[jnp.newaxis, :, :]
num_systems = cell_3d.shape[0]
is_batch = batch_idx is not None
fft_dims = (1, 2, 3) if is_batch else (0, 1, 2)
if cell_inv_t is None:
cell_inv = jnp.linalg.inv(cell_3d)
cell_inv_t_work = jnp.transpose(cell_inv, (0, 2, 1)).astype(dtype)
else:
cell_inv_t_work = cell_inv_t.astype(dtype)
if cell_inv_t_work.ndim == 2:
cell_inv_t_work = cell_inv_t_work[jnp.newaxis, :, :]
cell_inv = jnp.transpose(cell_inv_t_work, (0, 2, 1))
if volume is None:
volume_work = jnp.abs(jnp.linalg.det(cell_3d)).astype(dtype)
else:
volume_work = volume.astype(dtype)
if volume_work.ndim == 0:
volume_work = volume_work.reshape(1)
alpha_work = _pme_alpha_array(alpha, dtype, num_systems)
mesh_nx, mesh_ny, mesh_nz = mesh_dimensions
if k_squared is None:
if k_vectors is not None:
k_vectors_work = k_vectors.astype(dtype)
k_squared_work = jnp.sum(k_vectors_work * k_vectors_work, axis=-1)
else:
reciprocal_cell = (2.0 * jnp.pi) * cell_inv
_k_vectors, k_squared_work = generate_k_vectors_pme(
cell_cast,
mesh_dimensions,
reciprocal_cell=reciprocal_cell,
)
else:
k_squared_work = k_squared.astype(dtype)
if moduli_x is None or moduli_y is None or moduli_z is None:
miller_x = jnp.fft.fftfreq(mesh_nx, d=1.0 / mesh_nx).astype(dtype)
miller_y = jnp.fft.fftfreq(mesh_ny, d=1.0 / mesh_ny).astype(dtype)
miller_z = jnp.fft.rfftfreq(mesh_nz, d=1.0 / mesh_nz).astype(dtype)
moduli_x_work = compute_bspline_moduli_1d(miller_x, mesh_nx, spline_order)
moduli_y_work = compute_bspline_moduli_1d(miller_y, mesh_ny, spline_order)
moduli_z_work = compute_bspline_moduli_1d(miller_z, mesh_nz, spline_order)
else:
moduli_x_work = moduli_x.astype(dtype)
moduli_y_work = moduli_y.astype(dtype)
moduli_z_work = moduli_z.astype(dtype)
mesh_grid = spline_spread(
positions_cast,
charges_cast,
cell_cast,
mesh_dims=mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
complex_dtype = jnp.complex64 if dtype == jnp.float32 else jnp.complex128
mesh_fft = jnp.fft.rfftn(mesh_grid, axes=fft_dims, norm="backward").astype(
complex_dtype
)
convolved_mesh = pme_fused_convolve(
mesh_fft,
k_squared_work,
moduli_x_work,
moduli_y_work,
moduli_z_work,
alpha_work,
volume_work,
is_batch,
)
potential_mesh = jnp.fft.irfftn(
convolved_mesh,
s=mesh_dimensions,
axes=fft_dims,
norm="forward",
)
return (
positions_cast,
charges_cast,
cell_cast,
alpha_work,
batch_idx,
cell_inv_t_work,
volume_work,
k_squared_work,
moduli_x_work,
moduli_y_work,
moduli_z_work,
potential_mesh,
)
def _pme_reciprocal_energy_hvp_from_state(
v_positions: jax.Array,
v_charges: jax.Array,
positions_cast: jax.Array,
charges_cast: jax.Array,
cell_cast: jax.Array,
alpha_work: jax.Array,
batch_idx: jax.Array | None,
cell_inv_t_work: jax.Array,
volume_work: jax.Array,
k_squared_work: jax.Array,
moduli_x_work: jax.Array,
moduli_y_work: jax.Array,
moduli_z_work: jax.Array,
potential_mesh: jax.Array,
spline_order: int,
) -> tuple[jax.Array, jax.Array]:
"""Evaluate the linear PME reciprocal HVP from saved mesh state."""
dtype = _normalize_dtype(positions_cast.dtype)
is_batch = batch_idx is not None
mesh_dimensions = (
tuple(potential_mesh.shape[1:]) if is_batch else tuple(potential_mesh.shape)
)
fft_dims = (1, 2, 3) if is_batch else (0, 1, 2)
v_positions_cast = v_positions.astype(dtype)
v_charges_cast = v_charges.astype(dtype)
complex_dtype = jnp.complex64 if dtype == jnp.float32 else jnp.complex128
cell_3d = cell_cast if cell_cast.ndim == 3 else cell_cast[jnp.newaxis, :, :]
num_systems = cell_3d.shape[0]
v_frac = _per_atom_cell_inv_t_matvec(
cell_inv_t_work,
v_positions_cast,
batch_idx,
)
dmesh_charge = spline_spread(
positions_cast,
v_charges_cast,
cell_cast,
mesh_dims=mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
dmesh_position = _spline_spread_gradient_weights(
positions_cast,
charges_cast[:, jnp.newaxis] * v_frac,
cell_cast,
mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
dmesh = dmesh_charge + dmesh_position
dmesh_fft = jnp.fft.rfftn(dmesh, axes=fft_dims, norm="backward").astype(
complex_dtype
)
dconvolved_mesh = pme_fused_convolve(
dmesh_fft,
k_squared_work,
moduli_x_work,
moduli_y_work,
moduli_z_work,
alpha_work,
volume_work,
is_batch,
)
dpotential_mesh = jnp.fft.irfftn(
dconvolved_mesh,
s=mesh_dimensions,
axes=fft_dims,
norm="forward",
)
grad_raw_cart = spline_gather_gradient(
positions_cast,
-jnp.ones_like(charges_cast),
potential_mesh,
cell_cast,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
dforce_mesh = spline_gather_gradient(
positions_cast,
charges_cast,
dpotential_mesh,
cell_cast,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
dforce_position = _spline_gather_gradient_position_hessian(
positions_cast,
charges_cast,
v_frac,
cell_cast,
potential_mesh,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
hvp_positions = (
2.0 * v_charges_cast[:, jnp.newaxis] * grad_raw_cart
- 2.0 * dforce_mesh
- 2.0 * dforce_position
)
draw_mesh = spline_gather(
positions_cast,
dpotential_mesh,
cell_cast,
spline_order=spline_order,
batch_idx=batch_idx,
cell_inv_t=cell_inv_t_work,
)
draw_position = (grad_raw_cart * v_positions_cast).sum(axis=1)
alpha_atom, background_hvp = _pme_charge_background_hvp(
v_charges_cast,
alpha_work,
volume_work,
batch_idx,
num_systems,
)
hvp_charges = (
2.0 * (draw_mesh + draw_position)
- 2.0 * alpha_atom / jnp.sqrt(jnp.pi) * v_charges_cast
- background_hvp
)
return (
hvp_positions.astype(positions_cast.dtype),
hvp_charges.astype(charges_cast.dtype),
)
def _pme_reciprocal_energy_hvp_raw(
v_positions: jax.Array,
v_charges: jax.Array,
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> tuple[jax.Array, jax.Array]:
"""Evaluate fixed-cell PME reciprocal HVPs for positions and charges."""
if positions.shape[0] == 0:
dtype = _normalize_dtype(positions.dtype)
return (
jnp.zeros_like(positions, dtype=dtype),
jnp.zeros_like(charges, dtype=dtype),
)
state = _pme_reciprocal_hvp_state(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
return _pme_reciprocal_energy_hvp_from_state(
v_positions,
v_charges,
*state,
spline_order,
)
def _pme_reciprocal_energy_derivative_values(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> tuple[jax.Array, jax.Array]:
"""Return raw PME reciprocal ``dE/dR`` and ``dE/dq`` direct outputs."""
_energy, forces, charge_grads = _pme_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_dimensions=mesh_dimensions,
mesh_spacing=mesh_spacing,
spline_order=spline_order,
batch_idx=batch_idx,
k_vectors=k_vectors,
k_squared=k_squared,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
compute_forces=True,
compute_charge_gradients=True,
compute_virial=False,
hybrid_forces=False,
)
return -forces, charge_grads
@functools.partial(jax.custom_vjp, nondiff_argnums=(4, 5, 6))
def _pme_reciprocal_energy_derivatives(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> tuple[jax.Array, jax.Array]:
"""PME reciprocal ``(dE/dR, dE/dq)`` first-derivative values.
Primal returns the PME *mesh* first derivatives (so forces/charge gradients are
bit-identical to the direct-output path). The custom VJP below supplies the
private PME-native HVP needed for reverse-mode higher-order
position/charge losses.
"""
dpos, charge_grads = _pme_reciprocal_energy_derivative_values(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
return jax.lax.stop_gradient(dpos), jax.lax.stop_gradient(charge_grads)
def _pme_reciprocal_energy_derivatives_fwd(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> tuple[tuple[jax.Array, jax.Array], tuple[jax.Array, ...]]:
"""Forward rule for PME reciprocal first derivatives."""
primal_out = _pme_reciprocal_energy_derivatives(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
state = _pme_reciprocal_hvp_state(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
return primal_out, state
def _pme_reciprocal_energy_derivatives_bwd(
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
residuals: tuple[jax.Array, ...],
ct_out: tuple[jax.Array, jax.Array],
) -> tuple[jax.Array | None, ...]:
"""Backward rule for PME reciprocal first derivatives."""
del mesh_dimensions, mesh_spacing
positions_cast, charges_cast, *_rest = residuals
ct_positions, ct_charges = ct_out
grad_positions, grad_charges = _pme_reciprocal_energy_hvp_from_state(
_tangent_or_zeros(
ct_positions,
positions_cast,
dtype=positions_cast.dtype,
),
_tangent_or_zeros(
ct_charges,
charges_cast,
dtype=charges_cast.dtype,
),
*residuals,
spline_order,
)
return (
grad_positions,
grad_charges,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
_pme_reciprocal_energy_derivatives.defvjp(
_pme_reciprocal_energy_derivatives_fwd,
_pme_reciprocal_energy_derivatives_bwd,
)
@functools.partial(jax.custom_jvp, nondiff_argnums=(4, 5, 6))
def _pme_reciprocal_energy_jvp(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
batch_idx: jax.Array | None,
k_vectors: jax.Array | None,
k_squared: jax.Array | None,
volume: jax.Array | None,
cell_inv_t: jax.Array | None,
moduli_x: jax.Array | None,
moduli_y: jax.Array | None,
moduli_z: jax.Array | None,
) -> jax.Array:
"""Energy-only PME reciprocal wrapper with a custom JVP."""
energy = _pme_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_dimensions=mesh_dimensions,
mesh_spacing=mesh_spacing,
spline_order=spline_order,
batch_idx=batch_idx,
k_vectors=k_vectors,
k_squared=k_squared,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
compute_forces=False,
compute_charge_gradients=False,
compute_virial=False,
hybrid_forces=False,
)
return jax.lax.stop_gradient(energy)
def _pme_reciprocal_energy_jvp_rule(
mesh_dimensions: tuple[int, int, int] | None,
mesh_spacing: float | None,
spline_order: int,
primals: tuple[jax.Array | None, ...],
tangents: tuple[jax.Array | None, ...],
) -> tuple[jax.Array, jax.Array]:
"""JVP rule for the reciprocal PME per-atom energy vector."""
(
positions,
charges,
cell,
alpha,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
) = primals
(
t_positions,
t_charges,
t_cell,
_t_alpha,
_t_batch_idx,
_t_k_vectors,
_t_k_squared,
_t_volume,
_t_cell_inv_t,
_t_moduli_x,
_t_moduli_y,
_t_moduli_z,
) = tangents
del (
_t_alpha,
_t_batch_idx,
_t_k_vectors,
_t_k_squared,
_t_volume,
_t_cell_inv_t,
_t_moduli_x,
_t_moduli_y,
_t_moduli_z,
)
primal_out = _pme_reciprocal_energy_jvp(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
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)
charges_ref = charges.astype(jnp.float64)
tq_ref = tq.astype(jnp.float64)
# Keep the energy tangent in pure JAX. The primal still uses the Warp PME
# mesh path above, but JAX cannot safely transpose the current Warp spline
# FFI boundary for PME cell/strain HVPs. Position and charge tangents are
# therefore evaluated through a pure-JAX PME reference, while nonzero cell
# tangents are tagged below so higher-order cell/strain requests reject
# explicitly instead of silently dropping terms or falling back to Ewald.
reference_cell = (
_unsupported_pme_cell_hvp_primal(cell)
if not _is_symbolic_zero(t_cell)
else cell
)
_reference_out, tangent_out = jax.jvp(
lambda p, q, c: _pme_reciprocal_energy_reference(
p,
q,
c,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
),
(positions, charges_ref, reference_cell),
(tpos, tq_ref, tcell),
)
if not _is_symbolic_zero(t_cell):
# First-order cell gradients are allowed, but differentiating those
# gradients again requires a native transposable PME cell HVP.
tangent_out = _unsupported_pme_cell_hvp_gradient(tangent_out)
return primal_out, tangent_out.astype(primal_out.dtype)
_pme_reciprocal_energy_jvp.defjvp(
_pme_reciprocal_energy_jvp_rule,
symbolic_zeros=True,
)
[docs]
def pme_reciprocal_space(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: jax.Array,
mesh_dimensions: tuple[int, int, int] | None = None,
mesh_spacing: float | None = None,
spline_order: int = 4,
batch_idx: jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_squared: jax.Array | None = None,
compute_forces: bool = False,
compute_charge_gradients: bool = False,
compute_virial: bool = False,
hybrid_forces: bool = False,
*,
cell_inv_t: jax.Array | None = None,
volume: jax.Array | None = None,
moduli_x: jax.Array | None = None,
moduli_y: jax.Array | None = None,
moduli_z: jax.Array | None = None,
) -> (
jax.Array
| tuple[jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array, jax.Array]
):
"""Compute PME reciprocal-space contribution.
Energy-only calls use a custom JVP so JAX does not attempt to
differentiate the Warp spline/FFT FFI path. ``compute_forces=True`` remains
a forward/direct escape hatch for no-autograd MD/inference loops; charge
gradients, virial, and hybrid 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 with lattice vectors as rows.
alpha : jax.Array
Ewald splitting parameter.
mesh_dimensions : tuple[int, int, int] or None, default=None
Explicit FFT mesh dimensions. Required when ``cell``, ``alpha``, or
batch metadata are traced by ``jax.jit`` or other JAX transformations.
mesh_spacing : float or None, default=None
Target mesh spacing for eager-only mesh-size inference.
spline_order : int, default=4
B-spline interpolation order.
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``.
k_vectors, k_squared : jax.Array or None
Optional precomputed reciprocal grid values. These are setup constants
for the JAX custom-JVP path; tangents through them are ignored. When
supplied while differentiating with respect to ``cell``, they are
assumed to correspond to the current ``cell``.
compute_forces, compute_charge_gradients, compute_virial : bool
Direct-output flags. ``compute_forces=True`` remains supported for
no-autograd MD/inference use; charge-gradient and virial direct outputs
are deprecated for differentiable training.
hybrid_forces : bool, default=False
Deprecated charge-gradient injection mode for compatibility.
cell_inv_t, volume, moduli_x, moduli_y, moduli_z : jax.Array or None
Optional precomputed PME intermediates. These are setup constants for
JAX and are not differentiable inputs. Cell-derived metadata such as
``cell_inv_t`` and ``volume`` is accepted while differentiating with
respect to ``cell`` and is assumed to correspond to the current
``cell``.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom reciprocal-space energies.
forces : jax.Array, shape (N, 3), optional
Per-atom forces. Only present when ``compute_forces=True``.
charge_gradients : jax.Array, shape (N,), optional
Per-atom charge gradients :math:`\\partial E/\\partial q`. Only present when
``compute_charge_gradients=True`` (deprecated direct-output flag).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor. Only present when ``compute_virial=True`` (deprecated
direct-output flag). Always last in the return tuple.
Notes
-----
When ``cell`` or batch metadata are traced by ``jax.jit`` or other JAX
transformations, pass explicit ``mesh_dimensions``. If ``alpha`` would
otherwise be estimated, precompute and pass it explicitly as well.
``mesh_spacing`` and accuracy-based parameter estimation depend on concrete
setup values.
JAX PME higher-order support is limited to tested position and charge
losses. Stress/cell/strain HVPs, alpha HVPs, and precomputed-metadata HVPs
are unsupported until explicitly implemented and tested.
"""
_require_explicit_mesh_dimensions_in_tracing(
mesh_dimensions=mesh_dimensions,
cell=cell,
alpha=alpha,
batch_idx=batch_idx,
)
component_deprecated_flags = tuple(
name
for name, enabled in (
("compute_charge_gradients", compute_charge_gradients),
("compute_virial", compute_virial),
("hybrid_forces", hybrid_forces),
)
if enabled
)
if component_deprecated_flags:
warnings.warn(
_component_direct_output_deprecation_msg(
"pme_reciprocal_space", component_deprecated_flags
),
DeprecationWarning,
stacklevel=2,
)
if compute_forces or compute_charge_gradients or compute_virial or hybrid_forces:
return _pme_reciprocal_space_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_dimensions=mesh_dimensions,
mesh_spacing=mesh_spacing,
spline_order=spline_order,
batch_idx=batch_idx,
k_vectors=k_vectors,
k_squared=k_squared,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
compute_forces=compute_forces,
compute_charge_gradients=compute_charge_gradients,
compute_virial=compute_virial,
hybrid_forces=hybrid_forces,
)
if mesh_dimensions is None and mesh_spacing is not None:
mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing)
mesh_spacing = None
return _pme_reciprocal_energy_jvp(
positions,
charges,
cell,
alpha,
mesh_dimensions,
mesh_spacing,
spline_order,
batch_idx,
k_vectors,
k_squared,
volume,
cell_inv_t,
moduli_x,
moduli_y,
moduli_z,
)
def _particle_mesh_ewald_impl(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None = None,
mesh_spacing: float | None = None,
mesh_dimensions: tuple[int, int, int] | None = None,
spline_order: int = 4,
batch_idx: jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_squared: jax.Array | 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,
pbc: jax.Array | None = None,
slab_correction: bool = False,
hybrid_forces: bool = False,
volume: jax.Array | None = None,
cell_inv_t: jax.Array | None = None,
moduli_x: jax.Array | None = None,
moduli_y: jax.Array | None = None,
moduli_z: jax.Array | None = None,
) -> (
jax.Array
| tuple[jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array, jax.Array]
):
r"""Complete Particle Mesh Ewald (PME) calculation for long-range electrostatics.
Computes total Coulomb energy using the PME method, which achieves
:math:`O(N \log N)` scaling through FFT-based reciprocal space calculations.
Combines:
1. Real-space contribution (short-range, erfc-damped)
2. Reciprocal-space contribution (long-range, FFT + B-spline interpolation)
3. Self-energy and background corrections
Total Energy Formula:
.. math::
E_{\text{total}} = E_{\text{real}} + E_{\text{reciprocal}}
- E_{\text{self}} - E_{\text{background}}
where:
.. math::
\begin{aligned}
E_{\text{real}} &= \frac{1}{2} \sum_{i \neq j} q_i q_j
\frac{\operatorname{erfc}(\alpha r_{ij})}{r_{ij}} \\
E_{\text{reciprocal}} &= \text{FFT-based smooth long-range contribution} \\
E_{\text{self}} &= \sum_i \frac{\alpha}{\sqrt{\pi}} q_i^2 \\
E_{\text{background}} &= \frac{\pi}{2\alpha^2 V} Q_{\text{total}}^2
\end{aligned}
Parameters
----------
positions : jax.Array, shape (N, 3)
Atomic coordinates.
charges : jax.Array, shape (N,)
Atomic partial charges in elementary charge units.
cell : jax.Array, shape (3, 3) or (B, 3, 3)
Unit cell matrices with lattice vectors as rows. Shape (3, 3) is
automatically promoted to (1, 3, 3) for single-system mode.
alpha : float, jax.Array, or None, default=None
Ewald splitting parameter controlling real/reciprocal space balance.
- float: Same :math:`\alpha` for all systems
- Array shape (B,): Per-system :math:`\alpha` values
- None: Automatically estimated using Kolafa-Perram formula
mesh_spacing : float, optional
Target mesh spacing. Mesh dimensions computed as ceil(cell_length / mesh_spacing).
mesh_dimensions : tuple[int, int, int], optional
Explicit FFT mesh dimensions (nx, ny, nz). Power-of-2 values recommended.
spline_order : int, default=4
B-spline interpolation order (4 = cubic B-splines, recommended).
batch_idx : jax.Array, shape (N,), dtype=int32, optional
System index for each atom (0 to B-1). Determines execution mode:
- None: Single-system optimized kernels
- Provided: Batched kernels for multiple independent systems
When provided, atoms must be grouped by system: ``batch_idx`` must be
contiguous, nondecreasing, and use system IDs ``0..B-1``.
k_vectors : jax.Array, optional
Precomputed k-vectors from generate_k_vectors_pme. Providing this
along with k_squared skips k-vector generation.
k_squared : jax.Array, optional
Precomputed :math:`k^2` values from generate_k_vectors_pme.
neighbor_list : jax.Array, optional
CSR-format neighbor list indices. See ewald_real_space.
neighbor_ptr : jax.Array, optional
CSR-format neighbor list pointers. See ewald_real_space.
neighbor_shifts : jax.Array, optional
Periodic image shifts for neighbor list. See ewald_real_space.
neighbor_matrix : jax.Array, optional
Dense neighbor matrix. Alternative to CSR format.
neighbor_matrix_shifts : jax.Array, optional
Shifts for dense neighbor matrix.
mask_value : int, optional
Mask value for invalid neighbors in dense format.
compute_forces : bool, default=False
If True, compute per-atom forces.
compute_charge_gradients : bool, default=False
If True, compute per-atom charge gradients :math:`\partial E/\partial q`.
compute_virial : bool, default=False
If True, compute the virial tensor ``W = -dE/d(displacement)`` for the
row-vector displacement recipe.
Stress = -virial / volume.
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 PME outputs.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom total electrostatic energies.
forces : jax.Array, shape (N, 3), optional
Per-atom forces (only if compute_forces=True).
charge_gradients : jax.Array, shape (N,), optional
Per-atom charge gradients (only if compute_charge_gradients=True).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor (only if compute_virial=True). Always last in the return tuple.
Notes
-----
Automatic Parameter Estimation (when alpha is None):
Uses Kolafa-Perram formula for optimal :math:`\alpha` and mesh dimensions based on
requested accuracy.
Energy-derived first-order gradients are supported. Higher-order PME
reverse-mode higher-order position and charge losses use the private PME
mesh HVP path.
When ``cell`` or batch metadata are traced by ``jax.jit`` or other JAX
transformations, pass explicit ``mesh_dimensions``. When ``alpha`` would be
estimated from traced inputs, precompute it outside the transformation and
pass it explicitly. ``mesh_spacing`` and accuracy-based mesh sizing depend
on concrete setup values.
Examples
--------
Basic usage:
>>> energies = particle_mesh_ewald(
... positions, charges, cell, alpha=0.3,
... mesh_dimensions=(32, 32, 32),
... neighbor_list=nl, neighbor_ptr=ptr, neighbor_shifts=shifts,
... )
With forces and automatic parameters:
>>> energies, forces = particle_mesh_ewald(
... positions, charges, cell,
... mesh_spacing=1.0, accuracy=1e-5,
... neighbor_list=nl, neighbor_ptr=ptr, neighbor_shifts=shifts,
... compute_forces=True,
... )
Batched systems:
>>> energies = particle_mesh_ewald(
... positions, charges, cell,
... batch_idx=batch_idx,
... neighbor_list=nl, neighbor_ptr=ptr, neighbor_shifts=shifts,
... )
See Also
--------
pme_reciprocal_space : Reciprocal-space component only
ewald_real_space : Real-space component
estimate_pme_parameters : Automatic parameter estimation
"""
num_atoms = positions.shape[0]
# Prepare cell and slab pbc
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)
_require_explicit_mesh_dimensions_in_tracing(
mesh_dimensions=mesh_dimensions,
cell=cell,
alpha=alpha,
batch_idx=batch_idx,
)
# Estimate parameters if not provided
if alpha is None:
params = estimate_pme_parameters(positions, cell, batch_idx, accuracy)
alpha = params.alpha
if mesh_dimensions is None and mesh_spacing is None:
# Convert to explicit tuple[int, int, int]
md = params.mesh_dimensions
mesh_dimensions = (int(md[0]), int(md[1]), int(md[2]))
# Prepare alpha
if isinstance(alpha, (int, float)):
alpha = jnp.array([alpha] * num_systems, dtype=positions.dtype)
elif alpha.ndim == 0:
alpha = alpha.reshape(1)
if mask_value is None:
mask_value = num_atoms
# Determine mesh dimensions
if mesh_dimensions is None:
if mesh_spacing is not None:
mesh_dimensions = mesh_spacing_to_dimensions(cell, mesh_spacing)
else:
mesh_dimensions = estimate_pme_mesh_dimensions(cell, alpha, accuracy)
charges_orig = charges
need_charge_gradients = compute_charge_gradients or hybrid_forces
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)
if k_vectors is not None:
k_vectors = jax.lax.stop_gradient(k_vectors)
if k_squared is not None:
k_squared = jax.lax.stop_gradient(k_squared)
if volume is not None:
volume = jax.lax.stop_gradient(volume)
if cell_inv_t is not None:
cell_inv_t = jax.lax.stop_gradient(cell_inv_t)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=r"The component direct-output flag\(s\).*",
category=DeprecationWarning,
)
# Compute real-space contribution
rs = 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 contribution
rec = pme_reciprocal_space(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_dimensions=mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
compute_forces=compute_forces,
compute_charge_gradients=need_charge_gradients,
compute_virial=compute_virial,
k_vectors=k_vectors,
k_squared=k_squared,
hybrid_forces=False,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
)
slab = None
if slab_correction:
if compute_forces or need_charge_gradients or compute_virial:
slab = _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 = _slab_correction_energy_autodiff(
positions,
charges,
cell,
pbc,
batch_idx=batch_idx,
)
component_tuples = [
rs if isinstance(rs, tuple) else (rs,),
rec if isinstance(rec, tuple) else (rec,),
]
if slab is not None:
component_tuples.append(slab if isinstance(slab, tuple) else (slab,))
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(num_atoms, 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:])
return results[0] if len(results) == 1 else results
def _resolve_particle_mesh_ewald_parameters(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None,
mesh_spacing: float | None,
mesh_dimensions: tuple[int, int, int] | None,
batch_idx: jax.Array | None,
accuracy: float,
) -> tuple[jax.Array, jax.Array, tuple[int, int, int]]:
"""Resolve PME ``cell``, ``alpha``, and mesh dimensions for custom rules."""
_require_explicit_mesh_dimensions_in_tracing(
mesh_dimensions=mesh_dimensions,
cell=cell,
alpha=alpha,
batch_idx=batch_idx,
)
cell_3d = cell if cell.ndim == 3 else cell[jnp.newaxis, :, :]
num_systems = cell_3d.shape[0]
if alpha is None:
params = estimate_pme_parameters(positions, cell_3d, batch_idx, accuracy)
alpha = params.alpha
if mesh_dimensions is None and mesh_spacing is None:
md = params.mesh_dimensions
mesh_dimensions = (int(md[0]), int(md[1]), int(md[2]))
if isinstance(alpha, (int, float)):
alpha = jnp.array([alpha] * num_systems, dtype=positions.dtype)
elif alpha.ndim == 0:
alpha = alpha.reshape(1)
if mesh_dimensions is None:
if mesh_spacing is not None:
mesh_dimensions = mesh_spacing_to_dimensions(cell_3d, mesh_spacing)
else:
mesh_dimensions = estimate_pme_mesh_dimensions(cell_3d, alpha, accuracy)
return cell_3d, alpha, mesh_dimensions
[docs]
def particle_mesh_ewald(
positions: jax.Array,
charges: jax.Array,
cell: jax.Array,
alpha: float | jax.Array | None = None,
mesh_spacing: float | None = None,
mesh_dimensions: tuple[int, int, int] | None = None,
spline_order: int = 4,
batch_idx: jax.Array | None = None,
k_vectors: jax.Array | None = None,
k_squared: jax.Array | 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,
*,
cell_inv_t: jax.Array | None = None,
volume: jax.Array | None = None,
moduli_x: jax.Array | None = None,
moduli_y: jax.Array | None = None,
moduli_z: jax.Array | None = None,
) -> (
jax.Array
| tuple[jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array]
| tuple[jax.Array, jax.Array, jax.Array, jax.Array]
):
"""Complete Particle Mesh Ewald calculation for long-range electrostatics.
Computes the total Coulomb energy via the PME method, which achieves
:math:`O(N \\log N)` scaling through FFT-based reciprocal-space calculations:
.. 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 (3, 3) or (B, 3, 3)
Unit cell matrices with lattice vectors as rows.
alpha : float, jax.Array, or None, default=None
Ewald splitting parameter. If ``None``, estimated automatically.
mesh_spacing : float or None, default=None
Target mesh spacing used when ``mesh_dimensions`` is omitted.
mesh_dimensions : tuple[int, int, int] or None, default=None
Explicit FFT mesh dimensions.
spline_order : int, default=4
B-spline interpolation order.
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``.
k_vectors, k_squared : jax.Array or None
Precomputed PME reciprocal grid values.
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, compute_charge_gradients, compute_virial : bool
Deprecated direct-output flags. Compute energy and use JAX autodiff for
differentiable forces, charge gradients, and strain virials.
accuracy : float, default=1e-6
Target accuracy for automatic parameter estimation.
hybrid_forces : bool, default=False
Deprecated Torch-compatibility escape hatch for charge-gradient routing.
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.
volume, cell_inv_t, moduli_x, moduli_y, moduli_z : jax.Array or None
Optional precomputed PME intermediates. Cell-derived values supplied
while differentiating with respect to ``cell`` are treated as static
metadata that corresponds to the current ``cell``.
Returns
-------
energies : jax.Array, shape (N,)
Per-atom total electrostatic energies (real + reciprocal + slab).
forces : jax.Array, shape (N, 3), optional
Per-atom forces. Only present when ``compute_forces=True`` (deprecated).
charge_gradients : jax.Array, shape (N,), optional
Per-atom charge gradients :math:`\\partial E/\\partial q`. Only present when
``compute_charge_gradients=True`` (deprecated).
virial : jax.Array, shape (1, 3, 3) or (B, 3, 3), optional
Virial tensor. Only present when ``compute_virial=True`` (deprecated).
Always last in the return tuple.
Notes
-----
When ``cell``, ``alpha``, or batch metadata are traced by ``jax.jit`` or
other JAX transformations, pass explicit ``mesh_dimensions``.
``mesh_spacing`` and accuracy-based mesh sizing depend on concrete mesh
setup values. If ``alpha`` would otherwise be estimated from traced inputs,
precompute it outside the transformation and pass it explicitly.
"""
if compute_forces or compute_virial or compute_charge_gradients or hybrid_forces:
warnings.warn(
_direct_output_deprecation_msg("particle_mesh_ewald"),
DeprecationWarning,
stacklevel=2,
)
if compute_forces or compute_charge_gradients or compute_virial or hybrid_forces:
return _particle_mesh_ewald_impl(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_spacing=mesh_spacing,
mesh_dimensions=mesh_dimensions,
spline_order=spline_order,
batch_idx=batch_idx,
k_vectors=k_vectors,
k_squared=k_squared,
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,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
)
cell_3d, alpha_arr, mesh_dims = _resolve_particle_mesh_ewald_parameters(
positions=positions,
charges=charges,
cell=cell,
alpha=alpha,
mesh_spacing=mesh_spacing,
mesh_dimensions=mesh_dimensions,
batch_idx=batch_idx,
accuracy=accuracy,
)
if mask_value is None:
mask_value = positions.shape[0]
# Energy-only path: call the impl directly so the full energy is the sum of
# real-space and reciprocal terms. Component custom derivative rules provide
# energy gradients and reverse-mode position/charge higher-order losses.
return _particle_mesh_ewald_impl(
positions=positions,
charges=charges,
cell=cell_3d,
alpha=alpha_arr,
mesh_dimensions=mesh_dims,
spline_order=spline_order,
batch_idx=batch_idx,
k_vectors=k_vectors,
k_squared=k_squared,
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,
accuracy=accuracy,
hybrid_forces=False,
pbc=pbc,
slab_correction=slab_correction,
volume=volume,
cell_inv_t=cell_inv_t,
moduli_x=moduli_x,
moduli_y=moduli_y,
moduli_z=moduli_z,
)