Skip to content

Commit b9f7fad

Browse files
authored
Merge pull request #440 from CCPBioSim/439-general-use-nplinalgeigh
Use `eigh` for principal axes and fix moments near the periodic boundary
2 parents 7aca8ac + f6c724a commit b9f7fad

67 files changed

Lines changed: 756 additions & 568 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CodeEntropy/levels/axes.py‎

Lines changed: 86 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

‎CodeEntropy/levels/nodes/covariance.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -506,8 +506,8 @@ def _build_ua_vectors(
506506
# principal axes
507507
make_whole(residue.atoms)
508508
make_whole(bead)
509-
trans_axes = residue.atoms.principal_axes()
510-
rot_axes, moi = axes_manager.get_vanilla_axes(bead)
509+
trans_axes = axes_manager.get_principal_axes_from_group(residue.atoms)
510+
rot_axes, moi = axes_manager.get_molecule_axes(bead)
511511
center = bead.center_of_mass(unwrap=True)
512512

513513
force_vecs.append(
@@ -658,8 +658,8 @@ def _get_residue_axes(
658658
make_whole(mol.atoms)
659659
make_whole(bead)
660660

661-
trans_axes = mol.atoms.principal_axes()
662-
rot_axes, moi = axes_manager.get_vanilla_axes(bead)
661+
trans_axes = axes_manager.get_principal_axes_from_group(mol.atoms)
662+
rot_axes, moi = axes_manager.get_molecule_axes(bead)
663663
center = bead.center_of_mass(unwrap=True)
664664
return (
665665
np.asarray(trans_axes),
@@ -688,8 +688,8 @@ def _get_polymer_axes(
688688
make_whole(mol.atoms)
689689
make_whole(bead)
690690

691-
trans_axes = mol.atoms.principal_axes()
692-
rot_axes, moi = axes_manager.get_vanilla_axes(bead)
691+
trans_axes = axes_manager.get_principal_axes_from_group(mol.atoms)
692+
rot_axes, moi = axes_manager.get_molecule_axes(bead)
693693
center = bead.center_of_mass(unwrap=True)
694694

695695
return (

‎tests/regression/baselines/benzaldehyde/axes_off.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
"groups": {
33
"0": {
44
"components": {
5-
"united_atom:Transvibrational": 0.08982962903796131,
6-
"united_atom:Rovibrational": 32.16018134884085,
7-
"residue:FTmat-Transvibrational": 88.7671666695003,
8-
"residue:FTmat-Rovibrational": 61.61036267672132,
5+
"united_atom:Transvibrational": 0.06950663196232491,
6+
"united_atom:Rovibrational": 35.19655714759131,
7+
"residue:FTmat-Transvibrational": 86.6413638447789,
8+
"residue:FTmat-Rovibrational": 62.24303760058523,
99
"united_atom:Conformational": 0.0,
1010
"residue:Conformational": 0.0,
1111
"residue:Orientational": 20.481571492615355
1212
},
13-
"total": 203.1091118167158
13+
"total": 204.6320367175331
1414
}
1515
}
1616
}

‎tests/regression/baselines/benzaldehyde/combined_forcetorque_false.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
"groups": {
33
"0": {
44
"components": {
5-
"united_atom:Transvibrational": 0.07119323721997475,
6-
"united_atom:Rovibrational": 49.68669738152346,
7-
"residue:Transvibrational": 69.48692941204929,
8-
"residue:Rovibrational": 68.46147102540942,
5+
"united_atom:Transvibrational": 0.06817494136874551,
6+
"united_atom:Rovibrational": 49.686697381523466,
7+
"residue:Transvibrational": 69.25273989458984,
8+
"residue:Rovibrational": 68.64810133422232,
99
"united_atom:Conformational": 0.0,
1010
"residue:Conformational": 0.0,
1111
"residue:Orientational": 20.481571492615355
1212
},
13-
"total": 208.1878625488175
13+
"total": 208.13728504431973
1414
}
1515
}
1616
}

‎tests/regression/baselines/benzaldehyde/default.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
"groups": {
33
"0": {
44
"components": {
5-
"united_atom:Transvibrational": 24.88518233240474,
6-
"united_atom:Rovibrational": 27.950376507672583,
7-
"residue:FTmat-Transvibrational": 71.03412922724692,
8-
"residue:FTmat-Rovibrational": 59.44169664956799,
5+
"united_atom:Transvibrational": 24.921822103314035,
6+
"united_atom:Rovibrational": 27.950376507672594,
7+
"residue:FTmat-Transvibrational": 70.97090266225699,
8+
"residue:FTmat-Rovibrational": 59.588669973122094,
99
"united_atom:Conformational": 7.522643899702263,
1010
"residue:Conformational": 0.0,
1111
"residue:Orientational": 59.43920971558428
1212
},
13-
"total": 250.27323833217878
13+
"total": 250.39362486165226
1414
}
1515
}
1616
}

0 commit comments

Comments
 (0)