Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8812709
tried a coordinate system for terminal residue using average of bonde…
ioanaapapa Aug 17, 2026
578b596
updated docstring
ioanaapapa Aug 25, 2026
391f32a
updated docstring
ioanaapapa Aug 25, 2026
8934242
updated docstring
ioanaapapa Aug 25, 2026
10b9eda
updated docstring
ioanaapapa Aug 25, 2026
4ab4534
updated docstring
ioanaapapa Aug 25, 2026
97f72a7
updated docstring
ioanaapapa Aug 25, 2026
6642547
updated docstring
ioanaapapa Aug 25, 2026
ec73995
updated docstring
ioanaapapa Aug 25, 2026
7a92916
updated docstring
ioanaapapa Aug 25, 2026
11b68d5
updated documentation to reflect terminal residue changes
ioanaapapa Aug 25, 2026
fc8a81f
Merge branch 'main' into 399-terminal-residues. Update with latest ch…
ioanaapapa Aug 25, 2026
5ff1611
update unit tests + fix atom selection bug
ioanaapapa Aug 26, 2026
648c5d0
added edge cases for two points
ioanaapapa Aug 26, 2026
e0272a8
fix centre for 2 heavy atoms situation
ioanaapapa Aug 26, 2026
127c097
added edge case unit test
ioanaapapa Aug 27, 2026
2ea2de5
unit tests for edge cases
ioanaapapa Aug 27, 2026
af6daa2
Merge branch 'main' into 399-terminal-residues before terminal residu…
ioanaapapa Aug 27, 2026
95dce27
changed sign of x axis for consistency with non-terminal residues
ioanaapapa Aug 27, 2026
c01d650
correct average_other_atoms in get_residue_axes
ioanaapapa Sep 2, 2026
1b6bf1a
introduced get_terminal_axes and get_non_terminal_axes functions call…
ioanaapapa Sep 3, 2026
263a628
updated unit tests
ioanaapapa Sep 7, 2026
38e6154
Merge branch 'main' into 399-terminal-residues
ioanaapapa Sep 7, 2026
07b11d6
fix test_get_UA_axes_raises_when_only_rot_axes_fail and remove debugg…
ioanaapapa Sep 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 146 additions & 93 deletions CodeEntropy/levels/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,23 @@ def get_residue_axes(
(previous/next in sequence) using MDAnalysis bonded selections.
- If there are *no* bonds to other residues:
* Use a custom principal axes, from a moment-of-inertia (MOI) tensor
that uses positions of heavy atoms only, but including masses of
that uses positions of heavy atoms only, but includes masses of
heavy atom + bonded hydrogens.
* Set translational axes equal to rotational axes (as per the original
code convention).
- If bonded to other residues:

- If bonded to only one other residue:
Comment thread
ioanaapapa marked this conversation as resolved.
* Translational axes are principal axes of data_container.
* Find edge heavy atom (i.e. heavy atoms bonded to neighbour residue).
Compute rotation centre and axes as in get_terminal_axes.
custom MOI, using heavy atom positions and heavy atom + hydrogen masses.

- If bonded to at least two other residues:
* Translational axes are principal axes of data_container.
* Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues)
and find the shortest chain between them: the backbone. Edge
atoms + backbone COM are used to determine residue rotational axes.
(see get_residue_custom_axes).Compute a custom MOI, using heavy atom
positions and heavy atom + hydrogen masses.
* Find edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
Compute rotation centre and axes as in get_non_terminal_axes.
Compute a custom MOI, using heavy atom positions and
heavy atom + hydrogen masses.

Args:
data_container (MDAnalysis.Universe or AtomGroup):
Expand Down Expand Up @@ -143,33 +149,13 @@ def get_residue_axes(
else:
make_whole(data_container.atoms)
trans_axes = data_container.atoms.principal_axes()

if len(edge_atom_set) == 1:
if index == 0:
# first residue: use first heavy atom
edges = [residue.atoms[0], edge_atom_set[0]]
backbone = self.get_chain(
residue, residue.atoms[0], edge_atom_set[0]
)
else:
# last residue: last heavy atom
last_index = len(uas) - 1
last = None
if last_index > 0 and last is None:
heavy_atom = uas[last_index]
last = heavy_atom
edges = [edge_atom_set[0], last]

backbone = self.get_chain(residue, edge_atom_set[0], last)
edge_atom = edge_atom_set[0]
rot_center, rot_axes = self.get_terminal_axes(residue, edge_atom)
else:
edges = [edge_atom_set[0], edge_atom_set[1]]
backbone = self.get_chain(residue, edge_atom_set[0], edge_atom_set[1])
backbone_center = np.zeros(3)
for heavy_atom in backbone:
backbone_center += heavy_atom.position
backbone_center = backbone_center / len(backbone)
rot_center, rot_axes = self.get_residue_custom_axes(edges, backbone_center)

rot_center, rot_axes = self.get_non_terminal_axes(
residue, edge_atom_set
)
moment_of_inertia = self.get_custom_residue_moment_of_inertia(
center_of_mass=rot_center,
positions=uas.positions,
Expand Down Expand Up @@ -247,18 +233,17 @@ def get_UA_axes(self, data_container, index: int, res_position):
Use the same approach as residue level rotational.
Identify residue of interest and neighbours, then select
edge heavy atoms (i.e. heavy atoms bonded to neighbour residues).
If there are no bonds to neighbouring residues, use residue
.principal axes Otherwise, find the shortest chain between edge
residues: the backbone. Edge atoms + backbone COM are used to
determine UA translational axes (see get_residue_custom_axes)
- If there are *no* bonds to other residues, use a custom principal axes
from a moment-of-inertia (MOI) tensor that uses positions of heavy atoms
only, but includes masses of heavy atom + bonded hydrogens.
- If bonded to only one other residue, see get_terminal_axes.
- If bonded to at least two other residues, see get_non_terminal_axes.

- Rotational axes:
Identify heavy atoms in the residue/molecule of interest and choose
the `index`-th heavy atom (where index corresponds to the bead index).
Use bonded topology around that heavy atom to determine UA rotational
axes (see :meth:`get_bonded_axes`).
Compute a custom MOI tensor using heavy-atom coordinates but UA masses
(heavy + bonded H masses), then compute the principal axes from it.
axes (see :meth:`get_bonded_axes`). Compute a custom MOI tensor.

Args:
data_container (MDAnalysis.Universe or AtomGroup):
Expand Down Expand Up @@ -290,71 +275,47 @@ def get_UA_axes(self, data_container, index: int, res_position):
residue = data_container
trans_center = data_container.atoms.center_of_mass(unwrap=True)
trans_axes = data_container.atoms.principal_axes()
residue_heavy_atoms = heavy_atoms
else:
# residue of interest has at least one neighbour
if res_position == -1:
residue = data_container.residues[0]
resindex = residue.resindex
resindex_next = resindex + 1

second_edge = data_container.select_atoms(
f"resindex {resindex} and bonded resindex {resindex_next}"
)

edges = [residue.atoms[0], second_edge[0]]
backbone = self.get_chain(
residue, residue.atoms[0], second_edge.atoms[0]
if res_position == -1 or res_position == 1:
# look at a terminal residue
if res_position == -1:
# first residue
residue = data_container.residues[0]
resindex = residue.resindex
resindex_next = resindex + 1
edge_atom_set = data_container.select_atoms(
f"resindex {resindex} and bonded resindex {resindex_next}"
)
else:
# last residue
residue = data_container.residues[1]
resindex = residue.resindex
resindex_prev = resindex - 1
edge_atom_set = data_container.select_atoms(
f"resindex {resindex} and bonded resindex {resindex_prev}"
)
edge_atom = edge_atom_set[0]
trans_center, trans_axes = self.get_terminal_axes(
residue, edge_atom
)

elif res_position == 0:
else:
# between 2 residues
residue = data_container.residues[1]
resindex = residue.resindex
resindex_next = resindex + 1
resindex_prev = resindex - 1

edge_set = data_container.select_atoms(
residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
edge_atom_set = data_container.select_atoms(
f"resindex {resindex} and "
f"(bonded resindex {resindex_prev} or "
f"resindex {resindex_next})"
)

edges = [edge_set[0], edge_set[1]]
backbone = self.get_chain(residue, edge_set[0], edge_set[1])

else:
# last resid
# always resindex 1 in data_container
residue = data_container.residues[1]
resindex = residue.resindex
resindex_prev = resindex - 1
first_edge = data_container.select_atoms(
f"resindex {resindex} and bonded resindex {resindex_prev}"
trans_center, trans_axes = self.get_non_terminal_axes(
residue, edge_atom_set
)

last_index = len(heavy_atoms) - 1
last = None
# look for last heavy atom
# with only one bond to another
if last_index > 0 and last is None:
heavy_atom = heavy_atoms[last_index]
last = heavy_atom

edges = [first_edge.atoms[0], last]
backbone = self.get_chain(residue, first_edge.atoms[0], last)

backbone_center = np.zeros(3)
for heavy_atom in backbone:
backbone_center += heavy_atom.position
backbone_center = backbone_center / len(backbone)

trans_center, trans_axes = self.get_residue_custom_axes(
edges, backbone_center
)
residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")

# look for heavy atoms in residue of interest
residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
heavy_atom_indices = []
for atom in residue_heavy_atoms:
heavy_atom_indices.append(atom.index)
Expand Down Expand Up @@ -580,9 +541,9 @@ def get_residue_custom_axes(self, edges, center):
lies on the E1-E2 vector
rot_axes: (3,3) rotation axes of residue
"""
first_edge_centre_of_geometry_vector = center - edges[0].position
first_edge_centre_of_geometry_vector = center - edges[0]
# look for projection of E1-O onto E1-E2 (E1-C)
first_edge_second_edge_vector = edges[1].position - edges[0].position
first_edge_second_edge_vector = edges[1] - edges[0]
first_edge_origin_vector = (
np.dot(first_edge_second_edge_vector, first_edge_centre_of_geometry_vector)
/ (np.linalg.norm(first_edge_second_edge_vector) ** 2)
Expand All @@ -598,7 +559,99 @@ def get_residue_custom_axes(self, edges, center):
y_axis /= np.linalg.norm(y_axis)
z_axis /= np.linalg.norm(z_axis)
rot_axes = np.array([x_axis, y_axis, z_axis])
rot_center = first_edge_origin_vector + edges[0].position
rot_center = first_edge_origin_vector + edges[0]
return rot_center, rot_axes

def get_terminal_axes(self, residue, edge):
"""
Compute rotation axes at the residue level/translation axes at the UA level
for the terminal residues in a polymer, given the edge atom
(i.e. atom bonded to neighbour residue) and residue of interest.
Find all heavy atoms bonded to edge heavy atom and compute
their average position. Find all other heavy atoms in residue
and compute their average position. The three points are now used to
obtain determine residue rotational axes. (see get_residue_custom_axes)
If there are only two heavy atoms in the residue/all heavy atoms are bonded
to edge atom, x-axis is set along the vector between the
edge atom and average position of bonded atoms, y-axis is arbitrary
and z-axis is paralel to the two. This is the same as case 2 in get_bonded_axes.
If there no heavy atoms bonded to the edge atom (i.e. the edge atom is the only
heavy atom in the residue), centre is set on edge atom and axes are principal
axes.

Args:
residue: MDAnalysis AtomGroup
edge: MDAnalysis atom

Returns:
rot_center: (3,) rotation centre,
rot_axes: (3,3) rotation axes of residue
"""
heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
bonded_atoms = residue.atoms.select_atoms(
f"(mass 2 to 999) and bonded index {edge.index}"
)
if len(bonded_atoms) == 0:
# there is only one heavy atom in the residue
rot_center = edge.position
rot_axes = residue.atoms.principal_axes()
else:
average_bonded = np.zeros(3)
for bonded_atom in bonded_atoms:
average_bonded += bonded_atom.position
average_bonded /= len(bonded_atoms)
# find the average position of all other heavy atoms in residue
other_atoms = []
for atom in heavy_atoms:
if atom != edge and atom not in bonded_atoms:
other_atoms.append(atom)
if len(other_atoms) > 0:
average_other_atoms = np.zeros(3)
for atom in other_atoms:
average_other_atoms += atom.position
average_other_atoms /= len(other_atoms)
rot_center, rot_axes = self.get_residue_custom_axes(
[edge.position, average_other_atoms], average_bonded
)
else:
rot_center = edge.position
rot_axes = self.get_custom_axes(
a=edge.position, b=[average_bonded], c=np.zeros(3)
)
return rot_center, rot_axes

def get_non_terminal_axes(self, residue, edges):
"""
Compute rotation axes at the residue level/ translation axes at
the UA level for the non-terminal residues in a linear polymer, given the
edge atoms (i.e. heavy atoms bonded to neighbour residues) and
residue of interest. Find the shortest chain between edge atoms: the backbone.
Edges + backbone average position determine the residue rotational axes.
(see get_residue_custom_axes). If the two edge heavy atoms
are bonded to each other (i.e. there is no backbone), x-axis is set
along the vector between the edge atom and average position of bonded
atoms, y-axis is arbitrary and z-axis is paralel to the two. This is the
same as case 2 in get_bonded_axes.
Args:
residue: MDAnalysis AtomGroup
edges: MDAnalysis AtomGroup

Returns:
rot_center: (3,) rotation centre,
rot_axes: (3,3) rotation axes of residue
"""
backbone = self.get_chain(residue, edges[0], edges[1])
backbone_center = np.zeros(3)
if len(backbone) > 0:
for heavy_atom in backbone:
backbone_center += heavy_atom.position
backbone_center /= len(backbone)
rot_center, rot_axes = self.get_residue_custom_axes(
edges.positions, backbone_center
)
else:
rot_center = (edges[0].position + edges[1].position) / 2
rot_axes = self.get_custom_axes(a=rot_center, b=[edges[0]], c=np.zeros(3))
return rot_center, rot_axes

def get_bonded_axes(self, system, atom, dimensions: np.ndarray):
Expand Down
6 changes: 4 additions & 2 deletions docs/science.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ The axes for this transformation are calculated for each bead in each time step.

For the polymer level, the translational and rotational axes are defined as the principal axes of the molecule.

For the residue level, there are two situations.
For the residue level, there are three situations.
When the residue is not bonded to any other residues, the translational and rotational axes are the principal axes of the molecule.
When the residue is part of a larger polymer, the translational axes are the principal axes of the polymer, and the rotational axes are defined from the two heavy atoms bonded to neighbour residues(E1,E2) and the average position of all other backbone atoms in the residue (C). The backbone of a residue is defined as the shortest path between the two edge atoms of the residue, i.e. the two heavy atoms bonded to neighbour residues.The centre of rotation is located at the point where the perpendicular from C meets the E1-E2 vector.
When the residue is part of a larger polymer and is not a terminus of that polymer, the translational axes are the principal axes of the polymer, and the rotational axes are defined from the two heavy atoms bonded to neighbour residues (E1,E2) and the average position of all other backbone atoms in the residue (C). The backbone of a residue is defined as the shortest path between the two edge atoms of the residue, i.e.the two heavy atoms bonded to neighbour residues.The centre of rotation (O) is located at the point where the perpendicular from C meets the E1-E2 vector.
When the residue is part of a larger polymer and is a terminus of that polymer, the translational axes are the principal axes of the polymer, and the rotational axes are defined from the heavy atom bonded to a
neighbour residue (E1), the average position of all heavy atoms bonded to E1 (C) and the average position of all other heavy atoms in the residue (E2). The centre of rotation (O) is defined the same as above forthe non-terminal residue case.

For the united atom level, the translational axes are defined as the residue rotational axes and the rotational axes are defined from the average position of the bonds to neighbouring heavy atoms.
If there are no bonds to other heavy atoms, the principal axes of the molecule are used.
Expand Down
Loading