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
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:
Define a pattern as a set of bond requirements and atom conditions
Write one matching engine that can match any pattern
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=[],
)
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)
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()
}
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)
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
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:
- More maintainable (one engine, many patterns)
- More extensible (users can add their own patterns)
- More reusable (the same engine handles multiple use cases)
- 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!
Top comments (0)