Skip to content

aromaticity

Aromaticity perception for molecular graphs.

Implements Hückel's 4n+2 rule for detecting aromatic rings, with support for heteroatoms (N, O, S) in various bonding environments.

The detection flow is: 1. Get Kekulé bond orders from geometry (single/double/triple) 2. Find rings with conjugated bonds (alternating or equal unsaturation) 3. Check 4n+2 π electron count

count_pi_electrons(graph, ring)

Count the number of π electrons contributed to a ring.

Different atoms contribute different numbers based on their bonding environment: - Carbon (sp2): 1 electron - Nitrogen (pyridine-like, =N-): 1 electron - Nitrogen (pyrrole-like, -NH-): 2 electrons - Oxygen/Sulfur with lone pairs: 2 electrons

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required
ring tuple[int, ...]

Tuple of atom indices forming the ring.

required

Returns:

Type Description
int | None

Number of π electrons, or None if the ring cannot be aromatic.

Source code in chmpy/graph/aromaticity.py
def count_pi_electrons(
    graph: "MolecularGraph", ring: tuple[int, ...]
) -> int | None:
    """
    Count the number of π electrons contributed to a ring.

    Different atoms contribute different numbers based on their
    bonding environment:
    - Carbon (sp2): 1 electron
    - Nitrogen (pyridine-like, =N-): 1 electron
    - Nitrogen (pyrrole-like, -NH-): 2 electrons
    - Oxygen/Sulfur with lone pairs: 2 electrons

    Args:
        graph: A MolecularGraph instance.
        ring: Tuple of atom indices forming the ring.

    Returns:
        Number of π electrons, or None if the ring cannot be aromatic.
    """
    total = 0
    ring_set = set(ring)

    for atom_idx in ring:
        atomic_num = graph.atomic_numbers[atom_idx]
        neighbors = graph.neighbors(atom_idx)
        degree = len(neighbors)

        # Count neighbors in ring vs outside
        ring_neighbors = sum(1 for n in neighbors if n in ring_set)
        external_neighbors = degree - ring_neighbors

        contribution = _pi_electron_contribution(
            atomic_num, degree, ring_neighbors, external_neighbors, graph, atom_idx
        )

        if contribution is None:
            return None

        total += contribution

    return total

get_aromatic_rings(graph)

Get all aromatic rings in the graph.

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required

Returns:

Type Description
list[tuple[int, ...]]

List of tuples containing atom indices for each aromatic ring.

Source code in chmpy/graph/aromaticity.py
def get_aromatic_rings(graph: "MolecularGraph") -> list[tuple[int, ...]]:
    """
    Get all aromatic rings in the graph.

    Args:
        graph: A MolecularGraph instance.

    Returns:
        List of tuples containing atom indices for each aromatic ring.
    """
    rings = find_sssr(graph)
    aromatic = []

    for ring in rings:
        if is_aromatic_ring(graph, ring):
            aromatic.append(ring)

    # Check fused systems
    return _check_fused_systems(graph, rings, aromatic)

is_aromatic_atom(graph, atom_idx)

Check if an atom is aromatic.

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required
atom_idx int

Index of the atom to check.

required

Returns:

Type Description
bool

True if the atom is part of an aromatic ring.

Source code in chmpy/graph/aromaticity.py
def is_aromatic_atom(graph: "MolecularGraph", atom_idx: int) -> bool:
    """
    Check if an atom is aromatic.

    Args:
        graph: A MolecularGraph instance.
        atom_idx: Index of the atom to check.

    Returns:
        True if the atom is part of an aromatic ring.
    """
    aromatic_atoms, _ = perceive_aromaticity(graph)
    return aromatic_atoms[atom_idx]

is_aromatic_bond(graph, atom_i, atom_j)

Check if a bond is aromatic.

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required
atom_i int

First atom index.

required
atom_j int

Second atom index.

required

Returns:

Type Description
bool

True if the bond is part of an aromatic ring.

Source code in chmpy/graph/aromaticity.py
def is_aromatic_bond(graph: "MolecularGraph", atom_i: int, atom_j: int) -> bool:
    """
    Check if a bond is aromatic.

    Args:
        graph: A MolecularGraph instance.
        atom_i: First atom index.
        atom_j: Second atom index.

    Returns:
        True if the bond is part of an aromatic ring.
    """
    _, aromatic_bonds = perceive_aromaticity(graph)
    bond = (min(atom_i, atom_j), max(atom_i, atom_j))
    return bond in aromatic_bonds

is_aromatic_ring(graph, ring)

Check if a ring is aromatic using Hückel's 4n+2 rule.

A ring is aromatic if: 1. It is planar (assumed for small rings) 2. All atoms can participate in aromaticity (C, N, O, S, etc.) 3. Ring is conjugated (bonds are unsaturated based on geometry) 4. It has 4n+2 π electrons (n = 0, 1, 2, ...)

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required
ring tuple[int, ...]

Tuple of atom indices forming the ring.

required

Returns:

Type Description
bool

True if the ring is aromatic.

Source code in chmpy/graph/aromaticity.py
def is_aromatic_ring(graph: "MolecularGraph", ring: tuple[int, ...]) -> bool:
    """
    Check if a ring is aromatic using Hückel's 4n+2 rule.

    A ring is aromatic if:
    1. It is planar (assumed for small rings)
    2. All atoms can participate in aromaticity (C, N, O, S, etc.)
    3. Ring is conjugated (bonds are unsaturated based on geometry)
    4. It has 4n+2 π electrons (n = 0, 1, 2, ...)

    Args:
        graph: A MolecularGraph instance.
        ring: Tuple of atom indices forming the ring.

    Returns:
        True if the ring is aromatic.
    """
    # Check ring size - aromatic rings are typically 5-7 membered
    ring_size = len(ring)
    if ring_size < 5 or ring_size > 7:
        return False

    # Check if all atoms can participate in aromaticity
    if not _all_atoms_can_be_aromatic(graph, ring):
        return False

    # Check that ring has conjugated bonds (from Kekulé structure)
    if not _ring_is_conjugated(graph, ring):
        return False

    # Count π electrons
    pi_electrons = count_pi_electrons(graph, ring)

    if pi_electrons is None:
        return False

    # Check 4n+2 rule
    return _satisfies_huckel_rule(pi_electrons)

perceive_aromaticity(graph)

Perceive aromaticity in a molecular graph.

Identifies aromatic atoms and bonds based on ring membership and Hückel's 4n+2 rule for π electrons.

Parameters:

Name Type Description Default
graph MolecularGraph

A MolecularGraph instance.

required

Returns:

Type Description
ndarray

Tuple of (aromatic_atoms, aromatic_bonds) where:

ndarray
  • aromatic_atoms: boolean array (n_atoms,) marking aromatic atoms
tuple[ndarray, ndarray]
  • aromatic_bonds: list of (i, j) tuples for aromatic bonds
Source code in chmpy/graph/aromaticity.py
def perceive_aromaticity(graph: "MolecularGraph") -> tuple[np.ndarray, np.ndarray]:
    """
    Perceive aromaticity in a molecular graph.

    Identifies aromatic atoms and bonds based on ring membership and
    Hückel's 4n+2 rule for π electrons.

    Args:
        graph: A MolecularGraph instance.

    Returns:
        Tuple of (aromatic_atoms, aromatic_bonds) where:
        - aromatic_atoms: boolean array (n_atoms,) marking aromatic atoms
        - aromatic_bonds: list of (i, j) tuples for aromatic bonds
    """
    n = graph.n_atoms
    aromatic_atoms = np.zeros(n, dtype=bool)
    aromatic_bonds = []

    rings = find_sssr(graph)
    if not rings:
        return aromatic_atoms, aromatic_bonds

    # Check each ring for aromaticity
    aromatic_rings = []
    for ring in rings:
        if is_aromatic_ring(graph, ring):
            aromatic_rings.append(ring)

    # Handle fused aromatic systems (e.g., naphthalene)
    # If individual rings aren't aromatic, check if they form an aromatic system
    aromatic_rings = _check_fused_systems(graph, rings, aromatic_rings)

    # Mark aromatic atoms and bonds
    for ring in aromatic_rings:
        for atom_idx in ring:
            aromatic_atoms[atom_idx] = True

        ring_size = len(ring)
        for i in range(ring_size):
            a, b = ring[i], ring[(i + 1) % ring_size]
            bond = (min(a, b), max(a, b))
            if bond not in aromatic_bonds:
                aromatic_bonds.append(bond)

    return aromatic_atoms, aromatic_bonds