Skip to content

Commit c16dc8f

Browse files
refactor(lmp): share test infrastructure (deepmodeling#5770)
Summary: - add `lammps_test_utils.py` as the shared infrastructure layer for LAMMPS Python tests - centralize atomic and spin LAMMPS construction across backend, model-format, periodic/non-periodic, graph, fparam/aparam, and DeepTensor suites - support configurable boundaries, atom maps, unit systems, and one/two/three-type mass tables without duplicating command sequences - share backend gating, water data variants, generated-file cleanup, two-model MPI invocation, and DPA3 atomic/spin MPI output parsing - keep the atomic and spin model-deviation entry points thin through the existing shared `mpi_pair_deepmd.py` runner Reduction: - latest update changes 27 files with 494 additions and 1,063 deletions - existing pytest test names and node IDs remain unchanged Compatibility preserved: - unchanged LAMMPS units, boundaries, atom styles, neighbor settings, masses, timesteps, fixes, atom-map behavior, pair styles, processor grids, and runner arguments - `atom_map="no"` continues to omit the unsupported `atom_modify map no` command - periodic and non-periodic MPI flags remain scenario-specific - DPA3 failure-path subprocesses still return raw status/stdout/stderr, while successful runs retain the same parsed energy/force/virial contracts Validation: - `ruff check .` — passed - `ruff format .` — 1,599 files left unchanged - pre-commit hooks — passed - `git diff --check` — passed - `python -m compileall -q source/lmp/tests` — passed - `pytest --collect-only -q source/lmp/tests` — 244 tests collected - fake-LAMMPS compatibility harness — passed atomic/spin setup, periodic boundaries, atom-map omission, SI units, one/two/three-type mass tables, MPI output parsing, and failure capture - real numerical tests were attempted, but the installed LAMMPS reported loading 0 plugins from the existing build and therefore did not recognize `deepmd`/`deepspin`; this environment issue is outside the refactor Coding agent: Codex Codex version: codex-cli 0.144.1 Model: gpt-5.6-sol Reasoning effort: xhigh <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Consolidated DeepMD/DeepSpin two-rank MPI model-deviation execution into shared, parameterized runners and scenario configs. * Replaced standalone MPI launcher scripts with lightweight entrypoints using fixed scenario defaults. * Centralized LAMMPS test setup, water-data variant generation, backend gating, cleanup, and MPI output parsing; updated many LAMMPS-related tests to use shared helpers. * **Bug Fixes** * Improved MPI shutdown handling to ensure LAMMPS resources are released cleanly before MPI finalization. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: njzjz-bot <njzjz.bot@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2c0a54e commit c16dc8f

30 files changed

Lines changed: 605 additions & 1236 deletions
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Shared infrastructure for the LAMMPS Python tests.
3+
4+
Model paths, expected values, and scenario-specific assertions stay in their
5+
test modules. This module owns setup that must remain identical across model
6+
formats and backends, so changes to the LAMMPS test system have one source of
7+
truth.
8+
"""
9+
10+
from __future__ import (
11+
annotations,
12+
)
13+
14+
import os
15+
import subprocess as sp
16+
import sys
17+
import tempfile
18+
from pathlib import (
19+
Path,
20+
)
21+
from typing import (
22+
Any,
23+
)
24+
25+
import constants
26+
import numpy as np
27+
import pytest
28+
from lammps import (
29+
PyLammps,
30+
)
31+
from write_lmp_data import (
32+
write_lmp_data,
33+
)
34+
35+
36+
def require_backend(environment_variable: str, backend_name: str) -> None:
37+
"""Skip the current module when its compiled backend is unavailable."""
38+
if os.environ.get(environment_variable, "1") != "1":
39+
pytest.skip(f"Skip test because {backend_name} support is not enabled.")
40+
41+
42+
def remove_test_files(*paths: Path) -> None:
43+
"""Remove generated test files, tolerating partial setup and prior cleanup."""
44+
for path in paths:
45+
path.unlink(missing_ok=True)
46+
47+
48+
def write_water_data_variants(
49+
box: np.ndarray,
50+
coord: np.ndarray,
51+
type_oh: np.ndarray,
52+
type_ho: np.ndarray,
53+
data_file: Path,
54+
type_map_file: Path,
55+
si_file: Path,
56+
) -> None:
57+
"""Write the standard metal, type-map, and SI water test fixtures."""
58+
write_lmp_data(box, coord, type_oh, data_file)
59+
write_lmp_data(box, coord, type_ho, type_map_file)
60+
write_lmp_data(
61+
box * constants.dist_metal2si,
62+
coord * constants.dist_metal2si,
63+
type_oh,
64+
si_file,
65+
)
66+
67+
68+
def make_atomic_lammps(
69+
data_file: Path,
70+
units: str = "metal",
71+
*,
72+
boundary: str = "p p p",
73+
atom_map: str | None = None,
74+
masses: tuple[float, ...] = (16, 2),
75+
) -> PyLammps:
76+
"""Create the standard two-type atomic LAMMPS test system.
77+
78+
``atom_map="no"`` deliberately omits ``atom_modify`` because LAMMPS
79+
rejects ``atom_modify map no``; this preserves the no-map failure-path
80+
tests used by the graph-model fixtures.
81+
"""
82+
if units not in {"metal", "real", "si"}:
83+
raise ValueError("units should be metal, real, or si")
84+
85+
lammps = PyLammps()
86+
lammps.units(units)
87+
lammps.boundary(boundary)
88+
lammps.atom_style("atomic")
89+
if atom_map is not None and atom_map != "no":
90+
lammps.atom_modify(f"map {atom_map}")
91+
lammps.neighbor("2.0e-10 bin" if units == "si" else "2.0 bin")
92+
lammps.neigh_modify("every 10 delay 0 check no")
93+
lammps.read_data(data_file.resolve())
94+
for atom_type, mass in enumerate(masses, start=1):
95+
if units == "si":
96+
lammps.mass(f"{atom_type} {mass * constants.mass_metal2si:.10e}")
97+
else:
98+
lammps.mass(f"{atom_type} {mass:g}")
99+
lammps.timestep({"metal": 0.0005, "real": 0.5, "si": 5e-16}[units])
100+
lammps.fix("1 all nve")
101+
return lammps
102+
103+
104+
def make_spin_lammps(
105+
data_file: Path,
106+
units: str = "metal",
107+
*,
108+
boundary: str = "p p p",
109+
) -> PyLammps:
110+
"""Create the standard two-type DeepSpin LAMMPS test system."""
111+
if units != "metal":
112+
raise ValueError("units for spin should be metal")
113+
114+
lammps = PyLammps()
115+
lammps.units(units)
116+
lammps.boundary(boundary)
117+
lammps.atom_style("spin")
118+
lammps.neighbor("2.0 bin")
119+
lammps.neigh_modify("every 10 delay 0 check no")
120+
lammps.read_data(data_file.resolve())
121+
lammps.mass("1 58")
122+
lammps.mass("2 16")
123+
lammps.timestep(0.0005)
124+
lammps.fix("1 all nve")
125+
return lammps
126+
127+
128+
def run_mpi_pair_runner(
129+
runner: Path,
130+
data_file: Path,
131+
model_file: Path,
132+
*,
133+
nprocs: int = 2,
134+
processors: str | None = None,
135+
extra_args: list[str] | None = None,
136+
runner_args: list[str] | None = None,
137+
output_columns: tuple[tuple[str, int], ...] = (("forces", 3), ("virials", 9)),
138+
capture: bool = False,
139+
) -> dict[str, Any]:
140+
"""Invoke a DPA MPI runner and parse its energy/per-atom output.
141+
142+
The runner output contract is one energy line followed by a rectangular
143+
per-atom table. ``output_columns`` names and slices that table while each
144+
model-specific wrapper retains its own defaults and explanatory docstring.
145+
146+
If ``capture`` is true, skip parsing and return the subprocess result as
147+
``{"returncode": int, "stdout": str, "stderr": str}``.
148+
"""
149+
with tempfile.NamedTemporaryFile(mode="r", suffix=".out", delete=False) as f:
150+
output_path = Path(f.name)
151+
try:
152+
argv = [
153+
"mpirun",
154+
"-n",
155+
str(nprocs),
156+
sys.executable,
157+
str(runner),
158+
str(data_file.resolve()),
159+
str(model_file.resolve()),
160+
str(output_path),
161+
]
162+
if processors is not None:
163+
argv.extend(["--processors", processors])
164+
elif nprocs == 1:
165+
argv.extend(["--processors", "1 1 1"])
166+
if extra_args:
167+
argv.extend(extra_args)
168+
if runner_args:
169+
argv.extend(runner_args)
170+
if capture:
171+
proc = sp.run(argv, capture_output=True, text=True)
172+
return {
173+
"returncode": proc.returncode,
174+
"stdout": proc.stdout,
175+
"stderr": proc.stderr,
176+
}
177+
178+
sp.check_call(argv)
179+
lines = output_path.read_text().strip().splitlines()
180+
rows = np.array(
181+
[list(map(float, line.split())) for line in lines[1:]],
182+
dtype=np.float64,
183+
)
184+
result: dict[str, Any] = {"pe": float(lines[0])}
185+
start = 0
186+
for name, width in output_columns:
187+
result[name] = rows[:, start : start + width]
188+
start += width
189+
if rows.shape[1] != start:
190+
raise ValueError(
191+
f"MPI runner produced {rows.shape[1]} columns; expected {start}"
192+
)
193+
return result
194+
finally:
195+
output_path.unlink(missing_ok=True)
196+
197+
198+
def run_mpi_model_deviation(
199+
runner: Path,
200+
data_file: Path,
201+
model_file: Path,
202+
second_model_file: Path,
203+
deviation_file: Path,
204+
*,
205+
extra_args: list[str] | None = None,
206+
) -> float:
207+
"""Run the two-rank model-deviation driver and return rank-zero energy."""
208+
with tempfile.NamedTemporaryFile() as output:
209+
argv = [
210+
"mpirun",
211+
"-n",
212+
"2",
213+
sys.executable,
214+
str(runner),
215+
str(data_file),
216+
str(model_file),
217+
str(second_model_file),
218+
str(deviation_file),
219+
output.name,
220+
]
221+
if extra_args:
222+
argv.extend(extra_args)
223+
sp.check_call(argv)
224+
return float(np.loadtxt(output.name, ndmin=1)[0])
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""Shared MPI driver for DeepMD and DeepSpin LAMMPS pair-style tests."""
3+
4+
import argparse
5+
from typing import (
6+
NamedTuple,
7+
)
8+
9+
import numpy as np
10+
from lammps import (
11+
PyLammps,
12+
)
13+
from mpi4py import (
14+
MPI,
15+
)
16+
17+
18+
class PairStyleConfig(NamedTuple):
19+
"""LAMMPS commands that differ between the DeepMD and DeepSpin runners."""
20+
21+
atom_style: str
22+
masses: tuple[str, str]
23+
pair_style: str
24+
25+
26+
def run_mpi_pair_deepmd(config: PairStyleConfig) -> None:
27+
"""Run the common two-rank model-deviation scenario.
28+
29+
The public runner scripts remain separate because their model and data-file
30+
contracts differ. Keeping those scripts as wrappers also preserves their
31+
command-line entry points for pytest and external build tooling.
32+
"""
33+
parser = argparse.ArgumentParser()
34+
parser.add_argument("DATAFILE", type=str)
35+
parser.add_argument("PBFILE", type=str)
36+
parser.add_argument("PBFILE2", type=str)
37+
parser.add_argument("MD_FILE", type=str)
38+
parser.add_argument("OUTPUT", type=str)
39+
parser.add_argument("--balance", action="store_true")
40+
parser.add_argument("--nopbc", action="store_true")
41+
args = parser.parse_args()
42+
43+
comm = MPI.COMM_WORLD
44+
rank = comm.Get_rank()
45+
46+
lammps = PyLammps()
47+
if args.balance:
48+
# 4 and 2 atoms
49+
lammps.processors("2 1 1")
50+
else:
51+
# 6 and 0 atoms
52+
lammps.processors("1 2 1")
53+
lammps.units("metal")
54+
if args.nopbc:
55+
lammps.boundary("f f f")
56+
else:
57+
lammps.boundary("p p p")
58+
lammps.atom_style(config.atom_style)
59+
lammps.neighbor("2.0 bin")
60+
lammps.neigh_modify("every 10 delay 0 check no")
61+
lammps.read_data(args.DATAFILE)
62+
lammps.mass(f"1 {config.masses[0]}")
63+
lammps.mass(f"2 {config.masses[1]}")
64+
lammps.timestep(0.0005)
65+
lammps.fix("1 all nve")
66+
67+
relative = 1.0
68+
lammps.pair_style(
69+
f"{config.pair_style} {args.PBFILE} {args.PBFILE2} "
70+
f"out_file {args.MD_FILE} out_freq 1 atomic relative {relative}"
71+
)
72+
lammps.pair_coeff("* *")
73+
lammps.run(0)
74+
if rank == 0:
75+
pe = lammps.eval("pe")
76+
np.savetxt(args.OUTPUT, np.array([pe]))
77+
78+
# LAMMPS owns MPI resources, so its destructor must run before finalization.
79+
# Changing this order can make the destructor call MPI after MPI_Finalize.
80+
del lammps
81+
MPI.Finalize()
Lines changed: 8 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,12 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2-
"""Use mpi4py to run a LAMMPS pair_deepmd + model deviation (atomic, relative) task."""
2+
"""Run the pair_deepmd MPI model-deviation test scenario."""
33

4-
import argparse
5-
6-
import numpy as np
7-
from lammps import (
8-
PyLammps,
9-
)
10-
from mpi4py import (
11-
MPI,
4+
from mpi_pair_deepmd import (
5+
PairStyleConfig,
6+
run_mpi_pair_deepmd,
127
)
138

14-
comm = MPI.COMM_WORLD
15-
rank = comm.Get_rank()
16-
17-
parser = argparse.ArgumentParser()
18-
parser.add_argument("DATAFILE", type=str)
19-
parser.add_argument("PBFILE", type=str)
20-
parser.add_argument("PBFILE2", type=str)
21-
parser.add_argument("MD_FILE", type=str)
22-
parser.add_argument("OUTPUT", type=str)
23-
parser.add_argument("--balance", action="store_true")
24-
parser.add_argument("--nopbc", action="store_true")
25-
26-
args = parser.parse_args()
27-
data_file = args.DATAFILE
28-
pb_file = args.PBFILE
29-
pb_file2 = args.PBFILE2
30-
md_file = args.MD_FILE
31-
output = args.OUTPUT
32-
balance = args.balance
33-
34-
lammps = PyLammps()
35-
if balance:
36-
# 4 and 2 atoms
37-
lammps.processors("2 1 1")
38-
else:
39-
# 6 and 0 atoms
40-
lammps.processors("1 2 1")
41-
lammps.units("metal")
42-
if args.nopbc:
43-
lammps.boundary("f f f")
44-
else:
45-
lammps.boundary("p p p")
46-
lammps.atom_style("atomic")
47-
lammps.neighbor("2.0 bin")
48-
lammps.neigh_modify("every 10 delay 0 check no")
49-
lammps.read_data(data_file)
50-
lammps.mass("1 16")
51-
lammps.mass("2 2")
52-
lammps.timestep(0.0005)
53-
lammps.fix("1 all nve")
54-
55-
relative = 1.0
56-
lammps.pair_style(
57-
f"deepmd {pb_file} {pb_file2} out_file {md_file} out_freq 1 atomic relative {relative}"
58-
)
59-
lammps.pair_coeff("* *")
60-
lammps.run(0)
61-
if rank == 0:
62-
pe = lammps.eval("pe")
63-
arr = [pe]
64-
np.savetxt(output, np.array(arr))
65-
# Tear down LAMMPS before MPI.Finalize() to avoid MPI-after-Finalize
66-
# in the LAMMPS destructor. See run_mpi_pair_deepmd_spin_dpa3_pt2.py.
67-
del lammps
68-
MPI.Finalize()
9+
if __name__ == "__main__":
10+
run_mpi_pair_deepmd(
11+
PairStyleConfig(atom_style="atomic", masses=("16", "2"), pair_style="deepmd")
12+
)

0 commit comments

Comments
 (0)