5757 / "deeppot_dpa3_spin_mpi.pt2"
5858)
5959data_file = Path (__file__ ).parent / "data_dpa3_spin_pt2.lmp"
60+ # Elongated-box fixture for the spin empty-subdomain MPI test: x is
61+ # extended to 30 A while atoms remain in x in [3, 13]. Combined with
62+ # ``processors 2 1 1`` this leaves rank 1 (x >= 15) with zero local
63+ # atoms, exercising the ``copy_from_nlist`` empty-rank guard for spin.
64+ data_file_empty_subdomain = (
65+ Path (__file__ ).parent / "data_dpa3_spin_pt2_empty_subdomain.lmp"
66+ )
67+ # NULL-type fixture: 4 real Ni-O atoms + 2 LAMMPS type-3 atoms
68+ # straddling the x=6.5 rank boundary. With ``pair_coeff * * Ni O NULL``
69+ # LAMMPS type 3 maps to deepmd atype=-1, so those atoms are filtered
70+ # by ``select_real_atoms_coord`` and the comm tensors must be remapped
71+ # via ``fwd_map`` before being handed to the with-comm artifact.
72+ # Forces / force_mag on the 4 real atoms must match the no-NULL
73+ # baseline (mpi-1 reference run); NULL atoms get zero forces from the
74+ # deepmd model.
75+ data_file_null_type = Path (__file__ ).parent / "data_dpa3_spin_pt2_null_type.lmp"
6076
6177# 4-atom Ni-O system; same layout as ``test_lammps_spin_pt2.py``. With
6278# ``processors 2 1 1`` the split sits at x=6.5 -> 2 atoms per rank.
@@ -86,22 +102,56 @@ def setup_module() -> None:
86102 "Skip test because PyTorch support is not enabled." ,
87103 )
88104 write_lmp_data_spin (box , coord , spin , type_NiO , data_file )
105+ # Elongated x-axis; atoms unchanged. ``processors 2 1 1`` splits
106+ # at x=15 A and rank 1 owns x >= 15, which is empty.
107+ box_empty = np .array ([0 , 30 , 0 , 13 , 0 , 13 , 0 , 0 , 0 ])
108+ write_lmp_data_spin (box_empty , coord , spin , type_NiO , data_file_empty_subdomain )
109+ # NULL-type fixture: append 2 LAMMPS type-3 atoms within rcut
110+ # (~4 A) of real atoms on BOTH sides of the x=6.5 rank boundary,
111+ # so they appear in cross-rank sendlists and the fwd_map-based
112+ # comm-tensor remap is genuinely exercised. NULL atoms still need
113+ # spin coordinates (write_lmp_data_spin format); we give them
114+ # zero spin like the type-2 (O) atoms.
115+ coord_null = np .concatenate (
116+ [
117+ coord ,
118+ np .array (
119+ [
120+ [5.5 , 6.0 , 6.0 ], # rank 0 side, near boundary
121+ [7.5 , 7.0 , 7.0 ], # rank 1 side, near boundary
122+ ]
123+ ),
124+ ]
125+ )
126+ spin_null = np .concatenate ([spin , np .zeros ((2 , 3 ))])
127+ type_null = np .concatenate ([type_NiO , np .array ([3 , 3 ])])
128+ write_lmp_data_spin (box , coord_null , spin_null , type_null , data_file_null_type )
89129
90130
91131def teardown_module () -> None :
92- if data_file .exists ():
93- os .remove (data_file )
132+ for f in [data_file , data_file_empty_subdomain , data_file_null_type ]:
133+ if f .exists ():
134+ os .remove (f )
94135
95136
96137def _run_mpi_subprocess (
97138 nprocs : int ,
98139 extra_args : list [str ] | None = None ,
99140 processors : str | None = None ,
141+ data_path : Path | None = None ,
142+ runner_args : list [str ] | None = None ,
100143) -> dict :
101144 """Run ``run_mpi_pair_deepmd_spin_dpa3_pt2.py`` under
102145 ``mpirun -n <nprocs>`` and return
103146 ``{"pe", "forces", "force_mag", "virials"}``.
147+
148+ ``data_path`` (default ``data_file``) selects the LAMMPS data file
149+ -- the empty-subdomain and NULL-type tests point at non-default
150+ fixtures. ``runner_args`` flows additional flags (e.g.
151+ ``--pair-coeff``, ``--mass3``) to the subprocess runner.
104152 """
153+ if data_path is None :
154+ data_path = data_file
105155 with tempfile .NamedTemporaryFile (mode = "r" , suffix = ".out" , delete = False ) as f :
106156 out_path = f .name
107157 try :
@@ -111,7 +161,7 @@ def _run_mpi_subprocess(
111161 str (nprocs ),
112162 sys .executable ,
113163 str (Path (__file__ ).parent / "run_mpi_pair_deepmd_spin_dpa3_pt2.py" ),
114- str (data_file .resolve ()),
164+ str (data_path .resolve ()),
115165 str (pb_file_mpi .resolve ()),
116166 out_path ,
117167 ]
@@ -121,6 +171,8 @@ def _run_mpi_subprocess(
121171 argv .extend (["--processors" , "1 1 1" ])
122172 if extra_args :
123173 argv .extend (extra_args )
174+ if runner_args :
175+ argv .extend (runner_args )
124176 sp .check_call (argv )
125177 with open (out_path ) as fh :
126178 lines = fh .read ().strip ().splitlines ()
@@ -176,3 +228,77 @@ def test_pair_deepmd_mpi_dpa3_spin() -> None:
176228 np .testing .assert_allclose (
177229 out_mpi ["virials" ], out_ref ["virials" ], atol = 1e-8 , rtol = 0
178230 )
231+
232+
233+ @pytest .mark .skipif (
234+ shutil .which ("mpirun" ) is None , reason = "MPI is not installed on this system"
235+ )
236+ @pytest .mark .skipif (
237+ importlib .util .find_spec ("mpi4py" ) is None , reason = "mpi4py is not installed"
238+ )
239+ def test_pair_deepmd_mpi_dpa3_spin_empty_subdomain () -> None :
240+ """Spin DPA3 multi-rank with one empty rank.
241+
242+ Elongated x box (30 A) + ``processors 2 1 1`` puts all 4 atoms on
243+ rank 0; rank 1 has nloc=0. Exercises the C++ ``copy_from_nlist``
244+ empty-rank guard for the spin path (the with-comm artifact still
245+ runs on rank 1 with nloc_real=0). Compares against same-archive
246+ mpi-1 reference.
247+ """
248+ out_mpi = _run_mpi_subprocess (nprocs = 2 , data_path = data_file_empty_subdomain )
249+ out_ref = _run_mpi_subprocess (nprocs = 1 , data_path = data_file_empty_subdomain )
250+
251+ assert out_mpi ["pe" ] == pytest .approx (out_ref ["pe" ], rel = 1e-10 , abs = 1e-12 )
252+ np .testing .assert_allclose (out_mpi ["forces" ], out_ref ["forces" ], atol = 1e-8 , rtol = 0 )
253+ np .testing .assert_allclose (
254+ out_mpi ["force_mag" ], out_ref ["force_mag" ], atol = 1e-8 , rtol = 0
255+ )
256+ np .testing .assert_allclose (
257+ out_mpi ["virials" ], out_ref ["virials" ], atol = 1e-8 , rtol = 0
258+ )
259+
260+
261+ @pytest .mark .skipif (
262+ shutil .which ("mpirun" ) is None , reason = "MPI is not installed on this system"
263+ )
264+ @pytest .mark .skipif (
265+ importlib .util .find_spec ("mpi4py" ) is None , reason = "mpi4py is not installed"
266+ )
267+ def test_pair_deepmd_mpi_dpa3_spin_null_type () -> None :
268+ """Spin DPA3 multi-rank with NULL-type atoms straddling the
269+ rank boundary.
270+
271+ Two LAMMPS type-3 atoms (mapped to deepmd atype=-1 via
272+ ``pair_coeff * * Ni O NULL``) sit at x=5.5 and x=7.5, just inside
273+ the rcut window of either side of the x=6.5 boundary. They appear
274+ in the cross-rank sendlists and are filtered by
275+ ``select_real_atoms_coord`` -- so the spin path goes through
276+ ``DeepSpinPTExpt::compute`` with ``nall_real < nall``, triggering
277+ the ``has_null_atoms`` branch that calls
278+ ``build_comm_tensors_positional_with_virtual_atoms`` (fwd_map-based
279+ sendlist remap). Compares mpi-2 vs same-archive mpi-1 reference
280+ (nullifying NULL forces and using the same fwd_map remap on rank 0
281+ too).
282+ """
283+ runner_args = ["--pair-coeff" , "* * Ni O NULL" , "--mass3" , "1.0" ]
284+ out_mpi = _run_mpi_subprocess (
285+ nprocs = 2 , data_path = data_file_null_type , runner_args = runner_args
286+ )
287+ out_ref = _run_mpi_subprocess (
288+ nprocs = 1 , data_path = data_file_null_type , runner_args = runner_args
289+ )
290+
291+ assert out_mpi ["pe" ] == pytest .approx (out_ref ["pe" ], rel = 1e-10 , abs = 1e-12 )
292+ np .testing .assert_allclose (out_mpi ["forces" ], out_ref ["forces" ], atol = 1e-8 , rtol = 0 )
293+ np .testing .assert_allclose (
294+ out_mpi ["force_mag" ], out_ref ["force_mag" ], atol = 1e-8 , rtol = 0
295+ )
296+ np .testing .assert_allclose (
297+ out_mpi ["virials" ], out_ref ["virials" ], atol = 1e-8 , rtol = 0
298+ )
299+ # Sanity: NULL atoms (ids 5, 6) get exactly zero forces from the
300+ # deepmd model. ``write_lmp_data_spin`` writes atoms in the order
301+ # given (id 1..N), so type-3 NULL atoms are ids 5, 6 (after the 4
302+ # real Ni-O atoms).
303+ np .testing .assert_array_equal (out_mpi ["forces" ][4 :], np .zeros ((2 , 3 )))
304+ np .testing .assert_array_equal (out_mpi ["force_mag" ][4 :], np .zeros ((2 , 3 )))
0 commit comments