"""Names for drawn species, through vibe-qc's naming engine when it is there.
``vibeqc_naming`` is an optional standalone distribution. Drawn bonds and
RDKit-assigned CIP descriptors are passed directly to its graph API.
The engine's names are checked before they are shown (vibe-qc #539 lists
high-confidence wrong names). A name is mapped back to a structure and
compared with the drawing by the connectivity block of the InChIKey, in two
ways, because each of the engine's back-mappings has faults of its own:
1. the engine's 3D structure for the name (``structure_from_name``), with
bonds perceived by RDKit (``rdDetermineBonds.DetermineBonds``, RDKit's
implementation of xyz2mol: Kim and Kim, *Bull. Korean Chem. Soc.* 2015,
36, 1769);
2. the engine's SMILES for the name (``smiles_from_name``), which mangles
carbonyl groups (vibe-qc #539).
The outcome:
* **verified**: either mapping gives the drawn constitution;
* **rejected**: the 3D structure gives a different, valid molecule, so the
name describes something else and is not shown;
* **unverified**: no mapping could check it (only a SMILES mismatch, or none
at all), so it is shown as a suggestion with that caveat;
* **not-applicable**: an ion or a radical, which the engine cannot name yet
(it takes no charge or spin);
* **unavailable** or **failed**: no engine, or no 3D structure to give it.
Stereo-defined drawings require a full stereochemical identity match to
verify a name; a constitution-only match remains an unverified suggestion.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Literal
__all__ = [
"NameResult",
"NamingUnavailable",
"install_hint",
"name_for",
"naming_available",
"smiles_for_name",
]
Status = Literal["verified", "unverified", "rejected", "not-applicable", "unavailable", "failed"]
[docs]
class NamingUnavailable(RuntimeError):
"""``vibeqc_naming`` cannot be imported."""
def __init__(self) -> None:
super().__init__(install_hint())
[docs]
def install_hint() -> str:
from vibesketch.install_hints import not_in_app, self_contained
if self_contained():
return not_in_app("Naming", "vibe-qc's naming engine")
return (
"naming needs the standalone vibeqc-naming package. Install with "
"`python -m pip install --find-links "
"https://vibe-qc.com/updates/vibeqc-naming/0.1.1/index.html "
"'vibeqc-naming>=0.1.1,<0.2'`; no vibe-qc core is needed. "
"A source checkout remains available through --naming-path."
)
[docs]
@dataclass(frozen=True)
class NameResult:
status: Status
value: str | None = None
source: str | None = None
confidence: str | None = None
note: str | None = None
[docs]
@property
def shown(self) -> bool:
"""Whether the name may be shown to the user at all."""
return self.status in ("verified", "unverified") and self.value is not None
def _engine() -> Any:
try:
import vibeqc_naming
except ImportError as error:
raise NamingUnavailable() from error
return vibeqc_naming
[docs]
def naming_available() -> bool:
try:
_engine()
except NamingUnavailable:
return False
return True
def _connectivity(inchikey: str | None) -> str | None:
return inchikey.split("-")[0] if inchikey else None
def _drawn_graph(engine: Any, smiles: str) -> Any:
"""Transmit explicit H/bonds and CIP labels; never infer bonds from geometry."""
from rdkit import Chem
from rdkit.Chem import rdCIPLabeler
from vibesketch.chem.species import mol_from_smiles
mol = Chem.AddHs(mol_from_smiles(smiles))
# RDKit's CIP implementation supplies absolute descriptors; graph indices
# remain stable through Kekulization (IUPAC Blue Book 2013, P-9).
rdCIPLabeler.AssignCIPLabels(mol)
atom_stereo = {
atom.GetIdx(): atom.GetProp("_CIPCode")
for atom in mol.GetAtoms()
if atom.HasProp("_CIPCode")
}
bond_stereo = {
(bond.GetBeginAtomIdx(), bond.GetEndAtomIdx()): bond.GetProp("_CIPCode")
for bond in mol.GetBonds()
if bond.HasProp("_CIPCode")
}
Chem.Kekulize(mol, clearAromaticFlags=True)
return engine.MolecularGraph.from_bonds(
[atom.GetAtomicNum() for atom in mol.GetAtoms()],
[
(b.GetBeginAtomIdx(), b.GetEndAtomIdx(), int(b.GetBondTypeAsDouble()))
for b in mol.GetBonds()
],
atom_stereo=atom_stereo,
bond_stereo=bond_stereo,
)
[docs]
@lru_cache(maxsize=512)
def name_for(smiles: str, charge: int = 0, multiplicity: int = 1) -> NameResult:
"""The checked name of the species with this SMILES, charge and multiplicity."""
from vibesketch.chem.species import identity, mol_from_smiles
if charge != 0 or multiplicity != 1:
return NameResult(
"not-applicable",
note="the naming engine cannot name ions or radicals yet (vibe-qc #539)",
)
try:
engine = _engine()
except NamingUnavailable as error:
return NameResult("unavailable", note=str(error))
drawn = identity(mol_from_smiles(smiles))
try:
result = engine.name_from_graph_detailed(_drawn_graph(engine, smiles))
except Exception as error: # the engine is another project's code; report, do not crash
return NameResult("failed", note=f"the naming engine failed: {error}")
name = str(result.name).strip()
source = f"vibeqc_naming:{getattr(result.source, 'value', result.source)}"
confidence = str(getattr(result.confidence, "value", result.confidence))
if not name:
return NameResult("failed", note="the naming engine returned no name")
target = _connectivity(drawn.inchikey)
from_3d = _key_from_structure(engine, name)
from_smiles = _key_from_smiles(engine, name)
if drawn.has_stereo:
if drawn.inchikey is not None and drawn.inchikey in (from_3d, from_smiles):
return NameResult("verified", name, source, confidence)
if target is not None and target in (_connectivity(from_3d), _connectivity(from_smiles)):
return NameResult(
"unverified",
name,
source,
confidence,
"the name matches connectivity but its stereodescriptors are not verified",
)
elif target is not None and target in (_connectivity(from_3d), _connectivity(from_smiles)):
return NameResult("verified", name, source, confidence)
if from_3d is not None:
return NameResult(
"rejected",
name,
source,
confidence,
f"the engine's structure for {name!r} is a different molecule",
)
return NameResult(
"unverified",
name,
source,
confidence,
"the engine cannot map this name back to a structure that checks it",
)
def _key_from_structure(engine: Any, name: str) -> str | None:
"""InChIKey of the engine's 3D structure for ``name``, bonds perceived by RDKit."""
from rdkit import Chem, rdBase
from rdkit.Chem import inchi as inchi_module
from rdkit.Chem import rdDetermineBonds
from vibesketch.chem._rdkit import untyped
inchi = untyped(inchi_module)
try:
atoms = engine.structure_from_name(name)
except Exception:
return None
if not atoms:
return None
table = Chem.GetPeriodicTable()
lines = [str(len(atoms)), name]
lines += [f"{table.GetElementSymbol(int(z))} {x} {y} {w}" for z, x, y, w in atoms]
blocker = rdBase.BlockLogs()
try:
mol = Chem.MolFromXYZBlock("\n".join(lines))
rdDetermineBonds.DetermineBonds(mol, charge=0)
key = str(inchi.MolToInchiKey(Chem.RemoveHs(mol))) or None
except Exception:
key = None
del blocker
return key
def _key_from_smiles(engine: Any, name: str) -> str | None:
"""InChIKey of the engine's SMILES for ``name``, if RDKit can read it."""
from vibesketch.chem.species import identity, mol_from_smiles
try:
smiles = engine.smiles_from_name(name)
return identity(mol_from_smiles(str(smiles))).inchikey if smiles else None
except Exception:
return None
[docs]
def smiles_for_name(name: str) -> str | None:
"""The SMILES the engine knows for ``name``, or ``None``; raises :class:`NamingUnavailable`."""
engine = _engine()
try:
smiles = engine.smiles_from_name(name.strip())
except Exception:
return None
return str(smiles) if smiles else None