|
| 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