- 
                Notifications
    
You must be signed in to change notification settings  - Fork 734
 
FIX: LAMMPSDUMP Convert forces from kcal to kJ units #5117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
3c3c81c
              0671174
              518bca9
              e5ee9ac
              f960696
              04ee5ae
              4887d03
              9af7feb
              080fdae
              7b59148
              89d98e3
              74c83f2
              e517777
              4fe2f50
              8ef74d8
              77435a4
              740084a
              eeeccb5
              530db92
              f0e1060
              aba969f
              cd1fc7f
              4785029
              930eb12
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| 
          
            
          
           | 
    @@ -181,19 +181,13 @@ def client_DensityAnalysis(request): | |
| return request.param | ||
| 
     | 
||
| 
     | 
||
| # MDAnalysis.analysis.lineardensity | ||
| 
     | 
||
| 
     | 
||
| @pytest.fixture(scope="module", params=params_for_cls(LinearDensity)) | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't remove that.  | 
||
| def client_LinearDensity(request): | ||
| @pytest.fixture(scope="module", params=params_for_cls(InterRDF)) | ||
| def client_InterRDF(request): | ||
| return request.param | ||
| 
     | 
||
| 
     | 
||
| # MDAnalysis.analysis.polymer | ||
| 
     | 
||
| 
     | 
||
| @pytest.fixture(scope="module", params=params_for_cls(PersistenceLength)) | ||
| def client_PersistenceLength(request): | ||
| @pytest.fixture(scope="module", params=params_for_cls(InterRDF_s)) | ||
| def client_InterRDF_s(request): | ||
| return request.param | ||
| 
     | 
||
| 
     | 
||
| 
          
            
          
           | 
    ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| 
          
            
          
           | 
    @@ -28,6 +28,7 @@ | |
| 
     | 
||
| import MDAnalysis as mda | ||
| from MDAnalysis import NoDataError | ||
| from MDAnalysis.lib.util import anyopen | ||
| 
     | 
||
| from numpy.testing import assert_equal, assert_allclose | ||
| 
     | 
||
| 
        
          
        
         | 
    @@ -53,6 +54,8 @@ | |
| LAMMPSdata_additional_columns, | ||
| LAMMPSDUMP_additional_columns, | ||
| ) | ||
| from MDAnalysis.coordinates.LAMMPS import DumpReader | ||
| from MDAnalysis import units | ||
| 
     | 
||
| 
     | 
||
| def test_datareader_ValueError(): | ||
| 
          
            
          
           | 
    @@ -768,6 +771,186 @@ def test_warning(self, system, request): | |
| with pytest.warns(match="Some of the additional"): | ||
| request.getfixturevalue(system) | ||
| 
     | 
||
| def test_dump_reader_units_attribute(self): | ||
| """Test that DumpReader has proper units defined""" | ||
| 
     | 
||
| expected_units = { | ||
| "time": "fs", | ||
| "length": "Angstrom", | ||
| "velocity": "Angstrom/fs", | ||
| "force": "kcal/(mol*Angstrom)", | ||
| } | ||
| 
     | 
||
| assert DumpReader.units == expected_units | ||
| 
     | 
||
| def test_force_unit_conversion_factor(self): | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the only test that doesn't fail when the source patch in this PR is reverted. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test appears to be now passing?  | 
||
| """Test that the force conversion factor is correct""" | ||
| 
     | 
||
| # Get conversion factor from kcal/(mol*Angstrom) to kJ/(mol*Angstrom) | ||
| factor = units.get_conversion_factor( | ||
| "force", | ||
| "kcal/(mol*Angstrom)", # from (LAMMPS native) | ||
| "kJ/(mol*Angstrom)", # to (MDAnalysis base) | ||
| ) | ||
| 
     | 
||
| expected_factor = 4.184 # 1 kcal = 4.184 kJ | ||
| assert_allclose(factor, expected_factor, rtol=1e-6) | ||
| 
     | 
||
| def test_force_conversion_with_image_vf_file(self): | ||
| """Test force unit conversion using existing test file with forces""" | ||
| # Test with convert_units=True (default) | ||
| u_converted = mda.Universe( | ||
| LAMMPS_image_vf, LAMMPSDUMP_image_vf, format="LAMMPSDUMP" | ||
| ) | ||
| 
     | 
||
| # Test with convert_units=False | ||
| u_native = mda.Universe( | ||
| LAMMPS_image_vf, | ||
| LAMMPSDUMP_image_vf, | ||
| format="LAMMPSDUMP", | ||
| convert_units=False, | ||
| ) | ||
| 
     | 
||
| # Both should have forces | ||
| assert hasattr(u_converted.atoms, "forces") | ||
| assert hasattr(u_native.atoms, "forces") | ||
| 
     | 
||
| # Go to last frame where we know forces exist | ||
| u_converted.trajectory[-1] | ||
| u_native.trajectory[-1] | ||
| 
     | 
||
| forces_converted = u_converted.atoms.forces | ||
| forces_native = u_native.atoms.forces | ||
| 
     | 
||
| # Check that forces are different (converted vs native) | ||
| # The conversion factor should be 4.184 | ||
| expected_factor = 4.184 | ||
| forces_expected = forces_native * expected_factor | ||
| 
     | 
||
| # Test that converted forces match expected values | ||
| assert_allclose(forces_converted, forces_expected, rtol=1e-6) | ||
| 
     | 
||
| # Test that native forces are unchanged when convert_units=False | ||
| # Just check they are reasonable values (not zero everywhere) | ||
| assert not np.allclose(forces_native, 0.0) | ||
| 
         There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, this check is perhaps a bit less convincing. What about checking the first and last force with  There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is assuming that the forces in native units are zero? Consider using   | 
||
| 
     | 
||
| def test_force_conversion_consistency_across_frames(self): | ||
| """Test that force conversion works consistently across all frames""" | ||
| u_converted = mda.Universe( | ||
| LAMMPS_image_vf, LAMMPSDUMP_image_vf, format="LAMMPSDUMP" | ||
| ) | ||
| 
     | 
||
| u_native = mda.Universe( | ||
| LAMMPS_image_vf, | ||
| LAMMPSDUMP_image_vf, | ||
| format="LAMMPSDUMP", | ||
| convert_units=False, | ||
| ) | ||
| 
     | 
||
| conversion_factor = 4.184 | ||
| 
     | 
||
| # Test conversion in all frames | ||
| for ts_conv, ts_native in zip( | ||
| u_converted.trajectory, u_native.trajectory | ||
| ): | ||
| if ts_conv.has_forces: | ||
| forces_converted = ts_conv.forces | ||
| forces_native = ts_native.forces | ||
| forces_expected = forces_native * conversion_factor | ||
| 
     | 
||
| assert_allclose( | ||
| forces_converted, | ||
| forces_expected, | ||
| rtol=1e-6, | ||
| err_msg=f"Force conversion failed at frame {ts_conv.frame}", | ||
| ) | ||
| 
     | 
||
| def test_native_forces_preserved_first_last_atom(self): | ||
| """Check that native forces (convert_units=False) match raw dump values | ||
| for first and last atom (by LAMMPS id) on the last frame. | ||
| 
     | 
||
| This tightens the previous loose check that merely ensured native forces | ||
| were non-zero, by validating exact preservation of raw values. | ||
| """ | ||
| 
     | 
||
| # Parse last frame forces from the raw dump (keyed by LAMMPS atom id) | ||
| def parse_last_frame_forces(path): | ||
| last_forces = None | ||
| n_atoms = None | ||
| id_idx = fx_idx = fy_idx = fz_idx = None | ||
| with anyopen(path) as f: | ||
| while True: | ||
| line = f.readline() | ||
| if not line: | ||
| break | ||
| if line.startswith("ITEM: TIMESTEP"): | ||
| # timestep line and number | ||
| _ = f.readline() | ||
| # number of atoms header and value | ||
| assert f.readline().startswith("ITEM: NUMBER OF ATOMS") | ||
| n_atoms = int(f.readline().strip()) | ||
| # box bounds header + 3 lines | ||
| assert f.readline().startswith("ITEM: BOX BOUNDS") | ||
| f.readline(); f.readline(); f.readline() | ||
| # atoms header with columns | ||
| atoms_header = f.readline().strip() | ||
| assert atoms_header.startswith("ITEM: ATOMS ") | ||
| cols = atoms_header.split()[2:] # after 'ITEM: ATOMS' | ||
| # Identify indices for id and fx fy fz | ||
| try: | ||
| id_idx = cols.index("id") | ||
| fx_idx = cols.index("fx") | ||
| fy_idx = cols.index("fy") | ||
| fz_idx = cols.index("fz") | ||
| except ValueError as e: | ||
| raise AssertionError( | ||
| "Required columns 'id fx fy fz' not found in dump header" | ||
| ) from e | ||
| # Read this frame's atoms | ||
| frame_forces = {} | ||
| for _ in range(n_atoms): | ||
| parts = f.readline().split() | ||
| aid = int(parts[id_idx]) | ||
| fx = float(parts[fx_idx]) | ||
| fy = float(parts[fy_idx]) | ||
| fz = float(parts[fz_idx]) | ||
| frame_forces[aid] = np.array([fx, fy, fz], dtype=float) | ||
| # Keep updating last_forces; at EOF it will be the last frame | ||
| last_forces = frame_forces | ||
| assert last_forces is not None and n_atoms is not None | ||
| return last_forces, n_atoms | ||
| 
     | 
||
| raw_forces_by_id, n_atoms = parse_last_frame_forces(LAMMPSDUMP_image_vf) | ||
| 
     | 
||
| # Universe with native units preserved | ||
| u_native = mda.Universe( | ||
| LAMMPS_image_vf, | ||
| LAMMPSDUMP_image_vf, | ||
| format="LAMMPSDUMP", | ||
| convert_units=False, | ||
| ) | ||
| 
     | 
||
| u_native.trajectory[-1] | ||
| forces_native = u_native.atoms.forces | ||
| 
     | 
||
| # Determine smallest and largest atom ids present in the frame | ||
| min_id = min(raw_forces_by_id.keys()) | ||
| max_id = max(raw_forces_by_id.keys()) | ||
| 
     | 
||
| # Universe sorts by id, so index 0 corresponds to min_id, and -1 to max_id | ||
| expected_first = raw_forces_by_id[min_id] | ||
| expected_last = raw_forces_by_id[max_id] | ||
| 
     | 
||
| # Allow tiny numerical differences due to float32 storage in trajectory | ||
| assert_allclose( | ||
| forces_native[0], expected_first, rtol=0, atol=1e-6, | ||
| err_msg="Native first-atom force does not match raw dump value", | ||
| ) | ||
| assert_allclose( | ||
| forces_native[-1], expected_last, rtol=0, atol=1e-6, | ||
| err_msg="Native last-atom force does not match raw dump value", | ||
| ) | ||
| 
     | 
||
| 
     | 
||
| @pytest.mark.parametrize( | ||
| "convention", ["unscaled", "unwrapped", "scaled_unwrapped"] | ||
| 
          
            
          
           | 
    ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't change anything in this file unless you have to.