"""Stereodescriptors of a document's species, and as labels in its drawing (#92).
The descriptors come from :mod:`vibesketch.chem.cip`. In the drawing they
are KET ``cip`` properties on atoms and bonds, which the canvas shows as
"(R)" and "(E)". They are a snapshot: labelling writes them, clearing removes
them, and a label that no longer matches its structure after an edit is
reported as out of date rather than silently trusted.
"""
from __future__ import annotations
import copy
from typing import Any
from vibesketch.document.model import Canvas, Document
__all__ = ["described", "stale_labels", "stereo_report", "with_stereo_labels"]
[docs]
def described(mol: Any) -> dict[str, Any]:
"""The labels of ``mol``, with each atom named as its symbol and number (C2 for index 1)."""
from vibesketch.chem.cip import stereo_labels
labels = stereo_labels(mol)
def name(index: int) -> str:
return f"{mol.GetAtomWithIdx(index).GetSymbol()}{index + 1}"
return {
**labels.to_dict(),
"summary": [f"({label}) at {name(atom)}" for atom, label in labels.atoms]
+ [f"({label}) at {name(a)}={name(b)}" for (a, b), label in labels.bonds],
"undefined": [name(atom) for atom in labels.undefined_centres]
+ [f"{name(a)}={name(b)}" for a, b in labels.unspecified_double_bonds],
}
[docs]
def stereo_report(document: Document) -> dict[str, dict[str, Any]]:
"""Each species' descriptors, undefined stereo and enhanced-stereo groups."""
from vibesketch.document.formats import species_mols
return {
species: described(mol)
for species, mol in species_mols(document).items()
if mol is not None
}
def _expected(fragment: Any) -> tuple[dict[int, str], dict[frozenset[int], str]]:
"""What a fragment's KET atoms and bonds should carry, by KET index."""
from vibesketch.chem.cip import stereo_labels
labels = stereo_labels(fragment.mol)
ket = fragment.atoms
atoms = {ket[index]: label for index, label in labels.atoms}
bonds = {frozenset((ket[a], ket[b])): label for (a, b), label in labels.bonds}
return atoms, bonds
[docs]
def with_stereo_labels(document: Document, *, clear: bool = False) -> Document:
"""``document`` with every drawn structure's descriptors written as labels, or removed."""
from vibesketch.document.ket import read_scene
data: dict[str, Any] = copy.deepcopy(document.canvas.data)
for fragment in read_scene(data).fragments if data else []:
node = data.get(fragment.node)
if not isinstance(node, dict) or fragment.mol is None:
continue
atoms, bonds = ({}, {}) if clear else _expected(fragment)
mine = set(fragment.atoms)
for index, atom in enumerate(node.get("atoms", [])):
if index in mine:
atom.pop("cip", None)
if index in atoms:
atom["cip"] = atoms[index]
for bond in node.get("bonds", []):
ends = frozenset(bond.get("atoms", []))
if ends <= mine:
bond.pop("cip", None)
if ends in bonds:
bond["cip"] = bonds[ends]
return document.model_copy(update={"canvas": Canvas(format="ket", data=data)})
[docs]
def stale_labels(document: Document) -> list[str]:
"""The species whose drawn labels no longer match their structure."""
from vibesketch.chem.species import identity
from vibesketch.document.ket import read_scene
data: dict[str, Any] = document.canvas.data
if not data:
return []
by_smiles = {s.smiles: s.id for s in document.species}
stale: list[str] = []
for fragment in read_scene(data).fragments:
node: Any = data.get(fragment.node)
if not isinstance(node, dict) or fragment.mol is None:
continue
mine = set(fragment.atoms)
drawn_atoms = {
i: a["cip"]
for i, a in enumerate(node.get("atoms", []))
if i in mine and isinstance(a, dict) and "cip" in a
}
drawn_bonds = {
frozenset(b.get("atoms", [])): b["cip"]
for b in node.get("bonds", [])
if isinstance(b, dict) and "cip" in b and frozenset(b.get("atoms", [])) <= mine
}
if not drawn_atoms and not drawn_bonds:
continue
if (drawn_atoms, drawn_bonds) != _expected(fragment):
species = by_smiles.get(identity(fragment.mol).smiles)
if species is not None and species not in stale:
stale.append(species)
return stale