🔄 Ring Perception

Problem

You want to determine whether two atoms belong to the same ring. See examples in Table 1.

Ring perception is one of the fundamental algorithms when handling chemical structures. While OEChem TK does not provide a solution for SSSR (Smallest Set of Smallest Rings) it includes a wide range of functions (see Table 2) that answer related questions. This recipe will illustrate how to utilize these functions to determine whether two atoms belong to the same ring.

This recipe will also include examples:

See also

Table 1. Example of depiction of atoms (pink) and those that are perceived to be in the same ring(s) (blue)
../_images/ring_perception2img-01.svg ../_images/ring_perception2img-02.svg ../_images/ring_perception2img-03.svg ../_images/ring_perception2img-04.svg
Table 2. Ring perception API in OEChem TK

API

See subsection

OEFindRingAtomsAndBonds

Ring Perception section in OEChem TK

OEDetermineRingSystems

Identifying Ring Systems of a Molecule

OEDetermineAromaticRingSystems

Identifying Aromatic Ring Systems of a Molecule

OEAtomIsInRingSize / OEBondIsInRingSize

Identifying Atom in Certain Ring Size

OEAtomIsInAromaticRingSize / OEBondIsInAromaticRingSize

Identifying Atom in Certain Aromatic Ring Size

OEAtomGetSmallestRingSize / OEBondGetSmallestRingSize

Identifying Atoms’ Smallest Ring Size

Ingredients

Difficulty level

🌶️ 🌶️

Download

Download code

ring_perception.py

See also Usage subsection.

Source Code

ring_perception
#!/usr/bin/env python3
# (C) 2023 Cadence Design Systems, Inc. (Cadence)
# All rights reserved.
# TERMS FOR USE OF SAMPLE CODE The software below ("Sample Code") is
# provided to current licensees or subscribers of Cadence products or
# SaaS offerings (each a "Customer").
# Customer is hereby permitted to use, copy, and modify the Sample Code,
# subject to these terms. Cadence claims no rights to Customer's
# modifications. Modification of Sample Code is at Customer's sole and
# exclusive risk. Sample Code may require Customer to have a then
# current license or subscription to the applicable Cadence offering.
# THE SAMPLE CODE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED.  OPENEYE DISCLAIMS ALL WARRANTIES, INCLUDING, BUT
# NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
# PARTICULAR PURPOSE AND NONINFRINGEMENT. In no event shall Cadence be
# liable for any damages or liability in connection with the Sample Code
# or its use.

"""Identify rings in molecule."""

import argparse
import os
import pathlib
import sys

import rich.console
from openeye import oechem
from rich_argparse import HelpPreviewAction, RichHelpFormatter

__SCRIPT_NAME__ = pathlib.Path(__file__).absolute().stem
__SCRIPT_DESC__ = "Find atoms in same ring."
__SCRIPT_TOOLKITS__ = ["oechem"]
__SCRIPT_CATEGORIES__ = ["cheminformatics"]


def parse_options() -> argparse.Namespace:
    """Set up command line options."""
    parser = argparse.ArgumentParser(
        add_help=True,
        formatter_class=RichHelpFormatter,
        description="[yellow]" + __SCRIPT_DESC__ + "[/yellow]",
    )

    # input options
    input_group = parser.add_argument_group("Input molecule options")
    exclusive_image_group = input_group.add_mutually_exclusive_group(required=True)
    exclusive_image_group.add_argument(
        "-in",
        "--input",
        type=str,
        required=False,
        metavar="MOL-FILE",
        help="Input molecule file.",
    )
    exclusive_image_group.add_argument(
        "-s",
        "--smiles",
        type=str,
        metavar="SMILES",
        help="Input molecule smiles",
    )
    parser.add_argument("--help-image", action=HelpPreviewAction)
    return parser.parse_args()


def main() -> int:
    """Identify atoms in same ring."""
    args = parse_options()

    # initialize molecule
    mol = oechem.OEGraphMol()
    if args.input:
        ifs = oechem.oemolistream()
        if not ifs.open(args.input):
            oechem.OEThrow.Fatal("Cannot open input file!")
        if not oechem.OEReadMolecule(ifs, mol):
            oechem.OEThrow.Fatal("Cannot read input file!")
    elif args.smiles:
        if not oechem.OESmilesToMol(mol, args.smiles):
            oechem.OEThrow.Fatal("Cannot initialize molecule from smiles!")

    if not mol.IsValid():
        oechem.OEThrow.Fatal("Input molecule can not be initialized!")

    console = rich.console.Console()
    for atom in mol.GetAtoms(oechem.OEAtomIsInRing()):
        same_ring_atoms = [
            a
            for a in mol.GetAtoms(oechem.OEAtomIsInRing())
            if atoms_in_same_ring(atom, a)
        ]
        if len(same_ring_atoms) != 0:
            same_ring_str = " ".join([str(a) for a in same_ring_atoms])
            console.print(f"atom {atom!s} in the same ring as {same_ring_str}")

    return os.EX_OK


class ChainAtomOrAlreadyTraversed(oechem.OEUnaryAtomPred):
    """Predicate used to find atoms in same ring system."""

    def __init__(self, exclude: list[oechem.OEAtomBase]) -> None:
        """Initialize predicate."""
        oechem.OEUnaryAtomPred.__init__(self)
        self._exclude = exclude

    def __call__(self, atom: oechem.OEAtomBase) -> bool:
        """Evaluate atom."""
        if not atom.IsInRing():
            return False
        return atom in self._exclude

    def CreateCopy(self):  # noqa: ANN201, N802
        """Copy constructor."""
        return ChainAtomOrAlreadyTraversed(self._exclude).__disown__()


def atoms_in_same_ring(  # noqa: PLR0911
    atom_one: oechem.OEAtomBase, atom_two: oechem.OEAtomBase
) -> bool:
    """Determine whether two atoms belong to the same ring system."""
    if not atom_one.IsInRing() or not atom_two.IsInRing():
        # any of them is a chain atom
        return False

    if atom_one == atom_two:
        return True

    if (bond := atom_one.GetBond(atom_two)) is not None and not bond.IsInRing():
        return False

    first_path = list(
        oechem.OEShortestPath(atom_one, atom_two, oechem.OEAtomIsInChain())
    )
    first_path_length = len(first_path)

    if first_path_length == 2:  # noqa: PLR2004
        return True  # neighbors

    if first_path_length == 0:
        return False  # not is same ring system

    smallest_one = oechem.OEAtomGetSmallestRingSize(atom_one)
    smallest_two = oechem.OEAtomGetSmallestRingSize(atom_two)

    if first_path_length > smallest_one and first_path_length > smallest_two:
        return False  # too far away

    # try to find the second shortest different path
    exclude_pred = ChainAtomOrAlreadyTraversed(first_path[1:-1])
    second_path = list(oechem.OEShortestPath(atom_one, atom_two, exclude_pred))
    second_path_length = len(second_path)

    if second_path_length == 0:
        return False  # can not be in the same ring

    if second_path_length > smallest_one and second_path_length > smallest_two:
        return False  # too far away

    sum_ring_size = len(first_path) + len(second_path) - 2
    if sum_ring_size > smallest_one and sum_ring_size > smallest_two:
        return False

    in_ring_one = oechem.OEAtomIsInRingSize(atom_one, sum_ring_size)
    in_ring_two = oechem.OEAtomIsInRingSize(atom_two, sum_ring_size)
    return in_ring_one and in_ring_two


setattr(main, "__SCRIPT_NAME__", __SCRIPT_NAME__)
setattr(main, "__SCRIPT_DESC__", __SCRIPT_DESC__)
setattr(main, "__SCRIPT_TOOLKITS__", __SCRIPT_TOOLKITS__)
setattr(main, "__SCRIPT_CATEGORIES__", __SCRIPT_CATEGORIES__)

if __name__ == "__main__":
    sys.exit(main())

Solution

The algorithm implemented in atoms_in_same_ring is based on a simple concept: if two atoms belong to the same ring then there must be at least two alternative ring paths between them. These paths are identified by using the OEShortestPath function.

def atoms_in_same_ring(  # noqa: PLR0911
    atom_one: oechem.OEAtomBase, atom_two: oechem.OEAtomBase
) -> bool:
    """Determine whether two atoms belong to the same ring system."""
    if not atom_one.IsInRing() or not atom_two.IsInRing():
        # any of them is a chain atom
        return False

    if atom_one == atom_two:
        return True

    if (bond := atom_one.GetBond(atom_two)) is not None and not bond.IsInRing():
        return False

    first_path = list(
        oechem.OEShortestPath(atom_one, atom_two, oechem.OEAtomIsInChain())
    )
    first_path_length = len(first_path)

    if first_path_length == 2:  # noqa: PLR2004
        return True  # neighbors

    if first_path_length == 0:
        return False  # not is same ring system

    smallest_one = oechem.OEAtomGetSmallestRingSize(atom_one)
    smallest_two = oechem.OEAtomGetSmallestRingSize(atom_two)

    if first_path_length > smallest_one and first_path_length > smallest_two:
        return False  # too far away

    # try to find the second shortest different path
    exclude_pred = ChainAtomOrAlreadyTraversed(first_path[1:-1])
    second_path = list(oechem.OEShortestPath(atom_one, atom_two, exclude_pred))
    second_path_length = len(second_path)

    if second_path_length == 0:
        return False  # can not be in the same ring

    if second_path_length > smallest_one and second_path_length > smallest_two:
        return False  # too far away

    sum_ring_size = len(first_path) + len(second_path) - 2
    if sum_ring_size > smallest_one and sum_ring_size > smallest_two:
        return False

    in_ring_one = oechem.OEAtomIsInRingSize(atom_one, sum_ring_size)
    in_ring_two = oechem.OEAtomIsInRingSize(atom_two, sum_ring_size)
    return in_ring_one and in_ring_two

ChainAtomOrAlreadyTraversed is the atom predicate that is used with OEShortestPath to identify a second alternative path between two atoms.

 1class ChainAtomOrAlreadyTraversed(oechem.OEUnaryAtomPred):
 2    """Predicate used to find atoms in same ring system."""
 3
 4    def __init__(self, exclude: list[oechem.OEAtomBase]) -> None:
 5        """Initialize predicate."""
 6        oechem.OEUnaryAtomPred.__init__(self)
 7        self._exclude = exclude
 8
 9    def __call__(self, atom: oechem.OEAtomBase) -> bool:
10        """Evaluate atom."""
11        if not atom.IsInRing():
12            return False
13        return atom in self._exclude
14
15    def CreateCopy(self):  # noqa: ANN201, N802
16        """Copy constructor."""
17        return ChainAtomOrAlreadyTraversed(self._exclude).__disown__()

Usage

See Download section to download the script.

> ring_perception --help
../_images/ring_perception-help.svg
> ring_perception --smiles 'c1ccc2c(c1)cc[nH]2'
atom  0 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C
atom  1 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C
atom  2 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C
atom  3 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C  6 C  7 C  8 N
atom  4 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C  6 C  7 C  8 N
atom  5 C in the same ring as  0 C  1 C  2 C  3 C  4 C  5 C
atom  6 C in the same ring as  3 C  4 C  6 C  7 C  8 N
atom  7 C in the same ring as  3 C  4 C  6 C  7 C  8 N
atom  8 N in the same ring as  3 C  4 C  6 C  7 C  8 N

Discussion

The following examples show how to use ring perception functions available in OEChem TK.

Identifying Spiro Atoms of a Molecule

The atoms_in_same_ring can be used to identify spiro atoms, which are the single, central atom that connects two distinct molecular rings.


class IsSpiroAtom(oechem.OEUnaryAtomPred):
    """
    Predicate class to identify spiro atoms.

    Spiro atoms are the single, central atom that connects more distinct molecular rings.
    """

    def __call__(self, atom: oechem.OEAtomBase) -> bool:
        """Evaluate the atom."""
        spiro_atom_hvy_degree = 4
        if not atom.IsInRing() or atom.GetHvyDegree() != spiro_atom_hvy_degree:
            return False
        ring_neighs = [nbr for nbr in atom.GetAtoms() if nbr.IsInRing()]
        if len(ring_neighs) != spiro_atom_hvy_degree:
            # spiro atom have to have 4 ring neighbors
            return False

        # spiro atom has to have to 2 pairs of neighbors in 2 different rings)
        for neigh in ring_neighs:

            num_neighs_in_same_ring = 0
            for other_neigh in ring_neighs:
                if neigh.GetIdx() == other_neigh.GetIdx():
                    continue
                if atoms_in_same_ring(neigh, other_neigh):
                    num_neighs_in_same_ring += 1
            if num_neighs_in_same_ring != 1:
                #  bridgehead atom not a spiro atom
                return False
        return True


scale = oedepict.OEScale_AutoScale
opts = oedepict.OE2DMolDisplayOptions(width, height, scale)

disp = oedepict.OE2DMolDisplay(mol, opts)
highlight = oedepict.OEHighlightByBallAndStick(oechem.OEBlueTint)
oedepict.OEAddHighlighting(disp, highlight, IsSpiroAtom())
oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_spiro_atoms.svg", image)

Download code

depict_spiro_atoms.py

../_images/depict_spiro_atoms.svg

Identifying Macro-cycle Atoms of a Molecule

The atoms_in_same_ring can also be used to identify macro-cycle atoms, which are the atoms that are part of large rings in a molecule.


def get_macro_cycle_atoms(
    mol: oechem.OEMolBase, min_ring_size: int = 10
) -> list[oechem.OEAtomBase]:
    """Find atoms in rings larger than a specified size."""
    oechem.OEFindRingAtomsAndBonds(mol)
    if oechem.OECount(mol, oechem.OEAtomIsInRing()) == 0:
        return []  # no ring atoms

    small_ring_atoms: set[oechem.OEAtomBase] = set()
    for atom in mol.GetAtoms(oechem.OEAtomIsInRing()):
        if oechem.OEAtomGetSmallestRingSize(atom) <= min_ring_size:
            small_ring_atoms.add(atom)
    macro_cyclic_ring_seeds: set[oechem.OEAtomBase] = (
        set(mol.GetAtoms(oechem.OEAtomIsInRing())) - small_ring_atoms
    )
    if macro_cyclic_ring_seeds == set():
        return []  # no macro-cyclic atoms

    macro_cycle_atoms: set[oechem.OEAtomBase] = set()
    macro_cycle_atoms.update(macro_cyclic_ring_seeds)

    for atom_one in macro_cyclic_ring_seeds:
        for atom_two in small_ring_atoms:
            if atom_two in macro_cycle_atoms:
                continue  # already identified as macro-cyclic
            if atoms_in_same_ring(atom_one, atom_two):
                macro_cycle_atoms.add(atom_two)

    return list(macro_cycle_atoms)


macro_cycle_atoms = get_macro_cycle_atoms(mol, min_ring_size=10)
abset = oechem.OEAtomBondSet()
for atom in macro_cycle_atoms:
    abset.AddAtom(atom)


scale = oedepict.OEScale_AutoScale
opts = oedepict.OE2DMolDisplayOptions(width, height, scale)

disp = oedepict.OE2DMolDisplay(mol, opts)
highlight = oedepict.OEHighlightByBallAndStick(oechem.OEBlueTint)
oedepict.OEAddHighlighting(disp, highlight, abset)

oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_macro_cycle_atoms.svg", image)
../_images/depict_macro_cycle_atoms.svg

Identifying Ring Systems of a Molecule

num_rings, ring_list = oechem.OEDetermineRingSystems(mol)

disp = oedepict.OE2DMolDisplay(mol, opts)

highlight = oedepict.OEHighlightByLasso(oechem.OEBlack)
highlight.SetConsiderAtomLabelBoundingBox(True)

ring_pred = oechem.OEPartPredAtom(ring_list)
for ring_idx, color in zip(
    range(1, num_rings + 1), oechem.OEGetVividColors(), strict=False
):
    ring_pred.SelectPart(ring_idx)
    ring_set = oechem.OEAtomBondSet(mol.GetAtoms(ring_pred))
    highlight.SetColor(color)
    oedepict.OEAddHighlighting(disp, highlight, ring_set)

oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_ring_system.svg", image)

Download code

depict_ring_system.py

../_images/depict_ring_system.svg

See also

Identifying Aromatic Ring Systems of a Molecule

num_rings, ring_list = oechem.OEDetermineAromaticRingSystems(mol)

disp = oedepict.OE2DMolDisplay(mol, opts)

highlight = oedepict.OEHighlightByLasso(oechem.OEBlack)
highlight.SetConsiderAtomLabelBoundingBox(True)

ring_pred = oechem.OEPartPredAtom(ring_list)
for ring_idx, color in zip(
    range(1, num_rings + 1), oechem.OEGetVividColors(), strict=False
):
    ring_pred.SelectPart(ring_idx)
    ring_set = oechem.OEAtomBondSet(mol.GetAtoms(ring_pred))
    highlight.SetColor(color)
    oedepict.OEAddHighlighting(disp, highlight, ring_set)

oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_aromatic_ring_system.svg", image)
../_images/depict_aromatic_ring_system.svg

Identifying Atom in Certain Ring Size

class LabelRingSize(oedepict.OEDisplayAtomPropBase):
    """Functor to label atoms by their ring size."""

    def __init__(self, max_ring_size: int) -> None:
        """Initialize predicate."""
        self._max_ring_size = max_ring_size
        oedepict.OEDisplayAtomPropBase.__init__(self)

    def __call__(self, atom: oechem.OEAtomBase) -> str:
        """Return atom label to be displayed."""
        if not atom.IsInRing():
            return ""
        rings = [
            r
            for r in range(3, self._max_ring_size)
            if oechem.OEAtomIsInRingSize(atom, r)
        ]
        if len(rings) == 0:
            return ""
        return "(" + ",".join([str(r) for r in rings]) + ")"

    def CreateCopy(self):  # noqa: ANN201, N802
        """Copy constructor."""
        return LabelRingSize(self._max_ring_size).__disown__()


scale = oedepict.OEScale_AutoScale
opts = oedepict.OE2DMolDisplayOptions(width, height, scale)
opts.SetAtomColorStyle(oedepict.OEAtomColorStyle_WhiteMonochrome)
opts.SetAtomPropertyFunctor(LabelRingSize(max_ring_size=10))

disp = oedepict.OE2DMolDisplay(mol, opts)
oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_ring_size.svg", image)

Download code

depict_ring_size.py

../_images/depict_ring_size.svg

Identifying Atom in Certain Aromatic Ring Size

class LabelAromaticRingSize(oedepict.OEDisplayAtomPropBase):
    """Functor to label atoms by their aromatic ring size."""

    def __init__(self, max_ring_size: int) -> None:
        """Initialize predicate."""
        self._max_ring_size = max_ring_size
        oedepict.OEDisplayAtomPropBase.__init__(self)

    def __call__(self, atom: oechem.OEAtomBase) -> str:
        """Return atom label to be displayed."""
        if not atom.IsInRing():
            return ""
        rings = [
            r
            for r in range(3, self._max_ring_size)
            if oechem.OEAtomIsInAromaticRingSize(atom, r)
        ]
        if len(rings) == 0:
            return ""
        return "(" + ",".join([str(r) for r in rings]) + ")"

    def CreateCopy(self):  # noqa: ANN201, N802
        """Copy constructor."""
        return LabelAromaticRingSize(self._max_ring_size).__disown__()


scale = oedepict.OEScale_AutoScale
opts = oedepict.OE2DMolDisplayOptions(width, height, scale)
opts.SetAtomColorStyle(oedepict.OEAtomColorStyle_WhiteMonochrome)
opts.SetAtomPropertyFunctor(LabelAromaticRingSize(max_ring_size=10))

disp = oedepict.OE2DMolDisplay(mol, opts)
oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_aromatic_ring_size.svg", image)
../_images/depict_aromatic_ring_size.svg

Identifying Atoms’ Smallest Ring Size

    """Functor to label atoms by their smallest ring size."""

    def __init__(self) -> None:
        """Initialize predicate."""
        oedepict.OEDisplayAtomPropBase.__init__(self)

    def __call__(self, atom: oechem.OEAtomBase) -> str:
        """Return atom label to be displayed."""
        if not atom.IsInRing():
            return ""
        smallest = oechem.OEAtomGetSmallestRingSize(atom)
        return "(" + str(smallest) + ")"

    def CreateCopy(self):  # noqa: ANN201, N802
        """Copy constructor."""
        return LabelSmallestRingSize().__disown__()


scale = oedepict.OEScale_AutoScale
opts = oedepict.OE2DMolDisplayOptions(width, height, scale)
opts.SetAtomColorStyle(oedepict.OEAtomColorStyle_WhiteMonochrome)
opts.SetAtomPropertyFunctor(LabelSmallestRingSize())

disp = oedepict.OE2DMolDisplay(mol, opts)
oedepict.OERenderMolecule(image, disp)
oedepict.OEWriteImage("depict_smallest_ring_size.svg", image)
../_images/depict_smallest_ring_size.svg

See also in OEChem TK manual

Theory

API