Where We Left Off

In my last post, I introduced IUPACker: a tool that converts SMILES strings into IUPAC names. I covered the SMILES parser, the molecular graph, and the motif engine for functional group detection. Now it's time to tackle the heart of the problem: finding the functional groups.

17 Functions and Counting

When I started building the functional group detector for IUPACker, I did what any naive programmer would do: I wrote a function for each group. Every. Single. One.

python
def _is_carboxylic_acid(self, atom):
    # Check for C(=O)OH pattern
    for neighbor in atom.bonds:
        if neighbor is O and order == 2:
            # It's a carbonyl!
        if neighbor is O and order == 1 and neighbor.has_h:
            # It's a hydroxyl!
    # ... 20 more lines

def _is_ester(self, atom):
    # Check for C(=O)OR pattern
    # ... almost the same code, slightly different

def _is_aldehyde(self, atom):
    # Check for CHO pattern
    # ... more of the same

# ... and 14 more functions

Enter fullscreen mode Exit fullscreen mode

This was incredibly ugly. Every new functional group meant copying, pasting, and tweaking the same loop structure. The code was repetitive, and worse, adding a new functional group was a chore. Each one took 20-30 lines of boilerplate.

I realized quite quickly that this system was far from sustainable. It did the exact opposite of abstraction, leaving me knee-deep in messy code. I was writing the same loop over and over, just changing which bonds to look for.

What if I could define these patterns as data, not code? What if I could write one generic matching engine that could handle any pattern I threw at it?

Declaring my love for Declarative Patterns!

The answer? Declarative patterns! The idea was simple:

  1. Define a pattern as a set of bond requirements and atom conditions

  2. Write one matching engine that can match any pattern

  3. Add new functional groups by adding a new pattern definition (10 lines) instead of a new function (30 lines).

Here's what a pattern looks like now:

python

CARBOXYLIC_ACID = MotifPattern(
    name="carboxylic acid",
    priority=100,
    suffix="oic acid",
    prefix="carboxy-",
    center_symbol="C",
    center_conditions=[
        COND_NO_CHARGE,
        COND_AROMATIC_FALSE
    ],
    bonds=[BondReq(symbol="O",
            order=2,
            count=1,
            conditions=[
                COND_NO_H,
                COND_BOND_SUM_2
            ],
        ),
        BondReq(
            symbol="O",
            order=1,
            count=1,
            conditions=[
                COND_HAS_H,
                COND_BOND_SUM_2
            ],
        ),
    ],
    excludes=[],
)

Enter fullscreen mode Exit fullscreen mode

Compare that to the 20+ line function I had before. It's cleaner, more readable, and self-documenting.

Power up the Engines!

The matching engine is surprisingly simple. It walks through the molecule, finds candidate atoms, and checks if they match the pattern:

python

def _match_single(self, atom: Atom, pattern: MotifPattern, visited: set[int] = None) -> Optional[MotifMatch]:
    if not visited:
        visited = set()

    if atom.idx in visited:
        return None

    visited.add(atom.idx)
    used = set()  # Tracks consumed neighbours
    matches = []  # Tracks any matched neighbours

    for req in pattern.bonds:
        found_count = 0

        for neighbour_idx, order in atom.bonds.items():
            if neighbour_idx in used:
                continue

            neighbour = self.molecule[neighbour_idx]
            if neighbour.element.symbol == req.symbol and order == req.order:
                if all(self._condition_single(neighbour, cond) for cond in req.conditions):
                    used.add(neighbour_idx)
                    matches.append(neighbour_idx)
                    found_count += 1

                    # Checks for any further bonds that need to be made
                    if req.future_req:
                        future_match = self._match_single(neighbour, pattern, visited)
                        if not future_match:
                            return None
                        matches.extend(idx for idx in future_match.matched_atoms if idx not in matches)

        if found_count != req.count:
            return None

    return MotifMatch(pattern=pattern, matched_atoms=matches, center_idx=0)

Enter fullscreen mode Exit fullscreen mode

The engine uses a handler system to check atom properties. This makes it easy to add new conditions later:
python

python

CONDITION_HANDLER = {
    "charge": lambda atom: atom.charge,
    "aromatic": lambda atom: atom.is_aromatic,
    "has_h": lambda atom: atom.get_hydrogen_count() >= 1,
    "bond_sum": lambda atom: atom.bond_sum()
}

Enter fullscreen mode Exit fullscreen mode

Each condition is just a function that takes an atom and returns a value. The engine then compares that value to the expected value using the specified operator (eq, ne, lt, gt, le, ge).

Uh-oh Identity Crisis

One of the trickiest problems was overlapping functional groups. For example, acetic acid (CH3-COOH) matches:

Carboxylic acid (correct)

Carbonyl (sub-pattern)

Hydroxyl (sub-pattern)

Enter fullscreen mode Exit fullscreen mode

The solution? Priority-based overlap resolution:

python
@staticmethod
def resolve_overlaps(matches: list[MotifMatch]) -> list[MotifMatch]:
    used_atoms = set()
    resolved = []

    for match in matches:
        if any(idx in used_atoms for idx in match.matched_atoms):
            continue
        used_atoms.update(match.matched_atoms)
        resolved.append(match)

    return resolved

Enter fullscreen mode Exit fullscreen mode

The Sweet, Sweet Benefits

Because patterns are defined as data, users can add their own functional groups without touching the engine code, extending the usefulness of the library!

Plus, when I found a bug in the matching logic, I fixed it once in the engine, not 17 times in 17 functions.

Later, the same engine that detects functional groups can also be used for:

  • Substructure search (find molecules with specific patterns)

  • Toxicity checking (find dangerous patterns)

  • Reaction prediction (find reactive sites)

  • Drug-likeness filtering (check for specific pharmacophores)

Up and Running.

Sometimes, the best way to solve a problem is to step back and look for the pattern. By recognizing that all functional groups are just patterns, I was able to build a system that's:

  1. More maintainable (one engine, many patterns)
  2. More extensible (users can add their own patterns)
  3. More reusable (the same engine handles multiple use cases)
  4. More readable (patterns are self-documenting)

If you're writing the same loop over and over, ask yourself: "Am I really solving 17 different problems, or am I solving one problem 17 times?"


What's Next?

Now comes the hard part. I'll be implementing:

  • Parent chain identification – finding the longest chain containing the principal group
  • Substituent handling – identifying and naming branches
  • Numbering – assigning lowest possible locants
  • Name assembly – combining prefixes, suffixes, and locants

It's going to be a wild ride. See you in the next post!


*P.S. If you want to follow along, the code is on GitHub. Star it if you're feeling generous!