Source code for vibesketch.document.abbreviations

"""Abbreviations such as Ph, Boc and OTf: contracted and expanded on a drawn species (#90).

An abbreviation is the canvas engine's own superatom (``SUP``) S-group: the
KET molecule keeps every atom and bond, and the group names the atoms that
the canvas and figures show as one label. Contracting or expanding therefore
changes only how a structure is drawn; its SMILES, InChIKey, calculations
and exports are those of the full structure, and each atom stays where it
was drawn.

Groups are found with RDKit's abbreviation matcher (``rdAbbreviations``,
G. Landrum, RDKit 2020.09), over a table of the abbreviations the Journal
of Organic Chemistry lists as standard ("Standard Abbreviations and
Acronyms", J. Org. Chem. author guidelines) followed by RDKit's own
defaults. Larger groups come first, so Cbz is preferred over Bn and Bn over
Ph. Figures draw a contracted group as its label at the atom that carries
the bond, with the label reversed (TfO, EtO2C) when the bond leaves it to
the right.
"""

from __future__ import annotations

import copy
from collections.abc import Sequence
from typing import Any

from vibesketch.document.ket import KetAbbreviation
from vibesketch.document.model import Canvas, Document

__all__ = [
    "AbbreviationRefused",
    "abbreviation_labels",
    "abbreviations_of",
    "condensed",
    "with_abbreviations",
    "without_abbreviations",
]

#: Label, SMILES from the attachment atom, label reading east, label reading west.
#: Subscripts use RDKit's ``<sub>`` markup.
_TABLE = (
    ("TBDPS", "*[Si](c1ccccc1)(c1ccccc1)C(C)(C)C", "TBDPS", "TBDPS"),
    ("Fmoc", "*C(=O)OCC1c2ccccc2-c2ccccc21", "Fmoc", "Fmoc"),
    ("Cbz", "*C(=O)OCc1ccccc1", "Cbz", "Cbz"),
    ("OTf", "*OS(=O)(=O)C(F)(F)F", "OTf", "TfO"),
    ("OTs", "*OS(=O)(=O)c1ccc(C)cc1", "OTs", "TsO"),
    ("OMs", "*OS(=O)(=O)C", "OMs", "MsO"),
    ("Ts", "*S(=O)(=O)c1ccc(C)cc1", "Ts", "Ts"),
    ("Tf", "*S(=O)(=O)C(F)(F)F", "Tf", "Tf"),
    ("Ms", "*S(=O)(=O)C", "Ms", "Ms"),
    ("TIPS", "*[Si](C(C)C)(C(C)C)C(C)C", "TIPS", "TIPS"),
    ("TBS", "*[Si](C)(C)C(C)(C)C", "TBS", "TBS"),
    ("TMS", "*[Si](C)(C)C", "TMS", "TMS"),
    ("Boc", "*C(=O)OC(C)(C)C", "Boc", "Boc"),
    ("PMB", "*Cc1ccc(OC)cc1", "PMB", "PMB"),
    ("Bz", "*C(=O)c1ccccc1", "Bz", "Bz"),
    ("Bn", "*Cc1ccccc1", "Bn", "Bn"),
    ("Ph", "*c1ccccc1", "Ph", "Ph"),
    ("THP", "*C1CCCCO1", "THP", "THP"),
    ("MOM", "*COC", "MOM", "MOM"),
)
#: The share of a molecule's atoms that contracting every known group may hide.
#: RDKit's default; naming a label lifts it, so toluene can be drawn as PhMe.
_COVERAGE = 0.4
_INDEX = "vibesketch_index"


[docs] class AbbreviationRefused(ValueError): """Abbreviations that cannot be contracted or expanded as asked."""
def _definitions() -> list[Any]: """The abbreviation table as RDKit definitions, ours first, then RDKit's others.""" from rdkit.Chem import rdAbbreviations text = "\n".join(" ".join(row) for row in _TABLE) ours = list(rdAbbreviations.ParseAbbreviations(text)) labels = {row[0] for row in _TABLE} return ours + [a for a in rdAbbreviations.GetDefaultAbbreviations() if a.label not in labels]
[docs] def abbreviation_labels() -> list[str]: """The labels that can be contracted, largest groups first.""" return [a.label for a in _definitions()]
def _chosen(labels: Sequence[str] | None) -> list[Any]: definitions = _definitions() if not labels: return definitions known = {a.label: a for a in definitions} unknown = [label for label in labels if label not in known] if unknown: raise AbbreviationRefused( f"unknown abbreviation {', '.join(unknown)}; known: {', '.join(known)}" ) return [a for a in definitions if a.label in set(labels)] def _fragments(document: Document, species: str | None) -> tuple[dict[str, Any], list[Any]]: """A copy of the canvas data and its drawn fragments, those of ``species`` if given.""" from vibesketch.chem.species import identity from vibesketch.document.ket import read_scene data: dict[str, Any] = copy.deepcopy(document.canvas.data) if not data: raise AbbreviationRefused("the document has no drawing") key: str | None = None if species is not None: try: chosen = document.species_by_id(species) except KeyError: raise AbbreviationRefused(f"the document has no species {species!r}") from None key = chosen.inchikey or chosen.smiles or chosen.id fragments = [ f for f in read_scene(data).fragments if f.mol is not None and (key is None or identity(f.mol).key == key) ] if not fragments: what = f"species {species}" if species else "structure" raise AbbreviationRefused(f"the drawing has no {what} to abbreviate") return data, fragments def _supers(molecule: dict[str, Any], atoms: Sequence[int]) -> list[dict[str, Any]]: """The SUP S-groups of a KET molecule that lie within ``atoms``.""" inside = set(atoms) return [ g for g in molecule.get("sgroups", []) if g.get("type") == "SUP" and set(int(a) for a in g.get("atoms", [])) <= inside ] def _changed(document: Document, data: dict[str, Any]) -> Document: return document.model_copy(update={"canvas": Canvas(format="ket", data=data)})
[docs] def with_abbreviations( document: Document, species: str | None = None, labels: Sequence[str] | None = None ) -> Document: """``document`` with abbreviations contracted in the drawing of ``species``, or of all. Without ``labels`` every known group is contracted, as long as the groups leave most of each molecule drawn; named labels are contracted wherever they occur. A group that overlaps one already on the canvas is left alone. """ from rdkit import Chem from rdkit.Chem import rdAbbreviations definitions = _chosen(labels) coverage = 1.0 if labels else _COVERAGE data, fragments = _fragments(document, species) added = 0 for fragment in fragments: molecule = data[fragment.node] taken = [{int(a) for a in g.get("atoms", [])} for g in _supers(molecule, fragment.atoms)] found = rdAbbreviations.LabelMolAbbreviations(fragment.mol, definitions, coverage) for group in Chem.GetMolSubstanceGroups(found): atoms = sorted(fragment.atoms[i] for i in group.GetAtoms()) if any(set(atoms) & other for other in taken): continue molecule.setdefault("sgroups", []).append( { "type": "SUP", "atoms": atoms, "name": group.GetProp("LABEL"), "expanded": False, "attachmentPoints": [ {"attachmentAtom": fragment.atoms[point.aIdx]} for point in group.GetAttachPoints() ], } ) taken.append(set(atoms)) added += 1 if not added: wanted = ", ".join(labels) if labels else "known abbreviation" raise AbbreviationRefused(f"found no {wanted} to contract") return _changed(document, data)
[docs] def without_abbreviations( document: Document, species: str | None = None, labels: Sequence[str] | None = None ) -> Document: """``document`` with the abbreviations of ``species``, or of all, expanded to their atoms. ``labels`` limits it to groups of those names; any group the canvas holds, including one made in the canvas, can be named. """ data, fragments = _fragments(document, species) removed = 0 for fragment in fragments: molecule = data[fragment.node] drop = [ g for g in _supers(molecule, fragment.atoms) if not labels or str(g.get("name", "")) in labels ] if drop: molecule["sgroups"] = [g for g in molecule["sgroups"] if g not in drop] if not molecule["sgroups"]: del molecule["sgroups"] removed += len(drop) if not removed: raise AbbreviationRefused("found no abbreviation to expand") return _changed(document, data)
[docs] def abbreviations_of(document: Document) -> dict[str, list[str]]: """The contracted abbreviations drawn in each species, by species id.""" from vibesketch.chem.species import identity from vibesketch.document.ket import read_scene if not document.canvas.data: return {} by_key = {(s.inchikey or s.smiles or s.id): s.id for s in document.species} found: dict[str, list[str]] = {} for fragment in read_scene(document.canvas.data).fragments: if fragment.mol is None or not fragment.abbreviations: continue species = by_key.get(identity(fragment.mol).key) if species is not None: labels = found.setdefault(species, []) labels.extend(a.label for a in fragment.abbreviations if a.label not in labels) return found
def _display(label: str) -> tuple[str, str]: """A label as drawn east and west of its bond, in RDKit's label markup.""" for row in _TABLE: if row[0] == label: return row[2], row[3] for definition in _definitions(): if definition.label == label: return definition.displayLabel or label, definition.displayLabelW or label plain = "".join(c for c in label if c not in "<>&") return plain, plain
[docs] def condensed(mol: Any, abbreviations: Sequence[KetAbbreviation]) -> Any: """A copy of ``mol`` with each abbreviation drawn as one labelled atom, for figures. Each atom kept carries its index in ``mol`` as the ``vibesketch_index`` property, so highlights and other marks can follow it. A group with no bond to the rest of the molecule is drawn in full. """ from rdkit import Chem, rdBase from rdkit.Chem import rdAbbreviations copy_ = Chem.Mol(mol) for atom in copy_.GetAtoms(): atom.SetIntProp(_INDEX, atom.GetIdx()) labels: dict[int, str] = {} for abbreviation in abbreviations: members = set(abbreviation.atoms) crossing = [ b for b in copy_.GetBonds() if (b.GetBeginAtomIdx() in members) != (b.GetEndAtomIdx() in members) ] if not crossing: continue group = Chem.CreateMolSubstanceGroup(copy_, "SUP") for index in sorted(members): group.AddAtomWithIdx(index) for bond in crossing: group.AddBondWithIdx(bond.GetIdx()) inside, outside = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() if inside not in members: inside, outside = outside, inside group.AddAttachPoint(inside, outside, "1") labels[inside] = abbreviation.label group.SetProp("LABEL", abbreviation.label) if not labels: return copy_ quiet = rdBase.BlockLogs() result = rdAbbreviations.CondenseAbbreviationSubstanceGroups(copy_) del quiet for atom in result.GetAtoms(): original = atom.GetIntProp(_INDEX) if original in labels: east, west = _display(labels[original]) atom.SetProp("_displayLabel", east) atom.SetProp("_displayLabelW", west) return result