Skip to content
Open
Show file tree
Hide file tree
Changes from 19 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: 155 additions & 84 deletions CodeEntropy/levels/axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,35 @@ 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). 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. Compute a
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.
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). 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. Compute a custom MOI, using heavy
atom positions and heavy atom + hydrogen masses.

Args:
data_container (MDAnalysis.Universe or AtomGroup):
Expand Down Expand Up @@ -145,30 +163,51 @@ def get_residue_axes(
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]
edge_atom = edge_atom_set[0]
bonded_atoms = residue.select_atoms(
f"(mass 2 to 999) and bonded index {edge_atom.index}"
)
# find the average position of heavy atoms bonded to edge atom
if len(bonded_atoms) > 0:
average_bonded_atom = np.zeros(3)
for bonded_atom in bonded_atoms:
average_bonded_atom += bonded_atom.position
average_bonded_atom /= len(bonded_atoms)
# find the average position of all other heavy atoms in residue
other_atoms = []
for atom in uas:
if atom != edge_atom 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(average_other_atoms)
Comment thread
ioanaapapa marked this conversation as resolved.
Outdated
rot_center, rot_axes = self.get_residue_custom_axes(
[edge_atom.position, average_other_atoms], average_bonded_atom
Comment thread
ioanaapapa marked this conversation as resolved.
Outdated
)
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)
rot_center = edge_atom.position
rot_axes = self.get_custom_axes(
a=edge_atom.position, b=[average_bonded_atom], c=np.zeros(3)
)

else:
edges = [edge_atom_set[0], edge_atom_set[1]]
edges = [edge_atom_set[0].position, edge_atom_set[1].position]
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)
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, backbone_center
)
else:
rot_center = (edges[0] + edges[1]) / 2
rot_axes = self.get_custom_axes(
a=rot_center, b=[edges[0]], c=np.zeros(3)
)

moment_of_inertia = self.get_custom_residue_moment_of_inertia(
center_of_mass=rot_center,
Expand Down Expand Up @@ -247,18 +286,35 @@ 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 and 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 bonded to only one other residue, find edge heavy atom
(i.e. heavy atom bonded to neighbour residue). 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 bonded to at least two other residues, 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).
- If bonded to at least two other residues and 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.

- 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 @@ -293,66 +349,81 @@ def get_UA_axes(self, data_container, index: int, res_position):
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}"
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]
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_atom.index}"
)

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

elif res_position == 0:
# find the average position of heavy atoms bonded to edge atom
if len(bonded_atoms) > 0:
average_bonded_atom = np.zeros(3)
for atom in bonded_atoms:
average_bonded_atom += atom.position
average_bonded_atom /= len(bonded_atoms)
# find the average position of all other heavy atoms in residue
other_atoms = []
for atom in residue_heavy_atoms:
if atom != edge_atom and atom not in bonded_atoms:
other_atoms.append(atom)
average_other_atoms = np.zeros(3)
if len(other_atoms) > 0:
for atom in other_atoms:
average_other_atoms += atom.position
average_other_atoms /= len(other_atoms)
trans_center, trans_axes = self.get_residue_custom_axes(
[edge_atom.position, average_other_atoms],
average_bonded_atom,
)
else:
trans_center = edge_atom.position
trans_axes = self.get_custom_axes(
a=edge_atom.position, b=[average_bonded_atom], c=np.zeros(3)
)
else:
# between 2 residues
residue = data_container.residues[1]
resindex = residue.resindex
resindex_next = resindex + 1
resindex_prev = resindex - 1

residue_heavy_atoms = residue.atoms.select_atoms("mass 2 to 999")
edge_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]]
edges = edge_set.positions
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}"
)

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")
if len(backbone) > 0:
backbone_center = np.zeros(3)
for heavy_atom in backbone:
backbone_center += heavy_atom.position
backbone_center /= len(backbone)
trans_center, trans_axes = self.get_residue_custom_axes(
edges, backbone_center
)
else:
trans_center = (edges[0] + edges[1]) / 2
trans_axes = self.get_custom_axes(
a=trans_center, b=[edges[0]], c=np.zeros(3)
)

# look for heavy atoms in residue of interest
heavy_atom_indices = []
Expand Down Expand Up @@ -580,9 +651,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 +669,7 @@ 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_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