@@ -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 = self .get_principal_axes (data_container .atoms )
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 = self .get_principal_axes (mol .atoms )
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 = self .get_principal_axes (data_container .atoms )
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 = self .get_principal_axes (residue_atoms )
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 = self .get_principal_axes (residue .atoms )
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,12 @@ 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
818820 Compute the principal axes and moments of inertia for a molecule from its
819821 moment of inertia tensor.
820822
821- The original description is preserved:
822-
823823 - The molecule is made whole to ensure correct handling of PBC. The tensor
824824 is taken after that, so it does not depend on where the molecule sits
825825 relative to the periodic boundary.
@@ -836,10 +836,9 @@ def get_vanilla_axes(self, molecule):
836836 - moment_of_inertia: (3,) moments sorted descending by absolute value.
837837 """
838838 make_whole (molecule .atoms )
839- moment_of_inertia_tensor = molecule .moment_of_inertia ()
840- return self .get_reproducible_axes (moment_of_inertia_tensor )
839+ return self .get_principal_axes_from_tensor (molecule .moment_of_inertia ())
841840
842- def get_custom_axes (
841+ def get_bonded_vector_axes (
843842 self ,
844843 a : np .ndarray ,
845844 b_list : Sequence [np .ndarray ],
@@ -1071,96 +1070,75 @@ def get_moment_of_inertia_tensor(
10711070
10721071 return moment_of_inertia_tensor
10731072
1074- def get_custom_principal_axes (
1075- self , moment_of_inertia_tensor : np .ndarray
1076- ) -> tuple [np .ndarray , np .ndarray ]:
1077- """Compute principal axes and moments from a custom MOI tensor.
1078-
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`.
1083-
1084- Args:
1085- moment_of_inertia_tensor: (3, 3) custom inertia tensor.
1086-
1087- Returns:
1088- Tuple[np.ndarray, np.ndarray]:
1089- - principal_axes: (3, 3) principal axes (rows).
1090- - moment_of_inertia: (3,) principal moments sorted descending by
1091- absolute value.
1092- """
1093- return self .get_reproducible_axes (moment_of_inertia_tensor )
1094-
1095- def get_principal_axes (self , group , wrap : bool = False ) -> np .ndarray :
1096- """Compute the principal axes of an atom group from its moment of inertia.
1097-
1098- Args:
1099- group: Atom group (or anything with ``.atoms``).
1100- wrap: Whether to wrap atoms into the primary unit cell before computing
1101- the moment of inertia.
1102-
1103- Returns:
1104- np.ndarray: (3, 3) principal axes (rows), as given by
1105- :meth:`get_reproducible_axes`.
1106- """
1107- tensor = group .atoms .moment_of_inertia (wrap = wrap )
1108- principal_axes , _ = self .get_reproducible_axes (tensor )
1109- return principal_axes
1110-
1111- def get_reproducible_axes (
1112- self , tensor : np .ndarray , rtol : float = 1e-5
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 ),
11131078 ) -> tuple [np .ndarray , np .ndarray ]:
1114- """Diagonalise a symmetric tensor into reproducible principal axes .
1079+ """Compute reproducible principal axes and moments from a MOI tensor .
11151080
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 :
1081+ ``np.linalg.eigh`` fixes neither the sign of each eigenvector nor the basis
1082+ within a degenerate eigenspace, and both can differ between LAPACK builds.
1083+ The axes define the frame covariances are averaged in, so this changes the
1084+ entropy. Both are therefore fixed here :
11201085
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.
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.
11291093
1130- The result is reproducible but is a function of the lab-frame orientation,
1131- so it is not rotation covariant.
1094+ The reference is defined in the lab frame, so the result is reproducible
1095+ but not rotation covariant.
11321096
11331097 Args:
1134- tensor: (3, 3) symmetric tensor, e.g. a moment of inertia tensor.
1135- rtol: Relative tolerance for treating two moments as equal.
1098+ moment_of_inertia_tensor: (3, 3) symmetric inertia tensor.
1099+ degeneracy_rtol: Relative tolerance, with respect to the largest
1100+ moment, for treating moments as degenerate. The default is a
1101+ multiple of the float32 precision of the coordinates.
1102+ reference_vector: Fixed direction used to choose axis signs and the
1103+ basis within a degenerate eigenspace. Its components must be
1104+ distinct, so that no symmetric or axis-aligned direction is
1105+ perpendicular to it or ties on its components.
11361106
11371107 Returns:
11381108 Tuple[np.ndarray, np.ndarray]:
11391109 - principal_axes: (3, 3) principal axes (rows).
1140- - moments: (3,) principal moments sorted descending by absolute
1141- value.
1110+ - principal_moments: (3,) moments sorted by descending magnitude.
11421111 """
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
1112+ principal_moments , eigenvectors = np .linalg .eigh (moment_of_inertia_tensor )
1113+ order = np .argsort (np .abs (principal_moments ))[::- 1 ]
1114+ principal_moments = principal_moments [order ]
1115+ eigenvectors = eigenvectors [:, order ]
1116+
1117+ reference = np .asarray (reference_vector , dtype = float )
1118+ gaps = np .abs (np .diff (np .abs (principal_moments )))
1119+ boundaries = (
1120+ np .flatnonzero (gaps > degeneracy_rtol * np .abs (principal_moments [0 ])) + 1
1121+ )
1122+ for subspace in np .split (np .arange (3 ), boundaries ):
1123+ if len (subspace ) > 1 :
1124+ basis = eigenvectors [:, subspace ]
1125+ _ , rotation = np .linalg .eigh (basis .T @ np .diag (reference ) @ basis )
1126+ eigenvectors [:, subspace ] = basis @ rotation
1127+
1128+ for axis in (0 , 1 ):
1129+ if eigenvectors [:, axis ] @ reference < 0 :
1130+ eigenvectors [:, axis ] *= - 1
1131+
1132+ z_axis = np .cross (eigenvectors [:, 0 ], eigenvectors [:, 1 ])
1133+ principal_axes = np .array ([eigenvectors [:, 0 ], eigenvectors [:, 1 ], z_axis ])
1134+ return principal_axes , principal_moments
1135+
1136+ def get_principal_axes_from_group (self , group ) -> np .ndarray :
1137+ """Return the reproducible principal axes (rows) of an atom group."""
1138+ principal_axes , _ = self .get_principal_axes_from_tensor (
1139+ group .atoms .moment_of_inertia ()
1140+ )
1141+ return principal_axes
11641142
11651143 def get_UA_masses (self , molecule ) -> list [float ]:
11661144 """Return united-atom (UA) masses for a molecule.
0 commit comments