File size: 7,054 Bytes
cf5d356 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 | """Prepare RNA coordinates from a Biotite ``AtomArray`` for RiboSphere.
The output atom-channel order matches ``get_backbone_coords()``:
P, C5', C4', C3', C2', C1', O5', O4', O3', O2', N9/N1
The final channel is N9 for purines (A/G) and N1 for pyrimidines (C/U).
Coordinates read from a PDB file remain in Angstrom.
"""
from __future__ import annotations
from typing import Any, Iterator
import numpy as np
import torch
COMMON_BACKBONE_ATOMS = (
"P",
"C5'",
"C4'",
"C3'",
"C2'",
"C1'",
"O5'",
"O4'",
"O3'",
"O2'",
)
BASE_ANCHOR_ATOM = {
"A": "N9",
"G": "N9",
"C": "N1",
"U": "N1",
}
RIBOSPHERE_A11_CHANNELS = COMMON_BACKBONE_ATOMS + ("N9/N1",)
ATOM_SELECTION = {
"a1": (COMMON_BACKBONE_ATOMS.index("C4'"),),
"a10": tuple(range(len(COMMON_BACKBONE_ATOMS))),
"a11": tuple(range(len(COMMON_BACKBONE_ATOMS) + 1)),
}
STANDARD_RNA_RESIDUES = frozenset(BASE_ANCHOR_ATOM)
def _residue_slices(atom_array: Any) -> Iterator[slice]:
"""Yield contiguous slices for individual residues.
Residues are distinguished by chain ID, residue ID, and insertion code.
The input must be a single-model ``AtomArray`` whose atoms are ordered
contiguously by residue.
"""
if np.asarray(atom_array.coord).ndim != 2:
raise ValueError(
"Expected a single-model AtomArray; call "
"PDBFile.get_structure(model=1)"
)
start = 0
num_atoms = len(atom_array)
for index in range(1, num_atoms):
residue_changed = (
atom_array.chain_id[index] != atom_array.chain_id[start]
or atom_array.res_id[index] != atom_array.res_id[start]
or atom_array.ins_code[index] != atom_array.ins_code[start]
)
if residue_changed:
yield slice(start, index)
start = index
if num_atoms > 0:
yield slice(start, num_atoms)
def prepare_rna_coordinates(
atom_array: Any,
*,
atoms: str = "a11",
max_residues: int | None = 512,
) -> torch.Tensor:
"""Extract complete standard RNA residues for RiboSphere.
Parameters
----------
atom_array
A single-model Biotite ``AtomArray``. When reading a PDB file, use
``PDBFile.get_structure(model=1, altloc="first")``.
atoms
RiboSphere representation: ``"a1"`` (C4'), ``"a10"`` (ten backbone
atoms), or ``"a11"`` (ten backbone atoms plus N9/N1).
max_residues
Maximum number of retained complete residues. ``None`` keeps all
residues.
Returns
-------
torch.Tensor
Centered float32 coordinates with shape ``[1, L, A, 3]``. Coordinates
remain in Angstrom. Residues with non-standard names or missing
required A11 atoms are discarded.
"""
if atoms not in ATOM_SELECTION:
supported = ", ".join(sorted(ATOM_SELECTION))
raise ValueError(
f"Unsupported atom representation {atoms!r}; expected one of "
f"{supported}"
)
if max_residues is not None and max_residues <= 0:
raise ValueError("max_residues must be positive or None")
complete_residues: list[np.ndarray] = []
for residue_slice in _residue_slices(atom_array):
residue = atom_array[residue_slice]
residue_name = str(residue.res_name[0]).strip().upper()
if residue_name not in STANDARD_RNA_RESIDUES:
continue
# PDBFile.get_structure(..., altloc="first") should already resolve
# alternate locations. setdefault() makes the first remaining atom with
# a given name deterministic if duplicates are nevertheless present.
coordinates_by_name: dict[str, np.ndarray] = {}
for atom_name, coordinate, is_hetero in zip(
residue.atom_name,
residue.coord,
residue.hetero,
):
if bool(is_hetero):
continue
normalized_name = str(atom_name).strip()
coordinates_by_name.setdefault(
normalized_name,
np.asarray(coordinate),
)
base_anchor = BASE_ANCHOR_ATOM[residue_name]
required_atoms = COMMON_BACKBONE_ATOMS + (base_anchor,)
if not all(
atom_name in coordinates_by_name
for atom_name in required_atoms
):
continue
complete_residues.append(
np.stack(
[
coordinates_by_name[atom_name]
for atom_name in required_atoms
],
axis=0,
)
)
if not complete_residues:
raise ValueError("No complete standard RNA residues were found")
coordinates = torch.from_numpy(
np.stack(complete_residues, axis=0).astype(
np.float32,
copy=False,
)
)
if max_residues is not None:
coordinates = coordinates[:max_residues]
# Select the representation before centering so the returned A1/A10/A11
# coordinates are each independently centered.
atom_indices = torch.tensor(
ATOM_SELECTION[atoms],
dtype=torch.long,
device=coordinates.device,
)
coordinates = coordinates.index_select(1, atom_indices)
coordinates = coordinates - coordinates.mean(
dim=(0, 1),
keepdim=True,
)
return coordinates.unsqueeze(0)
def kabsch_rmsd(predicted: object, target: object) -> float:
"""Return translation- and rotation-aligned RMSD.
Both inputs must contain the same atoms in the same order and end in an
xyz dimension. The result uses the same length unit as the inputs.
"""
predicted_tensor = torch.as_tensor(
predicted,
dtype=torch.float64,
).reshape(-1, 3)
target_tensor = torch.as_tensor(
target,
dtype=torch.float64,
device=predicted_tensor.device,
).reshape(-1, 3)
if predicted_tensor.shape != target_tensor.shape:
raise ValueError(
"Coordinate shapes differ: "
f"{predicted_tensor.shape} vs {target_tensor.shape}"
)
if predicted_tensor.shape[0] == 0:
raise ValueError("Cannot compute RMSD for empty coordinates")
predicted_centered = predicted_tensor - predicted_tensor.mean(
dim=0,
keepdim=True,
)
target_centered = target_tensor - target_tensor.mean(
dim=0,
keepdim=True,
)
covariance = predicted_centered.T @ target_centered
left_vectors, _, right_vectors_transposed = torch.linalg.svd(covariance)
correction = torch.eye(
3,
dtype=predicted_centered.dtype,
device=predicted_centered.device,
)
if torch.det(left_vectors @ right_vectors_transposed) < 0:
correction[-1, -1] = -1
rotation = (
left_vectors
@ correction
@ right_vectors_transposed
)
aligned = predicted_centered @ rotation
return torch.sqrt(
((aligned - target_centered) ** 2).sum(dim=-1).mean()
).item()
|