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()