Skip to content

Commit 14336fe

Browse files
committed
better polymer detection for gap detection and termini
1 parent 02da7cb commit 14336fe

7 files changed

Lines changed: 407 additions & 63 deletions

File tree

‎moleculekit/rcsb.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,12 @@ def resolveFullSequences(mol, pdbid=None):
546546
of those covering the most entity residues (for display). Both are
547547
empty/``None`` when RCSB has no UniProt cross-reference for the chain.
548548
"""
549-
observed = mol.getSequence(dict_key="chain", sel="protein", _logger=False)
549+
# The derivation the gaps and termini downstream use, not
550+
# `getSequence(sel="protein")`: a chain whose residues are modelled short of
551+
# their backbone would be dropped here and never reach them.
552+
from moleculekit.tools.backbone import _observed_sequence
553+
554+
observed, _ = _observed_sequence(mol)
550555
resolved = {}
551556

552557
entity_seqs = _entity_sequences_for_pdbid(pdbid) if pdbid else {}

‎moleculekit/tools/autosegment.py‎

Lines changed: 10 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@
1717
CHAIN_ALPHABET = list(string.ascii_uppercase + string.ascii_lowercase + string.digits)
1818
SEGID_ALPHABET = list(string.ascii_uppercase + string.ascii_lowercase + string.digits)
1919

20-
# Protein residues are identified by the presence of these backbone atoms,
21-
# nucleic residues by any of these backbone link atoms (with ' / * variants).
22-
PROTEIN_BB = ("N", "CA", "C")
23-
NUCLEIC_LINK = ("P", "O3'", "O3*", "C3'", "C3*")
24-
25-
2620
def _residue_atom_coord(mol, res_atom_idx, names):
2721
"""Coordinate of the first atom in ``res_atom_idx`` whose name is in ``names``.
2822
@@ -41,9 +35,11 @@ def _classify_residues(mol, sel_mask):
4135
"""Classify every residue in the selection as one of
4236
protein / nucleic / water / ion / other.
4337
44-
Protein and nucleic are decided by *backbone-atom presence*, not by
45-
canonical resname, so noncanonical residues bonded into a chain are still
46-
treated as polymer.
38+
Protein and nucleic are decided by
39+
:func:`~moleculekit.tools.backbone.residuePolymerStatus`, which is shared with
40+
the sequence, gap and terminus code so one structure is not classified two
41+
ways. A capping group is reported separately there and folded into protein
42+
here, so the existing C-N geometric link attaches it to the residue it caps.
4743
4844
Returns
4945
-------
@@ -52,46 +48,12 @@ def _classify_residues(mol, sel_mask):
5248
residue_idx : list of np.ndarray
5349
Global atom indices for each residue, in the same order as ``cats``.
5450
"""
55-
from moleculekit.residues import (
56-
WATER_RESIDUE_NAMES,
57-
CAP_RESIDUE_NAMES,
58-
LIPID_RESIDUE_NAMES,
59-
ION_RESIDUE_NAMES,
60-
METAL_ION_RESIDUE_NAMES,
61-
)
62-
63-
sel_idx = np.where(sel_mask)[0]
64-
_, residue_idx = mol.getResidues(sel=sel_mask, return_idx=True)
65-
residue_idx = [sel_idx[idx] for idx in residue_idx]
51+
from moleculekit.tools.backbone import residuePolymerStatus
6652

67-
cats = []
68-
for idx in residue_idx:
69-
rep = idx[0]
70-
resname = mol.resname[rep]
71-
names = set(mol.name[idx])
72-
if resname in WATER_RESIDUE_NAMES:
73-
cats.append("water")
74-
# The single-atom guard keeps a polyatomic molecule whose code collides
75-
# with an element symbol (e.g. CO, carbon monoxide) out of this branch.
76-
elif resname in ION_RESIDUE_NAMES or (
77-
resname in METAL_ION_RESIDUE_NAMES and len(idx) == 1
78-
):
79-
cats.append("ion")
80-
elif resname in LIPID_RESIDUE_NAMES:
81-
# Membrane lipids are individual molecules; collapse them into one
82-
# segment (like water/ions) instead of one-per-molecule, so a bilayer
83-
# of hundreds of lipids does not exhaust the chain-letter alphabet.
84-
cats.append("lipid")
85-
elif resname in CAP_RESIDUE_NAMES:
86-
# A capping group joins the polymer traversal so the existing C-N
87-
# geometric link attaches it to the residue it caps.
88-
cats.append("protein")
89-
elif all(a in names for a in PROTEIN_BB):
90-
cats.append("protein")
91-
elif any(a in names for a in NUCLEIC_LINK):
92-
cats.append("nucleic")
93-
else:
94-
cats.append("other")
53+
cats, residue_idx = [], []
54+
for status, _key, atoms in residuePolymerStatus(mol, sel_mask):
55+
cats.append("protein" if status == "cap" else status)
56+
residue_idx.append(atoms)
9557
return cats, residue_idx
9658

9759

‎moleculekit/tools/backbone.py‎

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,217 @@ def _reconstruct_backbone_planar_atom(
222222
return (new_atom, insert_at)
223223

224224

225+
# Per polymer: the atomselect keyword, and the link kind that continues a chain of
226+
# it. A side-chain isopeptide is deliberately not here -- it crosslinks.
227+
_POLYMER_LINKS = {"protein": "peptide", "nucleic": "phosphodiester"}
228+
229+
230+
def _polymer_masks(mol: Molecule):
231+
"""``{"protein": mask, "nucleic": mask}`` -- the shared polymer test.
232+
233+
What ``atomselect("protein")`` is usually asked for, answered so that a residue
234+
modelled short of its backbone still counts. A residue is protein to that
235+
selection only once four connected backbone atoms are present, and the protein
236+
list is exactly ``N, CA, C, O``, so a residue missing only its carbonyl O is
237+
not protein: it drops out of any sequence derived from the selection, and an
238+
alignment against the reference then reports a residue missing that is sitting
239+
in the file, bonded to both its neighbours. Gaps, termini and the caps a build
240+
is asked for all inherit that.
241+
242+
The selection is added to, never reduced. Whatever it accepts stays accepted,
243+
so a non-canonical residue known to no name list and recognised only by its
244+
shape is unaffected, as is a structure whose coordinates carry no usable
245+
geometry. A residue it rejects joins the chain when its resname is a polymer
246+
one *and*
247+
:func:`~moleculekit.tools.nonstandard_residues.geometric_interresidue_links`
248+
puts a backbone link to a neighbour -- a peptide bond for protein, a
249+
phosphodiester for nucleic. The link is what separates a residue of the chain
250+
from a free amino acid or nucleotide in the solvent, which belongs to neither.
251+
A side-chain isopeptide does not count: it crosslinks, it does not continue a
252+
chain. Requiring the link is also what keeps a name from deciding alone: a
253+
phosphoserine carries a P and no nucleic backbone at all.
254+
255+
The same four-atom threshold applies to ``atomselect("nucleic")``, but its
256+
backbone list holds about ten names per nucleotide (``P``, ``OP1``, ``OP2``,
257+
``C5'``, ``O5'``, ``C4'``, ``C3'``, ``O3'`` and the ``*`` spellings), so four
258+
of them is a far looser bar than four of four and a nucleotide has to lose most
259+
of its backbone before it drops out.
260+
"""
261+
from moleculekit.residues import (
262+
MODIFIED_NUCLEIC_RESIDUE_NAMES,
263+
MODIFIED_PROTEIN_RESIDUE_NAMES,
264+
NUCLEIC_RESIDUE_NAMES_WITH_VARIANTS,
265+
PROTEIN_RESIDUE_NAMES_WITH_VARIANTS,
266+
)
267+
from moleculekit.tools.nonstandard_residues import geometric_interresidue_links
268+
269+
tables = {
270+
"protein": PROTEIN_RESIDUE_NAMES_WITH_VARIANTS | MODIFIED_PROTEIN_RESIDUE_NAMES,
271+
"nucleic": NUCLEIC_RESIDUE_NAMES_WITH_VARIANTS | MODIFIED_NUCLEIC_RESIDUE_NAMES,
272+
}
273+
masks = {w: mol.atomselect(w) for w in _POLYMER_LINKS}
274+
names = {w: {n.upper() for n in tables[w]} for w in _POLYMER_LINKS}
275+
276+
# No `sel` here on purpose: over "all" the returned indices are absolute.
277+
# Selecting narrows them to positions within the selection.
278+
_, res_idx = mol.getResidues(return_idx=True)
279+
280+
for i, atoms in enumerate(res_idx):
281+
atoms = np.asarray(atoms, dtype=np.int64)
282+
rname = str(mol.resname[atoms[0]]).upper()
283+
# Which polymers would take this residue on its name, and do not already
284+
# hold it. Anything the selections accepted needs no second opinion.
285+
wanted = {
286+
w: _POLYMER_LINKS[w]
287+
for w in _POLYMER_LINKS
288+
if rname in names[w] and not masks[w][atoms].any()
289+
}
290+
if not wanted:
291+
continue
292+
for other in (
293+
res_idx[i - 1] if i else None,
294+
res_idx[i + 1] if i + 1 < len(res_idx) else None,
295+
):
296+
if other is None:
297+
continue
298+
found = {
299+
kind for _ia, _ib, kind in
300+
geometric_interresidue_links(mol, atoms, np.asarray(other))
301+
}
302+
for w, kind in wanted.items():
303+
if kind in found:
304+
masks[w][atoms] = True
305+
return masks
306+
307+
308+
def chainResidueMask(mol: Molecule, polymer: str = "protein") -> np.ndarray:
309+
"""Boolean atom mask over the residues of a polymer chain.
310+
311+
The drop-in for ``mol.atomselect("protein")`` where a residue modelled short of
312+
its backbone has to count; see :func:`_polymer_masks` for the rule. Capping
313+
groups are excluded, as ``atomselect`` excludes them: a cap belongs to the
314+
chain it caps but is not a residue of its sequence. Combine polymers the way
315+
atomselect masks combine -- ``chainResidueMask(mol, "protein") |
316+
chainResidueMask(mol, "nucleic")``, which is what ``polymer="both"`` returns.
317+
318+
Parameters
319+
----------
320+
mol : :class:`Molecule <moleculekit.molecule.Molecule>`
321+
The molecule. Not modified.
322+
polymer : str
323+
``"protein"``, ``"nucleic"``, or ``"both"``.
324+
"""
325+
which = ("protein", "nucleic") if polymer == "both" else (polymer,)
326+
if any(w not in _POLYMER_LINKS for w in which):
327+
raise ValueError(
328+
f"polymer accepts one of 'protein', 'nucleic', 'both', not {polymer!r}"
329+
)
330+
masks = _polymer_masks(mol)
331+
out = np.zeros(mol.numAtoms, dtype=bool)
332+
for w in which:
333+
out |= masks[w]
334+
return out
335+
336+
337+
def residuePolymerStatus(mol: Molecule, sel="all"):
338+
"""Yield ``(status, (segid, chain, resid, insertion), atom_indices)`` per
339+
residue, in file order, with absolute atom indices.
340+
341+
``status`` is one of ``"protein"``, ``"nucleic"``, ``"cap"``, ``"water"``,
342+
``"ion"``, ``"lipid"`` or ``"other"``. One classification for every caller to
343+
read as its own purpose requires, rather than each deciding the chemistry
344+
again: segmentation wants a cap walked with the chain it caps, while a sequence,
345+
its gaps and its termini must not count a cap as a residue of the sequence.
346+
Folding ``"cap"`` into ``"protein"`` is what stopped one answer serving both.
347+
348+
Polymer status comes from :func:`_polymer_masks`, so a residue modelled short of
349+
its backbone keeps it. The indices index ``mol`` itself, unlike
350+
``Molecule.getResidues(sel=...)``, whose indices count within the selection.
351+
352+
Parameters
353+
----------
354+
mol : :class:`Molecule <moleculekit.molecule.Molecule>`
355+
The molecule. Not modified.
356+
sel : str or np.ndarray
357+
Atom selection to classify. A residue with no selected atom is skipped,
358+
and one partly selected yields only its selected atoms.
359+
"""
360+
from moleculekit.residues import (
361+
CAP_RESIDUE_NAMES,
362+
ION_RESIDUE_NAMES,
363+
LIPID_RESIDUE_NAMES,
364+
METAL_ION_RESIDUE_NAMES,
365+
WATER_RESIDUE_NAMES,
366+
)
367+
368+
masks = _polymer_masks(mol)
369+
selected = mol.atomselect(sel)
370+
_, res_idx = mol.getResidues(return_idx=True)
371+
372+
for atoms in res_idx:
373+
atoms = np.asarray(atoms, dtype=np.int64)
374+
atoms = atoms[selected[atoms]]
375+
if not len(atoms):
376+
continue
377+
first = int(atoms[0])
378+
key = (
379+
str(mol.segid[first]),
380+
str(mol.chain[first]),
381+
int(mol.resid[first]),
382+
str(mol.insertion[first]),
383+
)
384+
resname = str(mol.resname[first])
385+
if resname in WATER_RESIDUE_NAMES:
386+
status = "water"
387+
# The single-atom guard keeps a polyatomic molecule whose code collides
388+
# with an element symbol (e.g. CO, carbon monoxide) out of this branch.
389+
elif resname in ION_RESIDUE_NAMES or (
390+
resname in METAL_ION_RESIDUE_NAMES and len(atoms) == 1
391+
):
392+
status = "ion"
393+
elif resname in LIPID_RESIDUE_NAMES:
394+
status = "lipid"
395+
elif resname in CAP_RESIDUE_NAMES:
396+
status = "cap"
397+
elif masks["protein"][atoms].any():
398+
status = "protein"
399+
elif masks["nucleic"][atoms].any():
400+
status = "nucleic"
401+
else:
402+
status = "other"
403+
yield status, key, atoms
404+
405+
406+
def _observed_sequence(mol):
407+
"""``({chain: letters}, {chain: [atom_indices]})`` over the protein chains.
408+
409+
The shape ``Molecule.getSequence(dict_key="chain", return_idx=True)`` returns,
410+
over the residues :func:`chainResidueMask` admits, and lettered by the one
411+
table: a canonical residue by its own code, a known modified one by its
412+
parent's, anything else ``X``.
413+
414+
Every consumer of an observed protein sequence reads it from here -- gap
415+
detection, terminus classification and reference resolution -- so a chain is
416+
not dropped by one and analysed by the next.
417+
"""
418+
from moleculekit.molecule import _atoms_to_sequence
419+
from moleculekit.util import sequenceID
420+
421+
mask = chainResidueMask(mol)
422+
seqs, idxs = {}, {}
423+
for chain in np.unique(mol.chain[mask]):
424+
sel = mask & (mol.chain == chain)
425+
if not sel.any():
426+
continue
427+
increm = sequenceID((mol.resid[sel], mol.insertion[sel], mol.chain[sel]))
428+
letters, atoms = _atoms_to_sequence(
429+
mol, sel, oneletter=True, incremseg=increm, _logger=False
430+
)
431+
seqs[str(chain)] = "".join(letters)
432+
idxs[str(chain)] = atoms
433+
return seqs, idxs
434+
435+
225436
def _iterate_residues(mol: Molecule):
226437
BB_ATOM_NAMES = {"N", "CA", "C", "O"}
227438

‎moleculekit/tools/modelling.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from moleculekit.molecule import Molecule
2+
from moleculekit.tools.backbone import _observed_sequence
23
from moleculekit.util import find_executable
34
from subprocess import run
45
import numpy as np
@@ -553,9 +554,9 @@ def detectSequenceGaps(mol, sequences):
553554
where either side is ``X`` (unknown or modified residue) are not reported:
554555
they are not evidence of a substitution.
555556
"""
556-
obsseq, obsidx = mol.getSequence(
557-
dict_key="chain", return_idx=True, sel="protein", _logger=False
558-
)
557+
# Not `getSequence(sel="protein")`, which intersects the selection it is given
558+
# with its own, so a residue that selection rejects cannot be put back.
559+
obsseq, obsidx = _observed_sequence(mol)
559560
gaps = []
560561
skipped = []
561562
mismatches = []
@@ -647,9 +648,9 @@ def prepareGapModellingInput(mol, sequences, gaps, outdir):
647648

648649
selected = set((g["chain"], g["after_resid"], g["missing_seq"]) for g in gaps)
649650
matched = set()
650-
obsseq, obsidx = mol.getSequence(
651-
dict_key="chain", return_idx=True, sel="protein", _logger=False
652-
)
651+
# Not `getSequence(sel="protein")`, which intersects the selection it is given
652+
# with its own, so a residue that selection rejects cannot be put back.
653+
obsseq, obsidx = _observed_sequence(mol)
653654

654655
# Canonical protein chains we have a full sequence for. `obsseq` keys come from
655656
# np.unique (sorted), which fixes the chain order used for both the template

‎moleculekit/tools/preparation.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2196,9 +2196,10 @@ def _restore_rna_resnames(mol: Molecule) -> None:
21962196
as a non-standard ligand by any later residue-detection pass, and
21972197
``systemPrepare`` would not be idempotent for RNA.
21982198
2199-
A residue is renamed back only if it is genuinely a nucleotide, gated on
2200-
it carrying one of the backbone link atoms in
2201-
:data:`moleculekit.tools.autosegment.NUCLEIC_LINK`. Several of the
2199+
A residue is renamed back only if it is genuinely a nucleotide, gated on it
2200+
carrying one of the atoms a phosphodiester link is made of -- a ``P`` or one
2201+
of :data:`moleculekit.tools.nonstandard_residues._PHOSPHO_DONOR_NAMES`, which
2202+
is where that chemistry is defined. Several of the
22022203
``R``-prefixed names collide with unrelated PDB ligand codes (``RU`` is
22032204
ruthenium, ``RA3`` / ``RC5`` / ``RG3`` are inhibitor codes, ...), so a
22042205
non-nucleic residue that happens to share one of these names is left
@@ -2213,8 +2214,9 @@ def _restore_rna_resnames(mol: Molecule) -> None:
22132214
-------
22142215
None
22152216
"""
2216-
from moleculekit.tools.autosegment import NUCLEIC_LINK
2217+
from moleculekit.tools.nonstandard_residues import _PHOSPHO_DONOR_NAMES
22172218

2219+
nucleic_link = ("P",) + _PHOSPHO_DONOR_NAMES
22182220
uqres = mol.getResidues(return_idx=False)
22192221
for uq in set(uqres):
22202222
resatm = uqres == uq
@@ -2223,7 +2225,7 @@ def _restore_rna_resnames(mol: Molecule) -> None:
22232225
if new_resname is None:
22242226
continue
22252227
names = mol.name[resatm]
2226-
if not any(a in names for a in NUCLEIC_LINK):
2228+
if not any(a in names for a in nucleic_link):
22272229
continue
22282230
mol.resname[resatm] = new_resname
22292231

‎moleculekit/tools/termini.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,13 @@ def detectTermini(mol, sequences, gaps, chainmeta, mature_spans, skipped_chains=
246246
verified-unique atom selection for the residue, and is None when the
247247
terminus cannot be capped or cannot be selected unambiguously.
248248
"""
249-
obsseq, obsidx = mol.getSequence(
250-
dict_key="chain", return_idx=True, sel="protein", _logger=False
251-
)
249+
# The same derivation the gaps came from. `getSequence(sel="protein")` drops a
250+
# residue modelled short of its backbone, so a segment whose last residue is
251+
# one of those would end a residue early here while the gap it abuts starts a
252+
# residue later -- and the two answers are read together.
253+
from moleculekit.tools.backbone import _observed_sequence
254+
255+
obsseq, obsidx = _observed_sequence(mol)
252256
out = []
253257
for chain in sorted(obsseq):
254258
if not obsseq[chain]:

0 commit comments

Comments
 (0)