Skip to content

Commit f75ab0b

Browse files
committed
fix(tf): preserve non-PBC ASE neighbor semantics
Keep the original open-boundary decision separate from the identity box required by TensorFlow feeds, and let both ASE builders handle a missing cell without creating periodic ghosts. Add collected DeepPotential and DeepTensor regressions for non-periodic external neighbor-list inference. Coding-Agent: Codex Codex-Version: codex-cli 0.144.4 Model: gpt-5.6-sol Reasoning-Effort: xhigh
1 parent 6c3b985 commit f75ab0b

4 files changed

Lines changed: 105 additions & 10 deletions

File tree

deepmd/tf/infer/deep_eval.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -549,15 +549,23 @@ def build_neighbor_list(
549549
atype: np.ndarray,
550550
imap: np.ndarray,
551551
neighbor_list: "ase.neighborlist.NeighborList | None",
552-
) -> tuple[np.ndarray, np.ndarray]:
552+
) -> tuple[
553+
np.ndarray,
554+
np.ndarray,
555+
np.ndarray,
556+
np.ndarray,
557+
np.ndarray,
558+
np.ndarray,
559+
]:
553560
"""Make the mesh with neighbor list for a single frame.
554561
555562
Parameters
556563
----------
557564
coords : np.ndarray
558565
The coordinates of atoms. Should be of shape [natoms, 3]
559566
cell : Optional[np.ndarray]
560-
The cell of the system. Should be of shape [3, 3]
567+
The cell of the system. Should be of shape [3, 3]. None denotes
568+
open boundary conditions.
561569
atype : np.ndarray
562570
The type of atoms. Should be of shape [natoms]
563571
imap : np.ndarray
@@ -587,7 +595,11 @@ def build_neighbor_list(
587595
The index map of ghost atoms. Should be of shape [nghost]
588596
"""
589597
pbc = np.repeat(cell is not None, 3)
590-
cell = cell.reshape(3, 3)
598+
# ASE still requires a 3x3 cell for non-periodic systems, but the cell
599+
# must not be used to infer periodicity or create ghost atoms.
600+
cell = (
601+
np.zeros((3, 3), dtype=coords.dtype) if cell is None else cell.reshape(3, 3)
602+
)
591603
positions = coords.reshape(-1, 3)
592604
neighbor_list.bothways = True
593605
neighbor_list.self_interaction = False
@@ -814,6 +826,9 @@ def _prepare_feed_dict(
814826
else:
815827
pbc = True
816828
cells = np.array(cells).reshape([nframes, 9])
829+
# Keep the original boundary semantics separate from the identity box
830+
# used only to satisfy TensorFlow's non-optional box placeholder.
831+
neighbor_cell = cells if pbc else None
817832

818833
if self.has_fparam:
819834
assert fparam is not None
@@ -884,7 +899,7 @@ def _prepare_feed_dict(
884899
ghost_map,
885900
) = self.build_neighbor_list(
886901
coords,
887-
cells if cells is not None else None,
902+
neighbor_cell,
888903
atom_types,
889904
imap,
890905
self.neighbor_list,
@@ -1534,15 +1549,23 @@ def build_neighbor_list(
15341549
atype: np.ndarray,
15351550
imap: np.ndarray,
15361551
neighbor_list: "ase.neighborlist.NeighborList | None",
1537-
) -> tuple[np.ndarray, np.ndarray]:
1552+
) -> tuple[
1553+
np.ndarray,
1554+
np.ndarray,
1555+
np.ndarray,
1556+
np.ndarray,
1557+
np.ndarray,
1558+
np.ndarray,
1559+
]:
15381560
"""Make the mesh with neighbor list for a single frame.
15391561
15401562
Parameters
15411563
----------
15421564
coords : np.ndarray
15431565
The coordinates of atoms. Should be of shape [natoms, 3]
15441566
cell : Optional[np.ndarray]
1545-
The cell of the system. Should be of shape [3, 3]
1567+
The cell of the system. Should be of shape [3, 3]. None denotes
1568+
open boundary conditions.
15461569
atype : np.ndarray
15471570
The type of atoms. Should be of shape [natoms]
15481571
imap : np.ndarray
@@ -1572,7 +1595,11 @@ def build_neighbor_list(
15721595
The index map of ghost atoms. Should be of shape [nghost]
15731596
"""
15741597
pbc = np.repeat(cell is not None, 3)
1575-
cell = cell.reshape(3, 3)
1598+
# ASE still requires a 3x3 cell for non-periodic systems, but the cell
1599+
# must not be used to infer periodicity or create ghost atoms.
1600+
cell = (
1601+
np.zeros((3, 3), dtype=coords.dtype) if cell is None else cell.reshape(3, 3)
1602+
)
15761603
positions = coords.reshape(-1, 3)
15771604
neighbor_list.bothways = True
15781605
neighbor_list.self_interaction = False

deepmd/tf/infer/deep_tensor.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ def eval(
202202
else:
203203
pbc = True
204204
cells = np.array(cells).reshape([nframes, 9])
205+
# Keep the original boundary semantics separate from the identity box
206+
# used only to satisfy TensorFlow's non-optional box placeholder.
207+
neighbor_cell = cells if pbc else None
205208

206209
# sort inputs
207210
coords, atom_types, imap, sel_at, sel_imap = self.sort_input(
@@ -227,7 +230,7 @@ def eval(
227230
_,
228231
) = self.build_neighbor_list(
229232
coords,
230-
cells if cells is not None else None,
233+
neighbor_cell,
231234
atom_types,
232235
imap,
233236
self.neighbor_list,
@@ -346,6 +349,9 @@ def eval_full(
346349
else:
347350
pbc = True
348351
cells = np.array(cells).reshape([nframes, 9])
352+
# Keep the original boundary semantics separate from the identity box
353+
# used only to satisfy TensorFlow's non-optional box placeholder.
354+
neighbor_cell = cells if pbc else None
349355
nout = self.output_dim
350356

351357
# sort inputs
@@ -373,7 +379,7 @@ def eval_full(
373379
ghost_map,
374380
) = self.build_neighbor_list(
375381
coords,
376-
cells if cells is not None else None,
382+
neighbor_cell,
377383
atom_types,
378384
imap,
379385
self.neighbor_list,

source/tests/infer/test_models.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
22
import unittest
33

4-
import ase
4+
import ase.neighborlist
55
import dpdata
66
import numpy as np
77

@@ -421,3 +421,36 @@ def test_2frame_atm(self) -> None:
421421
@unittest.skip("Zero atoms not supported")
422422
def test_zero_input(self) -> None:
423423
pass
424+
425+
426+
def test_deep_pot_neighbor_list_nopbc() -> None:
427+
"""The ASE path must preserve a testcase's open-boundary semantics."""
428+
# This is intentionally standalone: the parameterized TestDeepPot symbol is
429+
# replaced by ``object``, so its neighbor-list subclass inherits no tests.
430+
case = get_cases()["se_e2_a"]
431+
result = next(result for result in case.results if result.box is None)
432+
with DeepEval(
433+
case.get_model(".pb"),
434+
neighbor_list=ase.neighborlist.NewPrimitiveNeighborList(
435+
cutoffs=case.rcut,
436+
bothways=True,
437+
),
438+
) as dp:
439+
ee, ff, vv, ae, av = dp.eval(
440+
result.coord,
441+
None,
442+
result.atype,
443+
atomic=True,
444+
fparam=result.fparam,
445+
aparam=result.aparam,
446+
)[:5]
447+
448+
np.testing.assert_almost_equal(ff.ravel(), result.force.ravel(), STRICT_PLACES)
449+
np.testing.assert_almost_equal(
450+
ae.ravel(), result.atomic_energy.ravel(), STRICT_PLACES
451+
)
452+
np.testing.assert_almost_equal(
453+
av.ravel(), result.atomic_virial.ravel(), STRICT_PLACES
454+
)
455+
np.testing.assert_almost_equal(ee.ravel(), result.energy, STRICT_PLACES)
456+
np.testing.assert_almost_equal(vv.ravel(), result.virial, STRICT_PLACES)

source/tests/tf/test_deepdipole.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,3 +1162,32 @@ def test_2frame_full_atm(self) -> None:
11621162
@unittest.skip("multiple frames not supported")
11631163
def test_2frame_old_atm(self) -> None:
11641164
pass
1165+
1166+
def test_nopbc_matches_native_neighbor_building(self) -> None:
1167+
"""ASE and native tensor inference must agree for an open system."""
1168+
native = DeepDipole("deepdipole_new.pb")
1169+
try:
1170+
actual_tensor = self.dp.eval(self.coords, None, self.atype, atomic=True)
1171+
expected_tensor = native.eval(self.coords, None, self.atype, atomic=True)
1172+
np.testing.assert_almost_equal(
1173+
actual_tensor,
1174+
expected_tensor,
1175+
default_places,
1176+
)
1177+
1178+
actual_full = self.dp.eval_full(
1179+
self.coords,
1180+
None,
1181+
self.atype,
1182+
atomic=True,
1183+
)
1184+
expected_full = native.eval_full(
1185+
self.coords,
1186+
None,
1187+
self.atype,
1188+
atomic=True,
1189+
)
1190+
for actual, expected in zip(actual_full, expected_full, strict=True):
1191+
np.testing.assert_almost_equal(actual, expected, default_places)
1192+
finally:
1193+
native.close()

0 commit comments

Comments
 (0)