@@ -73,17 +73,24 @@ def get_residue_axes(
7373 (previous/next in sequence) using MDAnalysis bonded selections.
7474 - If there are *no* bonds to other residues:
7575 * Use a custom principal axes, from a moment-of-inertia (MOI) tensor
76- that uses positions of heavy atoms only, but including masses of
76+ that uses positions of heavy atoms only, but includes masses of
7777 heavy atom + bonded hydrogens.
7878 * Set translational axes equal to rotational axes (as per the original
7979 code convention).
80- - If bonded to other residues:
80+
81+ - If bonded to only one other residue:
82+ * Translational axes are principal axes of data_container.
83+ * Find edge heavy atom (i.e. heavy atoms bonded to neighbour residue).
84+ Compute rotation centre and axes as in get_terminal_axes.
85+ Compute custom MOI, using heavy atom positions and
86+ heavy atom + hydrogen masses.
87+
88+ - If bonded to at least two other residues:
8189 * Translational axes are principal axes of data_container.
82- * Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues)
83- and find the shortest chain between them: the backbone. Edge
84- atoms + backbone COM are used to determine residue rotational axes.
85- (see get_residue_custom_axes).Compute a custom MOI, using heavy atom
86- positions and heavy atom + hydrogen masses.
90+ * Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
91+ Compute rotation centre and axes as in get_non_terminal_axes.
92+ Compute a custom MOI, using heavy atom positions and
93+ heavy atom + hydrogen masses.
8794
8895 Args:
8996 data_container (MDAnalysis.Universe or AtomGroup):
@@ -143,33 +150,19 @@ def get_residue_axes(
143150 else :
144151 make_whole (data_container .atoms )
145152 trans_axes = data_container .atoms .principal_axes ()
146-
147153 if len (edge_atom_set ) == 1 :
148- if index == 0 :
149- # first residue: use first heavy atom
150- edges = [residue .atoms [0 ], edge_atom_set [0 ]]
151- backbone = self .get_chain (
152- residue , residue .atoms [0 ], edge_atom_set [0 ]
153- )
154- else :
155- # last residue: last heavy atom
156- last_index = len (uas ) - 1
157- last = None
158- if last_index > 0 and last is None :
159- heavy_atom = uas [last_index ]
160- last = heavy_atom
161- edges = [edge_atom_set [0 ], last ]
162-
163- backbone = self .get_chain (residue , edge_atom_set [0 ], last )
154+ edge_atom = edge_atom_set [0 ]
155+ rot_center , rot_axes = self .get_terminal_axes (
156+ residue = residue ,
157+ edge = edge_atom ,
158+ dimensions = data_container .dimensions [:3 ],
159+ )
164160 else :
165- edges = [edge_atom_set [0 ], edge_atom_set [1 ]]
166- backbone = self .get_chain (residue , edge_atom_set [0 ], edge_atom_set [1 ])
167- backbone_center = np .zeros (3 )
168- for heavy_atom in backbone :
169- backbone_center += heavy_atom .position
170- backbone_center = backbone_center / len (backbone )
171- rot_center , rot_axes = self .get_residue_custom_axes (edges , backbone_center )
172-
161+ rot_center , rot_axes = self .get_non_terminal_axes (
162+ residue = residue ,
163+ edges = edge_atom_set ,
164+ dimensions = data_container .dimensions [:3 ],
165+ )
173166 moment_of_inertia = self .get_custom_residue_moment_of_inertia (
174167 center_of_mass = rot_center ,
175168 positions = uas .positions ,
@@ -247,18 +240,17 @@ def get_UA_axes(self, data_container, index: int, res_position):
247240 Use the same approach as residue level rotational.
248241 Identify residue of interest and neighbours, then select
249242 edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
250- If there are no bonds to neighbouring residues, use residue
251- .principal axes Otherwise, find the shortest chain between edge
252- residues: the backbone. Edge atoms + backbone COM are used to
253- determine UA translational axes (see get_residue_custom_axes)
243+ - If there are *no* bonds to other residues, use a custom principal axes
244+ from a moment-of-inertia (MOI) tensor that uses positions of heavy atoms
245+ only, but includes masses of heavy atom + bonded hydrogens.
246+ - If bonded to only one other residue, see get_terminal_axes.
247+ - If bonded to at least two other residues, see get_non_terminal_axes.
254248
255249 - Rotational axes:
256250 Identify heavy atoms in the residue/molecule of interest and choose
257251 the `index`-th heavy atom (where index corresponds to the bead index).
258252 Use bonded topology around that heavy atom to determine UA rotational
259- axes (see :meth:`get_bonded_axes`).
260- Compute a custom MOI tensor using heavy-atom coordinates but UA masses
261- (heavy + bonded H masses), then compute the principal axes from it.
253+ axes (see :meth:`get_bonded_axes`). Compute a custom MOI tensor.
262254
263255 Args:
264256 data_container (MDAnalysis.Universe or AtomGroup):
@@ -290,71 +282,51 @@ def get_UA_axes(self, data_container, index: int, res_position):
290282 residue = data_container
291283 trans_center = data_container .atoms .center_of_mass (unwrap = True )
292284 trans_axes = data_container .atoms .principal_axes ()
293- residue_heavy_atoms = heavy_atoms
294285 else :
295286 # residue of interest has at least one neighbour
296- if res_position == - 1 :
297- residue = data_container .residues [0 ]
298- resindex = residue .resindex
299- resindex_next = resindex + 1
300-
301- second_edge = data_container .select_atoms (
302- f"resindex { resindex } and bonded resindex { resindex_next } "
287+ if res_position == - 1 or res_position == 1 :
288+ # look at a terminal residue
289+ if res_position == - 1 :
290+ # first residue
291+ residue = data_container .residues [0 ]
292+ resindex = residue .resindex
293+ resindex_next = resindex + 1
294+ edge_atom_set = data_container .select_atoms (
295+ f"resindex { resindex } and bonded resindex { resindex_next } "
296+ )
297+ else :
298+ # last residue
299+ residue = data_container .residues [1 ]
300+ resindex = residue .resindex
301+ resindex_prev = resindex - 1
302+ edge_atom_set = data_container .select_atoms (
303+ f"resindex { resindex } and bonded resindex { resindex_prev } "
304+ )
305+ edge_atom = edge_atom_set [0 ]
306+ trans_center , trans_axes = self .get_terminal_axes (
307+ residue = residue ,
308+ edge = edge_atom ,
309+ dimensions = data_container .dimensions [:3 ],
303310 )
304-
305- edges = [residue .atoms [0 ], second_edge [0 ]]
306- backbone = self .get_chain (
307- residue , residue .atoms [0 ], second_edge .atoms [0 ]
308- )
309-
310- elif res_position == 0 :
311+ else :
311312 # between 2 residues
312313 residue = data_container .residues [1 ]
313314 resindex = residue .resindex
314315 resindex_next = resindex + 1
315316 resindex_prev = resindex - 1
316-
317- edge_set = data_container .select_atoms (
317+ edge_atom_set = data_container .select_atoms (
318318 f"resindex { resindex } and "
319319 f"(bonded resindex { resindex_prev } or "
320320 f"resindex { resindex_next } )"
321321 )
322-
323- edges = [edge_set [0 ], edge_set [1 ]]
324- backbone = self .get_chain (residue , edge_set [0 ], edge_set [1 ])
325-
326- else :
327- # last resid
328- # always resindex 1 in data_container
329- residue = data_container .residues [1 ]
330- resindex = residue .resindex
331- resindex_prev = resindex - 1
332- first_edge = data_container .select_atoms (
333- f"resindex { resindex } and bonded resindex { resindex_prev } "
322+ trans_center , trans_axes = self .get_non_terminal_axes (
323+ residue = residue ,
324+ edges = edge_atom_set ,
325+ dimensions = data_container .dimensions [:3 ],
334326 )
335-
336- last_index = len (heavy_atoms ) - 1
337- last = None
338- # look for last heavy atom
339- # with only one bond to another
340- if last_index > 0 and last is None :
341- heavy_atom = heavy_atoms [last_index ]
342- last = heavy_atom
343-
344- edges = [first_edge .atoms [0 ], last ]
345- backbone = self .get_chain (residue , first_edge .atoms [0 ], last )
346-
347- backbone_center = np .zeros (3 )
348- for heavy_atom in backbone :
349- backbone_center += heavy_atom .position
350- backbone_center = backbone_center / len (backbone )
351-
352- trans_center , trans_axes = self .get_residue_custom_axes (
353- edges , backbone_center
354- )
355- residue_heavy_atoms = residue .atoms .select_atoms ("mass 2 to 999" )
356-
357327 # look for heavy atoms in residue of interest
328+ residue_heavy_atoms = residue .atoms .select_atoms ("mass 2 to 999" )
329+
358330 heavy_atom_indices = []
359331 for atom in residue_heavy_atoms :
360332 heavy_atom_indices .append (atom .index )
@@ -579,10 +551,14 @@ def get_residue_custom_axes(self, edges, center):
579551 rot_center: (3,) rotation centre,
580552 lies on the E1-E2 vector
581553 rot_axes: (3,3) rotation axes of residue
554+
555+ Raises:
556+ ValueError: If axes cannot be normalized due to degeneracy.
557+
582558 """
583- first_edge_centre_of_geometry_vector = center - edges [0 ]. position
559+ first_edge_centre_of_geometry_vector = center - edges [0 ]
584560 # look for projection of E1-O onto E1-E2 (E1-C)
585- first_edge_second_edge_vector = edges [1 ]. position - edges [0 ]. position
561+ first_edge_second_edge_vector = edges [1 ] - edges [0 ]
586562 first_edge_origin_vector = (
587563 np .dot (first_edge_second_edge_vector , first_edge_centre_of_geometry_vector )
588564 / (np .linalg .norm (first_edge_second_edge_vector ) ** 2 )
@@ -594,15 +570,119 @@ def get_residue_custom_axes(self, edges, center):
594570 )
595571 y_axis = origin_centre_of_geometry_vector
596572 z_axis = np .cross (x_axis , y_axis )
597- x_axis /= np .linalg .norm (x_axis )
598- y_axis /= np .linalg .norm (y_axis )
599- z_axis /= np .linalg .norm (z_axis )
600- rot_axes = np .array ([x_axis , y_axis , z_axis ])
601- rot_center = first_edge_origin_vector + edges [0 ].position
573+ unscaled_rot_axes = np .array ((x_axis , y_axis , z_axis ), dtype = float )
574+ mod = np .sqrt (np .sum (unscaled_rot_axes ** 2 , axis = 1 ))
575+ if np .any (np .isclose (mod , 0.0 )):
576+ raise ValueError ("Degenerate custom axes: cannot normalize (zero norm)." )
577+ rot_axes = unscaled_rot_axes / mod [:, np .newaxis ]
578+ rot_center = first_edge_origin_vector + edges [0 ]
579+ return rot_center , rot_axes
580+
581+ def get_terminal_axes (self , residue , edge , dimensions ):
582+ """
583+ Compute rotation axes at the residue level/translation axes at the UA level
584+ for the terminal residues in a polymer, given the edge atom
585+ (i.e. atom bonded to neighbour residue) and residue of interest.
586+ Find all heavy atoms bonded to edge heavy atom and compute
587+ their average position. Find all other heavy atoms in residue
588+ and compute their average position. The three points are now used to
589+ obtain determine residue rotational axes. (see get_residue_custom_axes)
590+ If there are only two heavy atoms in the residue/all heavy atoms are bonded
591+ to edge atom, x-axis is set along the vector between the
592+ edge atom and average position of bonded atoms, y-axis is arbitrary
593+ and z-axis is paralel to the two. This is the same as case 2 in get_bonded_axes.
594+ If there no heavy atoms bonded to the edge atom (i.e. the edge atom is the only
595+ heavy atom in the residue), centre is set on edge atom and axes are principal
596+ axes.
597+
598+ Args:
599+ residue: MDAnalysis AtomGroup
600+ edge: MDAnalysis atom
601+ dimensions: (3,) dimensions of the simulation box
602+
603+ Returns:
604+ rot_center: (3,) rotation centre,
605+ rot_axes: (3,3) rotation axes of residue
606+ """
607+ heavy_atoms = residue .atoms .select_atoms ("mass 2 to 999" )
608+ bonded_atoms = residue .atoms .select_atoms (
609+ f"(mass 2 to 999) and bonded index { edge .index } "
610+ )
611+ if len (bonded_atoms ) == 0 :
612+ # there is only one heavy atom in the residue
613+ rot_center = edge .position
614+ rot_axes = residue .atoms .principal_axes ()
615+ else :
616+ average_bonded = np .zeros (3 )
617+ for bonded_atom in bonded_atoms :
618+ average_bonded += bonded_atom .position
619+ average_bonded /= len (bonded_atoms )
620+ # find the average position of all other heavy atoms in residue
621+ other_atoms = []
622+ for atom in heavy_atoms :
623+ if atom != edge and atom not in bonded_atoms :
624+ other_atoms .append (atom )
625+ if len (other_atoms ) > 0 :
626+ average_other_atoms = np .zeros (3 )
627+ for atom in other_atoms :
628+ average_other_atoms += atom .position
629+ average_other_atoms /= len (other_atoms )
630+ rot_center , rot_axes = self .get_residue_custom_axes (
631+ [edge .position , average_other_atoms ], average_bonded
632+ )
633+ else :
634+ rot_center = edge .position
635+ rot_axes = self .get_custom_axes (
636+ a = edge .position ,
637+ b_list = [average_bonded ],
638+ c = np .zeros (3 ),
639+ dimensions = dimensions ,
640+ )
641+ return rot_center , rot_axes
642+
643+ def get_non_terminal_axes (self , residue , edges , dimensions ):
644+ """
645+ Compute rotation axes at the residue level/ translation axes at
646+ the UA level for the non-terminal residues in a linear polymer, given the
647+ edge atoms (i.e. heavy atoms bonded to neighbour residues) and
648+ residue of interest. Find the shortest chain between edge atoms: the backbone.
649+ Edges + backbone average position determine the residue rotational axes.
650+ (see get_residue_custom_axes). If the two edge heavy atoms
651+ are bonded to each other (i.e. there is no backbone), x-axis is set
652+ along the vector between the edge atom and average position of bonded
653+ atoms, y-axis is arbitrary and z-axis is paralel to the two. This is the
654+ same as case 2 in get_bonded_axes.
655+ Args:
656+ residue: MDAnalysis AtomGroup
657+ edges: MDAnalysis AtomGroup
658+ dimensions: (3,) dimensions of the simulation box
659+
660+ Returns:
661+ rot_center: (3,) rotation centre,
662+ rot_axes: (3,3) rotation axes of residue
663+ """
664+ backbone = self .get_chain (residue , edges [0 ], edges [1 ])
665+ backbone_center = np .zeros (3 )
666+ if len (backbone ) > 0 :
667+ for heavy_atom in backbone :
668+ backbone_center += heavy_atom .position
669+ backbone_center /= len (backbone )
670+ rot_center , rot_axes = self .get_residue_custom_axes (
671+ edges .positions , backbone_center
672+ )
673+ else :
674+ rot_center = (edges [0 ].position + edges [1 ].position ) / 2
675+ rot_axes = self .get_custom_axes (
676+ a = rot_center ,
677+ b_list = [edges [0 ].position ],
678+ c = np .zeros (3 ),
679+ dimensions = dimensions ,
680+ )
602681 return rot_center , rot_axes
603682
604683 def get_bonded_axes (self , system , atom , dimensions : np .ndarray ):
605- r"""Compute UA rotational axes from bonded topology around a heavy atom.
684+ """
685+ Compute UA rotational axes from bonded topology around a heavy atom.
606686
607687 For a given heavy atom, use its bonded atoms to get the axes for rotating
608688 forces around. Few cases for choosing united atom axes, which are dependent
0 commit comments