diff --git a/dpdata/formats/lammps/lmp.py b/dpdata/formats/lammps/lmp.py index c9d60ec5..26278064 100644 --- a/dpdata/formats/lammps/lmp.py +++ b/dpdata/formats/lammps/lmp.py @@ -20,6 +20,9 @@ "molecular": (0, 2, 3, 4, 5, True, False, None), "dipole": (0, 1, 3, 4, 5, False, True, 2), "sphere": (0, 1, 4, 5, 6, False, False, None), + # LAMMPS ``atom_style spin`` stores x/y/z in the same columns as atomic + # style, followed by a unit spin direction and its magnetic moment. + "spin": (0, 1, 2, 3, 4, False, False, None), } @@ -327,6 +330,10 @@ def get_charges(lines: list[str], atom_style: str = "atomic") -> np.ndarray | No def get_spins(lines: list[str], atom_style: str = "atomic") -> np.ndarray | None: + if atom_style not in {"atomic", "spin"}: + # Extra columns in dipole/sphere/etc. styles describe those styles' + # own properties and must never be reinterpreted as magnetic spins. + return None atom_lines = get_atoms(lines) if len(atom_lines[0].split()) < 8: return None @@ -567,7 +574,11 @@ def from_system_data(system, f_idx=0): ret += mass_fmt % (ii + 1, mass, atom_name) ret += "\n" - ret += "Atoms # atomic\n" + # The extra direction/magnitude columns have an official LAMMPS layout: + # they belong to ``atom_style spin``, not ``atom_style atomic``. Using the + # matching section annotation lets ``read_data`` validate the rows. + atom_style = "spin" if "spins" in system else "atomic" + ret += f"Atoms # {atom_style}\n" ret += "\n" coord_fmt = ( ptr_int_fmt diff --git a/dpdata/plugins/lammps.py b/dpdata/plugins/lammps.py index 9a462265..13e8a143 100644 --- a/dpdata/plugins/lammps.py +++ b/dpdata/plugins/lammps.py @@ -60,7 +60,7 @@ def from_system( atom_style : str, optional The LAMMPS atom style. Default is "auto" which attempts to detect the style automatically from the file. Can also be explicitly set to: - atomic, full, charge, bond, angle, molecular, dipole, sphere + atomic, full, charge, bond, angle, molecular, dipole, sphere, spin **kwargs : dict Other parameters @@ -100,6 +100,7 @@ def from_system( - molecular: atom-ID molecule-ID atom-type x y z - dipole: atom-ID atom-type charge x y z mux muy muz - sphere: atom-ID atom-type diameter density x y z + - spin: atom-ID atom-type x y z spx spy spz sp """ with open_file(file_name) as fp: lines = [line.rstrip("\n") for line in fp] diff --git a/tests/test_lammps_spin.py b/tests/test_lammps_spin.py index bcb3442b..d8b28454 100644 --- a/tests/test_lammps_spin.py +++ b/tests/test_lammps_spin.py @@ -2,6 +2,7 @@ import os import shutil +import subprocess import unittest import numpy as np @@ -54,10 +55,20 @@ def test_dump_input(self): with open(self.lmp_coord_name) as f: c = f.read() + self.assertIn("Atoms # spin", c) coord_ref = """ 1 1 0.0000000000 0.0000000000 0.0000000000 0.6000000000 0.8000000000 0.0000000000 5.0000000000 2 2 1.2621856000 0.7018028000 0.5513885000 0.0000000000 0.8000000000 0.6000000000 5.0000000000""" self.assertTrue(coord_ref in c) + # Auto-detection must understand the official spin annotation and + # reconstruct the original vectors, including their magnitudes. + roundtrip = dpdata.System( + self.lmp_coord_name, fmt="lammps/lmp", type_map=["O", "H"] + ) + np.testing.assert_allclose( + roundtrip.data["spins"], self.tmp_system.data["spins"] + ) + def test_dump_input_zero_spin(self): self.tmp_system.data["spins"] = [[[0, 0, 0], [0, 0, 0]]] self.tmp_system.to("lammps/lmp", self.lmp_coord_name) @@ -68,6 +79,28 @@ def test_dump_input_zero_spin(self): 2 2 1.2621856000 0.7018028000 0.5513885000 0.0000000000 0.0000000000 1.0000000000 0.0000000000""" self.assertTrue(coord_ref in c) + def test_dump_input_is_accepted_by_lammps(self): + """The generated spin section must be valid for LAMMPS itself.""" + lmp = shutil.which("lmp") + if lmp is None: + self.skipTest("LAMMPS executable is not installed") + self.tmp_system.to("lammps/lmp", self.lmp_coord_name) + result = subprocess.run( + [lmp, "-log", "none", "-screen", "none"], + input=( + "units metal\n" + "atom_style spin\n" + f"read_data {self.lmp_coord_name}\n" + "run 0\n" + ), + text=True, + capture_output=True, + check=False, + ) + if result.returncode and "Unrecognized atom style" in result.stderr: + self.skipTest("LAMMPS was built without the SPIN package") + self.assertEqual(result.returncode, 0, result.stderr) + def test_read_input(self): # check if dpdata can read the spins tmp_system = dpdata.System(