Where We Left Off
In my last post, I introduced the entire process of finding functional groups, but these groups live on the molecule's backbone. So now we need to begin the hunt for the parent chain.
The Challenge: What Even IS a Parent Chain?
The parent chain is the backbone of IUPAC naming. It's the main structure that determines the base name (e.g., "pentane" or "hexanoic acid"). But choosing it is surprisingly complex.
Imagine you're a chemist looking at a molecule. You have to decide:
- "Which chain is the 'main' one?"
- "What about atoms that aren't carbon, like nitrogen or oxygen?"
- "What if there's a ring? Does that count?"
IUPAC has a whole decision tree for this. And I mean a whole decision tree.
The IUPAC Decision Tree (Simplified)
After a lot of reading and re-reading of the Blue Book, here's what I came up with:
Contains the principal functional group (highest priority)
Maximum number of principal characteristic groups
Parent based on senior element (N > P > Si > B > O > S > C)
Rings are senior to chains (if same elements)
Contains more atoms
Contains more multiple bonds
Lower locants for principal groups
Lower locants for unsaturation
Maximum number of substituents
Lowest set of substituent locants
Name cited earlier in alphabetical order
Yes, that's 11 rules. And I'm skipping a whole lot for feasibility.
The Journey: From Simple to Complex
Attempt 1: Just Find the Longest Chain
My first implementation was naive: just find the longest carbon chain and call it a day. This works for simple alkanes like CCCC (butane) or CCCCCC (hexane). But it fails spectacularly for anything with heteroatoms.
# This works:
"CCCCCC" → hexane (6 carbons)
# This doesn't:
"CSiCCNNN" → Wait, what's the parent here?
Enter fullscreen mode Exit fullscreen mode
The issue is that IUPAC prioritizes heteroatoms (non-carbon atoms) over chain length. Nitrogen is more "senior" than silicon, which is more senior than carbon. So a chain with 3 nitrogen atoms beats a chain with 5 silicon atoms, which beats a chain with 10 carbons.
Attempt 2: Follow the Most Senior Element
I decided to build a system that could find chains for any element, not just carbon. The idea was simple:
For each senior element (N, P, Si, B, O, S, C), find all chains of that element and then score them.
This worked for simple heteroatom chains. But it still couldn't handle:
- Molecules with functional groups
- Molecules where the parent chain contains multiple elements (e.g., C-N-C-O-C)
- Chains with rings
Attempt 3: The Two-Pass System (Current Implementation)
The current approach splits the problem into two cases:
Case 1: Molecules with functional groups
Find all functional groups (using the motif engine)
Pick the highest priority group (the "principal" group)
Find all chains that contain this group
-
Score them by:
- Number of principal groups in the chain - Most senior heteroatoms - Chain length - Number of multiple bonds
The highest-scoring chain is the parent chain
Case 2: Molecules with NO functional groups (parent hydrides)
- Find all chains of each senior element (N, P, Si, B, O, S, C)
-
Score them by:
- Most senior element present - Chain length - Number of multiple bonds
The highest-scoring chain is the parent hydride
The Code: How It Actually Works
The Candidate System
I introduced a _Candidate class to track each potential parent chain along with its element type:
python
class _Candidate(NamedTuple):
chain: list[int]
elem: str
Enter fullscreen mode Exit fullscreen mode
This allows me to keep track of which element a chain is made of, which is crucial for the seniority-based selection.
The key function is _chains_through(), which finds all possible chains of a given element that pass through a specific atom:
python
def _chains_through(self, idx: int, element: str, visited=None) -> list[list[int]]:
curr = self._molecule[idx]
visited = visited
visited.add(idx)
chains = self._follow_chain(idx, element, visited)
# If the current atom isn't the element type, or there's only one chain, return
if curr.element.symbol != element or len(chains) < 2:
return chains
# Group chains by their first step to avoid duplicates
by_subtree = {}
for chain in chains:
first_step = chain[1]
best = by_subtree.get(first_step)
if best is None or self._score_chain(_Candidate(best, element)) > self._score_chain(
_Candidate(best, element)):
by_subtree[first_step] = chain
final_chains = list(by_subtree.values())
# If there are at least two strong subtrees, merge them through the current atom
if len(final_chains) >= 2:
top_two = sorted(final_chains, key=lambda c: self._score_chain(_Candidate(c, element)), reverse=True)[:2]
merged = list(reversed(top_two[0][1:])) + [idx] + top_two[1][1:]
final_chains.append(merged)
return final_chains
Enter fullscreen mode Exit fullscreen mode
This is where the real magic happens. The function:
Finds all chains of a specific element starting from the current atom
Groups them by their first step (to avoid duplicates)
If there are at least two strong branches, merges them through the current atom
Then, the _follow_chain method uses DFS to find all chains of a given element:
python
def _follow_chain(self, curr_idx: int, element: str, visited=None) -> list[list[int]]:
if visited is None:
visited = set()
curr = self._molecule[curr_idx]
visited.add(curr_idx)
branches = []
for neighbour_idx, _ in curr.bonds.items():
if neighbour_idx in visited:
continue
neighbour = self._molecule[neighbour_idx]
if neighbour.element.symbol == element:
visited.add(neighbour_idx)
branches.extend(self._follow_chain(neighbour_idx, element, visited.copy()))
if branches:
return [[curr_idx] + chain for chain in branches]
elif self._molecule[curr_idx].element.symbol == element:
return [[curr_idx]]
else:
return []
Enter fullscreen mode Exit fullscreen mode
Now that we have potential chains, the scoring system prioritizes chains by:
Length (more atoms = better)
Multiple bonds (more double/triple bonds = better)
Double bonds (within multiple bonds, more double bonds = better)
python
def _score_chain(self, candidate: _Candidate) -> tuple[int, int, int]:
chain = candidate.chain
if not chain:
return 0, 0, 0
length = len(chain)
double_bonds = 0
triple_bonds = 0
for i in range(len(chain) - 1):
atom = self._molecule[chain[i]]
bond_order = atom.bonds.get(chain[i + 1])
if bond_order == 3:
triple_bonds += 1
elif bond_order == 2:
double_bonds += 1
multiple_bonds = double_bonds + triple_bonds
return length, multiple_bonds, double_bonds
Enter fullscreen mode Exit fullscreen mode
Selecting the Best Parent Chain
The _select_best_parent method applies the IUPAC rules in order:
python
def _select_best_parent(self, candidates: list[_Candidate], princip_idxs: Optional[set[int]] = None) -> Optional[_Candidate]:
if not candidates:
return None
# Rule 1: Maximize principal characteristic groups
if princip_idxs:
max_count = max(self._princip_count(candidate.chain, princip_idxs) for candidate in candidates)
candidates = [
candidate for candidate in candidates
if self._princip_count(candidate.chain, princip_idxs) == max_count
]
# Rule 2: Most senior element (N > P > Si > B > O > S > C)
for element in self._SENIOR_ELEMENTS:
element_candidates = [candidate for candidate in candidates if candidate.elem == element]
if element_candidates:
return self._score_chains(element_candidates)
return None
Enter fullscreen mode Exit fullscreen mode
The "Aha!" Moment
The biggest insight came when I realized that the parent chain doesn't have to be pure carbon. IUPAC allows chains of N, P, Si, B, O, and S to be parent hydrides. This means a molecule like CSiSiSiSiSiCNNNNNNNNNCNNNN has a parent chain of 9 nitrogen atoms (nonazane), not the carbon-silicon chain.
This is why the scoring system prioritizes the most senior element present, not just the longest chain. Nitrogen (N) is more senior than silicon (Si), which is more senior than carbon (C). So a chain with 9 N atoms beats a chain with 5 Si atoms, which beats a chain with 10 C atoms.
This is hardcoded into the scoring system, and it's what allows me to correctly select, for example, a nitrogen chain over a silicon chain.
The Takeaway
Building a parent chain finder from scratch has been one of the most challenging parts of this project. It forced me to understand IUPAC rules deeply and translate them into code. The recursive DFS approach, combined with the candidate system, gives me a flexible way to handle the complex decision tree.
Is it perfect? Not yet. But it's getting there. It's important to have an MVP running rather than hunting for optimizations at every step, that we we can learn much more about the product. Plus, this implementation works wonderfully!
What's Next?
Now onto a part I left behind! I'll be implementing:
- Ring Detection – finding rings for cyclic compounds
- 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!
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.