Skip to content

Commit 518bca9

Browse files
LAMMPS DumpReader: unit overrides (time/length/energy), default force conversion, and native-force preservation test
1 parent 0671174 commit 518bca9

File tree

3 files changed

+199
-6
lines changed

3 files changed

+199
-6
lines changed

package/CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ Fixes
4242
directly passing them. (Issue #3520, PR #5006)
4343

4444
Enhancements
45+
* LAMMPS DumpReader now converts forces to MDAnalysis base units by default
46+
and supports overriding native units via `timeunit`, `lengthunit`, and
47+
`energyunit` kwargs to accommodate different LAMMPS unit styles such as
48+
"real" and "metal" (Issue #5115, PR #5117)
4549
* Added support for reading and processing streamed data in `coordinates.base`
4650
with new `StreamFrameIteratorSliced` and `StreamReaderBase` (Issue #4827, PR #4923)
4751
* New coordinate reader: Added `IMDReader` for reading real-time streamed

package/MDAnalysis/coordinates/LAMMPS.py

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -615,16 +615,41 @@ class DumpReader(base.ReaderBase):
615615
**kwargs
616616
Other keyword arguments used in :class:`~MDAnalysis.coordinates.base.ReaderBase`
617617
618+
Additional keyword arguments
619+
----------------------------
620+
timeunit : str, optional
621+
Native time unit of the dump file (default: ``"fs"`` for LAMMPS
622+
"real" units). Must be a valid MDAnalysis time unit.
623+
lengthunit : str, optional
624+
Native length unit of the dump file (default: ``"Angstrom"`` for
625+
LAMMPS "real" units). Must be a valid MDAnalysis length unit.
626+
energyunit : str, optional
627+
Native energy unit per mole of the dump file (default:
628+
``"kcal/mol"`` for LAMMPS "real" units). Used together with
629+
`lengthunit` to derive the native force unit (energy/length).
630+
618631
Note
619632
----
620-
This reader assumes LAMMPS "real" units where time is in femtoseconds,
621-
length is in Angstroms, velocities in Angstrom/femtosecond, and forces
622-
in kcal/(mol*Angstrom). Forces are automatically converted to MDAnalysis
623-
base units (kJ/(mol*Angstrom)) for consistency with other trajectory formats.
633+
By default, this reader assumes LAMMPS "real" units where time is in
634+
femtoseconds (``fs``), length is in Angstroms (``Angstrom``), velocities
635+
in ``Angstrom/fs``, and forces in ``kcal/(mol*Angstrom)``. You can
636+
override the native units with `timeunit`, `lengthunit`, and `energyunit`.
637+
The velocity unit is derived as ``lengthunit/timeunit``; the force unit
638+
is derived as ``energyunit/lengthunit`` (preserving ``/mol`` if present).
639+
640+
When ``convert_units=True`` (default), positions, velocities, and forces
641+
are converted from the native units to MDAnalysis base units, i.e.,
642+
positions to ``Angstrom``, time to ``ps`` (affecting velocity), and
643+
forces to ``kJ/(mol*Angstrom)``. This ensures consistency with other
644+
trajectory formats.
624645
625646
.. versionchanged:: 2.8.0
626647
Reading of arbitrary, additional columns is now supported.
627648
(Issue `#3504 <https://github.com/MDAnalysis/mdanalysis/issues/3504>`__)
649+
.. versionchanged:: 2.10.0
650+
Forces are converted to MDAnalysis base units by default and native
651+
units can be overridden via `timeunit`, `lengthunit`, and `energyunit`
652+
to accommodate different LAMMPS unit styles.
628653
.. versionchanged:: 2.4.0
629654
Now imports velocities and forces, translates the box to the origin,
630655
and optionally unwraps trajectories with image flags upon loading.
@@ -671,6 +696,7 @@ def __init__(
671696
additional_columns=None,
672697
**kwargs,
673698
):
699+
# Initialize first to set convert_units and ts kwargs
674700
super(DumpReader, self).__init__(filename, **kwargs)
675701

676702
root, ext = os.path.splitext(self.filename)
@@ -685,6 +711,82 @@ def __init__(
685711
f"Please choose one of {option_string}"
686712
)
687713

714+
# Allow overriding native units per-instance to support LAMMPS unit styles
715+
# Defaults correspond to "real": time=fs, length=Angstrom, energy=kcal/mol
716+
timeunit = kwargs.pop("timeunit", None)
717+
lengthunit = kwargs.pop("lengthunit", None)
718+
energyunit = kwargs.pop("energyunit", None)
719+
720+
# Start from class defaults
721+
self.units = self.units.copy()
722+
723+
# Helper to (re)compute velocity and force units from time/length/energy
724+
def _compute_units(_time, _length, _energy):
725+
# velocity as length/time
726+
vel = None
727+
if _time is not None and _length is not None:
728+
vel = f"{_length}/{_time}"
729+
730+
# force as energy/length (add per-mol if provided in energy)
731+
# Examples:
732+
# - energy="kcal/mol", length="Angstrom" -> "kcal/(mol*Angstrom)"
733+
# - energy="eV", length="Angstrom" -> "eV/Angstrom"
734+
frc = None
735+
if _energy is not None and _length is not None:
736+
if "/mol" in _energy:
737+
base_energy = _energy.replace("/mol", "")
738+
frc = f"{base_energy}/(mol*{_length})"
739+
else:
740+
frc = f"{_energy}/{_length}"
741+
return vel, frc
742+
743+
# Apply overrides if provided
744+
if timeunit is not None:
745+
# Validate unit type if known
746+
try:
747+
if units.unit_types[timeunit] != 'time':
748+
raise TypeError(
749+
f"LAMMPS DumpReader: wrong unit {timeunit!r} for unit type 'time'"
750+
)
751+
except KeyError:
752+
raise ValueError(
753+
f"LAMMPS DumpReader: unknown time unit {timeunit!r}"
754+
) from None
755+
self.units['time'] = timeunit
756+
757+
if lengthunit is not None:
758+
try:
759+
if units.unit_types[lengthunit] != 'length':
760+
raise TypeError(
761+
f"LAMMPS DumpReader: wrong unit {lengthunit!r} for unit type 'length'"
762+
)
763+
except KeyError:
764+
raise ValueError(
765+
f"LAMMPS DumpReader: unknown length unit {lengthunit!r}"
766+
) from None
767+
self.units['length'] = lengthunit
768+
769+
# default energy for "real"
770+
default_energy = 'kcal/mol'
771+
if energyunit is None:
772+
energyunit = default_energy
773+
else:
774+
try:
775+
if units.unit_types[energyunit] != 'energy':
776+
raise TypeError(
777+
f"LAMMPS DumpReader: wrong unit {energyunit!r} for unit type 'energy'"
778+
)
779+
except KeyError:
780+
# Some compound forms like 'kcal/mol' may not be in unit_types; allow pass-through
781+
pass
782+
783+
# Derive velocity and force units based on final time/length/energy
784+
vunit, funit = _compute_units(self.units['time'], self.units['length'], energyunit)
785+
if vunit is not None:
786+
self.units['velocity'] = vunit
787+
if funit is not None:
788+
self.units['force'] = funit
789+
688790
self._unwrap = unwrap_images
689791

690792
if (

testsuite/MDAnalysisTests/coordinates/test_lammps.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import MDAnalysis as mda
3030
from MDAnalysis import NoDataError
31+
from MDAnalysis.lib.util import anyopen
3132

3233
from numpy.testing import assert_equal, assert_allclose
3334

@@ -53,6 +54,8 @@
5354
LAMMPSdata_additional_columns,
5455
LAMMPSDUMP_additional_columns,
5556
)
57+
from MDAnalysis.coordinates.LAMMPS import DumpReader
58+
from MDAnalysis import units
5659

5760

5861
def test_datareader_ValueError():
@@ -770,7 +773,6 @@ def test_warning(self, system, request):
770773

771774
def test_dump_reader_units_attribute(self):
772775
"""Test that DumpReader has proper units defined"""
773-
from MDAnalysis.coordinates.LAMMPS import DumpReader
774776

775777
expected_units = {
776778
"time": "fs",
@@ -783,7 +785,6 @@ def test_dump_reader_units_attribute(self):
783785

784786
def test_force_unit_conversion_factor(self):
785787
"""Test that the force conversion factor is correct"""
786-
from MDAnalysis import units
787788

788789
# Get conversion factor from kcal/(mol*Angstrom) to kJ/(mol*Angstrom)
789790
factor = units.get_conversion_factor(
@@ -864,6 +865,92 @@ def test_force_conversion_consistency_across_frames(self):
864865
err_msg=f"Force conversion failed at frame {ts_conv.frame}",
865866
)
866867

868+
def test_native_forces_preserved_first_last_atom(self):
869+
"""Check that native forces (convert_units=False) match raw dump values
870+
for first and last atom (by LAMMPS id) on the last frame.
871+
872+
This tightens the previous loose check that merely ensured native forces
873+
were non-zero, by validating exact preservation of raw values.
874+
"""
875+
876+
# Parse last frame forces from the raw dump (keyed by LAMMPS atom id)
877+
def parse_last_frame_forces(path):
878+
last_forces = None
879+
n_atoms = None
880+
id_idx = fx_idx = fy_idx = fz_idx = None
881+
with anyopen(path) as f:
882+
while True:
883+
line = f.readline()
884+
if not line:
885+
break
886+
if line.startswith("ITEM: TIMESTEP"):
887+
# timestep line and number
888+
_ = f.readline()
889+
# number of atoms header and value
890+
assert f.readline().startswith("ITEM: NUMBER OF ATOMS")
891+
n_atoms = int(f.readline().strip())
892+
# box bounds header + 3 lines
893+
assert f.readline().startswith("ITEM: BOX BOUNDS")
894+
f.readline(); f.readline(); f.readline()
895+
# atoms header with columns
896+
atoms_header = f.readline().strip()
897+
assert atoms_header.startswith("ITEM: ATOMS ")
898+
cols = atoms_header.split()[2:] # after 'ITEM: ATOMS'
899+
# Identify indices for id and fx fy fz
900+
try:
901+
id_idx = cols.index("id")
902+
fx_idx = cols.index("fx")
903+
fy_idx = cols.index("fy")
904+
fz_idx = cols.index("fz")
905+
except ValueError as e:
906+
raise AssertionError(
907+
"Required columns 'id fx fy fz' not found in dump header"
908+
) from e
909+
# Read this frame's atoms
910+
frame_forces = {}
911+
for _ in range(n_atoms):
912+
parts = f.readline().split()
913+
aid = int(parts[id_idx])
914+
fx = float(parts[fx_idx])
915+
fy = float(parts[fy_idx])
916+
fz = float(parts[fz_idx])
917+
frame_forces[aid] = np.array([fx, fy, fz], dtype=float)
918+
# Keep updating last_forces; at EOF it will be the last frame
919+
last_forces = frame_forces
920+
assert last_forces is not None and n_atoms is not None
921+
return last_forces, n_atoms
922+
923+
raw_forces_by_id, n_atoms = parse_last_frame_forces(LAMMPSDUMP_image_vf)
924+
925+
# Universe with native units preserved
926+
u_native = mda.Universe(
927+
LAMMPS_image_vf,
928+
LAMMPSDUMP_image_vf,
929+
format="LAMMPSDUMP",
930+
convert_units=False,
931+
)
932+
933+
u_native.trajectory[-1]
934+
forces_native = u_native.atoms.forces
935+
936+
# Determine smallest and largest atom ids present in the frame
937+
min_id = min(raw_forces_by_id.keys())
938+
max_id = max(raw_forces_by_id.keys())
939+
940+
# Universe sorts by id, so index 0 corresponds to min_id, and -1 to max_id
941+
expected_first = raw_forces_by_id[min_id]
942+
expected_last = raw_forces_by_id[max_id]
943+
944+
# Allow tiny numerical differences due to float32 storage in trajectory
945+
assert_allclose(
946+
forces_native[0], expected_first, rtol=0, atol=1e-6,
947+
err_msg="Native first-atom force does not match raw dump value",
948+
)
949+
assert_allclose(
950+
forces_native[-1], expected_last, rtol=0, atol=1e-6,
951+
err_msg="Native last-atom force does not match raw dump value",
952+
)
953+
867954

868955
@pytest.mark.parametrize(
869956
"convention", ["unscaled", "unwrapped", "scaled_unwrapped"]

0 commit comments

Comments
 (0)