Skip to content

Commit 63247ba

Browse files
authored
Expose Slab Correction in Electrostatics Wrappers (#103)
* Add slab correction support to electrostatics wrappers * Add slab correction wrapper tests * Address comments Signed-off-by: atulcthakur <atthakur@nvidia.com> --------- Signed-off-by: atulcthakur <atthakur@nvidia.com>
1 parent aa2f5e4 commit 63247ba

5 files changed

Lines changed: 415 additions & 83 deletions

File tree

nvalchemi/models/ewald.py

Lines changed: 39 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,13 @@ class EwaldModelWrapper(nn.Module, BaseModelMixin):
9494
coulomb_constant : float, optional
9595
Coulomb prefactor :math:`k_e` in eV·Å/e².
9696
Defaults to ``14.3996`` (standard value for Å/e/eV unit system).
97-
97+
slab_correction : bool, optional
98+
Whether to enable the two-dimensional slab correction. Defaults to
99+
``False``. When enabled, the input batch must provide ``data.pbc`` as
100+
a boolean tensor with shape ``(B, 3)``. Rows with exactly one
101+
``False`` entry mark slab systems, for example ``[True, True, False]``
102+
for a non-periodic z axis. Fully periodic rows are no-ops, so mixed
103+
slab and three-dimensional periodic batches are supported.
98104
99105
Attributes
100106
----------
@@ -116,12 +122,14 @@ def __init__(
116122
accuracy: float = 1e-6,
117123
coulomb_constant: float = 14.3996,
118124
hybrid_forces: bool = True,
125+
slab_correction: bool = False,
119126
) -> None:
120127
super().__init__()
121128
self.cutoff = cutoff
122129
self.accuracy = accuracy
123130
self.coulomb_constant = coulomb_constant
124131
self.hybrid_forces = hybrid_forces
132+
self.slab_correction = slab_correction
125133
self.model_config = ModelConfig(
126134
outputs=frozenset({"energy", "forces", "stress"}),
127135
active_outputs={"energy", "forces"},
@@ -184,7 +192,10 @@ def direct_derivative_keys(self) -> set[str]:
184192

185193
def input_data(self) -> set[str]:
186194
"""Return required input keys (override to drop ``atomic_numbers``)."""
187-
return {"positions", "charges", "neighbor_matrix", "num_neighbors"}
195+
keys = {"positions", "charges", "neighbor_matrix", "num_neighbors"}
196+
if self.slab_correction:
197+
keys.add("pbc")
198+
return keys
188199

189200
# ------------------------------------------------------------------
190201
# Cache management
@@ -251,6 +262,12 @@ def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]
251262
for key in self.input_data():
252263
value = getattr(data, key, None)
253264
if value is None:
265+
if key == "pbc" and self.slab_correction:
266+
raise ValueError(
267+
"EwaldModelWrapper with slab_correction=True requires "
268+
"periodic boundary condition flags "
269+
"(data.pbc must be present)."
270+
)
254271
raise KeyError(f"'{key}' required but not found in input data.")
255272
input_dict[key] = value
256273

@@ -268,6 +285,15 @@ def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]
268285
"(data.cell must be present)."
269286
)
270287

288+
if self.slab_correction:
289+
pbc = getattr(data, "pbc", None)
290+
if pbc is None:
291+
raise ValueError(
292+
"EwaldModelWrapper with slab_correction=True requires "
293+
"periodic boundary condition flags (data.pbc must be present)."
294+
)
295+
input_dict["pbc"] = pbc # (B, 3)
296+
271297
# neighbor_matrix and num_neighbors are already collected by the
272298
# input_data() loop above. In a pipeline, the pipeline adapts them
273299
# to this model's cutoff/format before calling forward().
@@ -327,8 +353,7 @@ def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
327353
``-W/V``).
328354
"""
329355
from nvalchemiops.torch.interactions.electrostatics.ewald import ( # lazy
330-
ewald_real_space,
331-
ewald_reciprocal_space,
356+
ewald_summation,
332357
)
333358

334359
inp = self.adapt_input(data, **kwargs)
@@ -343,6 +368,7 @@ def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
343368
B: int = inp["num_graphs"]
344369
neighbor_matrix = inp["neighbor_matrix"].contiguous()
345370
neighbor_matrix_shifts = inp.get("neighbor_matrix_shifts")
371+
pbc = inp.get("pbc")
346372

347373
compute_forces = "forces" in self.model_config.active_outputs
348374
compute_stresses = "stress" in self.model_config.active_outputs
@@ -385,36 +411,25 @@ def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
385411
self._null_shifts_shape = (N, K)
386412
neighbor_matrix_shifts = self._null_shifts
387413

388-
# --- Real-space contribution ---
389-
real_result = ewald_real_space(
414+
result = ewald_summation(
390415
positions=positions,
391416
charges=charges,
392417
cell=cell,
393418
alpha=alpha,
419+
k_vectors=k_vectors,
394420
neighbor_matrix=neighbor_matrix,
395421
neighbor_matrix_shifts=neighbor_matrix_shifts.contiguous(),
396422
mask_value=fill_value,
397423
batch_idx=batch_idx,
398424
compute_forces=compute_forces,
399425
compute_virial=compute_stresses,
400426
hybrid_forces=self.hybrid_forces,
427+
pbc=pbc,
428+
slab_correction=self.slab_correction,
401429
)
402430

403-
# --- Reciprocal-space contribution ---
404-
recip_result = ewald_reciprocal_space(
405-
positions=positions,
406-
charges=charges,
407-
cell=cell,
408-
k_vectors=k_vectors,
409-
alpha=alpha,
410-
batch_idx=batch_idx,
411-
compute_forces=compute_forces,
412-
compute_virial=compute_stresses,
413-
hybrid_forces=self.hybrid_forces,
414-
)
415-
416-
# Unpack results (energies always first; forces and virial follow
417-
# in the order they were requested).
431+
# Unpack results (energies always first; forces and virial follow in
432+
# the order they were requested).
418433
def _unpack(result, compute_f: bool, compute_v: bool):
419434
"""Extract (energies, forces_or_None, virial_or_None) from result."""
420435
if isinstance(result, torch.Tensor):
@@ -431,23 +446,10 @@ def _unpack(result, compute_f: bool, compute_v: bool):
431446
v = result_list[idx]
432447
return e, f, v
433448

434-
e_real, f_real, v_real = _unpack(real_result, compute_forces, compute_stresses)
435-
e_recip, f_recip, v_recip = _unpack(
436-
recip_result, compute_forces, compute_stresses
449+
per_atom_energies, forces, virial = _unpack(
450+
result, compute_forces, compute_stresses
437451
)
438-
439-
# Sum real + reciprocal contributions.
440-
per_atom_energies = (e_real + e_recip).to(
441-
positions.dtype
442-
) # (N,) float64->dtype
443-
444-
forces: torch.Tensor | None = None
445-
if compute_forces and f_real is not None and f_recip is not None:
446-
forces = f_real + f_recip # (N, 3)
447-
448-
virial: torch.Tensor | None = None
449-
if compute_stresses and v_real is not None and v_recip is not None:
450-
virial = v_real + v_recip # (B, 3, 3)
452+
per_atom_energies = per_atom_energies.to(positions.dtype)
451453

452454
# Scale by Coulomb constant.
453455
per_atom_energies = per_atom_energies * self.coulomb_constant

nvalchemi/models/pme.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ class PMEModelWrapper(nn.Module, BaseModelMixin):
109109
coulomb_constant : float, optional
110110
Coulomb prefactor :math:`k_e` in eV·Å/e².
111111
Defaults to ``14.3996`` (standard value for Å/e/eV unit system).
112+
slab_correction : bool, optional
113+
Whether to enable the two-dimensional slab correction. Defaults to
114+
``False``. When enabled, the input batch must provide ``data.pbc`` as
115+
a boolean tensor with shape ``(B, 3)``. Rows with exactly one
116+
``False`` entry mark slab systems, for example ``[True, True, False]``
117+
for a non-periodic z axis. Fully periodic rows are no-ops, so mixed
118+
slab and three-dimensional periodic batches are supported.
112119
113120
Attributes
114121
----------
@@ -134,6 +141,7 @@ def __init__(
134141
accuracy: float = 1e-6,
135142
coulomb_constant: float = 14.3996,
136143
hybrid_forces: bool = True,
144+
slab_correction: bool = False,
137145
) -> None:
138146
super().__init__()
139147
self.cutoff = cutoff
@@ -144,6 +152,7 @@ def __init__(
144152
self.accuracy = accuracy
145153
self.coulomb_constant = coulomb_constant
146154
self.hybrid_forces = hybrid_forces
155+
self.slab_correction = slab_correction
147156
self.model_config = ModelConfig(
148157
outputs=frozenset({"energy", "forces", "stress"}),
149158
active_outputs={"energy", "forces"},
@@ -207,7 +216,10 @@ def direct_derivative_keys(self) -> set[str]:
207216

208217
def input_data(self) -> set[str]:
209218
"""Return required input keys (override to drop ``atomic_numbers``)."""
210-
return {"positions", "charges", "neighbor_matrix", "num_neighbors"}
219+
keys = {"positions", "charges", "neighbor_matrix", "num_neighbors"}
220+
if self.slab_correction:
221+
keys.add("pbc")
222+
return keys
211223

212224
# ------------------------------------------------------------------
213225
# Cache management
@@ -287,6 +299,11 @@ def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]
287299
for key in self.input_data():
288300
value = getattr(data, key, None)
289301
if value is None:
302+
if key == "pbc" and self.slab_correction:
303+
raise ValueError(
304+
"PMEModelWrapper with slab_correction=True requires periodic "
305+
"boundary condition flags (data.pbc must be present)."
306+
)
290307
raise KeyError(f"'{key}' required but not found in input data.")
291308
input_dict[key] = value
292309

@@ -304,6 +321,15 @@ def adapt_input(self, data: AtomicData | Batch, **kwargs: Any) -> dict[str, Any]
304321
"(data.cell must be present)."
305322
)
306323

324+
if self.slab_correction:
325+
pbc = getattr(data, "pbc", None)
326+
if pbc is None:
327+
raise ValueError(
328+
"PMEModelWrapper with slab_correction=True requires periodic "
329+
"boundary condition flags (data.pbc must be present)."
330+
)
331+
input_dict["pbc"] = pbc # (B, 3)
332+
307333
# neighbor_matrix and num_neighbors are already collected by the
308334
# input_data() loop above. In a pipeline, the pipeline adapts them
309335
# to this model's cutoff/format before calling forward().
@@ -377,6 +403,7 @@ def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
377403
B: int = inp["num_graphs"]
378404
neighbor_matrix = inp["neighbor_matrix"].contiguous()
379405
neighbor_matrix_shifts = inp.get("neighbor_matrix_shifts")
406+
pbc = inp.get("pbc")
380407

381408
compute_forces = "forces" in self.model_config.active_outputs
382409
compute_stresses = "stress" in self.model_config.active_outputs
@@ -450,6 +477,8 @@ def forward(self, data: AtomicData | Batch, **kwargs: Any) -> ModelOutputs:
450477
compute_virial=compute_stresses,
451478
accuracy=self.accuracy,
452479
hybrid_forces=self.hybrid_forces,
480+
pbc=pbc,
481+
slab_correction=self.slab_correction,
453482
)
454483

455484
# Unpack tuple: (energies, [forces], [virial]).

test/models/test_ewald.py

Lines changed: 29 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,11 @@ def test_input_data_override(self):
208208
assert "neighbor_matrix" in keys
209209
assert "num_neighbors" in keys
210210

211+
def test_input_data_includes_pbc_for_slab_correction(self):
212+
"""Slab-enabled Ewald declares pbc as a required input."""
213+
w = _make_ewald(slab_correction=True)
214+
assert "pbc" in w.input_data()
215+
211216
def test_output_data_with_forces(self):
212217
w = _make_ewald()
213218
out = w.output_data()
@@ -514,31 +519,14 @@ def test_forward_stress_when_requested(self):
514519

515520
def test_forward_stress_is_negative_virial_over_volume(self):
516521
"""ASE-style stress == -virial / volume (eV/A^3)."""
517-
w = _make_ewald()
522+
w = _make_ewald(slab_correction=True)
518523
w.model_config.active_outputs = {"energy", "forces", "stress"}
519524
batch = _make_charged_batch(box_size=10.0)
520525
self._build_nl(batch, w)
521526

522-
real_virial_value = 2.0
523-
recip_virial_value = 3.0
524-
525-
def fake_real_space(**kw):
526-
positions = kw["positions"]
527-
cell = kw["cell"]
528-
return (
529-
torch.zeros(
530-
positions.shape[0], dtype=positions.dtype, device=positions.device
531-
),
532-
torch.zeros_like(positions),
533-
torch.full(
534-
(cell.shape[0], 3, 3),
535-
real_virial_value,
536-
dtype=positions.dtype,
537-
device=positions.device,
538-
),
539-
)
527+
virial_value = 5.0
540528

541-
def fake_reciprocal_space(**kw):
529+
def fake_ewald_summation(**kw):
542530
positions = kw["positions"]
543531
cell = kw["cell"]
544532
return (
@@ -548,33 +536,30 @@ def fake_reciprocal_space(**kw):
548536
torch.zeros_like(positions),
549537
torch.full(
550538
(cell.shape[0], 3, 3),
551-
recip_virial_value,
539+
virial_value,
552540
dtype=positions.dtype,
553541
device=positions.device,
554542
),
555543
)
556544

557-
with (
558-
patch(
559-
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_real_space",
560-
side_effect=fake_real_space,
561-
),
562-
patch(
563-
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_reciprocal_space",
564-
side_effect=fake_reciprocal_space,
565-
),
566-
):
545+
with patch(
546+
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_summation",
547+
side_effect=fake_ewald_summation,
548+
) as mock_ewald_summation:
567549
out = w.forward(batch)
568550

551+
call_kwargs = mock_ewald_summation.call_args.kwargs
552+
torch.testing.assert_close(call_kwargs["pbc"], batch.pbc)
553+
assert call_kwargs["slab_correction"] is True
554+
assert call_kwargs["compute_virial"] is True
555+
569556
volume = torch.det(batch.cell).abs().view(-1, 1, 1)
570-
expected = (
571-
-(real_virial_value + recip_virial_value) * w.coulomb_constant / volume
572-
)
557+
expected = -virial_value * w.coulomb_constant / volume
573558
torch.testing.assert_close(out["stress"], expected.expand_as(out["stress"]))
574559

575560
def test_forward_raises_when_virial_none(self):
576561
"""RuntimeError when stress is requested but kernels return no virial."""
577-
w = _make_ewald()
562+
w = _make_ewald(slab_correction=True)
578563
w.model_config.active_outputs = {"energy", "forces", "stress"}
579564
batch = _make_charged_batch()
580565
self._build_nl(batch, w)
@@ -586,19 +571,18 @@ def _fake_kernel(**kw):
586571
forces = torch.zeros(N, 3, dtype=torch.float64)
587572
return energies, forces
588573

589-
with (
590-
patch(
591-
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_real_space",
592-
side_effect=_fake_kernel,
593-
),
594-
patch(
595-
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_reciprocal_space",
596-
side_effect=_fake_kernel,
597-
),
598-
):
574+
with patch(
575+
"nvalchemiops.torch.interactions.electrostatics.ewald.ewald_summation",
576+
side_effect=_fake_kernel,
577+
) as mock_ewald_summation:
599578
with pytest.raises(RuntimeError, match="kernel did not return a virial"):
600579
w.forward(batch)
601580

581+
call_kwargs = mock_ewald_summation.call_args.kwargs
582+
torch.testing.assert_close(call_kwargs["pbc"], batch.pbc)
583+
assert call_kwargs["slab_correction"] is True
584+
assert call_kwargs["compute_virial"] is True
585+
602586
def test_cache_populated_after_forward(self):
603587
w = _make_ewald()
604588
batch = _make_charged_batch()

test/models/test_pme.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ def test_input_data_override(self):
242242
assert "neighbor_matrix" in keys
243243
assert "num_neighbors" in keys
244244

245+
def test_input_data_includes_pbc_for_slab_correction(self):
246+
"""Slab-enabled PME declares pbc as a required input."""
247+
w = _make_pme(slab_correction=True)
248+
assert "pbc" in w.input_data()
249+
245250
def test_output_data_with_forces(self):
246251
w = _make_pme()
247252
out = w.output_data()

0 commit comments

Comments
 (0)