@@ -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+
225436def _iterate_residues (mol : Molecule ):
226437 BB_ATOM_NAMES = {"N" , "CA" , "C" , "O" }
227438
0 commit comments