Skip to content

Commit 489c8aa

Browse files
authored
Size the D3 neighbor matrix to the largest system for non-periodic states (#610)
1 parent 5540d89 commit 489c8aa

4 files changed

Lines changed: 144 additions & 1 deletion

File tree

‎tests/models/test_dispersion.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,112 @@ def strained_energy(strain: torch.Tensor) -> torch.Tensor:
181181
test_d3_r2scan_outputs = make_validate_model_outputs_test(
182182
model_fixture_name="d3_model_r2scan", device=DEVICE, dtype=DTYPE
183183
)
184+
185+
186+
def test_d3_molecular_neighbor_cap_is_exact(monkeypatch: pytest.MonkeyPatch) -> None:
187+
"""Non-periodic states size the neighbor matrix to the largest system, exactly."""
188+
from ase.build import molecule
189+
190+
import torch_sim.models.dispersion as dispersion_module
191+
from torch_sim.neighbors import torchsim_nl
192+
193+
state = ts.io.atoms_to_state(
194+
[molecule("H2O"), molecule("C6H6"), molecule("CH3CH2OH")],
195+
device=DEVICE,
196+
dtype=DTYPE,
197+
)
198+
kwargs = {
199+
**PBE_BJ,
200+
"d3_params": _make_d3_params(),
201+
"cutoff": 50.0,
202+
"device": DEVICE,
203+
"dtype": DTYPE,
204+
"compute_stress": False,
205+
}
206+
seen: list[int | None] = []
207+
208+
def spy_nl(*args: object, **nl_kwargs: object) -> object:
209+
seen.append(nl_kwargs.get("max_neighbors"))
210+
return torchsim_nl(*args, **nl_kwargs)
211+
212+
def uncapped_nl(*args: object) -> object:
213+
# A different callable than torchsim_nl, so the model leaves the width alone.
214+
return torchsim_nl(*args)
215+
216+
capped = D3DispersionModel(**kwargs)
217+
uncapped = D3DispersionModel(**kwargs, neighbor_list_fn=uncapped_nl)
218+
monkeypatch.setattr(dispersion_module, "torchsim_nl", spy_nl)
219+
capped.neighbor_list_fn = spy_nl
220+
221+
out_capped = capped(state)
222+
out_uncapped = uncapped(state)
223+
224+
assert seen == [11] # benzene, 12 atoms
225+
torch.testing.assert_close(out_capped["energy"], out_uncapped["energy"])
226+
torch.testing.assert_close(out_capped["forces"], out_uncapped["forces"])
227+
228+
229+
def test_d3_molecular_cap_never_widens_default(monkeypatch: pytest.MonkeyPatch) -> None:
230+
"""Systems larger than the bulk-density estimate keep the default width."""
231+
import torch_sim.models.dispersion as dispersion_module
232+
from torch_sim.neighbors import torchsim_nl
233+
234+
seen: list[int | None] = []
235+
236+
def spy_nl(*args: object, **nl_kwargs: object) -> object:
237+
seen.append(nl_kwargs.get("max_neighbors"))
238+
return torchsim_nl(*args, **nl_kwargs)
239+
240+
monkeypatch.setattr(dispersion_module, "torchsim_nl", spy_nl)
241+
model = D3DispersionModel(
242+
**PBE_BJ,
243+
d3_params=_make_d3_params(),
244+
cutoff=2.0, # estimate_max_neighbors(2.0) == 16
245+
device=DEVICE,
246+
dtype=DTYPE,
247+
compute_stress=False,
248+
neighbor_list_fn=spy_nl,
249+
)
250+
n_atoms = 40
251+
positions = torch.zeros(n_atoms, 3, dtype=DTYPE)
252+
positions[:, 0] = 1.5 * torch.arange(n_atoms, dtype=DTYPE)
253+
state = ts.SimState(
254+
positions=positions,
255+
masses=torch.ones(n_atoms, dtype=DTYPE),
256+
cell=torch.zeros(1, 3, 3, dtype=DTYPE),
257+
pbc=False,
258+
atomic_numbers=torch.full((n_atoms,), 6),
259+
system_idx=torch.zeros(n_atoms, dtype=torch.int64),
260+
)
261+
model(state)
262+
assert seen == [None]
263+
264+
265+
def test_d3_periodic_state_keeps_default_neighbor_width() -> None:
266+
"""Periodic states must not be capped: images add neighbors beyond n_atoms - 1."""
267+
from torch_sim.neighbors import torchsim_nl
268+
269+
seen: list[int | None] = []
270+
271+
def spy_nl(*args: object, **nl_kwargs: object) -> object:
272+
seen.append(nl_kwargs.get("max_neighbors"))
273+
return torchsim_nl(*args, **nl_kwargs)
274+
275+
model = D3DispersionModel(
276+
**PBE_BJ,
277+
d3_params=_make_d3_params(),
278+
cutoff=12.0,
279+
device=DEVICE,
280+
dtype=DTYPE,
281+
neighbor_list_fn=spy_nl,
282+
)
283+
state = ts.SimState(
284+
positions=torch.tensor([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], dtype=DTYPE),
285+
masses=torch.ones(2, dtype=DTYPE),
286+
cell=(4.0 * torch.eye(3, dtype=DTYPE)).unsqueeze(0),
287+
pbc=True,
288+
atomic_numbers=torch.tensor([6, 8]),
289+
system_idx=torch.zeros(2, dtype=torch.int64),
290+
)
291+
model(state)
292+
assert seen == [None]

‎torch_sim/models/dispersion.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929

3030
try:
31+
from nvalchemiops.neighbors.neighbor_utils import estimate_max_neighbors
3132
from nvalchemiops.torch.interactions.dispersion import D3Parameters
3233
from nvalchemiops.torch.interactions.dispersion import dftd3 as nvalchemiops_dftd3
3334
except (ImportError, ModuleNotFoundError) as exc:
@@ -44,6 +45,10 @@ def nvalchemiops_dftd3(*_a: Any, _err: Exception = exc, **_kw: Any) -> Any:
4445
"""Raise the original import error."""
4546
raise _err
4647

48+
def estimate_max_neighbors(*_a: Any, _err: Exception = exc, **_kw: Any) -> Any:
49+
"""Raise the original import error."""
50+
raise _err
51+
4752

4853
if TYPE_CHECKING:
4954
from collections.abc import Callable
@@ -77,6 +82,9 @@ class D3DispersionModel(ModelInterface):
7782
compute_forces: Whether to return forces. Defaults to True.
7883
compute_stress: Whether to return stress. Defaults to True.
7984
neighbor_list_fn: Neighbor-list constructor. Defaults to ``torchsim_nl``.
85+
With the default and no periodic boundaries, the neighbor matrix
86+
is sized to the largest system when that is narrower than the
87+
bulk-density estimate for ``cutoff``; the bound is exact.
8088
8189
Example::
8290
@@ -135,12 +143,20 @@ def forward(self, state: SimState, **_kwargs: object) -> dict[str, torch.Tensor]
135143
dict with ``"energy"`` [n_systems], ``"forces"`` [n_atoms, 3],
136144
and (if ``compute_stress``) ``"stress"`` [n_systems, 3, 3].
137145
"""
146+
nl_kwargs = {}
147+
if self.neighbor_list_fn is torchsim_nl and not bool(state.pbc.any()):
148+
# Without images an atom has at most n_atoms - 1 neighbors. Only
149+
# narrow the default width, never widen it.
150+
n_cap = max(int(state.n_atoms_per_system.max().item()) - 1, 1)
151+
if n_cap < estimate_max_neighbors(self.cutoff):
152+
nl_kwargs["max_neighbors"] = n_cap
138153
edge_index, _mapping_system, unit_shifts = self.neighbor_list_fn(
139154
state.positions,
140155
state.row_vector_cell,
141156
state.pbc,
142157
self.cutoff,
143158
state.system_idx,
159+
**nl_kwargs,
144160
)
145161
edge_index_int, neighbor_ptr, unit_shifts_int = (
146162
transforms.build_csr_neighbor_list(

‎torch_sim/neighbors/__init__.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ def torchsim_nl(
5050
cutoff: float,
5151
system_idx: torch.Tensor,
5252
self_interaction: bool = False, # noqa: FBT001, FBT002
53+
*,
54+
max_neighbors: int | None = None,
5355
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
5456
"""Compute neighbor lists with automatic selection of best available implementation.
5557
@@ -66,6 +68,10 @@ def torchsim_nl(
6668
cutoff: Maximum distance for considering atoms as neighbors
6769
system_idx: Tensor [n_atoms] indicating which system each atom belongs to
6870
self_interaction: If True, include self-pairs. Default: False
71+
max_neighbors: Upper bound on neighbors per atom, used to size the
72+
Alchemiops neighbor matrix; an undersized bound raises
73+
NeighborOverflowError. Other backends return exact lists and
74+
ignore it. Default: None (Alchemiops' bulk-density estimate)
6975
7076
Returns:
7177
tuple containing:
@@ -84,7 +90,13 @@ def torchsim_nl(
8490
"""
8591
if ALCHEMIOPS_AVAILABLE:
8692
return alchemiops_nl_n2(
87-
positions, cell, pbc, cutoff, system_idx, self_interaction
93+
positions,
94+
cell,
95+
pbc,
96+
cutoff,
97+
system_idx,
98+
self_interaction,
99+
max_neighbors=max_neighbors,
88100
)
89101

90102
if VESIN_TORCHSCRIPT_AVAILABLE:

‎torch_sim/neighbors/alchemiops.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ def alchemiops_nl_n2(
3939
cutoff: float,
4040
system_idx: torch.Tensor,
4141
self_interaction: bool = False, # noqa: FBT001, FBT002
42+
*,
43+
max_neighbors: int | None = None,
4244
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
4345
"""Compute neighbor lists using Alchemiops naive N^2 algorithm.
4446
@@ -49,6 +51,9 @@ def alchemiops_nl_n2(
4951
cutoff: Maximum distance
5052
system_idx: Tensor [n_atoms] indicating system assignment
5153
self_interaction: If True, include self-pairs
54+
max_neighbors: Width of the per-atom neighbor matrix. Must be an upper
55+
bound; an undersized width raises NeighborOverflowError. Defaults
56+
to nvalchemiops' bulk-density estimate for ``cutoff``.
5257
5358
Returns:
5459
(mapping, system_mapping, shifts_idx)
@@ -65,6 +70,7 @@ def alchemiops_nl_n2(
6570
batch_idx=system_idx.to(torch.int32),
6671
cell=cell,
6772
pbc=pbc.to(torch.bool),
73+
max_neighbors=max_neighbors,
6874
return_neighbor_list=True,
6975
)
7076

0 commit comments

Comments
 (0)