@@ -837,13 +837,7 @@ def get_vanilla_axes(self, molecule):
837837 """
838838 make_whole (molecule .atoms )
839839 moment_of_inertia_tensor = molecule .moment_of_inertia ()
840- principal_axes = self .get_principal_axes (molecule )
841-
842- eigenvalues , _ = np .linalg .eigh (moment_of_inertia_tensor )
843- order = np .argsort (np .abs (eigenvalues ))[::- 1 ]
844- moment_of_inertia = eigenvalues [order ]
845-
846- return principal_axes , moment_of_inertia
840+ return self .get_reproducible_axes (moment_of_inertia_tensor )
847841
848842 def get_custom_axes (
849843 self ,
@@ -1082,67 +1076,91 @@ def get_custom_principal_axes(
10821076 ) -> tuple [np .ndarray , np .ndarray ]:
10831077 """Compute principal axes and moments from a custom MOI tensor.
10841078
1085- Principal axes and centre of axes from the ordered eigenvalues and
1086- eigenvectors of a moment of inertia tensor. This function allows for a
1087- custom moment of inertia tensor to be used, which isn't possible with the
1088- built-in MDAnalysis principal_axes() function.
1089-
1090- Original behaviour preserved:
1091-
1092- - Eigenvalues are sorted by descending absolute magnitude.
1093- - Eigenvectors are transposed so axes are returned as rows.
1094- - Z axis is flipped to enforce the same handedness convention as the
1095- original implementation.
1079+ Principal axes and moments from the ordered eigenvalues and eigenvectors of
1080+ a moment of inertia tensor. This function allows for a custom moment of
1081+ inertia tensor to be used. The axes are made reproducible by
1082+ :meth:`get_reproducible_axes`.
10961083
10971084 Args:
10981085 moment_of_inertia_tensor: (3, 3) custom inertia tensor.
10991086
11001087 Returns:
11011088 Tuple[np.ndarray, np.ndarray]:
11021089 - principal_axes: (3, 3) principal axes (rows).
1103- - moment_of_inertia: (3,) principal moments.
1090+ - moment_of_inertia: (3,) principal moments sorted descending by
1091+ absolute value.
11041092 """
1105- eigenvalues , eigenvectors = np .linalg .eigh (moment_of_inertia_tensor )
1106- order = np .abs (eigenvalues ).argsort ()[::- 1 ] # descending order
1107- transposed = np .transpose (eigenvectors ) # columns -> rows
1108- moment_of_inertia = eigenvalues [order ]
1109- principal_axes = transposed [order ]
1110-
1111- # point z axis in correct direction, as per original code
1112- cross_xy = np .cross (principal_axes [0 ], principal_axes [1 ])
1113- dot_z = float (np .dot (cross_xy , principal_axes [2 ]))
1114- if dot_z < 0 :
1115- principal_axes [2 ] *= - 1
1116-
1117- return principal_axes , moment_of_inertia
1093+ return self .get_reproducible_axes (moment_of_inertia_tensor )
11181094
11191095 def get_principal_axes (self , group , wrap : bool = False ) -> np .ndarray :
11201096 """Compute the principal axes of an atom group from its moment of inertia.
11211097
1122- Uses ``np.linalg.eigh`` rather than ``np.linalg.eig``. The moment of inertia
1123- tensor is symmetric, so ``eigh`` returns orthonormal axes, whereas ``eig``
1124- can return skewed axes for (near-)degenerate moments.
1125-
1126- - Axes are sorted by descending (signed) eigenvalue and returned as rows.
1127- - If the axes are left-handed, all three are flipped.
1128-
11291098 Args:
11301099 group: Atom group (or anything with ``.atoms``).
11311100 wrap: Whether to wrap atoms into the primary unit cell before computing
11321101 the moment of inertia.
11331102
11341103 Returns:
1135- np.ndarray: (3, 3) principal axes (rows).
1104+ np.ndarray: (3, 3) principal axes (rows), as given by
1105+ :meth:`get_reproducible_axes`.
11361106 """
1137- atomgroup = group .atoms
1138- e_val , e_vec = np .linalg .eigh (atomgroup .moment_of_inertia (wrap = wrap ))
1107+ tensor = group .atoms .moment_of_inertia (wrap = wrap )
1108+ principal_axes , _ = self .get_reproducible_axes (tensor )
1109+ return principal_axes
11391110
1140- e_vec = e_vec [:, np .argsort (e_val )[::- 1 ]].T
1111+ def get_reproducible_axes (
1112+ self , tensor : np .ndarray , rtol : float = 1e-5
1113+ ) -> tuple [np .ndarray , np .ndarray ]:
1114+ """Diagonalise a symmetric tensor into reproducible principal axes.
1115+
1116+ ``np.linalg.eigh`` returns orthonormal axes, but the sign of each axis, and
1117+ the choice of axes within a set of (near-)equal moments, can differ between
1118+ LAPACK builds. These axes are used to average covariances over molecules and
1119+ frames, so that choice changes the entropy. This method fixes it:
1120+
1121+ - Moments are sorted descending by absolute value.
1122+ - Axes whose moments are equal to within ``rtol`` (relative to the largest
1123+ moment) are re-chosen from the eigenvectors of a fixed reference tensor
1124+ projected onto the shared subspace. Axes with distinct moments are left as
1125+ they are.
1126+ - The first two axes are signed to point along the lab-frame direction
1127+ (1, 2, 3), and the third is their cross product, so the axes are
1128+ right-handed.
11411129
1142- if np .dot (np .cross (e_vec [0 ], e_vec [1 ]), e_vec [2 ]) < 0 :
1143- e_vec *= - 1
1130+ The result is reproducible but is a function of the lab-frame orientation,
1131+ so it is not rotation covariant.
1132+
1133+ Args:
1134+ tensor: (3, 3) symmetric tensor, e.g. a moment of inertia tensor.
1135+ rtol: Relative tolerance for treating two moments as equal.
11441136
1145- return e_vec
1137+ Returns:
1138+ Tuple[np.ndarray, np.ndarray]:
1139+ - principal_axes: (3, 3) principal axes (rows).
1140+ - moments: (3,) principal moments sorted descending by absolute
1141+ value.
1142+ """
1143+ moments , vectors = np .linalg .eigh (tensor )
1144+ order = np .argsort (np .abs (moments ))[::- 1 ]
1145+ moments , vectors = moments [order ], vectors [:, order ]
1146+
1147+ reference_direction = np .array ([1.0 , 2.0 , 3.0 ])
1148+ gaps = np .abs (np .diff (np .abs (moments )))
1149+ group_starts = np .flatnonzero (gaps > rtol * np .abs (moments [0 ])) + 1
1150+ for group in np .split (np .arange (3 ), group_starts ):
1151+ if len (group ) > 1 :
1152+ shared = vectors [:, group ]
1153+ reference = shared .T @ np .diag (reference_direction ) @ shared
1154+ _ , rotation = np .linalg .eigh (reference )
1155+ vectors [:, group ] = shared @ rotation
1156+
1157+ for i in (0 , 1 ):
1158+ if vectors [:, i ] @ reference_direction < 0 :
1159+ vectors [:, i ] *= - 1
1160+
1161+ third_axis = np .cross (vectors [:, 0 ], vectors [:, 1 ])
1162+ principal_axes = np .array ([vectors [:, 0 ], vectors [:, 1 ], third_axis ])
1163+ return principal_axes , moments
11461164
11471165 def get_UA_masses (self , molecule ) -> list [float ]:
11481166 """Return united-atom (UA) masses for a molecule.
0 commit comments