Skip to content

Commit 6af9314

Browse files
authored
feat(lammps): generate masses for lammps/lmp export (#963)
Problem - `lammps/lmp` export currently does not emit a `Masses` section. - For systems whose `atom_names` are known element symbols, the masses can be generated safely. Change - prefer explicitly stored `masses` data when present - otherwise infer per-type masses from `atom_names` when all names are valid element symbols - keep the previous behavior for unknown type names and add regression tests for both paths - make the new regression tests independent of the current working directory Closes #960 Authored by OpenClaw (model: custom-chat-jinzhezeng-group/gpt-5.4) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * LAMMPS output now includes a Masses section (with per-type masses and atom-name comments) when masses can be provided or safely inferred; placed between the header/box and atom coordinate blocks. * **Tests** * Added tests verifying Masses are written for known elements, omitted when types/masses cannot be determined, and that mismatched explicit masses raise an error. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9a4bc5b commit 6af9314

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

dpdata/lammps/lmp.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import numpy as np
55

6+
from dpdata.periodic_table import ELEMENTS, Element
7+
68
ptr_float_fmt = "%15.10f"
79
ptr_int_fmt = "%6d"
810
ptr_key_fmt = "%15s"
@@ -484,6 +486,47 @@ def rotate_to_lower_triangle(
484486
return cell, coord
485487

486488

489+
def _get_lammps_masses(system) -> np.ndarray | None:
490+
"""Get masses for the LAMMPS ``Masses`` section.
491+
492+
Prefer explicitly stored masses when available. Otherwise, infer masses from
493+
``atom_names`` when all names are valid chemical element symbols.
494+
495+
Parameters
496+
----------
497+
system : dict
498+
System data dictionary
499+
500+
Returns
501+
-------
502+
np.ndarray or None
503+
Per-type masses aligned with ``atom_names``. Returns ``None`` when the
504+
masses cannot be determined safely.
505+
506+
Raises
507+
------
508+
ValueError
509+
If explicit ``system["masses"]`` is present but does not match the
510+
length of ``atom_names``.
511+
"""
512+
atom_names = system["atom_names"]
513+
masses = system.get("masses")
514+
if masses is not None:
515+
masses = np.asarray(masses, dtype=float)
516+
if masses.ndim != 1 or len(masses) != len(atom_names):
517+
raise ValueError(
518+
'Explicit system["masses"] must be a 1D array with the same '
519+
'length as system["atom_names"] to write the LAMMPS Masses '
520+
"section."
521+
)
522+
return masses
523+
524+
if not all(name in ELEMENTS for name in atom_names):
525+
return None
526+
527+
return np.array([Element(name).mass for name in atom_names], dtype=float)
528+
529+
487530
def from_system_data(system, f_idx=0):
488531
ret = ""
489532
ret += "\n"
@@ -514,6 +557,16 @@ def from_system_data(system, f_idx=0):
514557
cell[2][1],
515558
) # noqa: UP031
516559
ret += "\n"
560+
561+
masses = _get_lammps_masses(system)
562+
if masses is not None:
563+
ret += "Masses\n"
564+
ret += "\n"
565+
mass_fmt = ptr_int_fmt + " " + ptr_float_fmt + " # %s\n" # noqa: UP031
566+
for ii, (mass, atom_name) in enumerate(zip(masses, system["atom_names"])):
567+
ret += mass_fmt % (ii + 1, mass, atom_name)
568+
ret += "\n"
569+
517570
ret += "Atoms # atomic\n"
518571
ret += "\n"
519572
coord_fmt = (

tests/test_lammps_lmp_dump.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import os
4+
import tempfile
45
import unittest
56

67
import numpy as np
@@ -9,6 +10,9 @@
910

1011
from dpdata.lammps.lmp import rotate_to_lower_triangle
1112

13+
TEST_DIR = os.path.dirname(__file__)
14+
POSCAR_CONF_LMP = os.path.join(TEST_DIR, "poscars", "conf.lmp")
15+
1216

1317
class TestLmpDump(unittest.TestCase, TestPOSCARoh):
1418
def setUp(self):
@@ -100,5 +104,39 @@ def test_negative_diagonal(self):
100104
)
101105

102106

107+
class TestLmpDumpMasses(unittest.TestCase):
108+
def test_dump_known_elements_writes_masses(self):
109+
system = dpdata.System(POSCAR_CONF_LMP, type_map=["O", "H"])
110+
with tempfile.TemporaryDirectory() as tmpdir:
111+
output = os.path.join(tmpdir, "tmp_masses.lmp")
112+
system.to_lammps_lmp(output)
113+
with open(output) as f:
114+
content = f.read()
115+
116+
self.assertIn("Masses\n", content)
117+
self.assertIn(" 1 15.9994000000 # O", content)
118+
self.assertIn(" 2 1.0079400000 # H", content)
119+
self.assertLess(content.index("Masses\n"), content.index("Atoms # atomic\n"))
120+
121+
def test_dump_unknown_types_skips_masses(self):
122+
system = dpdata.System(POSCAR_CONF_LMP)
123+
with tempfile.TemporaryDirectory() as tmpdir:
124+
output = os.path.join(tmpdir, "tmp_unknown_types.lmp")
125+
system.to_lammps_lmp(output)
126+
with open(output) as f:
127+
content = f.read()
128+
129+
self.assertNotIn("Masses\n", content)
130+
131+
def test_dump_rejects_mismatched_explicit_masses(self):
132+
system = dpdata.System(POSCAR_CONF_LMP, type_map=["O", "H"])
133+
system.data["masses"] = np.array([15.9994, 1.00794, 99.0])
134+
135+
with tempfile.TemporaryDirectory() as tmpdir:
136+
output = os.path.join(tmpdir, "tmp_bad_masses.lmp")
137+
with self.assertRaisesRegex(ValueError, r'system\["masses"\]'):
138+
system.to_lammps_lmp(output)
139+
140+
103141
if __name__ == "__main__":
104142
unittest.main()

0 commit comments

Comments
 (0)