"""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