Skip to content

Commit f61a80c

Browse files
committed
Add C18/C19 steroid labels, tests, and a small demo
1 parent 9f9755b commit f61a80c

2 files changed

Lines changed: 133 additions & 6 deletions

File tree

chebi_utils/extract_properties.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -238,12 +238,41 @@ def get_rings(mol: Chem.Mol) -> dict[str, list]:
238238
return atom_extensions
239239

240240

241+
def _add_angular_methyls(
242+
mol: Chem.Mol,
243+
atom_extensions: dict[str, list],
244+
iupac_to_atom: dict[int, int],
245+
) -> None:
246+
"""Label angular methyls C18 (on C13) and C19 (on C10) when present.
247+
248+
Looks for the unique carbon neighbour of the attachment atom that is not
249+
part of the gonane core (C1–C17). Estrogens without C19 simply yield no
250+
``steroid_19`` predicate.
251+
"""
252+
core_atom_indices = set(iupac_to_atom.values())
253+
for attachment_position, methyl_position in ((13, 18), (10, 19)):
254+
attachment_atom_idx = iupac_to_atom.get(attachment_position)
255+
if attachment_atom_idx is None:
256+
continue
257+
methyl_candidates = [
258+
neighbor.GetIdx()
259+
for neighbor in mol.GetAtomWithIdx(attachment_atom_idx).GetNeighbors()
260+
if neighbor.GetAtomicNum() == 6 and neighbor.GetIdx() not in core_atom_indices
261+
]
262+
if len(methyl_candidates) == 1:
263+
atom_extensions.setdefault(f"steroid_{methyl_position}", []).append(
264+
methyl_candidates[0]
265+
)
266+
267+
241268
def get_steroid_positions(mol: Chem.Mol) -> dict[str, list]:
242269
"""Extract steroid-nucleus position predicates.
243270
244271
Matches the molecule against the gonane core and, on a match, labels the
245272
ring atoms with their IUPAC steroid position as predicates ``steroid_1`` …
246-
``steroid_17``. Molecules without a gonane core yield no predicates.
273+
``steroid_17``. When present, angular methyls are added as ``steroid_18``
274+
(on C13) and ``steroid_19`` (on C10). Molecules without a gonane core yield
275+
no predicates.
247276
248277
Parameters
249278
----------
@@ -258,11 +287,17 @@ def get_steroid_positions(mol: Chem.Mol) -> dict[str, list]:
258287
"""
259288
atom_extensions: dict[str, list] = {}
260289
steroid_match = mol.GetSubstructMatch(_GONANE_PATTERN, useChirality=False)
261-
if steroid_match:
262-
for pat_idx, atom_idx in enumerate(steroid_match):
263-
iupac = _GONANE_IDX_TO_IUPAC.get(pat_idx)
264-
if iupac is not None:
265-
atom_extensions.setdefault(f"steroid_{iupac}", []).append(atom_idx)
290+
if not steroid_match:
291+
return atom_extensions
292+
293+
iupac_to_atom: dict[int, int] = {}
294+
for pat_idx, atom_idx in enumerate(steroid_match):
295+
iupac = _GONANE_IDX_TO_IUPAC.get(pat_idx)
296+
if iupac is not None:
297+
atom_extensions.setdefault(f"steroid_{iupac}", []).append(atom_idx)
298+
iupac_to_atom[iupac] = atom_idx
299+
300+
_add_angular_methyls(mol, atom_extensions, iupac_to_atom)
266301
return atom_extensions
267302

268303

@@ -289,3 +324,25 @@ def get_numerical_facts(mol: Chem.Mol) -> dict[str, list]:
289324
for ring in mol.GetRingInfo().AtomRings():
290325
atom_extensions.setdefault("ring_size", []).append(len(ring))
291326
return atom_extensions
327+
328+
329+
"""Manual check for steroid numbering (not part of the library API).
330+
331+
Run: python -m chebi_utils.extract_properties
332+
Expect: cholesterol -> steroid_1..19, estrone -> steroid_1..18, benzene -> []
333+
"""
334+
if __name__ == "__main__":
335+
from chebi_utils.read_molecule import smiles_or_inchi_to_mol
336+
337+
for name, smiles in {
338+
"cholesterol": (
339+
"C[C@H](CCCC(C)C)[C@H]1CC[C@@H]2[C@@]1(CC[C@H]3[C@H]2CC=C4[C@@]3(CC[C@@H](C4)O)C)C"
340+
),
341+
"estrone": "C[C@]12CC[C@H]3[C@H]([C@@H]1CCC2=O)CCc4c3ccc(O)c4",
342+
"benzene": "c1ccccc1",
343+
}.items():
344+
keys = sorted(
345+
get_steroid_positions(smiles_or_inchi_to_mol(smiles)),
346+
key=lambda k: int(k.split("_")[1]),
347+
)
348+
print(name, keys)

tests/test_extract_properties.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for chebi_utils.extract_properties steroid numbering."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from chebi_utils.extract_properties import get_steroid_positions, mol_to_fol_atoms
8+
from chebi_utils.read_molecule import smiles_or_inchi_to_mol
9+
10+
# Cholesterol: gonane core + C18 and C19 angular methyls
11+
CHOLESTEROL_SMILES = (
12+
"C[C@H](CCCC(C)C)[C@H]1CC[C@@H]2[C@@]1(CC[C@H]3[C@H]2CC=C4[C@@]3(CC[C@@H](C4)O)C)C"
13+
)
14+
# Estrone: aromatic ring A, C18 present, C19 absent
15+
ESTRONE_SMILES = "C[C@]12CC[C@H]3[C@H]([C@@H]1CCC2=O)CCc4c3ccc(O)c4"
16+
17+
CORE_POSITIONS = {f"steroid_{n}" for n in range(1, 18)}
18+
19+
20+
def _steroid_keys(smiles: str) -> set[str]:
21+
mol = smiles_or_inchi_to_mol(smiles)
22+
assert mol is not None
23+
return set(get_steroid_positions(mol))
24+
25+
26+
class TestSteroidPositions:
27+
def test_non_steroid_has_no_positions(self):
28+
assert get_steroid_positions(smiles_or_inchi_to_mol("c1ccccc1")) == {}
29+
30+
def test_cholesterol_has_core_and_angular_methyls(self):
31+
keys = _steroid_keys(CHOLESTEROL_SMILES)
32+
assert CORE_POSITIONS <= keys
33+
assert "steroid_18" in keys
34+
assert "steroid_19" in keys
35+
36+
def test_estrone_has_c18_but_not_c19(self):
37+
keys = _steroid_keys(ESTRONE_SMILES)
38+
assert CORE_POSITIONS <= keys
39+
assert "steroid_18" in keys
40+
assert "steroid_19" not in keys
41+
42+
@pytest.mark.parametrize(
43+
"smiles,attachment,methyl",
44+
[
45+
(CHOLESTEROL_SMILES, "steroid_13", "steroid_18"),
46+
(CHOLESTEROL_SMILES, "steroid_10", "steroid_19"),
47+
(ESTRONE_SMILES, "steroid_13", "steroid_18"),
48+
],
49+
)
50+
def test_angular_methyl_is_bonded_to_attachment_atom(self, smiles, attachment, methyl):
51+
mol = smiles_or_inchi_to_mol(smiles)
52+
positions = get_steroid_positions(mol)
53+
attachment_idx = positions[attachment][0]
54+
methyl_idx = positions[methyl][0]
55+
attachment_atom = mol.GetAtomWithIdx(attachment_idx)
56+
neighbor_indices = {neighbor.GetIdx() for neighbor in attachment_atom.GetNeighbors()}
57+
assert methyl_idx in neighbor_indices
58+
assert mol.GetAtomWithIdx(methyl_idx).GetAtomicNum() == 6
59+
60+
def test_mol_to_fol_atoms_includes_steroid_predicates(self):
61+
mol = smiles_or_inchi_to_mol(CHOLESTEROL_SMILES)
62+
atom_facts, _mol_facts = mol_to_fol_atoms(mol, with_steroids=True)
63+
assert "steroid_1" in atom_facts
64+
assert "steroid_18" in atom_facts
65+
assert "steroid_19" in atom_facts
66+
67+
def test_mol_to_fol_atoms_can_skip_steroids(self):
68+
mol = smiles_or_inchi_to_mol(CHOLESTEROL_SMILES)
69+
atom_facts, _mol_facts = mol_to_fol_atoms(mol, with_steroids=False)
70+
assert not any(key.startswith("steroid_") for key in atom_facts)

0 commit comments

Comments
 (0)