Skip to content

Commit d361cf3

Browse files
author
Han Wang
committed
test: align variant coverage with standard dpa4
Empty-rank behavior (zbl fail-fast twin; first test of the DeepSpin phantom path -- owned-empty ranks with ghosts phantom-pad and match), charge_spin via pair_deepspin, and dp-freeze default-CLI resolution for zbl and native-spin compositions (issue deepmodeling#5906 Task 12b).
1 parent 88ce0d8 commit d361cf3

4 files changed

Lines changed: 596 additions & 3 deletions

File tree

Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Test the LAMMPS ``charge_spin`` keyword through ``pair_style deepspin``.
3+
4+
The sibling ``test_lammps_chg_spin_pt2.py`` exercises the keyword through
5+
``pair_style deepmd`` (DPA3, non-spin); the DeepSpin ingestion seam is a
6+
SEPARATE code path (``pair_deepspin.cpp``'s own ``charge_spin`` keyword
7+
parse feeding ``DeepSpin::compute(..., charge_spin)``, see
8+
``source/lmp/pair_deepspin.cpp``), so a regression confined to it was
9+
invisible to that file -- every pre-existing spin fixture has
10+
``dim_chg_spin == 0``, making the whole argument inert. This file mirrors
11+
the sibling's three-test structure (default run, explicit run, sensitivity)
12+
against the COMBINED native-spin + charge-spin FiLM DPA4 archive
13+
``deeppot_dpa4_spin_chgspin.pt2`` (issue #5906 Task 12b: DPA4-variant
14+
behaviour must align with what the C++ gtest
15+
``source/api_cc/tests/test_deepspin_dpa4_chgspin_ptexpt.cc`` already proves
16+
for the C API -- the LAMMPS pair style is a distinct consumer).
17+
18+
The archive and its stored ``default_chg_spin = [0.0, 1.0]`` come from
19+
``source/tests/infer/gen_dpa4_spin_chgspin.py``; the explicit probe
20+
``charge_spin 1.0 2.0`` matches the generator's ``_EXPLICIT_CHG_SPIN``
21+
(the embedding is CATEGORICAL, so the two probes land on distinct rows in
22+
BOTH components -- see the generator's module docstring).
23+
24+
Reference values are computed LIVE at test-setup time via
25+
``deepmd.infer.DeepPot.eval`` on the archive itself (mirroring
26+
``test_lammps_dpa4_spin_graph_pt2.py``'s ``_compute_expected``, which
27+
explains the reasoning in full) rather than read from the generator's
28+
``.expected`` sidecar: the sidecar's evaluation uses a 6x6x6 A cell whose
29+
edge length equals DPA4's LAMMPS ghost cutoff exactly
30+
(rcut(4.0)+skin(2.0)=6.0), which is not a safe geometry for a periodic
31+
LAMMPS run. This module keeps the generator's 6-atom NiO geometry and
32+
spins verbatim, in a 13x13x13 A box instead (the same box-swap the ZBL twin
33+
``test_lammps_dpa4_zbl_pt2.py`` applies to its generator geometry).
34+
"""
35+
36+
import json
37+
import os
38+
import subprocess as sp
39+
import sys
40+
import textwrap
41+
from pathlib import (
42+
Path,
43+
)
44+
45+
import numpy as np
46+
import pytest
47+
from lammps import (
48+
PyLammps,
49+
)
50+
from write_lmp_data import (
51+
write_lmp_data_spin,
52+
)
53+
54+
pb_file = (
55+
Path(__file__).parent.parent.parent
56+
/ "tests"
57+
/ "infer"
58+
/ "deeppot_dpa4_spin_chgspin.pt2"
59+
)
60+
data_file = Path(__file__).parent / "data_dpa4_chg_spin_deepspin_pt2.lmp"
61+
62+
# 6-atom NiO system: coordinates, types and spins verbatim from
63+
# ``gen_dpa4_spin_chgspin.py`` (3 spin-active Ni + 3 O; the O spins are
64+
# deliberately nonzero there -- the model's own descriptor gating must zero
65+
# the non-spin rows internally), in a 13x13x13 A box instead of the
66+
# generator's 6x6x6 (see the module docstring).
67+
box = np.array([0, 13, 0, 13, 0, 13, 0, 0, 0])
68+
coord = np.array(
69+
[
70+
[1.0, 1.0, 1.0],
71+
[3.2, 1.4, 1.1],
72+
[1.3, 1.8, 1.0],
73+
[0.4, 1.2, 1.6],
74+
[3.6, 2.0, 1.3],
75+
[3.4, 0.7, 1.7],
76+
]
77+
)
78+
spin = np.array(
79+
[
80+
[0.11, 0.05, -0.02],
81+
[-0.07, 0.09, 0.03],
82+
[0.02, -0.06, 0.08],
83+
[0.01, -0.01, 0.02],
84+
[-0.02, 0.03, -0.01],
85+
[0.015, 0.02, -0.03],
86+
]
87+
)
88+
# Model ``type_map`` is ["Ni", "O"]; the generator's atype [0,0,0,1,1,1]
89+
# -> LAMMPS types [1,1,1,2,2,2] under identity ``pair_coeff * *``.
90+
type_NiO = np.array([1, 1, 1, 2, 2, 2])
91+
92+
# Explicit runtime probe, matching the generator's ``_EXPLICIT_CHG_SPIN``
93+
# (distinct from the stored default [0.0, 1.0] in BOTH categorical
94+
# components, so neither component alone can explain a response).
95+
_EXPLICIT_CHG_SPIN = [1.0, 2.0]
96+
97+
# LAMMPS's ``fm`` (what ``compute property/atom fmx fmy fmz`` reports) is
98+
# NOT the raw DeepEval force_mag: pair_deepspin.cpp scales it by
99+
# ``spin_norm / hbar`` per atom (metal-units ``hbar = 6.5821191e-04``; same
100+
# convention as test_lammps_dpa4_spin_graph_pt2.py, which documents it).
101+
_HBAR_METAL = 6.5821191e-04
102+
103+
# Reference values (energy / force / force_mag, default and explicit
104+
# charge_spin), populated by ``_compute_expected`` in ``setup_module``.
105+
expected_e_default = None
106+
expected_f_default = None
107+
expected_fm_default = None
108+
expected_e_explicit = None
109+
expected_f_explicit = None
110+
expected_fm_explicit = None
111+
112+
113+
def _cell_from_lammps_box(lmp_box: np.ndarray) -> np.ndarray:
114+
"""Convert a LAMMPS ``xlo xhi ylo yhi zlo zhi xy xz yz`` box spec to a
115+
flat, row-major 3x3 cell matrix (deepmd's ``box`` convention).
116+
"""
117+
xlo, xhi, ylo, yhi, zlo, zhi, xy, xz, yz = lmp_box
118+
return np.array([xhi - xlo, 0.0, 0.0, xy, yhi - ylo, 0.0, xz, yz, zhi - zlo])
119+
120+
121+
def _compute_expected() -> None:
122+
"""Load ``deeppot_dpa4_spin_chgspin.pt2`` via ``DeepPot`` and evaluate
123+
the module's fixed 6-atom NiO system, once with NO ``charge_spin``
124+
(stored default) and once with the explicit probe.
125+
126+
Runs in a subprocess to avoid importing ``deepmd`` in the LAMMPS test
127+
process (the LAMMPS plugin already loads ``libdeepmd_op_pt.so`` at the
128+
C++ level, and importing the Python package on top of that can
129+
segfault) -- the same precaution as ``test_lammps_dpa4_spin_graph_pt2.py``.
130+
"""
131+
global expected_e_default, expected_f_default, expected_fm_default
132+
global expected_e_explicit, expected_f_explicit, expected_fm_explicit
133+
134+
cell = _cell_from_lammps_box(box)
135+
atype = (type_NiO - 1).tolist() # LAMMPS 1-based -> deepmd 0-based (Ni=0, O=1)
136+
137+
# The archive lives in ``source/tests/infer`` next to ``gen_common.py``,
138+
# whose ``load_custom_ops()`` loads the build-tree ``libdeepmd_op_pt.so``
139+
# (registering ``deepmd::edge_force_virial``, which graph ``.pt2``
140+
# inference needs); ``import deepmd.pt`` alone only loads the op library
141+
# from SHARED_LIB_DIR, which the build-test env does not populate.
142+
infer_dir = str(pb_file.resolve().parent)
143+
script = textwrap.dedent(f"""\
144+
import json
145+
import sys
146+
import numpy as np
147+
148+
sys.path.insert(0, {infer_dir!r})
149+
import deepmd.pt # noqa: F401 (triggers the base op-library load)
150+
from gen_common import load_custom_ops
151+
152+
load_custom_ops()
153+
from deepmd.infer import DeepPot
154+
155+
dp = DeepPot({str(pb_file.resolve())!r})
156+
assert dp.deep_eval.get_dim_chg_spin() == 2
157+
out = {{}}
158+
for label, chg_spin in (
159+
("default", None),
160+
("explicit", {_EXPLICIT_CHG_SPIN!r}),
161+
):
162+
kwargs = {{}}
163+
if chg_spin is not None:
164+
kwargs["charge_spin"] = np.array([chg_spin], dtype=np.float64)
165+
e, f, v, ae, av, fm, mm = dp.eval(
166+
np.array({coord.tolist()!r}).reshape(1, -1, 3),
167+
np.array({cell.tolist()!r}).reshape(1, 9),
168+
{atype!r},
169+
atomic=True,
170+
spin=np.array({spin.tolist()!r}).reshape(1, -1, 3),
171+
**kwargs,
172+
)
173+
out[label] = {{
174+
"e": float(e[0, 0]),
175+
"f": np.asarray(f[0]).tolist(),
176+
"fm": np.asarray(fm[0]).tolist(),
177+
}}
178+
print(json.dumps(out))
179+
""")
180+
proc = sp.run([sys.executable, "-c", script], capture_output=True, text=True)
181+
if proc.returncode != 0:
182+
raise RuntimeError(f"Failed to compute expected values:\n{proc.stderr}")
183+
result = json.loads(proc.stdout.strip())
184+
185+
# Raw DeepEval force_mag (dE/dspin), scaled by LAMMPS's own
186+
# spin_norm / hbar unit convention (see ``_HBAR_METAL`` above) before
187+
# comparison.
188+
spin_norm_scale = (np.linalg.norm(spin, axis=1) / _HBAR_METAL)[:, None]
189+
190+
expected_e_default = result["default"]["e"]
191+
expected_f_default = np.array(result["default"]["f"])
192+
expected_fm_default = np.array(result["default"]["fm"]) * spin_norm_scale
193+
expected_e_explicit = result["explicit"]["e"]
194+
expected_f_explicit = np.array(result["explicit"]["f"])
195+
expected_fm_explicit = np.array(result["explicit"]["fm"]) * spin_norm_scale
196+
197+
# Anti-vacuity, checked once here so every test below is known to compare
198+
# against a non-degenerate reference: the explicit probe must MOVE the
199+
# energy (the generator asserts the same at generation time; re-asserting
200+
# on THIS geometry keeps the sensitivity test below meaningful), and the
201+
# charge-spin FiLM must not have killed the spin response.
202+
assert abs(expected_e_explicit - expected_e_default) > 1e-6, (
203+
f"charge_spin={_EXPLICIT_CHG_SPIN} left the energy unchanged vs the "
204+
f"stored default ({expected_e_default:.18e} vs "
205+
f"{expected_e_explicit:.18e}); the FiLM conditioning is not reaching "
206+
f"the forward on this geometry, so the sensitivity test is vacuous."
207+
)
208+
assert np.max(np.abs(expected_fm_default[:3])) > 1e-6, (
209+
"expected non-trivial force_mag on the spin-active (Ni) atoms; the "
210+
"fixture would be vacuous for the spin leaf."
211+
)
212+
213+
214+
def setup_module() -> None:
215+
if os.environ.get("ENABLE_PYTORCH", "1") != "1":
216+
pytest.skip("Skip test because PyTorch support is not enabled.")
217+
if not pb_file.exists():
218+
pytest.skip(
219+
"deeppot_dpa4_spin_chgspin.pt2 not found (run "
220+
"source/tests/infer/gen_dpa4_spin_chgspin.py)."
221+
)
222+
_compute_expected()
223+
write_lmp_data_spin(box, coord, spin, type_NiO, data_file)
224+
225+
226+
def teardown_module() -> None:
227+
if data_file.exists():
228+
os.remove(data_file)
229+
230+
231+
def _lammps(data_file, units="metal") -> PyLammps:
232+
"""Standard DeepSpin LAMMPS system, plus ``atom_modify map yes``.
233+
234+
Same setup as ``test_lammps_dpa4_spin_graph_pt2.py``: the native-spin
235+
DPA4 GRAPH ``.pt2`` needs the LAMMPS atom-map to resolve ghost-atom
236+
indices to local owners for single-rank inference.
237+
"""
238+
if units != "metal":
239+
raise ValueError("units for spin should be metal")
240+
241+
lammps = PyLammps()
242+
lammps.units(units)
243+
lammps.boundary("p p p")
244+
lammps.atom_style("spin")
245+
lammps.atom_modify("map yes")
246+
lammps.neighbor("2.0 bin")
247+
lammps.neigh_modify("every 10 delay 0 check no")
248+
lammps.read_data(data_file.resolve())
249+
lammps.mass("1 58") # Ni
250+
lammps.mass("2 16") # O
251+
lammps.timestep(0.0005)
252+
lammps.fix("1 all nve")
253+
return lammps
254+
255+
256+
@pytest.fixture
257+
def lammps():
258+
lmp = _lammps(data_file=data_file)
259+
yield lmp
260+
lmp.close()
261+
262+
263+
def _gather_force_mag(lammps: PyLammps, natoms: int) -> np.ndarray:
264+
"""Extract per-atom force_mag in atom-id order via
265+
``compute property/atom fmx fmy fmz`` + ``gather`` (LAMMPS does not
266+
expose ``fm`` through the legacy ``extract``/``gather_atoms`` registry;
267+
see ``test_lammps_dpa4_spin_graph_pt2.py``).
268+
"""
269+
fm_global = lammps.lmp.gather("c_fmprop", 1, 3)
270+
return np.array(fm_global, dtype=np.float64).reshape(natoms, 3)
271+
272+
273+
def _assert_run0_matches(
274+
lammps: PyLammps,
275+
e_ref: float,
276+
f_ref: np.ndarray,
277+
fm_ref: np.ndarray,
278+
) -> None:
279+
"""Run 0 steps and compare pe / force / force_mag to the given reference
280+
(both sides run the SAME compiled artifact, so ``atol=1e-8`` is a
281+
cross-consumer bound, not a cross-backend one -- same rationale as the
282+
sibling DPA4 LAMMPS tests).
283+
"""
284+
natoms = coord.shape[0]
285+
lammps.compute("fmprop all property/atom fmx fmy fmz")
286+
lammps.run(0)
287+
288+
assert lammps.eval("pe") == pytest.approx(e_ref, rel=1e-10)
289+
290+
forces = np.array(
291+
[lammps.atoms[ii].force for ii in range(natoms)], dtype=np.float64
292+
)
293+
ids = np.array([lammps.atoms[ii].id for ii in range(natoms)])
294+
np.testing.assert_allclose(forces, f_ref[ids - 1], atol=1e-8, rtol=0)
295+
296+
force_mag = _gather_force_mag(lammps, natoms)
297+
np.testing.assert_allclose(force_mag, fm_ref, atol=1e-8, rtol=0)
298+
# Native-spin design invariant: force_mag on the non-spin (O) atoms must
299+
# be exactly zero -- the model's own type gating, not the spin values,
300+
# decides (the O spins in this fixture are deliberately nonzero).
301+
np.testing.assert_array_equal(force_mag[3:], np.zeros((3, 3)))
302+
303+
304+
def test_pair_deepspin_charge_spin_default(lammps) -> None:
305+
"""No charge_spin keyword -> the model's stored default_chg_spin is used
306+
(the DeepSpin twin of the backward-compatibility contract the C++ gtest
307+
pins for an EMPTY runtime charge_spin).
308+
"""
309+
lammps.pair_style(f"deepspin {pb_file.resolve()}")
310+
lammps.pair_coeff("* *")
311+
_assert_run0_matches(
312+
lammps, expected_e_default, expected_f_default, expected_fm_default
313+
)
314+
lammps.run(1)
315+
316+
317+
def test_pair_deepspin_charge_spin_explicit(lammps) -> None:
318+
"""Explicit ``charge_spin`` keyword is parsed by pair_deepspin and
319+
threaded through DeepSpin to the model (energy, force AND force_mag --
320+
the spin-only output -- must all follow the explicit conditioning).
321+
"""
322+
cs = " ".join(str(v) for v in _EXPLICIT_CHG_SPIN)
323+
lammps.pair_style(f"deepspin {pb_file.resolve()} charge_spin {cs}")
324+
lammps.pair_coeff("* *")
325+
_assert_run0_matches(
326+
lammps, expected_e_explicit, expected_f_explicit, expected_fm_explicit
327+
)
328+
lammps.run(1)
329+
330+
331+
def test_charge_spin_changes_result(lammps) -> None:
332+
"""Different charge_spin must give a different energy (keyword takes
333+
effect through pair_deepspin; ``_compute_expected`` already pinned that
334+
the two references differ, so this catches the keyword being silently
335+
dropped on the LAMMPS side).
336+
"""
337+
cs = " ".join(str(v) for v in _EXPLICIT_CHG_SPIN)
338+
lammps.pair_style(f"deepspin {pb_file.resolve()} charge_spin {cs}")
339+
lammps.pair_coeff("* *")
340+
lammps.run(0)
341+
assert lammps.eval("pe") != pytest.approx(expected_e_default)

0 commit comments

Comments
 (0)