diff --git a/deepmd/dpmodel/model/spin_model.py b/deepmd/dpmodel/model/spin_model.py index 0d9da07355..972f94a04d 100644 --- a/deepmd/dpmodel/model/spin_model.py +++ b/deepmd/dpmodel/model/spin_model.py @@ -81,6 +81,20 @@ def _to_xp(self, arr: Any, xp: Any, ref_arr: Any) -> Any: """Convert a numpy array to the same namespace as ref_arr.""" return xp.asarray(arr, device=array_api_compat.device(ref_arr)) + def _lookup_type_values(self, values: Any, atype: Array, ref_arr: Array) -> Array: + """Gather per-type values while mapping virtual atom types to zero. + + Negative atom types are padding placeholders, not Python-style indices + from the end of the type table. Their spin scale and mask must remain + zero until the backbone model applies its normal virtual-atom mask. + """ + xp = array_api_compat.array_namespace(ref_arr) + values = self._to_xp(values, xp, ref_arr) + real_atom = atype >= 0 + safe_atype = xp.where(real_atom, atype, xp.zeros_like(atype)) + gathered = values[safe_atype] + return xp.where(real_atom, gathered, xp.zeros_like(gathered)) + def process_spin_input( self, coord: Array, atype: Array, spin: Array ) -> tuple[Array, Array, Array]: @@ -97,9 +111,12 @@ def process_spin_input( """ xp = array_api_compat.array_namespace(coord) nframes, nloc = coord.shape[:-1] - atype_spin = xp.concat([atype, atype + self.ntypes_real], axis=-1) - vsm = self._to_xp(self.virtual_scale_mask, xp, coord) - spin_dist = spin * xp.reshape(vsm[atype], (nframes, nloc, 1)) + virtual_atype = xp.where(atype >= 0, atype + self.ntypes_real, atype) + atype_spin = xp.concat([atype, virtual_atype], axis=-1) + spin_dist = spin * xp.reshape( + self._lookup_type_values(self.virtual_scale_mask, atype, coord), + (nframes, nloc, 1), + ) virtual_coord = coord + spin_dist coord_spin = xp.concat([coord, virtual_coord], axis=-2) # for spin virial correction @@ -151,12 +168,18 @@ def process_spin_input_lower( xp = array_api_compat.array_namespace(extended_coord) nframes, nall = extended_coord.shape[:2] nloc = nlist.shape[1] - vsm = self._to_xp(self.virtual_scale_mask, xp, extended_coord) extended_spin_dist = extended_spin * xp.reshape( - vsm[extended_atype], (nframes, nall, 1) + self._lookup_type_values( + self.virtual_scale_mask, extended_atype, extended_coord + ), + (nframes, nall, 1), ) virtual_extended_coord = extended_coord + extended_spin_dist - virtual_extended_atype = extended_atype + self.ntypes_real + virtual_extended_atype = xp.where( + extended_atype >= 0, + extended_atype + self.ntypes_real, + extended_atype, + ) extended_coord_updated = self.concat_switch_virtual( extended_coord, virtual_extended_coord, nloc ) @@ -222,9 +245,18 @@ def process_spin_output( if virtual_scale: mask = self._to_xp(self.virtual_scale_mask, xp, out_tensor) else: - mask = self._to_xp(self.spin_mask, xp, out_tensor) - atomic_mask = xp.reshape(mask[atype], (nframes, nloc, 1)) - out_real, out_mag = out_tensor[:, :nloc], out_tensor[:, nloc:] + # spin_mask is integral; it multiplies out_mag below, and the array + # API does not promote across kinds. + mask = xp.astype( + self._to_xp(self.spin_mask, xp, out_tensor), out_tensor.dtype + ) + atomic_mask = xp.reshape( + self._lookup_type_values(mask, atype, out_tensor), + (nframes, nloc, 1), + ) + # Trailing ellipsis: the array API does not specify numpy's implicit + # expansion of a partial multi-axis index. + out_real, out_mag = out_tensor[:, :nloc, ...], out_tensor[:, nloc:, ...] if add_mag: out_real = out_real + out_mag out_mag = xp.reshape( @@ -248,19 +280,27 @@ def process_spin_output_lower( if virtual_scale: mask = self._to_xp(self.virtual_scale_mask, xp, extended_out_tensor) else: - mask = self._to_xp(self.spin_mask, xp, extended_out_tensor) - atomic_mask = xp.reshape(mask[extended_atype], (nframes, nall, 1)) + # spin_mask is integral; it multiplies extended_out_mag below, and + # the array API does not promote across kinds. + mask = xp.astype( + self._to_xp(self.spin_mask, xp, extended_out_tensor), + extended_out_tensor.dtype, + ) + atomic_mask = xp.reshape( + self._lookup_type_values(mask, extended_atype, extended_out_tensor), + (nframes, nall, 1), + ) extended_out_real = xp.concat( [ - extended_out_tensor[:, :nloc], - extended_out_tensor[:, nloc + nloc : nloc + nall], + extended_out_tensor[:, :nloc, ...], + extended_out_tensor[:, nloc + nloc : nloc + nall, ...], ], axis=1, ) extended_out_mag = xp.concat( [ - extended_out_tensor[:, nloc : nloc + nloc], - extended_out_tensor[:, nloc + nall :], + extended_out_tensor[:, nloc : nloc + nloc, ...], + extended_out_tensor[:, nloc + nall :, ...], ], axis=1, ) @@ -698,8 +738,10 @@ def call_common( if "mask_mag" not in model_ret: xp = array_api_compat.array_namespace(atype) nframes_m, nloc_m = atype.shape[:2] - vsm = self._to_xp(self.virtual_scale_mask, xp, atype) - atomic_mask = xp.reshape(vsm[atype], (nframes_m, nloc_m, 1)) + atomic_mask = xp.reshape( + self._lookup_type_values(self.virtual_scale_mask, atype, atype), + (nframes_m, nloc_m, 1), + ) model_ret["mask_mag"] = atomic_mask > 0.0 return model_ret @@ -881,8 +923,12 @@ def call_common_lower( if "mask_mag" not in model_ret: xp = array_api_compat.array_namespace(extended_atype) nall = extended_atype.shape[1] - vsm = self._to_xp(self.virtual_scale_mask, xp, extended_atype) - atomic_mask = xp.reshape(vsm[extended_atype], (nframes, nall, 1)) + atomic_mask = xp.reshape( + self._lookup_type_values( + self.virtual_scale_mask, extended_atype, extended_atype + ), + (nframes, nall, 1), + ) model_ret["mask_mag"] = atomic_mask > 0.0 return model_ret diff --git a/deepmd/pt/model/model/spin_model.py b/deepmd/pt/model/model/spin_model.py index fed382f37e..4a25ab172e 100644 --- a/deepmd/pt/model/model/spin_model.py +++ b/deepmd/pt/model/model/spin_model.py @@ -91,21 +91,29 @@ def _pack_spin_stat_sample( def _lookup_type_values(values: torch.Tensor, atype: torch.Tensor) -> torch.Tensor: """ - Gather one scalar value per atom type. - - ``values[atype]`` is semantically equivalent, but AOTInductor may lower - that advanced-indexing form to a CUDA ``index.Tensor`` shim even for a CPU - ``.pt2`` package. ``index_select`` keeps the exported spin graph device - stable while preserving the same lookup semantics. - - Padding ghost slots carry ``atype == -1`` (batched extended regions are - padded to a uniform ``nall``). Unlike advanced indexing, ``index_select`` - rejects negative indices, so the padding entries are clamped to row 0; their - looked-up value is irrelevant because padding atoms carry zero spin and are - dropped from the per-local output downstream. + Gather one scalar value per atom type, mapping virtual atom types to zero. + + ``values[atype]`` is semantically equivalent for real atoms, but + AOTInductor may lower that advanced-indexing form to a CUDA + ``index.Tensor`` shim even for a CPU ``.pt2`` package. ``index_select`` + keeps the exported spin graph device stable. + + Padding slots carry ``atype == -1``: ``deepmd/utils/data.py`` appends it as + the virtual-atom padding for mixed-type systems, and batched extended + regions are padded to a uniform ``nall``. Those are placeholders, not + Python-style indices from the end of the type table, so they get zero + rather than row 0's value — otherwise a padded slot picks up a real spin + scale and mask whenever type 0 is magnetic. This matches + ``SpinModel._lookup_type_values`` in ``deepmd/dpmodel/model/spin_model.py``. """ - flat_atype = torch.clamp_min(atype.reshape(-1).to(dtype=torch.long), 0) - return torch.index_select(values.to(atype.device), 0, flat_atype).view(atype.shape) + long_atype = atype.to(dtype=torch.long) + real_atom = long_atype >= 0 + # index_select rejects negative indices, unlike advanced indexing. + flat_atype = torch.clamp_min(long_atype.reshape(-1), 0) + gathered = torch.index_select(values.to(atype.device), 0, flat_atype).view( + atype.shape + ) + return torch.where(real_atom, gathered, torch.zeros_like(gathered)) class SpinModel(torch.nn.Module): @@ -140,7 +148,12 @@ def process_spin_input( nframes, nloc = atype.shape coord = coord.reshape(nframes, nloc, 3) spin = spin.reshape(nframes, nloc, 3) - atype_spin = torch.concat([atype, atype + self.ntypes_real], dim=-1) + # Keep virtual placeholders at -1 instead of offsetting them into a + # real type of the spin half of the type table. + virtual_atype = torch.where( + atype >= 0, atype + self.ntypes_real, torch.full_like(atype, -1) + ) + atype_spin = torch.concat([atype, virtual_atype], dim=-1) # spin_dist = s_i * \mu_i spin_dist = spin * _lookup_type_values( self.virtual_scale_mask, @@ -193,7 +206,11 @@ def process_spin_input_lower( extended_atype, ).reshape([nframes, nall, 1]) virtual_extended_coord = extended_coord + extended_spin_dist - virtual_extended_atype = extended_atype + self.ntypes_real + virtual_extended_atype = torch.where( + extended_atype >= 0, + extended_atype + self.ntypes_real, + torch.full_like(extended_atype, -1), + ) extended_coord_updated = concat_switch_virtual( extended_coord, virtual_extended_coord, nloc ) diff --git a/source/tests/common/dpmodel/test_spin_model_virtual_types.py b/source/tests/common/dpmodel/test_spin_model_virtual_types.py new file mode 100644 index 0000000000..727eac3b53 --- /dev/null +++ b/source/tests/common/dpmodel/test_spin_model_virtual_types.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Regression tests for virtual placeholders in dpmodel SpinModel inputs.""" + +import numpy as np +import pytest + +from deepmd.dpmodel.common import ( + to_numpy_array, +) +from deepmd.dpmodel.model.model import ( + get_model, +) + +MODEL_CONFIG = { + "type_map": ["A", "B", "C"], + "descriptor": { + "type": "se_e2_a", + "sel": [4, 4, 4], + "rcut_smth": 0.5, + "rcut": 4.0, + "neuron": [3, 6], + "axis_neuron": 2, + "precision": "float64", + "type_one_side": True, + "seed": 1, + }, + "fitting_net": { + "type": "ener", + "neuron": [5, 5], + "precision": "float64", + "seed": 1, + }, + # Keep the final real type magnetic so an accidental ``mask[-1]`` lookup + # is observable instead of being hidden by a zero scale. + "spin": {"use_spin": [False, False, True], "virtual_scale": [0.5]}, +} + + +@pytest.fixture +def model(): + """Build a small NumPy spin model with a magnetic final real type.""" + return get_model(MODEL_CONFIG) + + +def test_dense_spin_expansion_preserves_virtual_types(model) -> None: + """A dense placeholder and its spin partner must both stay virtual.""" + coord = np.arange(9, dtype=np.float64).reshape(1, 3, 3) + atype = np.array([[0, -1, 2]], dtype=np.int32) + spin = np.ones_like(coord) + + coord_updated, atype_updated, coord_corr = model.process_spin_input( + coord, atype, spin + ) + + np.testing.assert_array_equal(atype_updated, [[0, -1, 2, 3, -1, 5]]) + # The placeholder's virtual partner has neither a displacement nor a + # virial correction, even though the final real type is magnetic. + np.testing.assert_array_equal(coord_updated[:, 4], coord[:, 1]) + np.testing.assert_array_equal(coord_corr[:, 4], 0.0) + + _, magnetic_output, magnetic_mask = model.process_spin_output( + atype, np.ones((1, 6, 3), dtype=np.float64) + ) + np.testing.assert_array_equal(magnetic_output[:, 1], 0.0) + np.testing.assert_array_equal(magnetic_mask[:, 1], False) + + prediction = model(coord, atype, spin, box=None) + np.testing.assert_array_equal(prediction["atom_energy"][:, 1], 0.0) + np.testing.assert_array_equal(prediction["mask_mag"][:, 1], False) + + changed_coord = coord.copy() + changed_spin = spin.copy() + changed_coord[:, 1] += 100.0 + changed_spin[:, 1] += 100.0 + changed_prediction = model(changed_coord, atype, changed_spin, box=None) + np.testing.assert_allclose(changed_prediction["energy"], prediction["energy"]) + np.testing.assert_allclose( + changed_prediction["atom_energy"][:, [0, 2]], + prediction["atom_energy"][:, [0, 2]], + ) + + +def test_lower_spin_expansion_preserves_virtual_types(model) -> None: + """Local and ghost placeholders remain negative in the switched layout.""" + extended_coord = np.arange(12, dtype=np.float64).reshape(1, 4, 3) + extended_atype = np.array([[0, -1, 2, -1]], dtype=np.int32) + extended_spin = np.ones_like(extended_coord) + nlist = np.array([[[2, -1], [0, -1]]], dtype=np.int32) + mapping = np.array([[0, 1, 0, 1]], dtype=np.int32) + + ( + coord_updated, + atype_updated, + _, + _, + coord_corr, + ) = model.process_spin_input_lower( + extended_coord, + extended_atype, + extended_spin, + nlist, + mapping=mapping, + ) + + np.testing.assert_array_equal(atype_updated, [[0, -1, 3, -1, 2, -1, 5, -1]]) + for real_index, virtual_index in ((1, 3), (3, 7)): + np.testing.assert_array_equal( + coord_updated[:, virtual_index], extended_coord[:, real_index] + ) + np.testing.assert_array_equal(coord_corr[:, virtual_index], 0.0) + + _, magnetic_output, magnetic_mask = model.process_spin_output_lower( + extended_atype, + np.ones((1, 8, 3), dtype=np.float64), + nloc=2, + ) + np.testing.assert_array_equal(magnetic_output[:, [1, 3]], 0.0) + np.testing.assert_array_equal(magnetic_mask[:, [1, 3]], False) + + +def test_virtual_type_lookup_supports_array_api_strict(model) -> None: + """The masked lookup must not rely on NumPy negative-index semantics.""" + xp = pytest.importorskip("array_api_strict") + coord = xp.asarray(np.arange(9, dtype=np.float64).reshape(1, 3, 3)) + atype = xp.asarray(np.array([[0, -1, 2]], dtype=np.int64)) + spin = xp.ones_like(coord) + + coord_updated, atype_updated, coord_corr = model.process_spin_input( + coord, atype, spin + ) + np.testing.assert_array_equal(to_numpy_array(atype_updated), [[0, -1, 2, 3, -1, 5]]) + np.testing.assert_array_equal(to_numpy_array(coord_updated)[:, 4], [[3, 4, 5]]) + np.testing.assert_array_equal(to_numpy_array(coord_corr)[:, 4], 0.0) + + +def test_call_lower_ignores_virtual_placeholders(model) -> None: + """The path LAMMPS and the C++ spin inference take must ignore padding. + + ``process_spin_input_lower`` alone only pins intermediate arrays. This runs + the whole lower interface, which is where batched extended regions produce + ``-1`` padding in the first place. + """ + extended_coord = np.array( + [[[0.0, 0.0, 0.0], [0.0, 0.0, 1.2], [3.0, 0.0, 0.0], [0.0, 3.0, 0.0]]] + ) + extended_atype = np.array([[0, 2, -1, -1]], dtype=np.int32) + extended_spin = np.zeros_like(extended_coord) + extended_spin[:, 1] = 1.0 + nlist = np.array([[[1, -1, -1], [0, -1, -1]]], dtype=np.int32) + mapping = np.array([[0, 1, 2, 3]], dtype=np.int32) + + prediction = model.call_lower( + extended_coord, extended_atype, extended_spin, nlist, mapping=mapping + ) + + # A mis-typed placeholder leaks into the backbone's reduction, so the + # reduced energy stops matching the real atoms' contributions. + np.testing.assert_allclose( + prediction["energy"], + np.sum(prediction["atom_energy"], axis=1), + ) + + # Moving a placeholder must not change anything the model reports. + moved_coord = extended_coord.copy() + moved_spin = extended_spin.copy() + moved_coord[:, 2:] += 100.0 + moved_spin[:, 2:] += 100.0 + moved_prediction = model.call_lower( + moved_coord, extended_atype, moved_spin, nlist, mapping=mapping + ) + np.testing.assert_allclose(moved_prediction["energy"], prediction["energy"]) + np.testing.assert_allclose( + moved_prediction["atom_energy"], prediction["atom_energy"] + ) + + +def test_spin_mask_branch_zeroes_virtual_placeholders(model) -> None: + """``virtual_scale=False`` selects spin_mask and must mask padding too.""" + atype = np.array([[0, -1, 2]], dtype=np.int32) + out_tensor = np.ones((1, 6, 3), dtype=np.float64) + + _, magnetic_output, magnetic_mask = model.process_spin_output( + atype, out_tensor, virtual_scale=False + ) + np.testing.assert_array_equal(magnetic_output[:, 1], 0.0) + np.testing.assert_array_equal(magnetic_mask[:, 1], False) + # The real magnetic type still passes through the unscaled mask. + np.testing.assert_array_equal(magnetic_mask[:, 2], True) + + extended_atype = np.array([[0, -1, 2, -1]], dtype=np.int32) + _, extended_magnetic, extended_mask = model.process_spin_output_lower( + extended_atype, + np.ones((1, 8, 3), dtype=np.float64), + nloc=2, + virtual_scale=False, + ) + np.testing.assert_array_equal(extended_magnetic[:, [1, 3]], 0.0) + np.testing.assert_array_equal(extended_mask[:, [1, 3]], False) + + +def test_array_api_strict_covers_output_and_mask_sites(model) -> None: + """The output and mask lookups must also avoid negative-index semantics.""" + xp = pytest.importorskip("array_api_strict") + atype = xp.asarray(np.array([[0, -1, 2]], dtype=np.int64)) + out_tensor = xp.asarray(np.ones((1, 6, 3), dtype=np.float64)) + + for virtual_scale in (True, False): + _, magnetic_output, magnetic_mask = model.process_spin_output( + atype, out_tensor, virtual_scale=virtual_scale + ) + np.testing.assert_array_equal(to_numpy_array(magnetic_output)[:, 1], 0.0) + np.testing.assert_array_equal(to_numpy_array(magnetic_mask)[:, 1], False) + + extended_atype = xp.asarray(np.array([[0, -1, 2, -1]], dtype=np.int64)) + extended_out = xp.asarray(np.ones((1, 8, 3), dtype=np.float64)) + for virtual_scale in (True, False): + _, extended_magnetic, extended_mask = model.process_spin_output_lower( + extended_atype, extended_out, nloc=2, virtual_scale=virtual_scale + ) + np.testing.assert_array_equal(to_numpy_array(extended_magnetic)[:, [1, 3]], 0.0) + np.testing.assert_array_equal(to_numpy_array(extended_mask)[:, [1, 3]], False) diff --git a/source/tests/pt/model/test_spin_model_virtual_types.py b/source/tests/pt/model/test_spin_model_virtual_types.py new file mode 100644 index 0000000000..b8d8dbf772 --- /dev/null +++ b/source/tests/pt/model/test_spin_model_virtual_types.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""The pt SpinModel must treat virtual placeholders like the dpmodel one. + +``deepmd/pt_expt`` subclasses the dpmodel ``SpinModel`` without overriding +these methods, so a divergence here means the same weights give different +answers depending on the backend. ``-1`` reaches this code in ordinary use: +``deepmd/utils/data.py`` appends it as the virtual-atom padding for mixed-type +systems, and batched extended regions are padded to a uniform ``nall``. +""" + +import unittest + +import numpy as np +import torch + +from deepmd.dpmodel.model.model import get_model as get_dp_model +from deepmd.pt.model.model import get_model as get_pt_model +from deepmd.pt.utils import ( + env, +) +from deepmd.pt.utils.utils import ( + to_numpy_array, +) + +MODEL_CONFIG = { + "type_map": ["A", "B", "C"], + "descriptor": { + "type": "se_e2_a", + "sel": [4, 4, 4], + "rcut_smth": 0.5, + "rcut": 4.0, + "neuron": [3, 6], + "axis_neuron": 2, + "precision": "float64", + "type_one_side": True, + "seed": 1, + }, + "fitting_net": { + "type": "ener", + "neuron": [5, 5], + "precision": "float64", + "seed": 1, + }, + # Keep the final real type magnetic so an accidental ``mask[-1]`` lookup is + # observable instead of being hidden by a zero scale. + "spin": {"use_spin": [False, False, True], "virtual_scale": [0.5]}, +} + +# The first real type is magnetic here, which is when the pt clamp-to-row-0 +# lookup gave a padded slot a real spin scale and a True magnetic mask. +MAGNETIC_FIRST_CONFIG = { + **MODEL_CONFIG, + "spin": {"use_spin": [True, False, False], "virtual_scale": [0.5]}, +} + + +def _tensor(array: np.ndarray) -> torch.Tensor: + return torch.from_numpy(array).to(device=env.DEVICE) + + +class TestPtSpinModelVirtualTypes(unittest.TestCase): + """Every pt lookup must mask ``atype < 0`` exactly as dpmodel does.""" + + def test_dense_spin_expansion_preserves_virtual_types(self) -> None: + model = get_pt_model(MODEL_CONFIG) + coord = np.arange(9, dtype=np.float64).reshape(1, 3, 3) + atype = np.array([[0, -1, 2]], dtype=np.int64) + spin = np.ones_like(coord) + + coord_updated, atype_updated, coord_corr = model.process_spin_input( + _tensor(coord), _tensor(atype), _tensor(spin) + ) + + np.testing.assert_array_equal( + to_numpy_array(atype_updated), [[0, -1, 2, 3, -1, 5]] + ) + np.testing.assert_array_equal(to_numpy_array(coord_updated)[:, 4], coord[:, 1]) + np.testing.assert_array_equal(to_numpy_array(coord_corr)[:, 4], 0.0) + + def test_lower_spin_expansion_preserves_virtual_types(self) -> None: + model = get_pt_model(MODEL_CONFIG) + extended_coord = np.arange(12, dtype=np.float64).reshape(1, 4, 3) + extended_atype = np.array([[0, -1, 2, -1]], dtype=np.int64) + extended_spin = np.ones_like(extended_coord) + nlist = np.array([[[2, -1], [0, -1]]], dtype=np.int64) + + ( + coord_updated, + atype_updated, + _, + _, + coord_corr, + ) = model.process_spin_input_lower( + _tensor(extended_coord), + _tensor(extended_atype), + _tensor(extended_spin), + _tensor(nlist), + mapping=_tensor(np.array([[0, 1, 0, 1]], dtype=np.int64)), + ) + + np.testing.assert_array_equal( + to_numpy_array(atype_updated), [[0, -1, 3, -1, 2, -1, 5, -1]] + ) + for real_index, virtual_index in ((1, 3), (3, 7)): + np.testing.assert_array_equal( + to_numpy_array(coord_updated)[:, virtual_index], + extended_coord[:, real_index], + ) + np.testing.assert_array_equal( + to_numpy_array(coord_corr)[:, virtual_index], 0.0 + ) + + def test_matches_dpmodel_when_the_first_real_type_is_magnetic(self) -> None: + """The clamp-to-row-0 lookup only diverged when type 0 is magnetic.""" + pt_model = get_pt_model(MAGNETIC_FIRST_CONFIG) + dp_model = get_dp_model(MAGNETIC_FIRST_CONFIG) + coord = np.arange(9, dtype=np.float64).reshape(1, 3, 3) + atype = np.array([[0, -1, 2]], dtype=np.int64) + spin = np.ones_like(coord) + out_tensor = np.ones((1, 6, 3), dtype=np.float64) + + pt_coord, pt_atype, pt_corr = pt_model.process_spin_input( + _tensor(coord), _tensor(atype), _tensor(spin) + ) + dp_coord, dp_atype, dp_corr = dp_model.process_spin_input(coord, atype, spin) + np.testing.assert_array_equal(to_numpy_array(pt_atype), dp_atype) + np.testing.assert_allclose(to_numpy_array(pt_coord), dp_coord) + np.testing.assert_allclose(to_numpy_array(pt_corr), dp_corr) + # The placeholder gets no displacement even though type 0 is magnetic. + np.testing.assert_array_equal(to_numpy_array(pt_coord)[:, 4], coord[:, 1]) + + _, pt_mag, pt_mask = pt_model.process_spin_output( + _tensor(atype), _tensor(out_tensor) + ) + _, dp_mag, dp_mask = dp_model.process_spin_output(atype, out_tensor) + np.testing.assert_allclose(to_numpy_array(pt_mag), dp_mag) + np.testing.assert_array_equal(to_numpy_array(pt_mask), dp_mask) + # mask_mag feeds the magnetic-force loss, so a True here would count a + # padded slot as a real magnetic atom. + np.testing.assert_array_equal(to_numpy_array(pt_mask)[:, 1], False) + + +if __name__ == "__main__": + unittest.main()