Source code for vibesketch.document.monomers

"""Peptide modifications beyond RDKit's HELM library, from a curated table (#98).

RDKit reads HELM peptides built from its own monomer library, which has
the acetyl (``ac``) and amide (``am``) caps but lacks other common
modifications, such as N-methyl amino acids and phosphorylated residues.
This table adds a few, by the symbols of the Pistoia Alliance HELM core monomer
library (github.com/PistoiaHELM/HELMMonomerSets), so HELM written by other
programs reads here. Only the symbols are taken from that library, no
structures: each modification is made on the standard residue RDKit
builds, by its PDB atom name, and tested against a hand-written structure.
A HELM that RDKit cannot read because of them may also carry the caps,
which are then made here the same way.

The table grows on demand, each entry with its test (maintainer decision,
2026-10-24).
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any, Literal

__all__ = ["CAPS", "MODIFIED", "Modification", "MonomerRefused", "curated_peptide"]


[docs] class MonomerRefused(ValueError): """The HELM uses a curated monomer where it cannot stand."""
[docs] @dataclass(frozen=True) class Modification: """A standard residue and what is changed on it.""" #: The residue's one-letter code. residue: str #: A methyl on the backbone nitrogen, or a phosphate on the side-chain hydroxyl. change: Literal["methyl", "phospho"] #: How a figure prints it: IUPAC-IUB JCBN 1983 symbols, a substituent on #: the side chain in parentheses and an N-methyl as Me before the symbol. printed: str
#: The modified residues, by their HELM core library symbol. MODIFIED: dict[str, Modification] = { "meA": Modification("A", "methyl", "MeAla"), "meF": Modification("F", "methyl", "MePhe"), "meG": Modification("G", "methyl", "Sar"), "meH": Modification("H", "methyl", "MeHis"), "meL": Modification("L", "methyl", "MeLeu"), "meW": Modification("W", "methyl", "MeTrp"), "Ser_PO3H2": Modification("S", "phospho", "Ser(PO3H2)"), "Thr_PO3H2": Modification("T", "phospho", "Thr(PO3H2)"), "Tyr_PO3H2": Modification("Y", "phospho", "Tyr(PO3H2)"), } #: The terminal caps: N-acetyl first in the chain, C-terminal amide last, #: and what a figure prints in place of H- and -OH. CAPS = {"ac": "Ac", "am": "NH2"} #: Where each change is made: the PDB atom name on the residue. _SIDE_CHAIN_OXYGEN = {"S": " OG ", "T": " OG1", "Y": " OH "} _CHAIN = re.compile(r"PEPTIDE1\{([^{}$]+)\}\$([^$]*)\$\$\$(?:V2\.0)?") _LINK = re.compile(r"PEPTIDE1,PEPTIDE1,(\d+):(R\d)-(\d+):(R\d)") def _symbol(monomer: str) -> str: return monomer[1:-1] if monomer.startswith("[") and monomer.endswith("]") else monomer
[docs] def curated_peptide(helm: str) -> Any | None: """The molecule of a one-chain peptide HELM using curated monomers, or ``None``. ``None`` when the HELM uses none of them, so RDKit's own reading stands. Refuses a cap anywhere but its end of the chain and links to a cap. """ from rdkit import Chem match = _CHAIN.fullmatch(helm) if match is None: return None monomers = match.group(1).split(".") symbols = [_symbol(m) for m in monomers] if not any(s in MODIFIED or s in CAPS for s in symbols): return None acetyl = symbols[0] == "ac" amide = len(symbols) > 1 and symbols[-1] == "am" body = monomers[int(acetyl) : len(monomers) - int(amide)] if not body or any(_symbol(m) in CAPS for m in body): raise MonomerRefused( "a cap stands at its end of the chain: ac (acetyl) first, am (amide) last" ) links = [] for link in filter(None, match.group(2).split("|")): found = _LINK.fullmatch(link) if found is None: raise MonomerRefused(f"the link {link!r} is not within the chain") first, second = int(found.group(1)) - int(acetyl), int(found.group(3)) - int(acetyl) if not (1 <= first <= len(body) and 1 <= second <= len(body)): raise MonomerRefused(f"the link {link!r} joins a cap") links.append(f"PEPTIDE1,PEPTIDE1,{first}:{found.group(2)}-{second}:{found.group(4)}") changes = { index: MODIFIED[_symbol(m)] for index, m in enumerate(body, start=1) if _symbol(m) in MODIFIED } core = [changes[i].residue if i in changes else m for i, m in enumerate(body, start=1)] mol = Chem.MolFromHELM(f"PEPTIDE1{{{'.'.join(core)}}}${'|'.join(links)}$$$") if mol is None: return None return _modified(mol, changes, acetyl=acetyl, amide=amide, residues=len(body))
def _modified( mol: Any, changes: dict[int, Modification], *, acetyl: bool, amide: bool, residues: int ) -> Any: """``mol`` with each change made at its residue's atom, and the caps.""" from rdkit import Chem from vibesketch.chem._rdkit import untyped named = {} for atom in mol.GetAtoms(): info = atom.GetPDBResidueInfo() if info is not None: named[(info.GetResidueNumber(), info.GetName())] = atom.GetIdx() edited = Chem.RWMol(mol) def add(element: str, to: int, order: Any = Chem.BondType.SINGLE) -> int: index = int(edited.AddAtom(Chem.Atom(element))) edited.AddBond(to, index, order) return index def freed(index: int) -> int: """The atom, its hydrogens to be counted afresh after the change.""" atom = edited.GetAtomWithIdx(index) atom.SetNumExplicitHs(0) atom.SetNoImplicit(False) return index for residue, change in changes.items(): if change.change == "methyl": add("C", freed(named[(residue, " N ")])) else: phosphorus = add("P", freed(named[(residue, _SIDE_CHAIN_OXYGEN[change.residue])])) add("O", phosphorus, Chem.BondType.DOUBLE) add("O", phosphorus) add("O", phosphorus) if acetyl: carbonyl = add("C", freed(named[(1, " N ")])) add("O", carbonyl, Chem.BondType.DOUBLE) add("C", carbonyl) if amide: oxygen = edited.GetAtomWithIdx(freed(named[(residues, " OXT")])) oxygen.SetAtomicNum(7) result = edited.GetMol() for atom in result.GetAtoms(): untyped(atom).SetMonomerInfo(None) # the residues are no longer RDKit's own Chem.SanitizeMol(result) return result