Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
Built on top of RDKit, the package walks the molecular graph, detects functional groups and ring systems, selects the principal parent, assigns locants, and constructs the corresponding substitutive IUPAC name. Every step is recorded in an inspectable
decision trace so the *why* of a name is recoverable, not just the *what*.

Systematic backbone stems are supported for chains and rings containing from 1
through 1000 skeletal atoms, following IUPAC Blue Book P-14.2.1.

> **Status:** beta. The naming engine handles a broad slice of organic
> structures (alkanes/alkenes/alkynes, common functional groups, simple
> heterocycles, fused/spiro/bridged systems, retained names from the Blue
Expand Down Expand Up @@ -283,4 +286,4 @@ If you are using OPSIN for verification, please cite the original OPSIN publicat
doi = {10.1021/ci100384d},
url = {https://doi.org/10.1021/ci100384d}
}
```
```
21 changes: 13 additions & 8 deletions src/openclatura/chains.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,19 +462,24 @@ def find_all_carbon_paths(mol: Molecule, exclude_atoms: set[int] = None) -> list

all_paths = []

def dfs(current: int, path: list[int], visited: set[int]):
neighbors = [n for n in mol.get_neighbors(current) if n in valid_nodes and n not in visited]
if not neighbors:
all_paths.append(path)
return
for n in neighbors:
dfs(n, path + [n], visited | {n})
def collect_paths(start: int) -> None:
# Long unbranched backbones can exceed Python's recursion limit. Keep
# the existing depth-first order without using the interpreter stack.
stack = [(start, [start], {start})]
while stack:
current, path, visited = stack.pop()
neighbors = [n for n in mol.get_neighbors(current) if n in valid_nodes and n not in visited]
if not neighbors:
all_paths.append(path)
continue
for neighbor in reversed(neighbors):
stack.append((neighbor, path + [neighbor], visited | {neighbor}))

endpoints = [n for n in valid_nodes if sum(1 for x in mol.get_neighbors(n) if x in valid_nodes) <= 1]
start_nodes = endpoints if endpoints else valid_nodes

for start in start_nodes:
dfs(start, [start], {start})
collect_paths(start)

unique_paths = []
seen = set()
Expand Down
3 changes: 2 additions & 1 deletion src/openclatura/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ def substituted_alkoxy_prefix(branch: str) -> str | None:

if "hydroxy" not in branch:
return None
for stem in stems.STEMS.values():
for length in range(stems.MAX_STEM_LENGTH, stems.MIN_STEM_LENGTH - 1, -1):
stem = stems.get(length)
terminal = f"{stem.stem}yl"
replacement = f"{stem.stem}oxy"
if branch.endswith(terminal):
Expand Down
2 changes: 1 addition & 1 deletion src/openclatura/ring_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def von_baeyer_cycle_count(descriptor: str | None) -> int | None:
for count, multiplier in multipliers.MULTIPLIERS.items():
if prefix == multiplier.basic:
return count
for count, stem in stems.STEMS.items():
for count in range(stems.MIN_STEM_LENGTH, stems.MAX_STEM_LENGTH + 1):
if prefix == _basic_cycle_prefix(count):
return count
return None
Expand Down
105 changes: 100 additions & 5 deletions src/openclatura/rules/stems.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

from dataclasses import dataclass
from functools import lru_cache


@dataclass(frozen=True)
Expand All @@ -20,7 +21,8 @@ class Stem:

# Stems 1-4 are retained (non-systematic) names.
# Stems 5+ are derived from Greek/Latin numerical roots.
# Coverage up to 30; extend as needed (IUPAC defines stems well beyond this).
# Retained and established spellings through 30. Larger stems are generated
# from the basic numerical terms in Blue Book P-14.2.1.
STEMS: dict[int, Stem] = {
1: Stem(1, "meth", retained=True),
2: Stem(2, "eth", retained=True),
Expand Down Expand Up @@ -54,12 +56,105 @@ class Stem:
30: Stem(30, "triacont", retained=False),
}

MIN_STEM_LENGTH = 1
MAX_STEM_LENGTH = 1000

_UNITS = {
1: "hen",
2: "do",
3: "tri",
4: "tetra",
5: "penta",
6: "hexa",
7: "hepta",
8: "octa",
9: "nona",
}

_TENS = {
1: "deca",
2: "icosa",
3: "triaconta",
4: "tetraconta",
5: "pentaconta",
6: "hexaconta",
7: "heptaconta",
8: "octaconta",
9: "nonaconta",
}

_HUNDREDS = {
1: "hecta",
2: "dicta",
3: "tricta",
4: "tetracta",
5: "pentacta",
6: "hexacta",
7: "heptacta",
8: "octacta",
9: "nonacta",
}


def _validate_length(length: int) -> None:
if isinstance(length, bool) or not isinstance(length, int):
raise ValueError("Stem length must be an integer from 1 through 1000")
if not MIN_STEM_LENGTH <= length <= MAX_STEM_LENGTH:
raise ValueError("Stem length must be from 1 through 1000")


def _under_one_hundred(value: int) -> str:
"""Return the basic numerical term for a value from 1 through 99."""

if value == 11:
return "undeca"

units = value % 10
tens = value // 10
unit_term = _UNITS.get(units, "")
tens_term = _TENS.get(tens, "")

# The initial i of icosa is elided after a vowel (P-14.2.1.2), e.g.
# do + icosa -> docosa, but hen + icosa -> henicosa.
if unit_term and tens == 2 and unit_term[-1] in "aeiou":
tens_term = tens_term[1:]
return unit_term + tens_term


def _numerical_term(length: int) -> str:
"""Build the P-14.2.1 basic numerical term for ``length``."""

_validate_length(length)
if length == 1000:
return "kilia"

hundreds, remainder = divmod(length, 100)
parts = []
if remainder:
parts.append(_under_one_hundred(remainder))
if hundreds:
parts.append(_HUNDREDS[hundreds])
return "".join(parts)


def get(length: int) -> Stem:
"""Look up a stem by chain length. Raises KeyError if out of range."""
return STEMS[length]
"""Return the chain stem for a supported skeletal-atom count."""

_validate_length(length)
return _get_cached(length)


@lru_cache(maxsize=MAX_STEM_LENGTH)
def _get_cached(length: int) -> Stem:
"""Return a validated stem while caching generated values."""

if length in STEMS:
return STEMS[length]
numerical_term = _numerical_term(length)
return Stem(length, numerical_term.removesuffix("a"), retained=False)


def stem_for(length: int) -> str:
"""Return just the stem string for a given chain length."""
return STEMS[length].stem
"""Return just the stem string for a supported chain length."""

return get(length).stem
89 changes: 89 additions & 0 deletions src/openclatura/tests/test_stems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Regression tests for systematic chain-length stems."""

import pytest

from openclatura import name
from openclatura.rules import stems

LEGACY_STEMS = (
"meth",
"eth",
"prop",
"but",
"pent",
"hex",
"hept",
"oct",
"non",
"dec",
"undec",
"dodec",
"tridec",
"tetradec",
"pentadec",
"hexadec",
"heptadec",
"octadec",
"nonadec",
"icos",
"henicos",
"docos",
"tricos",
"tetracos",
"pentacos",
"hexacos",
"heptacos",
"octacos",
"nonacos",
"triacont",
)


@pytest.mark.parametrize("length, expected", enumerate(LEGACY_STEMS, start=1))
def test_legacy_stems_are_unchanged(length, expected):
assert stems.stem_for(length) == expected


@pytest.mark.parametrize(
"length, expected",
[
(31, "hentriacont"),
(39, "nonatriacont"),
(40, "tetracont"),
(41, "hentetracont"),
(52, "dopentacont"),
(99, "nonanonacont"),
(100, "hect"),
(101, "henhect"),
(111, "undecahect"),
(199, "nonanonacontahect"),
(200, "dict"),
(363, "trihexacontatrict"),
(486, "hexaoctacontatetract"),
(999, "nonanonacontanonact"),
(1000, "kili"),
],
)
def test_systematic_stem_examples(length, expected):
assert stems.stem_for(length) == expected
assert stems.get(length) == stems.Stem(length, expected, retained=False)


@pytest.mark.parametrize("length", [None, 1.0, "31", [], {}, True, False, 0, -1, 1001])
def test_invalid_stem_lengths_raise_value_error(length):
with pytest.raises(ValueError, match="1 through 1000"):
stems.stem_for(length)


def test_generated_stems_are_unique():
generated = [stems.stem_for(length) for length in range(1, 1001)]
assert len(generated) == len(set(generated)) == 1000


@pytest.mark.slow
@pytest.mark.parametrize("length", range(1, 1001))
def test_linear_alkanes_from_one_through_one_thousand(length):
result = name("C" * length)
expected = stems.stem_for(length) + "ane"
assert result.error is None, f"C{length}: {result.error}"
assert result.name == expected
Loading