Skip to content

Commit 008fbbc

Browse files
authored
Merge pull request #375 from CCPBioSim/374-cache-customised-residue-axes-topology
Cache customised residue axes topology for frame covariance
2 parents e075ae1 + 09bc892 commit 008fbbc

6 files changed

Lines changed: 434 additions & 19 deletions

File tree

CodeEntropy/levels/axes.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,64 @@ def get_residue_axes(self, data_container, index: int, residue=None):
135135

136136
return trans_axes, rot_axes, center, moment_of_inertia
137137

138+
def get_residue_axes_from_topology(
139+
self,
140+
*,
141+
u,
142+
mol,
143+
residue_atoms,
144+
topology,
145+
box: np.ndarray | None,
146+
):
147+
"""Compute residue axes using cached static topology.
148+
149+
This is the cached-index equivalent of ``get_residue_axes``. It keeps
150+
all frame-dependent numerical work frame-local, but avoids repeated
151+
MDAnalysis selections for residue heavy atoms, UA masses, and neighbour
152+
bond discovery.
153+
154+
Args:
155+
u: Current-frame universe used to resolve cached atom indices.
156+
mol: Current-frame molecule fragment.
157+
residue_atoms: AtomGroup for the residue in the current frame.
158+
topology: Cached ``ResidueAxesTopology`` for this residue.
159+
box: Current periodic box lengths. If omitted, ``u.dimensions`` is used.
160+
161+
Returns:
162+
Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
163+
- trans_axes: Translational axes, shape ``(3, 3)``.
164+
- rot_axes: Rotational axes, shape ``(3, 3)``.
165+
- center: Residue centre, shape ``(3,)``.
166+
- moment_of_inertia: Principal moments, shape ``(3,)``.
167+
"""
168+
dimensions = (
169+
np.asarray(box, dtype=float)
170+
if box is not None
171+
else np.asarray(u.dimensions[:3], dtype=float)
172+
)
173+
174+
center = residue_atoms.center_of_mass(unwrap=True)
175+
176+
if not topology.has_neighbor_bonds:
177+
heavy_atoms = u.atoms[topology.residue_heavy_indices]
178+
moment_of_inertia_tensor = self.get_moment_of_inertia_tensor(
179+
center_of_mass=center,
180+
positions=heavy_atoms.positions,
181+
masses=topology.residue_ua_masses,
182+
dimensions=dimensions,
183+
)
184+
rot_axes, moment_of_inertia = self.get_custom_principal_axes(
185+
moment_of_inertia_tensor
186+
)
187+
trans_axes = rot_axes
188+
else:
189+
make_whole(mol.atoms)
190+
trans_axes = mol.atoms.principal_axes()
191+
rot_axes, moment_of_inertia = self.get_vanilla_axes(residue_atoms)
192+
center = residue_atoms.center_of_mass(unwrap=True)
193+
194+
return trans_axes, rot_axes, center, moment_of_inertia
195+
138196
def get_UA_axes(self, data_container, index: int):
139197
"""Compute united-atom-level translational and rotational axes.
140198

CodeEntropy/levels/nodes/axes_topology.py

Lines changed: 106 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Build static axes-topology metadata for frame covariance calculations.
22
33
This module caches topology-only atom-index relationships needed by customised
4-
united-atom axes calculations. The cache avoids repeated MDAnalysis selection
5-
parsing inside the frame-local covariance loop while preserving frame-dependent
6-
positions, forces, centres, axes, torques, and moments of inertia.
4+
axes calculations. The cache avoids repeated MDAnalysis selection parsing inside
5+
the frame-local covariance loop while preserving frame-dependent positions,
6+
forces, centres, axes, torques, and moments of inertia.
77
"""
88

99
from __future__ import annotations
@@ -17,6 +17,7 @@
1717
logger = logging.getLogger(__name__)
1818

1919
UAKey = tuple[int, int, int]
20+
ResidueKey = tuple[int, int]
2021

2122

2223
@dataclass(frozen=True)
@@ -44,16 +45,35 @@ class UAAxesTopology:
4445
residue_ua_masses: np.ndarray
4546

4647

48+
@dataclass(frozen=True)
49+
class ResidueAxesTopology:
50+
"""Static topology required to compute customised residue axes.
51+
52+
Attributes:
53+
residue_heavy_indices: Heavy atom indices in the residue.
54+
residue_ua_masses: UA masses for heavy atoms in the residue.
55+
has_neighbor_bonds: Whether the residue is bonded to a neighbouring
56+
residue according to the original customised residue-axis selection.
57+
"""
58+
59+
residue_heavy_indices: np.ndarray
60+
residue_ua_masses: np.ndarray
61+
has_neighbor_bonds: bool
62+
63+
4764
@dataclass(frozen=True)
4865
class AxesTopology:
4966
"""Cached axes topology for frame covariance calculations.
5067
5168
Attributes:
5269
ua: Mapping from ``(mol_id, local_residue_id, ua_id)`` to cached
5370
united-atom axes topology.
71+
residue: Mapping from ``(mol_id, local_residue_id)`` to cached
72+
residue axes topology.
5473
"""
5574

5675
ua: dict[UAKey, UAAxesTopology] = field(default_factory=dict)
76+
residue: dict[ResidueKey, ResidueAxesTopology] = field(default_factory=dict)
5777

5878

5979
class BuildAxesTopologyNode:
@@ -86,24 +106,76 @@ def run(self, shared_data: dict[str, Any]) -> dict[str, Any]:
86106
beads = shared_data["beads"]
87107

88108
ua_topology: dict[UAKey, UAAxesTopology] = {}
109+
residue_topology: dict[ResidueKey, ResidueAxesTopology] = {}
89110
fragments = u.atoms.fragments
90111

91112
for mol_id, level_list in enumerate(levels):
92-
if "united_atom" not in level_list:
93-
continue
113+
mol = fragments[mol_id]
114+
115+
if "residue" in level_list:
116+
self._add_residue_topology(
117+
mol=mol,
118+
mol_id=mol_id,
119+
beads=beads,
120+
out=residue_topology,
121+
)
94122

95-
self._add_ua_topology(
96-
u=u,
97-
mol=fragments[mol_id],
98-
mol_id=mol_id,
99-
beads=beads,
100-
out=ua_topology,
101-
)
123+
if "united_atom" in level_list:
124+
self._add_ua_topology(
125+
u=u,
126+
mol=mol,
127+
mol_id=mol_id,
128+
beads=beads,
129+
out=ua_topology,
130+
)
102131

103-
topology = AxesTopology(ua=ua_topology)
132+
topology = AxesTopology(ua=ua_topology, residue=residue_topology)
104133
shared_data["axes_topology"] = topology
105134
return {"axes_topology": topology}
106135

136+
def _add_residue_topology(
137+
self,
138+
*,
139+
mol: Any,
140+
mol_id: int,
141+
beads: dict[Any, list[np.ndarray]],
142+
out: dict[ResidueKey, ResidueAxesTopology],
143+
) -> None:
144+
"""Cache static residue axes topology for one molecule.
145+
146+
Args:
147+
mol: Molecule AtomGroup.
148+
mol_id: Molecule index.
149+
beads: Bead-index mapping produced by ``BuildBeadsNode``.
150+
out: Output residue topology mapping mutated in place.
151+
"""
152+
bead_key = (mol_id, "residue")
153+
bead_idx_list = beads.get(bead_key, [])
154+
if not bead_idx_list:
155+
return
156+
157+
for local_res_i, residue in enumerate(mol.residues):
158+
if local_res_i >= len(bead_idx_list):
159+
continue
160+
161+
residue_atoms = residue.atoms
162+
residue_heavy = residue_atoms.select_atoms("mass 2 to 999")
163+
residue_heavy_indices = residue_heavy.indices.astype(int, copy=True)
164+
residue_ua_masses = np.asarray(
165+
self._get_ua_masses_from_topology(residue_atoms),
166+
dtype=float,
167+
)
168+
has_neighbor_bonds = self._has_neighbor_bonds(
169+
mol=mol,
170+
local_res_i=local_res_i,
171+
)
172+
173+
out[(mol_id, local_res_i)] = ResidueAxesTopology(
174+
residue_heavy_indices=residue_heavy_indices,
175+
residue_ua_masses=residue_ua_masses,
176+
has_neighbor_bonds=has_neighbor_bonds,
177+
)
178+
107179
def _add_ua_topology(
108180
self,
109181
*,
@@ -177,6 +249,27 @@ def _add_ua_topology(
177249
residue_ua_masses=residue_ua_masses,
178250
)
179251

252+
@staticmethod
253+
def _has_neighbor_bonds(*, mol: Any, local_res_i: int) -> bool:
254+
"""Return whether a residue is bonded to neighbouring residues.
255+
256+
Args:
257+
mol: Molecule AtomGroup used for the original bonded-neighbour
258+
selection.
259+
local_res_i: Residue index local to ``mol``.
260+
261+
Returns:
262+
True when the residue has bonded atoms in the previous or next
263+
residue according to the original customised residue-axis query.
264+
"""
265+
index_prev = local_res_i - 1
266+
index_next = local_res_i + 1
267+
atom_set = mol.select_atoms(
268+
f"(resindex {index_prev} or resindex {index_next}) "
269+
f"and bonded resid {local_res_i}"
270+
)
271+
return len(atom_set) > 0
272+
180273
@staticmethod
181274
def _split_bonded_atoms(atom: Any) -> tuple[Any, Any]:
182275
"""Return bonded heavy and light atoms for one atom.

CodeEntropy/levels/nodes/covariance.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def run(self, ctx: FrameCtx) -> dict[str, Any]:
129129
group_id=group_id,
130130
beads=beads,
131131
axes_manager=axes_manager,
132+
axes_topology=axes_topology,
132133
box=box,
133134
customised_axes=customised_axes,
134135
force_partitioning=fp,
@@ -242,6 +243,7 @@ def _process_residue(
242243
group_id: int,
243244
beads: dict[Any, list[Any]],
244245
axes_manager: Any,
246+
axes_topology: Any | None,
245247
box: np.ndarray | None,
246248
customised_axes: bool,
247249
force_partitioning: float,
@@ -261,6 +263,7 @@ def _process_residue(
261263
group_id: Molecule-group identifier used for within-frame averaging.
262264
beads: Mapping of bead keys to reduced-universe atom-index arrays.
263265
axes_manager: Axes helper used to build translation and rotation axes.
266+
axes_topology: Optional cached axes topology generated during static setup.
264267
box: Optional periodic box vector.
265268
customised_axes: Whether customised residue axes should be used.
266269
force_partitioning: Force partitioning factor for highest-level vectors.
@@ -281,9 +284,12 @@ def _process_residue(
281284
return
282285

283286
force_vecs, torque_vecs = self._build_residue_vectors(
287+
u=u,
284288
mol=mol,
289+
mol_id=mol_id,
285290
bead_groups=bead_groups,
286291
axes_manager=axes_manager,
292+
axes_topology=axes_topology,
287293
box=box,
288294
customised_axes=customised_axes,
289295
force_partitioning=force_partitioning,
@@ -481,9 +487,12 @@ def _build_ua_vectors(
481487
def _build_residue_vectors(
482488
self,
483489
*,
490+
u: Any,
484491
mol: Any,
492+
mol_id: int,
485493
bead_groups: list[Any],
486494
axes_manager: Any,
495+
axes_topology: Any | None,
487496
box: np.ndarray | None,
488497
customised_axes: bool,
489498
force_partitioning: float,
@@ -492,9 +501,12 @@ def _build_residue_vectors(
492501
"""Build force and torque vectors for residue beads.
493502
494503
Args:
504+
u: Universe-like object used to resolve cached atom indices.
495505
mol: Molecule fragment containing residues and atoms.
506+
mol_id: Molecule index used in axes-topology lookup keys.
496507
bead_groups: Atom groups representing residue beads.
497508
axes_manager: Axes helper used to select axes, centres, and moments.
509+
axes_topology: Optional cached axes topology generated during static setup.
498510
box: Optional periodic box vector.
499511
customised_axes: Whether customised residue axes should be used.
500512
force_partitioning: Force partitioning factor for highest-level vectors.
@@ -508,10 +520,14 @@ def _build_residue_vectors(
508520

509521
for local_res_i, bead in enumerate(bead_groups):
510522
trans_axes, rot_axes, center, moi = self._get_residue_axes(
523+
u=u,
511524
mol=mol,
525+
mol_id=mol_id,
512526
bead=bead,
513527
local_res_i=local_res_i,
514528
axes_manager=axes_manager,
529+
axes_topology=axes_topology,
530+
box=box,
515531
customised_axes=customised_axes,
516532
)
517533

@@ -540,26 +556,47 @@ def _build_residue_vectors(
540556
def _get_residue_axes(
541557
self,
542558
*,
559+
u: Any,
543560
mol: Any,
561+
mol_id: int,
544562
bead: Any,
545563
local_res_i: int,
546564
axes_manager: Any,
565+
axes_topology: Any | None,
566+
box: np.ndarray | None,
547567
customised_axes: bool,
548568
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
549569
"""Return axes, centre, and inertia data for a residue bead.
550570
551571
Args:
572+
u: Universe-like object used to resolve cached atom indices.
552573
mol: Molecule fragment containing residues and atoms.
574+
mol_id: Molecule index used in axes-topology lookup keys.
553575
bead: Atom group representing the residue bead.
554576
local_res_i: Residue index local to ``mol``.
555577
axes_manager: Axes helper used to select axes, centres, and moments.
578+
axes_topology: Optional cached axes topology generated during static setup.
579+
box: Optional periodic box vector.
556580
customised_axes: Whether customised residue axes should be used.
557581
558582
Returns:
559583
A tuple of translation axes, rotation axes, centre, and moments of inertia.
560584
"""
561585
if customised_axes:
562586
res = mol.residues[local_res_i]
587+
residue_topology = None
588+
if axes_topology is not None:
589+
residue_topology = axes_topology.residue.get((mol_id, local_res_i))
590+
591+
if residue_topology is not None:
592+
return axes_manager.get_residue_axes_from_topology(
593+
u=u,
594+
mol=mol,
595+
residue_atoms=res.atoms,
596+
topology=residue_topology,
597+
box=box,
598+
)
599+
563600
return axes_manager.get_residue_axes(mol, local_res_i, residue=res.atoms)
564601

565602
make_whole(mol.atoms)

0 commit comments

Comments
 (0)