Note
Go to the end to download the full example code.
Coulomb Electrostatic Interactions#
This example demonstrates how to compute Coulomb electrostatic interactions using GPU-accelerated kernels. The implementation supports both direct (undamped) Coulomb interactions and damped interactions used as the real-space component in Ewald/PME methods.
In this example you will learn:
How to compute Coulomb energies and forces between charged particles
The difference between undamped (1/r) and damped (erfc(αr)/r) interactions
Using both neighbor list (COO) and neighbor matrix formats
Computing forces via autograd vs explicit force kernels
Batch evaluation for multiple systems
Computing gradients with respect to charges
Important
This script is intended as an API demonstration. Do not use this script for performance benchmarking; refer to the benchmarks folder instead.
Setup and Imports#
First, we import the necessary modules. The Coulomb API provides three main
functions: coulomb_energy, coulomb_forces, and coulomb_energy_forces.
from __future__ import annotations
import torch
from nvalchemiops.interactions.electrostatics.coulomb import (
coulomb_energy,
coulomb_energy_forces,
)
Configure Device#
We use CUDA if available for GPU acceleration.
if torch.cuda.is_available():
device = torch.device("cuda:0")
print(f"Using CUDA device: {torch.cuda.get_device_name(0)}")
else:
device = torch.device("cpu")
print("Using CPU")
Using CUDA device: NVIDIA L4
Basic Coulomb Calculation#
We start with a simple two-atom system: a +1 and -1 charge separated by 2.0 Å. This demonstrates both undamped Coulomb (direct 1/r) and damped Coulomb (erfc(αr)/r used in Ewald/PME real-space).
The undamped Coulomb energy for a pair is:
The damped Coulomb (Ewald real-space) energy is:
# Two charges: +1 and -1 separated by 2.0 Angstroms
positions = torch.tensor(
[
[0.0, 0.0, 0.0], # Charge +1
[2.0, 0.0, 0.0], # Charge -1
],
dtype=torch.float64,
device=device,
)
charges = torch.tensor([1.0, -1.0], dtype=torch.float64, device=device)
# Large cell to avoid periodic images
cell = torch.tensor(
[[[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 0.0, 100.0]]],
dtype=torch.float64,
device=device,
)
# Direct neighbor specification: pair (0, 1)
neighbor_list = torch.tensor([[0, 1], [1, 0]], dtype=torch.int32, device=device)
neighbor_ptr = torch.tensor([0, 1, 2], dtype=torch.int32, device=device)
neighbor_shifts = torch.zeros((2, 3), dtype=torch.int32, device=device)
print("System setup:")
print(f" Positions: {positions.cpu().numpy()}")
print(f" Charges: {charges.cpu().numpy()}")
print(" Distance: 2.0 Å")
System setup:
Positions: [[0. 0. 0.]
[2. 0. 0.]]
Charges: [ 1. -1.]
Distance: 2.0 Å
Undamped Coulomb#
With α=0, we get the direct Coulomb interaction.
energies_undamped, forces_undamped = coulomb_energy_forces(
positions,
charges,
cell,
cutoff=10.0,
alpha=0.0, # Undamped
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
)
r = 2.0
expected_energy = 1.0 * (-1.0) / r # = -0.5
print("\nUndamped Coulomb (α=0):")
print(
f" Total energy: {energies_undamped.sum().item():.6f} (expected: {expected_energy:.6f})"
)
print(f" Forces on atom 0: {forces_undamped[0].cpu().numpy()}")
print(f" Forces on atom 1: {forces_undamped[1].cpu().numpy()}")
Undamped Coulomb (α=0):
Total energy: -0.500000 (expected: -0.500000)
Forces on atom 0: [0.25 0. 0. ]
Forces on atom 1: [-0.25 0. 0. ]
Damped Coulomb (Ewald Real-Space)#
With α > 0, the interaction is damped by erfc(αr). This is the short-range component used in Ewald/PME methods.
energies_damped, forces_damped = coulomb_energy_forces(
positions,
charges,
cell,
cutoff=10.0,
alpha=0.3, # Damped with α=0.3
neighbor_list=neighbor_list,
neighbor_ptr=neighbor_ptr,
neighbor_shifts=neighbor_shifts,
)
print("\nDamped Coulomb (α=0.3):")
print(f" Total energy: {energies_damped.sum().item():.6f}")
print(f" Forces on atom 0: {forces_damped[0].cpu().numpy()}")
print(f" Forces on atom 1: {forces_damped[1].cpu().numpy()}")
Damped Coulomb (α=0.3):
Total energy: -0.198072
Forces on atom 0: [0.21712248 0. 0. ]
Forces on atom 1: [-0.21712248 0. 0. ]
Newton’s Third Law Check#
The sum of forces should be zero for an isolated system.
net_force = forces_undamped.sum(dim=0)
print("\nNewton's 3rd law check:")
print(f" Net force: {net_force.cpu().numpy()}")
print(f" Magnitude: {net_force.norm().item():.2e} (should be ~0)")
Newton's 3rd law check:
Net force: [0. 0. 0.]
Magnitude: 0.00e+00 (should be ~0)
Autograd vs Explicit Forces#
The Coulomb functions support automatic differentiation. Forces computed via autograd should match the explicit forces returned by the kernel.
positions_grad = torch.tensor(
[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[1.0, 1.73, 0.0], # Equilateral triangle
],
dtype=torch.float64,
device=device,
requires_grad=True,
)
charges_3 = torch.tensor([1.0, -0.5, -0.5], dtype=torch.float64, device=device)
cell_3 = torch.tensor(
[[[50.0, 0.0, 0.0], [0.0, 50.0, 0.0], [0.0, 0.0, 50.0]]],
dtype=torch.float64,
device=device,
)
# All pairs as neighbors
neighbor_list_3 = torch.tensor(
[[0, 0, 1, 1, 2, 2], [1, 2, 0, 2, 0, 1]], dtype=torch.int32, device=device
)
neighbor_ptr_3 = torch.tensor([0, 2, 4, 6], dtype=torch.int32, device=device)
neighbor_shifts_3 = torch.zeros((6, 3), dtype=torch.int32, device=device)
print("\nThree-atom triangular system:")
print(f" Charges: {charges_3.cpu().numpy()}")
Three-atom triangular system:
Charges: [ 1. -0.5 -0.5]
Get explicit forces from the kernel:
_, explicit_forces = coulomb_energy_forces(
positions_grad.detach(),
charges_3,
cell_3,
cutoff=10.0,
alpha=0.0,
neighbor_list=neighbor_list_3,
neighbor_ptr=neighbor_ptr_3,
neighbor_shifts=neighbor_shifts_3,
)
print("\nExplicit Forces:")
for i, f in enumerate(explicit_forces):
print(f" Atom {i}: {f.cpu().numpy()}")
Explicit Forces:
Atom 0: [0.18766678 0.10841352 0. ]
Atom 1: [-0.09366661 -0.05420676 0. ]
Atom 2: [-0.09400016 -0.05420676 0. ]
Get autograd forces via backpropagation:
energies = coulomb_energy(
positions_grad,
charges_3,
cell_3,
cutoff=10.0,
alpha=0.0,
neighbor_list=neighbor_list_3,
neighbor_ptr=neighbor_ptr_3,
neighbor_shifts=neighbor_shifts_3,
)
total_energy = energies.sum()
total_energy.backward()
autograd_forces = -positions_grad.grad
print(f"\nTotal energy: {total_energy.item():.6f}")
print("\nAutograd Forces (-∂E/∂r):")
for i, f in enumerate(autograd_forces):
print(f" Atom {i}: {f.cpu().numpy()}")
force_diff = (autograd_forces - explicit_forces).abs().max().item()
print(f"\nMax force difference: {force_diff:.2e}")
print(f"Forces match: {'✓' if force_diff < 1e-8 else '✗'}")
Total energy: -0.375111
Autograd Forces (-∂E/∂r):
Atom 0: [ 0.18766678 0.10841352 -0. ]
Atom 1: [-0.09366661 -0.05420676 -0. ]
Atom 2: [-0.09400016 -0.05420676 -0. ]
Max force difference: 0.00e+00
Forces match: ✓
Neighbor List vs Neighbor Matrix Format#
The Coulomb functions support two neighbor formats:
Neighbor List (COO): Shape
(2, num_pairs)where each pair is listed onceNeighbor Matrix: Shape
(N, max_neighbors)with padding
Both formats produce identical results.
# Create a 4-atom system in a square
positions_4 = torch.tensor(
[
[0.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 2.0, 0.0],
[2.0, 2.0, 0.0],
],
dtype=torch.float64,
device=device,
)
charges_4 = torch.tensor([1.0, -1.0, -1.0, 1.0], dtype=torch.float64, device=device)
cell_4 = torch.tensor(
[[[50.0, 0.0, 0.0], [0.0, 50.0, 0.0], [0.0, 0.0, 50.0]]],
dtype=torch.float64,
device=device,
)
print("\n4-atom square system:")
print(f" Charges: {charges_4.cpu().numpy()}")
4-atom square system:
Charges: [ 1. -1. -1. 1.]
Neighbor list format (COO):
neighbor_list_coo = torch.tensor(
[[0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3], [1, 2, 3, 0, 2, 3, 0, 1, 3, 0, 1, 2]],
dtype=torch.int32,
device=device,
)
neighbor_ptr_coo = torch.tensor([0, 3, 6, 9, 12], dtype=torch.int32, device=device)
neighbor_shifts_coo = torch.zeros((12, 3), dtype=torch.int32, device=device)
energies_list, forces_list = coulomb_energy_forces(
positions_4,
charges_4,
cell_4,
cutoff=10.0,
alpha=0.0,
neighbor_list=neighbor_list_coo,
neighbor_ptr=neighbor_ptr_coo,
neighbor_shifts=neighbor_shifts_coo,
)
print(f"\nNeighbor List format: Total energy = {energies_list.sum().item():.6f}")
Neighbor List format: Total energy = -1.292893
Neighbor matrix format:
fill_value = 4
neighbor_matrix = torch.tensor(
[
[1, 2, 3, fill_value],
[0, 2, 3, fill_value],
[0, 1, 3, fill_value],
[0, 1, 2, fill_value],
],
dtype=torch.int32,
device=device,
)
neighbor_matrix_shifts = torch.zeros((4, 4, 3), dtype=torch.int32, device=device)
energies_matrix, forces_matrix = coulomb_energy_forces(
positions_4,
charges_4,
cell_4,
cutoff=10.0,
alpha=0.0,
neighbor_matrix=neighbor_matrix,
neighbor_matrix_shifts=neighbor_matrix_shifts,
fill_value=fill_value,
)
print(f"Neighbor Matrix format: Total energy = {energies_matrix.sum().item():.6f}")
energy_diff = (energies_list.sum() - energies_matrix.sum()).abs().item()
force_diff = (forces_list - forces_matrix).abs().max().item()
print(f"\nEnergy difference: {energy_diff:.2e}")
print(f"Max force difference: {force_diff:.2e}")
print(f"Formats match: {'✓' if energy_diff < 1e-10 and force_diff < 1e-10 else '✗'}")
Neighbor Matrix format: Total energy = -1.292893
Energy difference: 0.00e+00
Max force difference: 0.00e+00
Formats match: ✓
Batch Evaluation#
Multiple independent systems can be evaluated simultaneously using the
batch_idx parameter. Each atom is assigned to a system index.
# Create two identical 2-atom systems
system_positions = torch.tensor(
[[0.0, 0.0, 0.0], [2.5, 0.0, 0.0]], dtype=torch.float64, device=device
)
system_charges = torch.tensor([1.0, -1.0], dtype=torch.float64, device=device)
# Concatenate for batch (second system offset by 100 Å)
positions_batch = torch.cat([system_positions, system_positions + 100.0])
charges_batch = torch.cat([system_charges, system_charges])
batch_idx = torch.tensor([0, 0, 1, 1], dtype=torch.int32, device=device)
cell_batch = torch.tensor(
[[[200.0, 0.0, 0.0], [0.0, 200.0, 0.0], [0.0, 0.0, 200.0]]],
dtype=torch.float64,
device=device,
)
# Neighbor pairs: (0,1) and (2,3)
nl_batch = torch.tensor([[0, 1, 2, 3], [1, 0, 3, 2]], dtype=torch.int32, device=device)
ptr_batch = torch.tensor([0, 1, 2, 3, 4], dtype=torch.int32, device=device)
shifts_batch = torch.zeros((4, 3), dtype=torch.int32, device=device)
print("\nBatch Evaluation: Two identical 2-atom systems")
Batch Evaluation: Two identical 2-atom systems
Batch calculation:
energies_batch, _ = coulomb_energy_forces(
positions_batch,
charges_batch,
cell_batch,
cutoff=10.0,
alpha=0.0,
neighbor_list=nl_batch,
neighbor_ptr=ptr_batch,
neighbor_shifts=shifts_batch,
batch_idx=batch_idx,
)
# Single system for comparison
single_nl = torch.tensor([[0, 1], [1, 0]], dtype=torch.int32, device=device)
single_ptr = torch.tensor([0, 1, 2], dtype=torch.int32, device=device)
single_shifts = torch.zeros((1, 3), dtype=torch.int32, device=device)
energies_single, _ = coulomb_energy_forces(
system_positions,
system_charges,
cell_batch,
cutoff=10.0,
alpha=0.0,
neighbor_list=single_nl,
neighbor_ptr=single_ptr,
neighbor_shifts=single_shifts,
)
print(f"Single system energy: {energies_single.sum().item():.6f}")
print(f"Batch system 0 energy: {energies_batch[:2].sum().item():.6f}")
print(f"Batch system 1 energy: {energies_batch[2:].sum().item():.6f}")
Single system energy: -0.200000
Batch system 0 energy: -0.400000
Batch system 1 energy: -0.400000
Charge Gradients#
The autograd support extends to charge gradients, enabling sensitivity analysis and optimization with respect to charges.
positions_q = torch.tensor(
[[0.0, 0.0, 0.0], [3.0, 0.0, 0.0]], dtype=torch.float64, device=device
)
charges_q = torch.tensor(
[1.0, -1.0], dtype=torch.float64, device=device, requires_grad=True
)
cell_q = torch.tensor(
[[[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 0.0, 100.0]]],
dtype=torch.float64,
device=device,
)
nl_q = torch.tensor([[0, 1], [1, 0]], dtype=torch.int32, device=device)
ptr_q = torch.tensor([0, 1, 2], dtype=torch.int32, device=device)
shifts_q = torch.zeros((1, 3), dtype=torch.int32, device=device)
energies_q = coulomb_energy(
positions_q,
charges_q,
cell_q,
cutoff=10.0,
alpha=0.0,
neighbor_list=nl_q,
neighbor_ptr=ptr_q,
neighbor_shifts=shifts_q,
)
total_energy_q = energies_q.sum()
total_energy_q.backward()
print("\nCharge Gradients")
print(f"Two atoms at r=3.0 Å, charges: {charges_q.data.cpu().numpy()}")
print(f"Total energy: {total_energy_q.item():.6f}")
Charge Gradients
Two atoms at r=3.0 Å, charges: [ 1. -1.]
Total energy: -0.166667
Verify against analytical result:
For E = q₀q₁/r:
∂E/∂q₀ = q₁/r = -1/3 ≈ -0.333
∂E/∂q₁ = q₀/r = 1/3 ≈ 0.333
print(f"\n∂E/∂q₀ = {charges_q.grad[0].item():.6f} (expected: {-1.0 / 3.0:.6f})")
print(f"∂E/∂q₁ = {charges_q.grad[1].item():.6f} (expected: {1.0 / 3.0:.6f})")
match_q0 = abs(charges_q.grad[0].item() - (-1.0 / 3.0)) < 1e-6
match_q1 = abs(charges_q.grad[1].item() - (1.0 / 3.0)) < 1e-6
print(f"Match: {'✓' if match_q0 and match_q1 else '✗'}")
∂E/∂q₀ = -0.166667 (expected: -0.333333)
∂E/∂q₁ = 0.166667 (expected: 0.333333)
Match: ✗
Summary#
This example demonstrated:
Undamped Coulomb: Direct 1/r interactions with
alpha=0Damped Coulomb: Ewald real-space with
alpha > 0Autograd support: Forces via
-positions.gradNeighbor formats: Both COO list and matrix formats
Batch evaluation: Multiple systems with
batch_idxCharge gradients: Sensitivity via
charges.grad
For visualization of energy/force profiles as a function of distance, see the separate visualization scripts in the examples folder.
print("\nCoulomb example complete!")
Coulomb example complete!
Total running time of the script: (0 minutes 2.610 seconds)