Note
Go to the end to download the full example code.
Long-Range Electrostatics with Ewald Summation#
Lennard-Jones and other short-range potentials truncate interactions at a cutoff radius, which works well for neutral systems with rapidly decaying forces. Ionic systems — salts, molten metals, charged proteins — require long-range Coulomb interactions that decay as 1/r and cannot be safely truncated without severe artefacts.
The Ewald summation method handles this by splitting the Coulomb sum into:
A real-space term — short-range, erfc-damped, evaluated over a neighbor list exactly as for LJ.
A reciprocal-space term — smooth structure-factor sum in Fourier space that captures the long-range tail.
Together they reproduce the exact infinite-lattice Coulomb energy for a periodic system.
This example:
Builds a minimal NaCl rock-salt crystal (2×2×2 supercell, 64 atoms) with correct formal charges +1 e (Na) and −1 e (Cl).
Computes energy and forces with
EwaldModelWrapper.Compares the total Ewald energy to the analytical Madelung energy.
Note
For molecular-dynamics simulations of ionic systems you need a short-range
repulsion term alongside the Ewald electrostatics. Pure Coulomb has no
equilibrium — without repulsion ions accelerate toward each other without
bound. See Example 07 (Additive Model Composition) for the complete
LennardJones + Ewald setup used in production MD runs.
Key concepts demonstrated#
Constructing an
AtomicDatawithnode_charges(shape[N, 1], elementary charge units).Instantiating
EwaldModelWrapperwith a real-space cutoff and auto-estimated Ewald parameters.Computing energy and forces for a single snapshot via a direct
model(batch)call.Using
invalidate_cache()when the simulation cell changes between evaluations.
from __future__ import annotations
import torch
from nvalchemi.data import AtomicData, Batch
from nvalchemi.dynamics.hooks import NeighborListHook
from nvalchemi.models.ewald import EwaldModelWrapper
Ewald model setup#
EwaldModelWrapper only needs a real-space
cutoff. The Ewald splitting parameter α and reciprocal-space cutoff are
estimated automatically from the accuracy target (default 1e-6) each
time the simulation cell changes. For a fixed-cell run the cache is
computed once and reused.
CUTOFF = 10.0 # Å — real-space cutoff
model = EwaldModelWrapper(cutoff=CUTOFF, accuracy=1e-6, max_neighbors=256)
model.model_config.compute_forces = True
Building a NaCl rock-salt supercell#
The NaCl rock-salt lattice has two interpenetrating FCC sub-lattices. We build a 2×2×2 conventional cubic supercell (64 atoms).
Important: Na and Cl positions are collected separately, then
concatenated, so that atomic_numbers and node_charges align
correctly with positions. Interleaving the two species within the
same image ordering would silently mis-assign charges.
Lattice constant a₀ = 5.64 Å (experimental value). Formal charges: Na → +1 e, Cl → −1 e. The system is overall charge-neutral (32 Na + 32 Cl).
A0 = 5.64 # Å — NaCl lattice constant
SUPERCELL = 2 # repeat units along each axis
M_NA = 22.990 # amu
M_CL = 35.453 # amu
cell_size = SUPERCELL * A0 # 11.28 Å per side
# Fractional coordinates of the two sub-lattices within one conventional cell.
# Na occupies the FCC vertices; Cl is offset by (0.5, 0, 0) etc.
_na_basis = torch.tensor(
[[0.0, 0.0, 0.0], [0.5, 0.5, 0.0], [0.5, 0.0, 0.5], [0.0, 0.5, 0.5]],
dtype=torch.float32,
) # 4 Na per unit cell
_cl_basis = torch.tensor(
[[0.5, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.5], [0.5, 0.5, 0.5]],
dtype=torch.float32,
) # 4 Cl per unit cell
# Supercell image offsets (in unit-cell units).
offsets = torch.tensor(
[
[ix, iy, iz]
for ix in range(SUPERCELL)
for iy in range(SUPERCELL)
for iz in range(SUPERCELL)
],
dtype=torch.float32,
) # (8, 3)
# Build Na positions: (n_images, n_basis, 3) → (N_Na, 3).
# Dividing by SUPERCELL converts unit-cell fractional to supercell fractional;
# multiplying by cell_size converts to Cartesian Å.
na_positions = ((_na_basis.unsqueeze(0) + offsets.unsqueeze(1)) / SUPERCELL).reshape(
-1, 3
) * cell_size
cl_positions = ((_cl_basis.unsqueeze(0) + offsets.unsqueeze(1)) / SUPERCELL).reshape(
-1, 3
) * cell_size
positions = torch.cat([na_positions, cl_positions], dim=0) # (64, 3)
n_na, n_cl = na_positions.shape[0], cl_positions.shape[0] # 32, 32
n_atoms = n_na + n_cl # 64
# Atomic numbers and formal charges — correctly aligned with positions.
atomic_numbers = torch.cat(
[
torch.full((n_na,), 11, dtype=torch.long),
torch.full((n_cl,), 17, dtype=torch.long),
]
)
charges = torch.cat([torch.ones(n_na, 1), -torch.ones(n_cl, 1)]) # (N, 1)
cell = torch.eye(3).unsqueeze(0) * cell_size # (1, 3, 3)
data = AtomicData(
positions=positions,
atomic_numbers=atomic_numbers,
node_charges=charges, # (N, 1) — required shape for AtomicData
forces=torch.zeros(n_atoms, 3),
energies=torch.zeros(1, 1),
cell=cell,
pbc=torch.tensor([[True, True, True]]),
)
batch = Batch.from_data_list([data])
print(
f"System: {n_na} Na + {n_cl} Cl, box={cell_size:.2f} Å, "
f"nearest Na–Cl distance={A0 / 2:.3f} Å"
)
System: 32 Na + 32 Cl, box=11.28 Å, nearest Na–Cl distance=2.820 Å
Building the neighbor list and evaluating energy + forces#
For a one-shot energy evaluation, build the neighbor list manually using
NeighborListHook outside the dynamics loop.
nl_hook = NeighborListHook(model.model_card.neighbor_config)
nl_hook(batch, None) # populates batch.neighbor_matrix / batch.num_neighbors
result = model(batch)
energy_eV = result["energies"].item()
forces = result["forces"] # (N, 3) eV/Å
print(f"\nEwald energy: {energy_eV:.4f} eV")
print(f"Max force magnitude: {forces.norm(dim=-1).max().item():.4f} eV/Å")
Warp DeprecationWarning: The symbol `warp.types.Any` will soon be removed from the public API. It can still be accessed from `warp._src.types.Any` but might be changed or removed without notice.
Ewald energy: -285.5506 eV
Max force magnitude: 0.0000 eV/Å
Comparison with the analytical Madelung energy#
For an infinite NaCl rock-salt crystal the total Coulomb energy per formula unit is:
E₀ = −A * k_e * q² / r_{Na–Cl}
where A = 1.7476 is the Madelung constant for the rock-salt structure, k_e = 14.3996 eV·Å/e², q = 1 e, and r_{Na–Cl} = a₀/2.
For a finite supercell the Ewald result converges to the thermodynamic limit to within the accuracy set in the model (1e-6 here).
MADELUNG_NaCL = 1.7476
K_E = 14.3996 # eV·Å/e²
r_nn = A0 / 2 # nearest-neighbour Na–Cl distance
n_formula_units = n_na # 32 (one per Na–Cl pair)
E_madelung = -MADELUNG_NaCL * K_E * 1.0**2 / r_nn * n_formula_units
print(f"\nAnalytical Madelung energy ({n_formula_units} f.u.): {E_madelung:.4f} eV")
print(f"Ewald energy: {energy_eV:.4f} eV")
print(
f"Relative error: {abs(energy_eV - E_madelung) / abs(E_madelung):.2e}"
)
Analytical Madelung energy (32 f.u.): -285.5573 eV
Ewald energy: -285.5506 eV
Relative error: 2.36e-05
Forces should vanish at the perfect-crystal geometry#
At the ideal rock-salt lattice sites every atom sits at a potential minimum by symmetry, so the net force on each atom is zero. Finite cell and numerical accuracy leave a small residual.
mean_force = forces.norm(dim=-1).mean().item()
print(
f"\nMean force magnitude at ideal geometry: {mean_force:.4e} eV/Å (should be ≈0)"
)
Mean force magnitude at ideal geometry: 5.3939e-07 eV/Å (should be ≈0)
Evaluating with a perturbed geometry#
Displace atoms by a small random amount and re-evaluate to show non-zero forces.
Call invalidate_cache() if the
cell changes between evaluations (here the cell is unchanged, so not required).
torch.manual_seed(7)
perturbed_positions = positions + 0.05 * torch.randn_like(positions)
data_pert = AtomicData(
positions=perturbed_positions,
atomic_numbers=atomic_numbers,
node_charges=charges,
forces=torch.zeros(n_atoms, 3),
energies=torch.zeros(1, 1),
cell=cell,
pbc=torch.tensor([[True, True, True]]),
)
batch_pert = Batch.from_data_list([data_pert])
nl_hook(batch_pert, None)
result_pert = model(batch_pert)
print("\nPerturbed geometry:")
print(f" Ewald energy: {result_pert['energies'].item():.4f} eV")
print(
f" Max force magnitude: {result_pert['forces'].norm(dim=-1).max().item():.4f} eV/Å"
)
Perturbed geometry:
Ewald energy: -285.5374 eV
Max force magnitude: 0.4427 eV/Å
Total running time of the script: (0 minutes 0.344 seconds)