Source code for vibesketch.document.ket

"""The KET bridge: between the canvas engine's JSON and RDKit molecules.

KET is Ketcher's document format. Reading goes KET to a molfile to
RDKit, so wedge bonds become stereo centres and double-bond geometry becomes
E/Z exactly as a molfile reader would see them. KET coordinates have the same
orientation as a molfile's, so no axis flips. Writing goes RDKit to KET with
a bond length of 1.0, the length Ketcher draws with.

Anything the reader cannot turn into a well-defined molecule is reported,
never guessed: query atoms and bonds, R-groups, polymer brackets and
valence errors make a fragment ``unsupported`` with the reason.
"""

from __future__ import annotations

import json
import math
import re
from dataclasses import dataclass, field
from typing import Any

from vibesketch.elements import ATOMIC_NUMBER

__all__ = [
    "ARROW_MODES",
    "BOND_LENGTH",
    "KetAbbreviation",
    "KetArrow",
    "KetFragment",
    "KetScene",
    "KetText",
    "SceneStep",
    "molecule_reason",
    "molfile",
    "placed_molecule",
    "rgroup_node",
    "read_scene",
    "stereo_reason",
    "text_node",
    "write_scene",
]

#: KET bond types that are ordinary bonds; the rest are query bonds.
_PLAIN_BONDS = {1, 2, 3, 4}
#: KET bond stereo values, which are the molfile's.
_BOND_STEREO = {0: 0, 1: 1, 3: 3, 4: 4, 6: 6}
#: S-group types that change what the drawing means as a molecule.
_POLYMER_SGROUPS = {"SRU", "COP", "MUL", "GEN"}


[docs] @dataclass(frozen=True) class KetAbbreviation: """A contracted superatom (SUP) S-group, such as Ph, drawn as its label.""" label: str #: Its atoms, as indices of the fragment's molecule. atoms: tuple[int, ...]
[docs] @dataclass class KetFragment: """One connected molecule on the canvas.""" #: The RDKit molecule with the canvas coordinates, or ``None`` if unsupported. mol: Any #: The KET node it came from, for example ``mol0``. node: str #: Centre of its atoms, in KET coordinates. centre: tuple[float, float] #: Bounding box ``(xmin, ymin, xmax, ymax)``. bounds: tuple[float, float, float, float] #: Why the fragment cannot be used as a species, or ``None``. unsupported: str | None = None #: Its atoms' indices in the KET node, in the molecule's atom order. atoms: tuple[int, ...] = () #: Contracted abbreviations within it; their atoms stay in ``mol``. abbreviations: tuple[KetAbbreviation, ...] = () #: A molecule to draw in figures when it cannot be a species, such as a #: repeat unit in brackets; ``None`` when it cannot be drawn either. figure: Any = None #: Text figures print to its left, such as ``R1 =`` before an R-group's definition. caption: str | None = None
[docs] @dataclass class KetArrow: tail: tuple[float, float] head: tuple[float, float] mode: str height: float = 1.0
[docs] @dataclass class KetText: text: str centre: tuple[float, float]
[docs] @dataclass class KetScene: """What the canvas holds, in chemical terms.""" fragments: list[KetFragment] = field(default_factory=list) arrows: list[KetArrow] = field(default_factory=list) pluses: list[tuple[float, float]] = field(default_factory=list) texts: list[KetText] = field(default_factory=list)
# ── Reading ─────────────────────────────────────────────────────────────
[docs] def molfile(atoms: list[dict[str, Any]], bonds: list[dict[str, Any]]) -> str: """A molfile for KET atoms and bonds, in their order; callers check :func:`molecule_reason` first.""" if len(atoms) > 999 or len(bonds) > 999 or any(a.get("mapping", 0) > 999 for a in atoms): return _v3000(atoms, bonds) lines = ["", " vibesketch", ""] lines.append(f"{len(atoms):3d}{len(bonds):3d} 0 0 0 0 0 0 0 0999 V2000") charges: list[tuple[int, int]] = [] radicals: list[tuple[int, int]] = [] isotopes: list[tuple[int, int]] = [] for index, atom in enumerate(atoms, start=1): x, y, z = (float(v) for v in (list(atom.get("location", [0, 0, 0])) + [0, 0, 0])[:3]) valence = atom.get("explicitValence") valence_field = 0 if valence is None or valence < 0 else (15 if valence == 0 else valence) lines.append( f"{x:10.4f}{y:10.4f}{z:10.4f} {atom['label']:<3} 0 0 0 0 0{valence_field:3d}" f" 0 0 0{int(atom.get('mapping', 0)):3d} 0 0" ) if atom.get("charge"): charges.append((index, int(atom["charge"]))) if atom.get("radical"): radicals.append((index, int(atom["radical"]))) if atom.get("isotope"): isotopes.append((index, int(atom["isotope"]))) for bond in bonds: first, second = (int(i) + 1 for i in bond["atoms"]) stereo = _BOND_STEREO.get(int(bond.get("stereo", 0)), 0) lines.append(f"{first:3d}{second:3d}{int(bond['type']):3d}{stereo:3d}") for tag, values in (("CHG", charges), ("RAD", radicals), ("ISO", isotopes)): for start in range(0, len(values), 8): chunk = values[start : start + 8] body = "".join(f" {i:3d} {v:3d}" for i, v in chunk) lines.append(f"M {tag}{len(chunk):3d}{body}") lines.append("M END") return "\n".join(lines) + "\n"
def _v3000(atoms: list[dict[str, Any]], bonds: list[dict[str, Any]]) -> str: """The same KET properties without V2000's three-column count limits. MDL, CTfile Formats (December 1999), chapter 10, tables 10-1 and 10-2: https://mdli.com/downloads/literature/ctfile.pdf """ lines = [ "", " vibesketch", "", " 0 0 0 0 0 999 V3000", "M V30 BEGIN CTAB", f"M V30 COUNTS {len(atoms)} {len(bonds)} 0 0 0", "M V30 BEGIN ATOM", ] # fmt: skip for index, atom in enumerate(atoms, start=1): x, y, z = (float(v) for v in (list(atom.get("location", [0, 0, 0])) + [0, 0, 0])[:3]) properties = "" for key, tag in (("charge", "CHG"), ("radical", "RAD"), ("isotope", "MASS")): if atom.get(key): properties += f" {tag}={int(atom[key])}" valence = atom.get("explicitValence") if valence is not None and valence >= 0: properties += f" VAL={-1 if valence == 0 else int(valence)}" lines.append( f"M V30 {index} {atom['label']} {x:.4f} {y:.4f} {z:.4f} " f"{int(atom.get('mapping', 0))}{properties}" ) lines.extend(["M V30 END ATOM", "M V30 BEGIN BOND"]) # KET uses V2000 stereo: up=1, down=6, either single=4 or double=3. configuration = {1: 1, 6: 3, 4: 2, 3: 2} for index, bond in enumerate(bonds, start=1): first, second = (int(i) + 1 for i in bond["atoms"]) stereo = configuration.get(int(bond.get("stereo", 0)), 0) properties = f" CFG={stereo}" if stereo else "" lines.append(f"M V30 {index} {int(bond['type'])} {first} {second}{properties}") lines.extend(["M V30 END BOND", "M V30 END CTAB", "M END"]) return "\n".join(lines) + "\n" def _read_failure(block: str) -> str: """Distinguish malformed molfile data from a specific chemistry refusal.""" from rdkit import Chem try: unsanitized = Chem.MolFromMolBlock(block, sanitize=False, removeHs=False) if unsanitized is not None: Chem.SanitizeMol(unsanitized) except (ValueError, RuntimeError) as error: return f"RDKit refused the structure: {error}" return "RDKit could not parse the KET molfile"
[docs] def stereo_reason(molecule: dict[str, Any]) -> str | None: """Why the drawing's stereo cannot describe one absolute species, or ``None``.""" for index, atom in enumerate(molecule.get("atoms", [])): label = atom.get("stereoLabel") if label not in (None, "", "abs"): return ( f"atom {index} uses relative or mixture stereochemistry ({label}); " "choose an explicit absolute stereoisomer before calculation or export" ) return None
[docs] def molecule_reason(molecule: dict[str, Any]) -> str | None: """Why a KET molecule cannot be read as a plain structure, or ``None``.""" if reason := stereo_reason(molecule): return reason polymer = [g for g in molecule.get("sgroups", []) if g.get("type") in _POLYMER_SGROUPS] ends = ( _chain_ends(list(molecule.get("atoms", [])), list(molecule.get("bonds", [])), polymer) if polymer else set() ) for index, atom in enumerate(molecule.get("atoms", [])): if index in ends: continue # an open chain end, judged with its bracket below mapping = atom.get("mapping", 0) if type(mapping) is not int or not 0 <= mapping <= 999999999: return f"atom {index} has an unsupported atom-map number" kind = atom.get("type", "atom") if kind != "atom": return f"atom {index} is a query or R-group atom ({kind})" label = atom.get("label") if (named := attachment_label(atom)) is not None: return ( f"atom {index} is the label {named!r}, which stands for a material: " "a generic structure, not a species" ) if label not in ATOMIC_NUMBER: return f"atom {index} has the label {label!r}, which is not an element" for index, bond in enumerate(molecule.get("bonds", [])): if int(bond.get("type", 1)) not in _PLAIN_BONDS: return f"bond {index} is a query bond (type {bond.get('type')})" for sgroup in molecule.get("sgroups", []): if sgroup.get("type") in _POLYMER_SGROUPS: kind = sgroup.get("type") return f"it carries a {kind} bracket, which a single structure cannot express" return None
#: How far a bracket reaches either side of the bond it crosses, and what each #: connectivity prints beside it (head-to-tail is the default). _BRACKET = 0.5 _CONNECTIVITY = {"HT": None, "HH": "hh", "EU": "eu"} #: The label after a copolymer's closing bracket, per KET subtype: the #: connectives of "Source-based nomenclature for copolymers", IUPAC #: Recommendations 1985, Pure Appl. Chem. 57, 1427 (doi:10.1351/pac198557101427). _COPOLYMER = {"": "co", "ALT": "alt", "RAN": "ran", "BLO": "block"} #: How a bracket around another is set apart: further along the crossed bond #: (as a fraction of it) and taller, or further out around the atoms. _NESTED_ALONG = 0.3 _NESTED_TALLER = 0.6 _NESTED_AROUND = 0.3 #: How far a bracket around a group with no bonds leaving it stands off its atoms. _AROUND = 0.4 def _bracket_label(group: dict[str, Any]) -> tuple[dict[str, str], str | None]: """The RDKit ``LABEL`` and ``CONNECT`` of one KET bracket, or why it has none.""" kind = group.get("type") props: dict[str, str] = {} if kind == "MUL": count = group.get("mul", 1) if not isinstance(count, int) or isinstance(count, bool) or count < 1: return props, f"its multiple group has the multiplier {count!r}, not a whole number" props["LABEL"] = str(count) return props, None if kind == "GEN": return props, None if kind == "COP": subtype = str(group.get("subtype") or "").upper() if subtype not in _COPOLYMER: return props, f"its copolymer has the unknown subtype {subtype!r}" props["LABEL"] = _COPOLYMER[subtype] else: props["LABEL"] = str(group.get("subscript") or "n") connectivity = str(group.get("connectivity") or "HT").upper() if connectivity not in _CONNECTIVITY: name = "copolymer" if kind == "COP" else "repeat unit" return props, f"its {name} has the unknown connectivity {connectivity!r}" if label := _CONNECTIVITY[connectivity]: props["CONNECT"] = label return props, None def _chain_ends( atoms: list[dict[str, Any]], bonds: list[dict[str, Any]], groups: list[dict[str, Any]] ) -> set[int]: """The ``*`` atoms that end a polymer chain: outside every bracket, one bond into one. A repeat unit is drawn as –[CH2–CH(Ph)]n–, its bonds crossing the brackets to open ends; molfiles and CXSMILES write those ends as ``*``. """ inside = {int(a) for g in groups for a in g.get("atoms", [])} ends: set[int] = set() for index, atom in enumerate(atoms): if index in inside or atom.get("label") != "*" or atom.get("alias"): continue neighbours = [ int(b["atoms"][1] if int(b["atoms"][0]) == index else b["atoms"][0]) for b in bonds if index in (int(b["atoms"][0]), int(b["atoms"][1])) ] if len(neighbours) == 1 and neighbours[0] in inside: ends.add(index) return ends def _brackets(molecule: dict[str, Any]) -> tuple[Any, str | None]: """The molecule with its polymer brackets for figures, or why it cannot be drawn. Only a structure whose sole problem is brackets is drawn: repeat units (SRU), copolymers (COP), multiple groups (MUL) and generic brackets (GEN). A bracket crosses each of the two bonds leaving its group, with the label after the closing one, as "Graphic representations (chemical formulae) of macromolecules", IUPAC Recommendations 1994, Pure Appl. Chem. 66, 2469 (doi:10.1351/pac199466122469) draws them; a group with no bond leaving it is bracketed on its left and right. A bracket around another stands further out, so a copolymer's encloses its repeat units'. """ from rdkit import Chem, rdBase from rdkit.Geometry import Point3D groups = [g for g in molecule.get("sgroups", []) if g.get("type") in _POLYMER_SGROUPS] if not groups: return None, None atoms, bonds = list(molecule.get("atoms", [])), list(molecule.get("bonds", [])) ends = _chain_ends(atoms, bonds, groups) plain = { **{k: v for k, v in molecule.items() if k != "sgroups"}, "atoms": [{**a, "label": "C"} if i in ends else a for i, a in enumerate(atoms)], } if molecule_reason(plain) is not None: return None, None quiet = rdBase.BlockLogs() mol = Chem.MolFromMolBlock(molfile(atoms, bonds), sanitize=True, removeHs=False) del quiet if mol is None: return None, None for index in ends: # An open chain end: the bond leaves the bracket and ends, with no label (#145). end = mol.GetAtomWithIdx(index) end.SetProp("polymerEnd", "1") end.SetProp("_displayLabel", "") end.SetProp("_displayLabelW", "") conformer = mol.GetConformer() every = set(range(mol.GetNumAtoms())) members: list[frozenset[int]] = [] crossings: list[list[Any]] = [] for group in groups: found = frozenset(int(a) for a in group.get("atoms", [])) name = "repeat unit" if group.get("type") == "SRU" else f"{group.get('type')} bracket" if not found or not found <= every: return None, f"its {name} names atoms the molecule does not have" crossing = [ b for b in mol.GetBonds() if (b.GetBeginAtomIdx() in found) != (b.GetEndAtomIdx() in found) ] allowed = (2,) if group.get("type") == "SRU" else (0, 2) if len(crossing) not in allowed: count = f"{len(crossing)} bond{'' if len(crossing) == 1 else 's'}" needs = "two" if allowed == (2,) else "none or two" return None, f"its {name} has {count} leaving it, and a bracket needs {needs}" members.append(found) crossings.append(crossing) for i, first in enumerate(members): for second in members[i + 1 :]: if first == second: return None, "two of its brackets enclose the same atoms" if first & second and not (first <= second or second <= first): return None, "two of its brackets overlap without one enclosing the other" for index, group in enumerate(groups): props, why = _bracket_label(group) if why: return None, why found = members[index] inner = [j for j, other in enumerate(members) if other < found] bracket = Chem.CreateMolSubstanceGroup(mol, str(group["type"])) for atom in sorted(found): bracket.AddAtomWithIdx(atom) for key, value in props.items(): bracket.SetProp(key, value) for bond in crossings[index]: bracket.AddBondWithIdx(bond.GetIdx()) inside, outside = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() if inside not in found: inside, outside = outside, inside start, end = conformer.GetAtomPosition(inside), conformer.GetAtomPosition(outside) length = math.hypot(end.x - start.x, end.y - start.y) if length < 1e-8: return None, "a bracket crosses a bond of zero length" nested = sum(1 for j in inner if bond.GetIdx() in {b.GetIdx() for b in crossings[j]}) if nested > 1: return None, "three of its brackets cross one bond, and a figure can set apart two" along = 0.5 + _NESTED_ALONG * nested mx, my = start.x + (end.x - start.x) * along, start.y + (end.y - start.y) * along reach = _BRACKET * (1 + _NESTED_TALLER * nested) px, py = -(end.y - start.y) / length * reach, (end.x - start.x) / length * reach # RDKit's bracket is three points; the third is unused for a straight one. first, second = Point3D(mx + px, my + py, 0), Point3D(mx - px, my - py, 0) bracket.AddBracket((first, second, Point3D(0, 0, 0))) if not crossings[index]: xs = [conformer.GetAtomPosition(a).x for a in found] ys = [conformer.GetAtomPosition(a).y for a in found] pad = _AROUND + _NESTED_AROUND * len(inner) low, high = min(ys) - pad, max(ys) + pad for x in (min(xs) - pad, max(xs) + pad): bracket.AddBracket((Point3D(x, high, 0), Point3D(x, low, 0), Point3D(0, 0, 0))) return mol, None #: KET's attachment point values and RDKit's ``molAttchpt`` for them: first, #: second, or both on one atom. _ATTACHMENTS = {1: 1, 2: 2, 3: -1} def _r_groups(molecule: dict[str, Any], *, definition: bool = False) -> tuple[Any, str | None]: """The molecule with its R-group atoms labelled for figures, or why it cannot be drawn. An R-group atom becomes a dummy atom labelled ``R1`` (``R`` with no number); a definition's attachment points become dummy atoms that figures draw as wavy attachment bonds. Only a structure whose sole problem is R-groups is drawn. ``(None, None)`` means nothing here applies. """ from rdkit import Chem, rdBase atoms = list(molecule.get("atoms", [])) labels: dict[int, str] = {} for index, atom in enumerate(atoms): if atom.get("type") != "rg-label": continue refs = [str(r) for r in atom.get("$refs", [])] if len(refs) > 1: names = ", ".join(r.replace("rg-", "R") for r in refs) return None, f"atom {index} stands for several R-groups ({names}), one label cannot" number = refs[0].removeprefix("rg-") if refs else "" if number and not number.isdigit(): return None, f"atom {index} names the unknown R-group {refs[0]!r}" labels[index] = f"R{number}" points = { i: _ATTACHMENTS[int(a["attachmentPoints"])] for i, a in enumerate(atoms) if int(a.get("attachmentPoints") or 0) in _ATTACHMENTS } if not labels and not (definition and points): return None, None if definition and labels: return None, "an R-group inside an R-group's definition cannot be drawn yet" if any(g.get("type") in _POLYMER_SGROUPS for g in molecule.get("sgroups", [])): return None, None # Read the R-group atoms as carbons to find any other problem, then as dummies. plain = [{**a, "type": "atom", "label": "C"} if i in labels else a for i, a in enumerate(atoms)] if molecule_reason({**molecule, "atoms": plain}) is not None: return None, None dummies = [{**a, "label": "R#"} if i in labels else a for i, a in enumerate(plain)] quiet = rdBase.BlockLogs() mol = Chem.MolFromMolBlock( molfile(dummies, list(molecule.get("bonds", []))), sanitize=True, removeHs=False ) del quiet if mol is None: return None, None for index, label in labels.items(): mol.GetAtomWithIdx(index).SetProp("dummyLabel", label) if definition: for index, point in points.items(): mol.GetAtomWithIdx(index).SetIntProp("molAttchpt", point) mol = Chem.RWMol(mol) Chem.ExpandAttachmentPoints(mol, addAsQueries=False) mol = mol.GetMol() return mol, None #: Atom labels that are queries, not names of a material (MDL CTfile and Ketcher). _QUERY_LABELS = frozenset({"A", "AH", "Q", "QH", "X", "XH", "M", "MH", "L", "L#", "R#", "D", "T"}) #: What a labelled attachment atom may say: CNT, Au, NP, SiO2, GC electrode. _ATTACHMENT = re.compile(r"^[A-Za-z0-9*][A-Za-z0-9 ()+._/-]{0,23}$") def attachment_label(atom: dict[str, Any]) -> str | None: """The label a KET atom shows for a material it stands for, or ``None`` (#139). That is Ketcher's free-text atom label (a pseudo-atom, such as ``CNT``), or ``*`` or ``A`` with an alias, as a molfile writes a pseudo-atom (``Au`` for a gold surface rather than a gold atom). A bare ``*`` is an attachment with no label. """ if atom.get("type", "atom") != "atom": return None label = str(atom.get("label", "")) alias = str(atom.get("alias") or "").strip() if label in ("*", "A") and alias: return alias if _ATTACHMENT.fullmatch(alias) else None if label == "*": return "*" if label in ATOMIC_NUMBER or label in _QUERY_LABELS or re.fullmatch(r"R\d*", label): return None return label if _ATTACHMENT.fullmatch(label) else None def _labelled(molecule: dict[str, Any]) -> tuple[Any, str | None]: """A figure of a structure bonded to labelled attachment atoms (#139). Each such atom becomes a dummy atom carrying its label (``dummyLabel`` for figures, ``molFileAlias`` for molfiles); the rest must be a plain structure. ``(None, None)`` means nothing here applies. """ from rdkit import Chem, rdBase atoms = list(molecule.get("atoms", [])) labels = {i: label for i, a in enumerate(atoms) if (label := attachment_label(a)) is not None} if not labels: return None, None if any(g.get("type") in _POLYMER_SGROUPS for g in molecule.get("sgroups", [])): return None, None plain = [ {k: v for k, v in {**a, "label": "C"}.items() if k != "alias"} if i in labels else a for i, a in enumerate(atoms) ] if molecule_reason({**molecule, "atoms": plain}) is not None: return None, None for index in labels: bonds = [b for b in molecule.get("bonds", []) if index in b["atoms"]] if len(bonds) > 1 or any(int(b.get("type", 1)) != 1 for b in bonds): return None, ( f"the label {labels[index]!r} (atom {index}) must have one single bond to the " "structure it carries" ) dummies = [{**a, "label": "*"} if i in labels else a for i, a in enumerate(plain)] quiet = rdBase.BlockLogs() mol = Chem.MolFromMolBlock( molfile(dummies, list(molecule.get("bonds", []))), sanitize=True, removeHs=False ) del quiet if mol is None: return None, None for index, label in labels.items(): atom = mol.GetAtomWithIdx(index) atom.SetProp("dummyLabel", label) atom.SetProp("attachmentLabel", label) atom.SetProp("molFileAlias", label) # Printed as written on either side: RDKit would reverse "CNT" to "TNC" # on a bond leaving left, as it does a formula such as OH. shown = re.sub(r"(?<=[A-Za-z)])(\d+)", r"<sub>\1</sub>", label) atom.SetProp("_displayLabel", shown) atom.SetProp("_displayLabelW", shown) return mol, None #: One item of an R-group's occurrence range: a count, a span or a bound, #: as in the MDL CTfile's R-group logic line. _OCCURRENCE = re.compile(r"\s*(?:[<>=]?\s*\d+|\d+\s*-\s*\d+)\s*") def _r_logic(name: str, logic: dict[str, Any]) -> tuple[list[str], str | None]: """The lines that print an R-group's conditions, or why they cannot be printed. The wording is Ketcher's, so a figure says what the canvas shows: the occurrence range (``R1=1-3``, a bound such as ``R1>2`` as written), ``(RestH)`` when unsubstituted positions must be hydrogen, and ``IF … THEN R2`` when the group requires another. With none of them set nothing is printed; Ketcher's ``R1>0`` then only says the group occurs. """ occurrence = str(logic.get("range") or "").strip() rest = logic.get("resth") is True then = logic.get("ifthen") or 0 if not isinstance(then, int) or isinstance(then, bool) or then < 0: return [], f"{name}'s if-then condition names {then!r}, not an R-group number" if not (occurrence or rest or then): return [], None if occurrence and not all(_OCCURRENCE.fullmatch(item) for item in occurrence.split(",")): return [], f"{name}'s occurrence {occurrence!r} is not a count, span or bound" if occurrence: occurrence = re.sub(r"\s+", "", occurrence) occurrence = occurrence if occurrence[0] in "<>=" else f"={occurrence}" line = f"{'IF ' if then else ''}{name}{occurrence or '>0'}{' (RestH)' if rest else ''}" return [line, f"THEN R{then}"] if then else [line], None def _definition(node: str, molecule: dict[str, Any]) -> list[KetFragment]: """An R-group's definition: its substituents, drawn after ``R1 =`` in figures.""" atoms = list(molecule.get("atoms", [])) if not atoms: return [] logic = molecule.get("rlogic") or {} name = f"R{logic.get('number', '')}" reason = f"it is the definition of the R-group {name}, not one structure" figure: Any = None conditions, why = _r_logic(name, logic) if why is None: figure, why = _r_groups(molecule, definition=True) if figure is None and why is None: why = "its substituents are not plain structures with attachment points" if why: reason = f"{reason}; {why}" if figure is not None: conformer = figure.GetConformer() points = [(float(p[0]), float(p[1])) for p in conformer.GetPositions()] else: points = [(float(a["location"][0]), float(a["location"][1])) for a in atoms] box = _bounds(points) centre = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2) everything = tuple(range(len(atoms))) return [ KetFragment( None, node, centre, box, reason, everything, figure=figure, caption="\n".join([f"{name} =", *conditions]), ) ] def _bounds(points: list[tuple[float, float]]) -> tuple[float, float, float, float]: xs = [p[0] for p in points] or [0.0] ys = [p[1] for p in points] or [0.0] return (min(xs), min(ys), max(xs), max(ys)) def _components(count: int, bonds: list[dict[str, Any]]) -> list[list[int]]: """The connected pieces of a KET molecule's atoms, each in atom order.""" parent = list(range(count)) def root(i: int) -> int: while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i for bond in bonds: first, second = (int(a) for a in bond["atoms"]) if not (0 <= first < count and 0 <= second < count): return [list(range(count))] # malformed: kept whole, so its own reason stands parent[root(first)] = root(second) pieces: dict[int, list[int]] = {} for atom in range(count): pieces.setdefault(root(atom), []).append(atom) return sorted(pieces.values()) def _part(molecule: dict[str, Any], members: list[int]) -> dict[str, Any]: """The KET molecule of ``members``: their atoms, bonds and the S-groups within them.""" new = {old: index for index, old in enumerate(members)} part: dict[str, Any] = { "type": molecule.get("type", "molecule"), "atoms": [molecule["atoms"][i] for i in members], "bonds": [ {**bond, "atoms": [new[int(a)] for a in bond["atoms"]]} for bond in molecule.get("bonds", []) if all(int(a) in new for a in bond["atoms"]) ], } sgroups = [] for sgroup in molecule.get("sgroups", []): atoms = [int(a) for a in sgroup.get("atoms", [])] if atoms and all(a in new for a in atoms): moved = {**sgroup, "atoms": [new[a] for a in atoms]} if "attachmentPoints" in sgroup: moved["attachmentPoints"] = [ {**point, "attachmentAtom": new[int(point["attachmentAtom"])]} for point in sgroup["attachmentPoints"] if int(point.get("attachmentAtom", -1)) in new ] sgroups.append(moved) if sgroups: part["sgroups"] = sgroups return part def _fragments(node: str, molecule: dict[str, Any]) -> list[KetFragment]: from rdkit import Chem atoms: list[dict[str, Any]] = list(molecule.get("atoms", [])) bonds: list[dict[str, Any]] = list(molecule.get("bonds", [])) points = [(float(a["location"][0]), float(a["location"][1])) for a in atoms] if not atoms: return [] reason = molecule_reason(molecule) if reason is None: from rdkit import rdBase _quiet = rdBase.BlockLogs() # a refused molecule is reported, not logged block = molfile(atoms, bonds) mol = Chem.MolFromMolBlock(block, sanitize=True, removeHs=False) if mol is None: reason = _read_failure(block) else: from vibesketch.chem.species import inert_pair_radicals # A radical the canvas draws is kept; RDKit's guess for Pb²⁺ is not (#147). inert_pair_radicals(mol, lambda index: bool(atoms[index].get("radical"))) del _quiet if reason is not None and len(components := _components(len(atoms), bonds)) > 1: # One refused piece, such as a polymer read from a MOL file beside other # molecules, does not take the others with it: each is read on its own. result: list[KetFragment] = [] for members in components: for fragment in _fragments(node, _part(molecule, members)): fragment.atoms = tuple(members[i] for i in fragment.atoms) result.append(fragment) return result if reason is not None: box = _bounds(points) centre = ((box[0] + box[2]) / 2, (box[1] + box[3]) / 2) figure, why = _brackets(molecule) if figure is None and why is None: figure, why = _r_groups(molecule) if figure is None and why is None: figure, why = _labelled(molecule) if why: reason = f"{reason}; {why}" everything = tuple(range(len(atoms))) return [KetFragment(None, node, centre, box, reason, everything, figure=figure)] pieces = Chem.GetMolFrags(mol, asMols=True, sanitizeFrags=True) # The same order as the molecules above, as RDKit documents. indices = Chem.GetMolFrags(mol, asMols=False, sanitizeFrags=False) result = [] for piece, members in zip(pieces, indices, strict=True): conformer = piece.GetConformer() coords = [ (conformer.GetAtomPosition(i).x, conformer.GetAtomPosition(i).y) for i in range(piece.GetNumAtoms()) ] box = _bounds(coords) centre = (sum(c[0] for c in coords) / len(coords), sum(c[1] for c in coords) / len(coords)) ket_atoms = tuple(int(i) for i in members) abbreviations = _abbreviations(molecule, ket_atoms) result.append(KetFragment(piece, node, centre, box, None, ket_atoms, abbreviations)) return result def _abbreviations( molecule: dict[str, Any], members: tuple[int, ...] ) -> tuple[KetAbbreviation, ...]: """The contracted SUP S-groups wholly inside the fragment of KET atoms ``members``. A group that is expanded, unnamed, or reaches outside the fragment is left out, so its atoms are drawn as atoms: the structure is never hidden. """ position = {atom: index for index, atom in enumerate(members)} found = [] for sgroup in molecule.get("sgroups", []): if sgroup.get("type") != "SUP" or sgroup.get("expanded") is True: continue label = str(sgroup.get("name") or "").strip() atoms = [int(a) for a in sgroup.get("atoms", [])] if label and atoms and all(a in position for a in atoms): found.append(KetAbbreviation(label, tuple(sorted(position[a] for a in atoms)))) return tuple(found) def _text_of(node: dict[str, Any]) -> str: """The text of a KET text node, in the KET 2.0 or the legacy form.""" paragraphs = node.get("paragraphs") if isinstance(paragraphs, list): lines = [ "".join(str(part.get("text", "")) for part in paragraph.get("parts", [])) for paragraph in paragraphs ] return "\n".join(lines).strip() content = node.get("data", {}).get("content") if isinstance(content, str): try: blocks = json.loads(content).get("blocks", []) return "\n".join(str(block.get("text", "")) for block in blocks).strip() except (ValueError, AttributeError): return content.strip() return "" def _text_centre(node: dict[str, Any]) -> tuple[float, float]: box = node.get("boundingBox") if isinstance(box, dict): return ( float(box["x"]) + float(box.get("width", 0)) / 2, float(box["y"]) - float(box.get("height", 0)) / 2, ) position = node.get("data", {}).get("position") or node.get("data", {}).get("pos", [{}])[0] return (float(position.get("x", 0)), float(position.get("y", 0)))
[docs] def read_scene(ket: dict[str, Any]) -> KetScene: """The molecules, arrows, plus signs and text on a KET canvas.""" scene = KetScene() root = ket.get("root", {}) for node in root.get("nodes", []): if "$ref" in node: molecule = ket.get(node["$ref"]) if isinstance(molecule, dict) and molecule.get("type", "molecule") == "molecule": scene.fragments.extend(_fragments(node["$ref"], molecule)) elif isinstance(molecule, dict) and molecule.get("type") == "rgroup": scene.fragments.extend(_definition(node["$ref"], molecule)) continue kind = node.get("type") if kind == "arrow": pos = node["data"]["pos"] scene.arrows.append( KetArrow( (float(pos[0]["x"]), float(pos[0]["y"])), (float(pos[1]["x"]), float(pos[1]["y"])), str(node["data"].get("mode", "open-angle")), float(node["data"].get("height", 1.0)), ) ) elif kind == "plus": location = node.get("location", [0, 0]) scene.pluses.append((float(location[0]), float(location[1]))) elif kind == "text": text = _text_of(node) if text: scene.texts.append(KetText(text, _text_centre(node))) return scene
# ── Writing ───────────────────────────────────────────────────────────── #: The bond length Ketcher draws with, in KET units. BOND_LENGTH = 1.0 #: Horizontal gap between laid-out items, in bond lengths. GAP = 1.5 def _prepared(mol: Any) -> Any: """A copy without unneeded hydrogens, with 2D coordinates, Kekulé bonds and wedges.""" from rdkit import Chem, rdBase from vibesketch.chem.layout import compute_2d if any( group.GetGroupType() != Chem.StereoGroupType.STEREO_ABSOLUTE for group in mol.GetStereoGroups() ): raise ValueError( "relative or mixture stereochemistry cannot become one absolute canvas species" ) blocker = rdBase.BlockLogs() # "not removing hydrogen atom without neighbors" copy = Chem.RemoveHs(Chem.Mol(mol), sanitize=True) del blocker # Layout needs the E/Z assignment before it chooses atom coordinates. Chem.AssignStereochemistry(copy, cleanIt=True, force=True) for centre in Chem.FindPotentialStereo(copy): if ( centre.type == Chem.StereoType.Bond_Double and centre.specified == Chem.StereoSpecified.Unspecified ): # A crossed KET double bond prevents layout from inventing E/Z. copy.GetBondWithIdx(centre.centeredOn).SetStereo(Chem.BondStereo.STEREOANY) if copy.GetNumConformers() == 0 or copy.GetConformer().Is3D(): compute_2d(copy) Chem.Kekulize(copy, clearAromaticFlags=True) Chem.WedgeMolBonds(copy, copy.GetConformer()) return copy def _scale(mol: Any) -> float: # All positions in one call: one Point3D per atom is far slower for large molecules. positions = mol.GetConformer().GetPositions().tolist() lengths = [ math.dist(positions[b.GetBeginAtomIdx()][:2], positions[b.GetEndAtomIdx()][:2]) for b in mol.GetBonds() ] mean = sum(lengths) / len(lengths) if lengths else 1.5 return BOND_LENGTH / mean if mean > 1e-6 else 1.0 #: RDKit's ``molAttchpt`` as KET's attachment point value. _ATTACHMENTS_BACK = {1: 1, 2: 2, -1: 3} def _r_number(atom: Any) -> int | None: """An R-group atom's number, 0 when it has none, or ``None`` for any other atom.""" if atom.GetAtomicNum() != 0: return None if atom.HasProp("_MolFileRLabel"): return int(atom.GetIntProp("_MolFileRLabel")) label = atom.GetProp("dummyLabel") if atom.HasProp("dummyLabel") else "" if label == "R" or label == "R#": return 0 return int(label[1:]) if label[:1] == "R" and label[1:].isdigit() else None def _attachment_of(atom: Any) -> str | None: """The label of an RDKit dummy atom that stands for a material, or ``None`` (#139). A molfile writes one as ``A`` or ``*`` with an alias, which RDKit keeps as ``molFileAlias``; a SMILES ``*`` has no label. """ if atom.GetAtomicNum() != 0: return None if atom.HasProp("attachmentLabel"): return str(atom.GetProp("attachmentLabel")) if atom.HasProp("molFileAlias"): return attachment_label({"label": "*", "alias": str(atom.GetProp("molFileAlias"))}) label = atom.GetProp("dummyLabel") if atom.HasProp("dummyLabel") else "" return "*" if label == "*" and not atom.HasProp("_MolFileRLabel") else None
[docs] def rgroup_node( number: int, logic: dict[str, Any], members: list[Any], origin: tuple[float, float] ) -> tuple[dict[str, Any], float]: """A KET R-group definition of ``members`` in a row from ``origin``; it and its width.""" atoms: list[dict[str, Any]] = [] bonds: list[dict[str, Any]] = [] x = origin[0] for index, member in enumerate(members): if index: x += GAP molecule, width = _ket_molecule(member, (x, origin[1])) offset = len(atoms) atoms.extend(molecule["atoms"]) bonds.extend( {**bond, "atoms": [a + offset for a in bond["atoms"]]} for bond in molecule["bonds"] ) x += width node = { "type": "rgroup", "rlogic": {"number": number, **logic}, "atoms": atoms, "bonds": bonds, } return node, x - origin[0]
def _ket_molecule(mol: Any, origin: tuple[float, float]) -> tuple[dict[str, Any], float]: """A KET molecule placed with its left edge at ``origin``; returns it and its width.""" prepared = _prepared(mol) factor = _scale(prepared) conformer = prepared.GetConformer() raw = [ (conformer.GetAtomPosition(i).x * factor, conformer.GetAtomPosition(i).y * factor) for i in range(prepared.GetNumAtoms()) ] xmin, ymin, xmax, ymax = _bounds(raw) dx, dy = origin[0] - xmin, origin[1] - (ymin + ymax) / 2 return _ket_entries(prepared, [(x + dx, y + dy) for x, y in raw]), xmax - xmin
[docs] def placed_molecule(mol: Any, scale: float = 1.0) -> dict[str, Any]: """A KET molecule where ``mol``'s 2D coordinates put it, multiplied by ``scale``. For structures whose drawing is kept, such as those read from CDXML. """ prepared = _prepared(mol) conformer = prepared.GetConformer() points = [ (conformer.GetAtomPosition(i).x * scale, conformer.GetAtomPosition(i).y * scale) for i in range(prepared.GetNumAtoms()) ] return _ket_entries(prepared, points)
def _ket_entries(prepared: Any, points: list[tuple[float, float]]) -> dict[str, Any]: """The KET atoms and bonds of a :func:`_prepared` molecule at ``points``.""" from rdkit import Chem if not all(math.isfinite(v) for point in points for v in point): # Never hand the canvas a NaN: Ketcher drops the whole document. raise ValueError("2D layout produced non-finite coordinates") atoms: list[dict[str, Any]] = [] for atom, (x, y) in zip(prepared.GetAtoms(), points, strict=True): location = [round(x, 6), round(y, 6), 0.0] named = _attachment_of(atom) if named is not None: # A material the structure is bonded to (#139): Ketcher's pseudo-atom # label, or an alias where the label is an element's symbol (Au). pseudo = attachment_label({"label": named}) == named atoms.append( {"label": named, "location": location} if pseudo else {"label": "A", "alias": named, "location": location} ) continue number = _r_number(atom) if number is not None: # An R-group atom, as a molfile's R# with its RGROUPS (#90). atoms.append( {"type": "rg-label", "location": location, "$refs": [f"rg-{number}"]} if number else {"type": "rg-label", "location": location} ) continue entry: dict[str, Any] = {"label": atom.GetSymbol(), "location": location} if atom.GetAtomMapNum(): entry["mapping"] = atom.GetAtomMapNum() if atom.HasProp("molAttchpt"): point = _ATTACHMENTS_BACK.get(atom.GetIntProp("molAttchpt")) if point: entry["attachmentPoints"] = point if atom.GetFormalCharge(): entry["charge"] = atom.GetFormalCharge() if atom.GetIsotope(): entry["isotope"] = atom.GetIsotope() from vibesketch.chem.species import unpaired if unpaired(atom): entry["radical"] = 1 + unpaired(atom) atoms.append(entry) bonds: list[dict[str, Any]] = [] for bond in prepared.GetBonds(): order = { Chem.BondType.SINGLE: 1, Chem.BondType.DOUBLE: 2, Chem.BondType.TRIPLE: 3, Chem.BondType.AROMATIC: 4, }.get(bond.GetBondType(), 1) entry = {"type": order, "atoms": [bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()]} direction = bond.GetBondDir() if direction == Chem.BondDir.BEGINWEDGE: entry["stereo"] = 1 elif direction == Chem.BondDir.BEGINDASH: entry["stereo"] = 6 elif direction == Chem.BondDir.UNKNOWN: entry["stereo"] = 4 elif bond.GetStereo() == Chem.BondStereo.STEREOANY: entry["stereo"] = 3 bonds.append(entry) molecule: dict[str, Any] = {"type": "molecule", "atoms": atoms, "bonds": bonds} if sgroups := [*_superatoms(prepared), *_polymer_sgroups(prepared)]: molecule["sgroups"] = sgroups return molecule #: A molfile copolymer's subtype (MDL ``SST``) as KET writes it, and back. COPOLYMER_SUBTYPES = {"ALT": "ALT", "RAN": "RAN", "BLO": "BLO"} def _polymer_sgroups(mol: Any) -> list[dict[str, Any]]: """A molecule's repeat-unit, copolymer and generic brackets, as the canvas's S-groups. MOL and SDF files carry them as SRU, COP and GEN S-groups (MDL CTfile formats, the ``STY``, ``SST``, ``SMT`` and ``SCN`` lines); the canvas redraws the brackets itself. Verified multiple groups (MUL) arrive contracted by the importer; foreign expanded groups remain explicit. """ from rdkit import Chem found = [] for group in Chem.GetMolSubstanceGroups(mol): kind = group.GetProp("TYPE") atoms = sorted(int(a) for a in group.GetAtoms()) if kind not in ("SRU", "COP", "GEN", "MUL") or not atoms: continue if kind == "MUL" and group.GetParentAtoms(): continue # Foreign expanded records not verified by the importer. entry: dict[str, Any] = {"type": kind, "atoms": atoms} if kind == "MUL": entry["mul"] = int(group.GetProp("LABEL")) if kind in ("SRU", "COP"): connect = group.GetProp("CONNECT") if group.HasProp("CONNECT") else "HT" entry["connectivity"] = str(connect).upper() if kind == "SRU": entry["subscript"] = str(group.GetProp("LABEL")) if group.HasProp("LABEL") else "n" if kind == "COP" and group.HasProp("SUBTYPE"): subtype = COPOLYMER_SUBTYPES.get(str(group.GetProp("SUBTYPE")).upper()) if subtype: entry["subtype"] = subtype found.append(entry) return found def _superatoms(mol: Any) -> list[dict[str, Any]]: """A molecule's labelled superatom (SUP) S-groups, as the canvas's abbreviations. A MOL, SDF or RXN file names contracted groups such as Boc this way; they stay contracted on the canvas unless the file marks them expanded (``SDS EXP``). The structure keeps every atom either way. """ from rdkit import Chem found = [] for group in Chem.GetMolSubstanceGroups(mol): if group.GetProp("TYPE") != "SUP" or not group.HasProp("LABEL"): continue label = str(group.GetProp("LABEL")).strip() atoms = sorted(int(a) for a in group.GetAtoms()) if not label or not atoms: continue expanded = group.HasProp("ESTATE") and group.GetProp("ESTATE") == "E" found.append( { "type": "SUP", "atoms": atoms, "name": label, "expanded": expanded, "attachmentPoints": [ {"attachmentAtom": int(point.aIdx)} for point in group.GetAttachPoints() ], } ) return found
[docs] def text_node(text: str, centre: tuple[float, float]) -> dict[str, Any]: """A KET text centred at ``centre``, a paragraph per line.""" lines = text.split("\n") width = max(0.4, 0.22 * max(len(line) for line in lines)) height = 0.4 * len(lines) return { "type": "text", "boundingBox": { "x": centre[0] - width / 2, "y": centre[1] + height / 2, "width": width, "height": height, }, "paragraphs": [{"parts": [{"text": line}]} for line in lines], }
#: The KET arrow mode that draws each of the document's arrow kinds. ARROW_MODES = { "forward": "open-angle", "equilibrium": "equilibrium-open-angle", "retrosynthetic": "retrosynthetic", "resonance": "both-ends-filled-triangle", } #: Vertical gap between the rows of a scheme, in bond lengths. ROW_GAP = 2.5
[docs] @dataclass(frozen=True) class SceneStep: """One reaction row: the molecules at the arrow's tail and head, and what is at it.""" tail: list[Any] head: list[Any] reagents: list[Any] = field(default_factory=list) arrow: str = "forward" conditions: list[str] = field(default_factory=list)
def _extent(node: dict[str, Any], document: dict[str, Any]) -> tuple[float, float]: """The lowest and highest y of a node.""" if "$ref" in node: ys = [a["location"][1] for a in document[node["$ref"]]["atoms"]] return min(ys), max(ys) if node["type"] == "text": box = node["boundingBox"] return box["y"] - box["height"], box["y"] if node["type"] == "arrow": ys = [p["y"] for p in node["data"]["pos"]] return min(ys), max(ys) return node["location"][1], node["location"][1] def _shift(node: dict[str, Any], document: dict[str, Any], dy: float) -> None: if "$ref" in node: for atom in document[node["$ref"]]["atoms"]: atom["location"][1] = round(atom["location"][1] + dy, 6) elif node["type"] == "text": node["boundingBox"]["y"] += dy elif node["type"] == "arrow": for point in node["data"]["pos"]: point["y"] += dy else: node["location"][1] += dy
[docs] def write_scene( molecules: list[Any], *, reaction: tuple[list[Any], list[Any], list[Any]] | None = None, conditions: list[str] | None = None, steps: list[SceneStep] | None = None, ) -> dict[str, Any]: """A KET document laying ``molecules`` out in a row, or a reaction scheme. With ``reaction=(reactants, reagents, products)`` the row is the reactants joined by plus signs, an arrow with the reagents above it and the ``conditions`` below, then the products. With ``steps`` each step is such a row, the rows one below the other, a species that takes part in several steps drawn in each, and ``molecules`` in a last row. """ document: dict[str, Any] = { "ket_version": "2.0.0", "root": {"nodes": [], "connections": [], "templates": []}, } nodes: list[dict[str, Any]] = document["root"]["nodes"] counter = 0 def place(row: list[dict[str, Any]], mol: Any, x: float, y: float = 0.0) -> float: nonlocal counter molecule, width = _ket_molecule(mol, (x, y)) name = f"mol{counter}" counter += 1 document[name] = molecule row.append({"$ref": name}) return x + width def side(row: list[dict[str, Any]], mols: list[Any], x: float) -> float: for index, mol in enumerate(mols): if index: row.append({"type": "plus", "location": [x + GAP, 0.0, 0.0]}) x += GAP * 2 x = place(row, mol, x) return x def step_row(step: SceneStep) -> list[dict[str, Any]]: row: list[dict[str, Any]] = [] tail = side(row, step.tail, 0.0) + GAP above_width = 0.0 above_x = tail + 0.5 for mol in step.reagents: above_x = place(row, mol, above_x, 1.6) + GAP above_width = above_x - tail longest = max((len(line) for line in step.conditions), default=0) * 0.22 head = tail + max(3.0, above_width, longest + 1.0) mode = ARROW_MODES.get(step.arrow, ARROW_MODES["forward"]) row.append( { "type": "arrow", "data": { "mode": mode, "pos": [{"x": tail, "y": 0.0, "z": 0.0}, {"x": head, "y": 0.0, "z": 0.0}], }, } ) for number, line in enumerate(step.conditions): row.append(text_node(line, ((tail + head) / 2, -0.6 - 0.5 * number))) side(row, step.head, head + GAP) return row if reaction is not None: reactants, reagents, products = reaction steps = [SceneStep(reactants, products, reagents, "forward", list(conditions or []))] if not steps: row: list[dict[str, Any]] = [] x = 0.0 for index, mol in enumerate(molecules): if index: x += GAP * 2 x = place(row, mol, x) nodes.extend(row) return document rows = [step_row(step) for step in steps] if molecules: # what takes part in no step, in a row of its own below loose: list[dict[str, Any]] = [] x = 0.0 for index, mol in enumerate(molecules): if index: x += GAP * 2 x = place(loose, mol, x) rows.append(loose) bottom = None for row in rows: if bottom is not None: top = max(_extent(node, document)[1] for node in row) for node in row: _shift(node, document, bottom - ROW_GAP - top) bottom = min(_extent(node, document)[0] for node in row) nodes.extend(row) return document