@@ -144,12 +144,14 @@ def get_residue_axes(
144144 masses = ua_masses ,
145145 dimensions = data_container .dimensions [:3 ],
146146 )
147- rot_axes , moment_of_inertia = self .get_custom_principal_axes (moi_tensor )
147+ rot_axes , moment_of_inertia = self .get_principal_axes_from_tensor (
148+ moi_tensor
149+ )
148150 trans_axes = rot_axes # per original convention
149151 rot_center = np .array (residue .center_of_mass ())
150152 else :
151153 make_whole (data_container .atoms )
152- trans_axes = data_container .atoms . principal_axes ( )
154+ trans_axes = self . get_principal_axes_from_group ( data_container .atoms )
153155 if len (edge_atom_set ) == 1 :
154156 edge_atom = edge_atom_set [0 ]
155157 rot_center , rot_axes = self .get_terminal_axes (
@@ -217,14 +219,14 @@ def get_residue_axes_from_topology(
217219 masses = topology .residue_ua_masses ,
218220 dimensions = dimensions ,
219221 )
220- rot_axes , moment_of_inertia = self .get_custom_principal_axes (
222+ rot_axes , moment_of_inertia = self .get_principal_axes_from_tensor (
221223 moment_of_inertia_tensor
222224 )
223225 trans_axes = rot_axes
224226 else :
225227 make_whole (mol .atoms )
226- trans_axes = mol .atoms . principal_axes ( )
227- rot_axes , moment_of_inertia = self .get_vanilla_axes (residue_atoms )
228+ trans_axes = self . get_principal_axes_from_group ( mol .atoms )
229+ rot_axes , moment_of_inertia = self .get_molecule_axes (residue_atoms )
228230 center = residue_atoms .center_of_mass (unwrap = True )
229231
230232 return trans_axes , rot_axes , center , moment_of_inertia
@@ -281,7 +283,7 @@ def get_UA_axes(self, data_container, index: int, res_position):
281283 # only the one residue => use principal axes
282284 residue = data_container
283285 trans_center = data_container .atoms .center_of_mass (unwrap = True )
284- trans_axes = data_container .atoms . principal_axes ( )
286+ trans_axes = self . get_principal_axes_from_group ( data_container .atoms )
285287 else :
286288 # residue of interest has at least one neighbour
287289 if res_position == - 1 or res_position == 1 :
@@ -416,12 +418,12 @@ def get_UA_axes_from_topology(
416418 masses = topology .residue_ua_masses ,
417419 dimensions = dimensions ,
418420 )
419- trans_axes , _moment_of_inertia = self .get_custom_principal_axes (
421+ trans_axes , _moment_of_inertia = self .get_principal_axes_from_tensor (
420422 moment_of_inertia_tensor
421423 )
422424 else :
423425 make_whole (residue_atoms )
424- trans_axes = residue_atoms . principal_axes ( )
426+ trans_axes = self . get_principal_axes_from_group ( residue_atoms )
425427
426428 center = heavy_atom .position
427429 rot_axes , moment_of_inertia = self .get_bonded_axes_from_topology (
@@ -478,26 +480,26 @@ def get_bonded_axes_from_topology(
478480 ua_all = u .atoms [topology .ua_all_atom_indices ]
479481
480482 if len (heavy_bonded ) == 0 :
481- custom_axes , custom_moment_of_inertia = self .get_vanilla_axes (ua_all )
483+ custom_axes , custom_moment_of_inertia = self .get_molecule_axes (ua_all )
482484
483485 if len (heavy_bonded ) == 1 and len (light_bonded ) == 0 :
484- custom_axes = self .get_custom_axes (
486+ custom_axes = self .get_bonded_vector_axes (
485487 a = heavy_atom .position ,
486488 b_list = [heavy_bonded [0 ].position ],
487489 c = np .zeros (3 ),
488490 dimensions = dimensions ,
489491 )
490492
491493 if len (heavy_bonded ) == 1 and len (light_bonded ) >= 1 :
492- custom_axes = self .get_custom_axes (
494+ custom_axes = self .get_bonded_vector_axes (
493495 a = heavy_atom .position ,
494496 b_list = [heavy_bonded [0 ].position ],
495497 c = light_bonded [0 ].position ,
496498 dimensions = dimensions ,
497499 )
498500
499501 if len (heavy_bonded ) >= 2 :
500- custom_axes = self .get_custom_axes (
502+ custom_axes = self .get_bonded_vector_axes (
501503 a = heavy_atom .position ,
502504 b_list = heavy_bonded .positions ,
503505 c = heavy_bonded [1 ].position ,
@@ -611,7 +613,7 @@ def get_terminal_axes(self, residue, edge, dimensions):
611613 if len (bonded_atoms ) == 0 :
612614 # there is only one heavy atom in the residue
613615 rot_center = edge .position
614- rot_axes = residue .atoms . principal_axes ( )
616+ rot_axes = self . get_principal_axes_from_group ( residue .atoms )
615617 else :
616618 average_bonded = np .zeros (3 )
617619 for bonded_atom in bonded_atoms :
@@ -632,7 +634,7 @@ def get_terminal_axes(self, residue, edge, dimensions):
632634 )
633635 else :
634636 rot_center = edge .position
635- rot_axes = self .get_custom_axes (
637+ rot_axes = self .get_bonded_vector_axes (
636638 a = edge .position ,
637639 b_list = [average_bonded ],
638640 c = np .zeros (3 ),
@@ -672,7 +674,7 @@ def get_non_terminal_axes(self, residue, edges, dimensions):
672674 )
673675 else :
674676 rot_center = (edges [0 ].position + edges [1 ].position ) / 2
675- rot_axes = self .get_custom_axes (
677+ rot_axes = self .get_bonded_vector_axes (
676678 a = rot_center ,
677679 b_list = [edges [0 ].position ],
678680 c = np .zeros (3 ),
@@ -749,11 +751,11 @@ def get_bonded_axes(self, system, atom, dimensions: np.ndarray):
749751
750752 # case1
751753 if len (heavy_bonded ) == 0 :
752- custom_axes , custom_moment_of_inertia = self .get_vanilla_axes (ua_all )
754+ custom_axes , custom_moment_of_inertia = self .get_molecule_axes (ua_all )
753755
754756 # case2
755757 if len (heavy_bonded ) == 1 and len (light_bonded ) == 0 :
756- custom_axes = self .get_custom_axes (
758+ custom_axes = self .get_bonded_vector_axes (
757759 a = atom .position ,
758760 b_list = [heavy_bonded [0 ].position ],
759761 c = np .zeros (3 ),
@@ -762,7 +764,7 @@ def get_bonded_axes(self, system, atom, dimensions: np.ndarray):
762764
763765 # case3
764766 if len (heavy_bonded ) == 1 and len (light_bonded ) >= 1 :
765- custom_axes = self .get_custom_axes (
767+ custom_axes = self .get_bonded_vector_axes (
766768 a = atom .position ,
767769 b_list = [heavy_bonded [0 ].position ],
768770 c = light_bonded [0 ].position ,
@@ -772,7 +774,7 @@ def get_bonded_axes(self, system, atom, dimensions: np.ndarray):
772774 # case4 (not used in original 2019 code; case5 used instead)
773775 # case5
774776 if len (heavy_bonded ) >= 2 :
775- custom_axes = self .get_custom_axes (
777+ custom_axes = self .get_bonded_vector_axes (
776778 a = atom .position ,
777779 b_list = heavy_bonded .positions ,
778780 c = heavy_bonded [1 ].position ,
@@ -812,14 +814,15 @@ def find_bonded_atoms(self, atom_idx: int, system):
812814 bonded_H_atoms = bonded_atoms .select_atoms ("mass 1 to 1.1" )
813815 return bonded_heavy_atoms , bonded_H_atoms
814816
815- def get_vanilla_axes (self , molecule ):
816- """Get principal axes and sorted principal moments (vanilla method) .
817+ def get_molecule_axes (self , molecule ):
818+ """Get principal axes and sorted principal moments.
817819
818- Compute the principal axes and moments of inertia for a molecule using
819- MDAnalysis built-in functionality .
820+ Compute the principal axes and moments of inertia for a molecule from its
821+ moment of inertia tensor .
820822
821- The original description is preserved:
822- - The molecule is made whole to ensure correct handling of PBC.
823+ - The molecule is made whole to ensure correct handling of PBC. The tensor
824+ is taken after that, so it does not depend on where the molecule sits
825+ relative to the periodic boundary.
823826 - The moments are obtained by diagonalising the moment of inertia tensor.
824827 - Eigenvalues are returned sorted from largest to smallest magnitude.
825828
@@ -832,17 +835,10 @@ def get_vanilla_axes(self, molecule):
832835 - principal_axes: (3, 3) axes.
833836 - moment_of_inertia: (3,) moments sorted descending by absolute value.
834837 """
835- moment_of_inertia_tensor = molecule .moment_of_inertia (unwrap = True )
836838 make_whole (molecule .atoms )
837- principal_axes = molecule .principal_axes ()
838-
839- eigenvalues , _ = np .linalg .eig (moment_of_inertia_tensor )
840- order = np .argsort (np .abs (eigenvalues ))[::- 1 ]
841- moment_of_inertia = eigenvalues [order ]
839+ return self .get_principal_axes_from_tensor (molecule .moment_of_inertia ())
842840
843- return principal_axes , moment_of_inertia
844-
845- def get_custom_axes (
841+ def get_bonded_vector_axes (
846842 self ,
847843 a : np .ndarray ,
848844 b_list : Sequence [np .ndarray ],
@@ -1074,44 +1070,72 @@ def get_moment_of_inertia_tensor(
10741070
10751071 return moment_of_inertia_tensor
10761072
1077- def get_custom_principal_axes (
1078- self , moment_of_inertia_tensor : np .ndarray
1073+ def get_principal_axes_from_tensor (
1074+ self ,
1075+ moment_of_inertia_tensor : np .ndarray ,
1076+ degeneracy_rtol : float = 100 * np .finfo (np .float32 ).eps ,
1077+ reference_vector : Sequence [float ] = (1.0 , 2.0 , 3.0 ),
10791078 ) -> tuple [np .ndarray , np .ndarray ]:
1080- """Compute principal axes and moments from a custom MOI tensor.
1079+ """Compute the principal axes and moments of a moment of inertia tensor.
10811080
1082- Principal axes and centre of axes from the ordered eigenvalues and
1083- eigenvectors of a moment of inertia tensor. This function allows for a
1084- custom moment of inertia tensor to be used, which isn't possible with the
1085- built-in MDAnalysis principal_axes() function.
1081+ The eigenvectors of a symmetric tensor are only defined up to a sign, and,
1082+ for equal moments, up to a rotation within the degenerate eigenspace. The
1083+ axes are returned in a canonical form, so that a given tensor always
1084+ produces the same axes. The convention is:
10861085
1087- Original behaviour preserved:
1088-
1089- - Eigenvalues are sorted by descending absolute magnitude.
1090- - Eigenvectors are transposed so axes are returned as rows.
1091- - Z axis is flipped to enforce the same handedness convention as the
1092- original implementation.
1086+ - Moments are sorted by descending absolute value.
1087+ - Within a degenerate eigenspace (moments equal to within
1088+ ``degeneracy_rtol`` of the largest), the basis is the eigenvectors of a
1089+ reference tensor built from ``reference_vector``, projected onto that
1090+ eigenspace.
1091+ - The first two axes are signed to have a positive projection onto
1092+ ``reference_vector``, and the third is their cross product.
10931093
10941094 Args:
1095- moment_of_inertia_tensor: (3, 3) custom inertia tensor.
1095+ moment_of_inertia_tensor: (3, 3) symmetric inertia tensor.
1096+ degeneracy_rtol: Relative tolerance, with respect to the largest
1097+ moment, for treating moments as degenerate. The default is a
1098+ multiple of the float32 precision of the coordinates.
1099+ reference_vector: Fixed direction used to choose axis signs and the
1100+ basis within a degenerate eigenspace. Its components must be
1101+ distinct, so that no symmetric or axis-aligned direction is
1102+ perpendicular to it or ties on its components.
10961103
10971104 Returns:
10981105 Tuple[np.ndarray, np.ndarray]:
10991106 - principal_axes: (3, 3) principal axes (rows).
1100- - moment_of_inertia : (3,) principal moments.
1107+ - principal_moments : (3,) moments sorted by descending magnitude .
11011108 """
1102- eigenvalues , eigenvectors = np .linalg .eig (moment_of_inertia_tensor )
1103- order = np .abs (eigenvalues ).argsort ()[::- 1 ] # descending order
1104- transposed = np .transpose (eigenvectors ) # columns -> rows
1105- moment_of_inertia = eigenvalues [order ]
1106- principal_axes = transposed [order ]
1107-
1108- # point z axis in correct direction, as per original code
1109- cross_xy = np .cross (principal_axes [0 ], principal_axes [1 ])
1110- dot_z = float (np .dot (cross_xy , principal_axes [2 ]))
1111- if dot_z < 0 :
1112- principal_axes [2 ] *= - 1
1113-
1114- return principal_axes , moment_of_inertia
1109+ principal_moments , eigenvectors = np .linalg .eigh (moment_of_inertia_tensor )
1110+ order = np .argsort (np .abs (principal_moments ))[::- 1 ]
1111+ principal_moments = principal_moments [order ]
1112+ eigenvectors = eigenvectors [:, order ]
1113+
1114+ reference = np .asarray (reference_vector , dtype = float )
1115+ gaps = np .abs (np .diff (np .abs (principal_moments )))
1116+ boundaries = (
1117+ np .flatnonzero (gaps > degeneracy_rtol * np .abs (principal_moments [0 ])) + 1
1118+ )
1119+ for subspace in np .split (np .arange (3 ), boundaries ):
1120+ if len (subspace ) > 1 :
1121+ basis = eigenvectors [:, subspace ]
1122+ _ , rotation = np .linalg .eigh (basis .T @ np .diag (reference ) @ basis )
1123+ eigenvectors [:, subspace ] = basis @ rotation
1124+
1125+ for axis in (0 , 1 ):
1126+ if eigenvectors [:, axis ] @ reference < 0 :
1127+ eigenvectors [:, axis ] *= - 1
1128+
1129+ z_axis = np .cross (eigenvectors [:, 0 ], eigenvectors [:, 1 ])
1130+ principal_axes = np .array ([eigenvectors [:, 0 ], eigenvectors [:, 1 ], z_axis ])
1131+ return principal_axes , principal_moments
1132+
1133+ def get_principal_axes_from_group (self , group ) -> np .ndarray :
1134+ """Return the canonical principal axes (rows) of an atom group."""
1135+ principal_axes , _ = self .get_principal_axes_from_tensor (
1136+ group .atoms .moment_of_inertia ()
1137+ )
1138+ return principal_axes
11151139
11161140 def get_UA_masses (self , molecule ) -> list [float ]:
11171141 """Return united-atom (UA) masses for a molecule.
0 commit comments