Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions package/CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ The rules for this file:
* 2.11.0

Fixes
* `AtomGroup.rotate()` and the `rotateby` trajectory transformation now
also rotate velocities and forces besides positions. This also affects
`MDAnalysis.analysis.align.alignto()` and `AlignTraj`, since they
apply their fit via `AtomGroup.rotate()`. Note: this changes existing
behavior, as velocities/forces were previously left untouched by
rotation. (Issue #5421, PR #5452)
* Fix FileLock tests for XTC and TRR: lock file is no longer removed (#5382)
* InterRDF now correctly returns bins in parallel (PR #5344)
* `Merge()` no longer raises a TypeError on Universes that have a `cmaps`
Expand Down
14 changes: 12 additions & 2 deletions package/MDAnalysis/core/groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,8 @@ def rotate(self, R, point=(0, 0, 0)):
rotateby : rotate around given axis and angle
MDAnalysis.lib.transformations : module of all coordinate transforms

.. versionchanged: 2.11.0
Also rotate velocities and forces if present in Timestep.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be formatted correctly. You need two blank lines (quirk of reST and the Napoleon parser) and you need to indent properly:

Suggested change
.. versionchanged: 2.11.0
Also rotate velocities and forces if present in Timestep.
.. versionchanged: 2.11.0
Also rotate velocities and forces if present in Timestep.

Always check the readthedocs check that the docs get rendered properly, see eg https://mdanalysis--5452.org.readthedocs.build/en/5452/documentation_pages/core/groups.html#MDAnalysis.core.groups.AtomGroup.rotate where the versionchanged is not visible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please also add the versionchanged to the docs for rotateby https://mdanalysis--5452.org.readthedocs.build/en/5452/documentation_pages/core/groups.html#MDAnalysis.core.groups.AtomGroup.rotateby — you didn't change that one but from a user's perspective it's not obvious. Please check that these docs also render properly. Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for missing out the doc update in rotateby and the indentation. I've update the docs and ensured they render properly.

"""
R = np.asarray(R)
point = np.asarray(point)
Expand All @@ -1610,11 +1612,19 @@ def rotate(self, R, point=(0, 0, 0)):
require_translation = bool(np.count_nonzero(point))
if require_translation:
atomgroup.translate(-point)
x = atomgroup.universe.trajectory.ts.positions
ts = atomgroup.universe.trajectory.ts
R_T = R.T
x = ts.positions
Comment thread
ParthUppal523 marked this conversation as resolved.
idx = atomgroup.indices
x[idx] = np.dot(x[idx], R.T)
x[idx] = np.dot(x[idx], R_T)
if require_translation:
atomgroup.translate(point)
if ts.has_velocities:
v = ts.velocities
v[idx] = np.dot(v[idx], R_T)
if ts.has_forces:
f = ts.forces
f[idx] = np.dot(f[idx], R_T)

return self

Expand Down
4 changes: 4 additions & 0 deletions package/MDAnalysis/transformations/rotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,8 @@ def _transform(self, ts):
translation = matrix[:3, 3]
ts.positions = np.dot(ts.positions, rotation)
ts.positions += translation
if ts.has_velocities:
Comment thread
orbeckst marked this conversation as resolved.
ts.velocities = np.dot(ts.velocities, rotation)
if ts.has_forces:
ts.forces = np.dot(ts.forces, rotation)
return ts
43 changes: 43 additions & 0 deletions testsuite/MDAnalysisTests/analysis/test_align.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import pytest
from MDAnalysis import SelectionError, SelectionWarning
from MDAnalysisTests import executable_not_found
from MDAnalysis.lib import transformations
from MDAnalysisTests.datafiles import (
PSF,
DCD,
Expand Down Expand Up @@ -821,3 +822,45 @@ def test_alignto_reorder_atomgroups():
ref = u.atoms[[3, 2, 1, 0]]
rmsd = align.alignto(mobile, ref, select="bynum 1-4")
assert_allclose(rmsd, (0.0, 0.0))


def test_alignto_rotates_velocities_and_forces():
mobile = mda.Universe.empty(
4, trajectory=True, velocities=True, forces=True
)
reference = mda.Universe.empty(4, trajectory=True)

mobile.add_TopologyAttr("masses", [1.0, 1.0, 1.0, 1.0])
reference.add_TopologyAttr("masses", [1.0, 1.0, 1.0, 1.0])

ref_pos = np.array(
[[1, 0, 0], [0, 1, 0], [-1, 0, 0], [0, -1, 0]], dtype=np.float64
)
reference.atoms.positions = ref_pos

angle = 23
known_R = transformations.rotation_matrix(np.deg2rad(angle), [-1, 2, -3])[
:3, :3
]
mobile.atoms.positions = np.dot(ref_pos, known_R.T)

rng = np.random.RandomState(0)
orig_v = rng.random((4, 3))
orig_f = rng.random((4, 3))
mobile.atoms.velocities = orig_v.copy()
mobile.atoms.forces = orig_f.copy()

mobile_centered = (
mobile.atoms.positions - mobile.atoms.center_of_geometry()
)
ref_centered = ref_pos - ref_pos.mean(axis=0)
expected_R, _ = align.rotation_matrix(mobile_centered, ref_centered)

align.alignto(mobile, reference)

assert_allclose(
mobile.atoms.velocities, np.dot(orig_v, expected_R.T), atol=1e-6
)
assert_allclose(
mobile.atoms.forces, np.dot(orig_f, expected_R.T), atol=1e-6
)
28 changes: 28 additions & 0 deletions testsuite/MDAnalysisTests/core/test_atomgroup.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,34 @@ def test_rotateby(self, u, coords):
[-2 * np.cos(angle) + 1, -2 * np.sin(angle), 0],
)

def test_rotate_velocities_forces(self):
Comment thread
orbeckst marked this conversation as resolved.
u = mda.Universe.empty(
2, trajectory=True, velocities=True, forces=True
)
u.atoms.positions = np.array([[1, 0, 0], [-1, 0, 0]])
u.atoms.velocities = np.array([[1, 0, 0], [0, 1, 0]])
u.atoms.forces = np.array([[0, 0, 1], [1, 1, 0]])

orig_v = u.atoms.velocities.copy()
orig_f = u.atoms.forces.copy()

axis = np.array([0, 0, 1])
for angle in np.linspace(0, np.pi):
R = transformations.rotation_matrix(angle, axis)[:3, :3]
u.atoms.velocities = orig_v.copy()
u.atoms.forces = orig_f.copy()
u.atoms.rotate(R)
assert_almost_equal(u.atoms.velocities, np.dot(orig_v, R.T))
assert_almost_equal(u.atoms.forces, np.dot(orig_f, R.T))

def test_rotate_no_velocities_forces_does_not_raise(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this test testing? Is it necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test (and the similar one in test_rotate.py) covers the case when a universe has no velocity/force data, which may be true in some cases. They confirm rotate()/rotateby() still rotate positions correctly and don't raise NoDataError when velocities/forces are absent.

The original version only checked 'doesn't crash'. To strengthen these tests, I've added a position assertion so it also confirms rotation still works correctly in that case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about the other tests that were present before? Don't these tests already cover this case naturally?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your suggestion @orbeckst . I checked, and the fixtures used by test_rotate/test_rotateby are already covering the case; my tests were duplicating that coverage. I'll remove these tests.

u = mda.Universe.empty(2, trajectory=True)
orig_pos = np.array([[1, 0, 0], [-1, 0, 0]])
u.atoms.positions = orig_pos.copy()
R = transformations.rotation_matrix(1, [-1, 2, -3])[:3, :3]
u.atoms.rotate(R)
assert_almost_equal(u.atoms.positions, np.dot(orig_pos, R.T))

def test_transform_rotation_only(self, u, coords):
R = np.eye(3)
u.atoms.rotate(R)
Expand Down
40 changes: 40 additions & 0 deletions testsuite/MDAnalysisTests/transformations/test_rotate.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,46 @@ def test_rotateby_atomgroup_com_pbc(rotate_universes):
assert_array_almost_equal(transformed.positions, ref.positions, decimal=6)


def test_rotateby_velocities_forces():
u = mda.Universe.empty(2, trajectory=True, velocities=True, forces=True)
u.atoms.positions = np.array([[1, 0, 0], [-1, 0, 0]])
u.atoms.velocities = np.array([[1, 0, 0], [0, 1, 0]])
u.atoms.forces = np.array([[0, 0, 1], [1, 1, 0]])
ts = u.trajectory.ts

orig_v = ts.velocities.copy()
orig_f = ts.forces.copy()

axis = [-1, 2, -3]
point = [0, 0, 0]
angle = 23
matrix = rotation_matrix(np.deg2rad(angle), axis, point)
rotation = matrix[:3, :3].T

transformed_ts = rotateby(angle, axis, point=point)(ts)

assert_array_almost_equal(
transformed_ts.velocities, np.dot(orig_v, rotation), decimal=6
)
assert_array_almost_equal(
transformed_ts.forces, np.dot(orig_f, rotation), decimal=6
)


def test_rotateby_no_velocities_forces_does_not_raise():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same question above, is this a necessary test?

@ParthUppal523 ParthUppal523 Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason as for test_rotate_no_velocities_forces_does_not_raise(self)

u = mda.Universe.empty(2, trajectory=True)
orig_pos = np.array([[1, 0, 0], [-1, 0, 0]])
u.atoms.positions = orig_pos.copy()
ts = u.trajectory.ts
angle = 5
matrix = rotation_matrix(np.deg2rad(angle), [-1, 2, -3], [0, 0, 0])
rotation = matrix[:3, :3].T
transformed_ts = rotateby(angle, [-1, 2, -3], point=[0, 0, 0])(ts)
assert_array_almost_equal(
transformed_ts.positions, np.dot(orig_pos, rotation), decimal=6
)


@pytest.mark.parametrize(
"ag",
(
Expand Down
Loading