orthonym.rules.stereochemistry#
Note
Internal API. Names and behaviour may change between releases.
Stereochemistry rules - IUPAC stereodescriptor collection and formatting.
This module handles the mapping from RDKit stereochemistry (indexed by atom indices) to IUPAC nomenclature (indexed by locants on the principal chain/ring).
Key functions: - collect_stereodescriptors: Get R/S and E/Z descriptors with IUPAC locants - format_stereodescriptor_string: Format as “(2R,3S)-” prefix - get_double_bond_locant: Get lower locant for E/Z double bond
Ring junction stereochemistry functions: - get_bridgehead_atoms: Find ring fusion stereocenter atoms - collect_ring_junction_stereo: Collect junction stereo with ‘a’ suffix locants - format_ring_junction_stereo: Format as “(4aR,8aS)-” or “(4ar,8ac)-” - determine_simple_cis_trans: Return “cis” or “trans” for simple bicyclics
IMPORTANT: This module requires atom_to_locant mapping to be provided by the caller (computed during chain/ring classification). It does NOT fall back to atom indices as those are NOT valid IUPAC locants.
- orthonym.rules.stereochemistry.collect_stereodescriptors(mol, atom_to_locant, include_near_parent_ez=False, *, skip_bonds=())#
Collect all stereodescriptors from a molecule using IUPAC locants.
- Parameters:
mol – RDKit Mol object (stereochemistry should already be assigned)
atom_to_locant (Dict[int, int]) – Mapping from atom index to IUPAC locant number. Only atoms in this mapping are considered (principal chain/ring atoms).
include_near_parent_ez (bool) –
RETAINED FOR CALL-SITE COMPATIBILITY; NO LONGER EMITS. It used to admit E/Z bonds one hop away from the parent (neither bond atom in atom_to_locant, but one has a neighbour that is), citing the neighbour’s locant, on the stated authority of “IUPAC “. That citation is wrong — (the Blue Book) is “von Baeyer compounds”.
(the Blue Book) instead requires a substituent’s
descriptor to be cited “at the front of the corresponding prefix”, so a bond lying wholly inside a substituent has no locant in the PARENT’s numbering and is now skipped. See the fail-closed branch below for the full derivation.
skip_bonds (Iterable[int]) – indices of E/Z bonds this scope must NOT cite because another scope of the same name cites them. The one caller is a ‘-ylidene’ substituent whose attachment double bond its host already cites with the host’s locant (1)(a), the Blue Book): citing it in both scopes is one stereogenic unit cited twice. Default empty (every other caller unchanged).
- Returns:
List of (locant, cip_code) tuples, sorted by locant ascending. cip_code is ‘R’, ‘S’, ‘r’, ‘s’ (lowercase for pseudoasymmetric), or ‘E’, ‘Z’ for double bonds.
- Return type:
List[Tuple[int, str]]
Example
>>> mol = Chem.MolFromSmiles('C[C@H](O)CC') >>> rdCIPLabeler.AssignCIPLabels(mol) >>> atom_to_locant = {0: 4, 1: 3, 3: 2, 4: 1} # chain oriented >>> collect_stereodescriptors(mol, atom_to_locant) [(3, 'R')]
- orthonym.rules.stereochemistry.get_double_bond_locant(bond, atom_to_locant)#
Get the IUPAC locant for a double bond.
Per IUPAC convention, the locant is the lower of the two atom locants.
- Parameters:
bond – RDKit Bond object
atom_to_locant (Dict[int, int]) – Mapping from atom index to IUPAC locant
- Returns:
Lower locant of the two bond atoms, or None if either atom is not in the mapping (not on principal chain/ring).
- Return type:
int | None
Example
>>> mol = Chem.MolFromSmiles('C/C=C/C') >>> bond = mol.GetBondWithIdx(1) # the C=C bond >>> atom_to_locant = {0: 1, 1: 2, 2: 3, 3: 4} >>> get_double_bond_locant(bond, atom_to_locant) 2
- orthonym.rules.stereochemistry.format_stereodescriptor_string(descriptors)#
Format stereodescriptors as an IUPAC name prefix.
STEREO-04: Multiple descriptors in single block with comma separation.
- Parameters:
descriptors (List[Tuple[int, str]]) – List of (locant, cip_code) tuples, sorted by locant.
- Returns:
Formatted string like “(2R)-”, “(2R,3S)-”, “(2E,3R,5Z)-” Returns empty string if no descriptors.
- Return type:
str
Examples
>>> format_stereodescriptor_string() '' >>> format_stereodescriptor_string([(2, 'R')]) '(2R)-' >>> format_stereodescriptor_string([(2, 'R'), (3, 'S')]) '(2R,3S)-' >>> format_stereodescriptor_string([(2, 'E'), (3, 'R'), (5, 'Z')]) '(2E,3R,5Z)-' >>> format_stereodescriptor_string([(2, 'r'), (3, 's')]) '(2r,3s)-'
- orthonym.rules.stereochemistry.strip_stereo(name)#
Return name with leading stereo / relative-config descriptor blocks removed (to a fixpoint) — the OPSIN-validity stereo carve-out “where does OPSIN fail” probe (internal notes).
READ-ONLY: this is NOT a postprocessor on shipped names. The validity gate uses it only to test whether a name’s CONSTITUTIONAL (stereo-stripped) form parses; the SHIPPED name keeps its stereo. Strips leading
(2R)-/(1s,4s)-/(E)-/rel-/rac-/cis-/trans-/(±)-blocks; leaves a name with no leading descriptor (hexane) and substituent enclosing groups ((2-chloroethyl)) untouched.
- orthonym.rules.stereochemistry.strip_stereo_blocks(name)#
(strip_stereo(name), blocks): the stripped name and every descriptor block it removed, in the order removed – a parenthesised block WITH its parentheses ('(7R,8S)','(1r,4r)') or a relative-configuration word ('rel','rac','cis','trans','(±)').READ-ONLY, like:func:strip_stereo (which is this function’s first element). The validity gate’s stereo-layer carve-out reads the removed blocks to check every descriptor it would ship against the input’s CIP labels (
namer._stereo_descriptors_verified).
- orthonym.rules.stereochemistry.needs_stereo_injection(mol, name)#
Return True iff mol carries CIP stereo not represented in name.
Pure read-only predicate used both by the namer.py backstop (after a phase refactor) and by inject_stereo_from_locant_map.
Per, the three name-side detection patterns are byte-identical to namer.py:_final_stereo_check lines 62-75. Per, do NOT broaden.
- Parameters:
mol – RDKit Mol object (may be None).
name (str) – Generated IUPAC name string (may be empty / ‘unknown’).
- Returns:
True iff (a) mol is not None and name is non-empty and != ‘unknown’ AND (b) name does NOT match any of the three stereo-detection patterns AND (c) mol has at least one atom or bond with _CIPCode set after idempotent assign_stereochemistry.
- Return type:
bool
- orthonym.rules.stereochemistry.count_defined_stereo_elements(mol)#
Count the DEFINED CIP stereogenic units on mol (Phase S Task 1).
- Counts, after idempotent CIP assignment:
every atom with
_CIPCode(R/S/r/s tetrahedral + pseudoasymmetric);every bond with
_CIPCodeEXCEPT ring bonds whose smallest ring is <8 (ring-strain-fixed geometry — not a free stereogenic unit; the SAME exclusioncollect_stereodescriptorsapplies, so what is counted as defined matches exactly what CAN be expressed,;every detected axial element with a determined CIP label.
Read-only. Used by
general_engine_stereo_completefor the all-or-nothing completeness gate on general-engine emissions.
- orthonym.rules.stereochemistry.count_defined_stereo_in_fragment(mol, frag_atoms)#
Count the DEFINED CIP stereogenic units that lie INSIDE
frag_atoms.The fragment-scoped twin of:func:count_defined_stereo_elements, for the substituent-prefix producers: a prefix names only its own fragment, so only the stereo elements inside that fragment are its obligation to express.
Same three membership rules as the whole-molecule counter, so the two agree on any fragment that happens to be the whole molecule:
every fragment atom carrying
_CIPCode;every bond with
_CIPCodewhose BOTH ends are in the fragment, EXCEPT a ring bond whose smallest ring is <8 —### **** Omission of stereodescriptors(the Blue Book) recommends omitting the descriptor for “three- through seven-membered unsaturated alicyclic compounds where any double bond has a fixed configuration”, so such a bond is not an expressible unit and must not be demanded of the name;axial elements are NOT counted.
count_expressed_stereo_descriptorscannot count anRa/Satoken either, so counting them here would make a correctly-axial name look INCOMPLETE. Both sides omit them, which keeps the identity honest instead of biased.
A bond with exactly ONE end in the fragment is deliberately excluded: its geometry is expressed by whoever names the atom on the other side (the parent), not by this prefix.
Read-only apart from the idempotent CIP assignment.
- orthonym.rules.stereochemistry.count_expressed_stereo_descriptors(name)#
Count the stereodescriptor TOKENS the name actually carries.
Sums the comma-separated descriptors across every
(...)stereo block (leading, embedded, or nested substituent blocks), using the same block grammar as_STEREO_EMBEDDED_RE(R/S/r/s/E/Z with optional composite locants like7a). AxialRa/Sa/M/Pblocks fall outside that grammar and are NOT counted — which only ever UNDER-counts, so the completeness gate fails CLOSED (the safe direction) rather than over-claim.
- orthonym.rules.stereochemistry.general_engine_stereo_complete(mol, name)#
Phase S Task 1 (accuracy keystone): all-or-nothing stereo completeness for GENERAL-ENGINE emissions.
Returns True iff name expresses EXACTLY every defined CIP stereo element mol carries (descriptor count == defined count). This REPLACES the coarse name-side boolean (
not needs_stereo_injection) at the general-engine emission sites, closing the verified hole where a PARTIAL-stereo name (some elements expressed, others dropped) matched Pattern A and shipped as if fully specified — invisible to the stereo-blind: a PIN must specify every stereogenic unit).The exact
==(not>=) ALSO fail-closes on OVER-expression (a spurious / double-counted descriptor, e.g. the nested-block double-apply bug) — a name that cites MORE stereo than the structure defines is an attribution error and must not ship as complete. Any mismatch -> False (fail-closed; best-effort then ships the flagged constitution-superset name, complete abstains). AxialRa/Satokens are not countable, so a name expressing axial chirality fails closed here (safe; axial detection is out of Phase S scope).
- orthonym.rules.stereochemistry.inject_stereo_from_locant_map(name, mol, atom_to_locant, *, include_near_parent_ez=True)#
Prepend a stereo descriptor block to name using authoritative locants.
Per IUPAC /, prepends a (R/S/E/Z)- block built from collect_stereodescriptors + format_stereodescriptor_string.
Per, no atom-index fallback: when atom_to_locant is None, empty, or all-zero (degenerate), returns name unchanged and emits a single DEBUG log line. The backstop in namer.py will still log WARNING in this case so handler attribution is preserved.
- Parameters:
name (str) – The candidate IUPAC name from a handler.
mol – RDKit Mol object with stereo info.
atom_to_locant (Dict[int, int] | None) – Authoritative {atom_idx: 1-indexed locant} map from the handler’s own perception (heterocycle / benzene / cycloalkane / cycloalkene). Must NOT be derived from raw atom indices .
include_near_parent_ez (bool) –
When True (default – preserves benzene / heterocycle Tier-A behaviour), exocyclic E/Z bonds one hop from the parent are attributed to the lowest neighbouring locant per . When False (cycloalkane / cycloalkene caller post-
fix), exocyclic E/Z bonds are NOT attributed to ring
locants; only ring-atom R/S and ring-bond E/Z are emitted. This is the conservative gate per (“better a missing stereo block than a wrong one”) for handlers where exocyclic E/Z can be mis-attributed via include_near_parent_ez=True.
- Returns:
name unchanged (predicate False / no locant map / no descriptors) OR prefix + name where prefix is e.g. ‘(2R)-’, ‘(2R,3S)-‘, ‘(2E,3R,5Z)-’, ‘(2r,3s)-’ per.
- Return type:
str
Example
>>> mol = Chem.MolFromSmiles('C[C@@H](O)CC') >>> rdCIPLabeler.AssignCIPLabels(mol) >>> inject_stereo_from_locant_map('butan-2-ol', mol, {1: 2}) '(2R)-butan-2-ol'
- orthonym.rules.stereochemistry.inject_stereo_reanchored_rt_gated(base_name, mol, builder_map, *, include_near_parent_ez=True, input_smiles=None)#
Inject a stereo block on base_name, RT-gating the LOCANT numbering (the contributor guide a project rule — offer numberings, keep the one that round-trips).
Candidate A uses
builder_map(the handler’s own numbering) exactly asinject_stereo_from_locant_mapdoes. If A full-round-trips (name -> OPSIN -> InChI == input’s InChI), A is returned — BYTE-IDENTICAL to the plain injector for every currently-passing name. Only when A does NOT full-round- trip is the numbering RE-ANCHORED to OPSIN’s OWN locants for base_name (opsin_atom_locant_map, the authoritative numbering the name is read back with) and candidate B injected on that map; B is returned ONLY if it full-round-trips. Otherwise A is returned unchanged.This rescues the mixed-spiro-fused leak class (a spiro-of-fused-component parent whose
combined_locantsmap is numbered inconsistently with the printed descriptor, so the stereo descriptor lands on the wrong locant and the full name is OPSIN-unparseable) WITHOUT disturbing a legitimate OPSIN-can’t-parse-the-stereo-layer carve-out PIN: that PIN’s re-anchored form also fails full-RT (OPSIN cannot parse the layer in ANY spelling), so A is kept. Fail-OPEN on any OPSIN unavailability -> returns A (current behaviour).
- orthonym.rules.stereochemistry.collect_ring_stereodescriptors(mol, ring_atom_to_locant)#
Collect stereodescriptors for ring compounds.
Same as collect_stereodescriptors but specifically for ring naming, where only atoms in the ring are considered.
- Parameters:
mol – RDKit Mol object (stereochemistry should already be assigned)
ring_atom_to_locant (Dict[int, int]) – Mapping from ring atom indices to IUPAC locants. Only atoms that are keys in this dict are included.
- Returns:
List of (locant, cip_code) tuples, sorted by locant ascending.
- Return type:
List[Tuple[int, str]]
Note
For rings, double bond E/Z is less common (ring strain), but we still handle it for completeness.
- orthonym.rules.stereochemistry.determine_ring_cis_trans(mol, ring_atoms, sub1_idx, sub2_idx)#
Determine if two substituents on a ring are cis or trans.
For a ring with exactly 2 substituents at specified positions, determine their relative stereochemistry based on CIP labels.
The rule for 1,2-disubstituted rings: - SAME CIP codes (R,R or S,S) -> CIS (substituents on same face) - DIFFERENT CIP codes (R,S or S,R) -> TRANS (substituents on opposite faces)
This is because in a ring, atoms with the same absolute configuration at adjacent positions have their substituents on the same face.
- Parameters:
mol – RDKit Mol object (CIP labels should already be assigned)
ring_atoms (List[int]) – List of atom indices that form the ring
sub1_idx (int) – Atom index of first substituted ring carbon
sub2_idx (int) – Atom index of second substituted ring carbon
- Returns:
‘cis’ or ‘trans’, or None if cannot be determined (missing CIP labels)
- Return type:
str | None
Example
>>> mol = Chem.MolFromSmiles('C[C@H]1CCCC[C@@H]1C') # cis-1,2-dimethylcyclohexane >>> rdCIPLabeler.AssignCIPLabels(mol) >>> ring_atoms = [1, 2, 3, 4, 5, 6] >>> determine_ring_cis_trans(mol, ring_atoms, 1, 6) 'cis'
- orthonym.rules.stereochemistry.get_simple_ring_stereo(mol, ring_atoms, ring_substituents)#
Get cis/trans prefix for simple disubstituted rings.
This function handles the common case of exactly 2 substituted positions on a ring, returning a cis- or trans- prefix for the name.
Note: This is a simplification. More complex rings (3+ substituents) would need the full IUPAC r/c/t reference system, which is deferred.
- Parameters:
mol – RDKit Mol object (CIP labels should already be assigned)
ring_atoms (List[int]) – List of atom indices forming the ring
ring_substituents (Dict[int, str]) – Dict mapping ring atom locant -> substituent name. Only keys (locants) are used to identify substituted positions.
- Returns:
‘cis-’ or ‘trans-’ prefix string, or None if –
Not exactly 2 substituted positions
Cannot determine stereochemistry
- Return type:
str | None
Example
>>> mol = Chem.MolFromSmiles('C[C@H]1CCCC[C@@H]1C') >>> rdCIPLabeler.AssignCIPLabels(mol) >>> ring_atoms = [1, 2, 3, 4, 5, 6] >>> # Assuming oriented_ring maps locant -> atom_idx >>> ring_substituents = {1: 'methyl', 2: 'methyl'} >>> get_simple_ring_stereo(mol, ring_atoms, ring_substituents) 'cis-'
- orthonym.rules.stereochemistry.get_simple_ring_stereo_from_atoms(mol, ring_atoms, substituted_atom_indices)#
Get cis/trans prefix given the actual atom indices of substituted positions.
- Parameters:
mol – RDKit Mol object (CIP labels should already be assigned)
ring_atoms (List[int]) – List of atom indices forming the ring
substituted_atom_indices (List[int]) – List of exactly 2 atom indices that have substituents
- Returns:
‘cis-’ or ‘trans-’ prefix string, or None if cannot determine
- Return type:
str | None
Example
>>> mol = Chem.MolFromSmiles('C[C@H]1CCCC[C@@H]1C') >>> rdCIPLabeler.AssignCIPLabels(mol) >>> ring_atoms = [1, 2, 3, 4, 5, 6] >>> get_simple_ring_stereo_from_atoms(mol, ring_atoms, [1, 6]) 'cis-'
- orthonym.rules.stereochemistry.format_ring_stereo_with_descriptors(ring_cis_trans, descriptors)#
Format ring stereo prefix with optional R/S descriptors.
IUPAC format for ring stereo: - If only cis/trans: “cis-1,2-dimethylcyclohexane” - If cis/trans + R/S: “cis-(1R,2S)-1,2-dimethylcyclohexane” - The cis/trans goes BEFORE the stereodescriptors
- Parameters:
ring_cis_trans (str | None) – ‘cis-’ or ‘trans-’ prefix, or None
descriptors (List[Tuple[int, str]]) – List of (locant, cip_code) tuples from collect_stereodescriptors
- Returns:
Combined prefix string like “cis-”, “trans-(1R,2S)-”, etc. Empty string if no stereo information.
- Return type:
str
Example
>>> format_ring_stereo_with_descriptors('cis-', [(1, 'R'), (2, 'S')]) 'cis-(1R,2S)-' >>> format_ring_stereo_with_descriptors('trans-', ) 'trans-' >>> format_ring_stereo_with_descriptors(None, [(1, 'R')]) '(1R)-'
- orthonym.rules.stereochemistry.get_bridgehead_atoms(mol)#
Find atoms at ring fusion points (bridgehead atoms).
Bridgehead atoms are atoms that are shared by multiple rings. For fused ring systems like decalin, these are the atoms at the junction where rings meet.
- Parameters:
mol – RDKit Mol object
- Returns:
List of atom indices that are bridgehead atoms (in 2+ rings and sp3)
- Return type:
List[int]
Examples
>>> mol = Chem.MolFromSmiles('C1CCC2CCCCC2C1') # decalin >>> bridgeheads = get_bridgehead_atoms(mol) >>> len(bridgeheads) # 2 bridgehead atoms 2 >>> mol = Chem.MolFromSmiles('c1ccc2ccccc2c1') # naphthalene (aromatic) >>> bridgeheads = get_bridgehead_atoms(mol) >>> len(bridgeheads) # 0 - sp2 atoms are not stereocenters 0
- orthonym.rules.stereochemistry.collect_ring_junction_stereo(mol, bridgehead_atoms, atom_to_locant)#
Collect stereodescriptors for ring junction (bridgehead) atoms.
Ring junction atoms in fused systems use ‘a’ suffix locants in IUPAC naming. For example, in decahydronaphthalene, the junction atoms are 4a and 8a.
- Parameters:
mol – RDKit Mol object (CIP labels should already be assigned)
bridgehead_atoms (List[int]) – List of atom indices at ring junctions
atom_to_locant (Dict[int, int | str]) – Mapping from atom index to IUPAC locant (int or str) The locant should already include ‘a’ suffix if needed
- Returns:
List of (locant_str, cip_code) tuples, sorted by locant. Locant is string to handle ‘a’ suffix (e.g., ‘4a’, ‘8a’).
- Return type:
List[Tuple[str, str]]
Examples
>>> mol = Chem.MolFromSmiles('C1CC[C@@H]2CCCC[C@@H]2C1') # cis-decalin >>> rdCIPLabeler.AssignCIPLabels(mol) >>> bridgeheads = get_bridgehead_atoms(mol) >>> atom_to_locant = {3: '4a', 8: '8a'} # junction atoms with 'a' suffix >>> stereo = collect_ring_junction_stereo(mol, bridgeheads, atom_to_locant) >>> # Returns [('4a', 'S'), ('8a', 'S')] or similar
- orthonym.rules.stereochemistry.format_ring_junction_stereo(descriptors, use_rct=False)#
Format ring junction stereodescriptors as IUPAC name prefix.
IUPAC 2013 provides two notations for ring junction stereo: 1. R/S notation (PIN style): “(4aR,8aS)-” 2. r/c/t notation (reference plane): “(4ar,8ac)-”
The r/c/t notation uses: - r: reference stereocenter (first one) - c: cis to reference (same CIP code) - t: trans to reference (different CIP code)
- Parameters:
descriptors (List[Tuple[str, str]]) – List of (locant_str, cip_code) tuples from collect_ring_junction_stereo
use_rct (bool) – If True, use r/c/t notation; if False (default), use R/S
- Returns:
Formatted string like “(4aR,8aS)-” or “(4ar,8ac)-” Returns empty string if no descriptors.
- Return type:
str
Examples
>>> format_ring_junction_stereo([('4a', 'S'), ('8a', 'S')]) '(4aS,8aS)-' >>> format_ring_junction_stereo([('4a', 'S'), ('8a', 'S')], use_rct=True) '(4ar,8ac)-' >>> format_ring_junction_stereo([('4a', 'R'), ('8a', 'S')], use_rct=True) '(4ar,8at)-'
- orthonym.rules.stereochemistry.determine_simple_cis_trans(mol, junction_atoms)#
Determine cis/trans for simple bicyclic systems (decalin type).
For simple fused bicyclics with exactly 2 junction atoms: - Same chiral tag (both CCW or both CW) = cis (both H on same face) - Different chiral tags (one CCW, one CW) = trans (H on opposite faces)
Note: CIP codes (R/S) are NOT reliable for cis/trans determination in symmetric fused systems like decalin, because the molecular symmetry can cause both junction atoms to have the same CIP code even in trans. Instead, we use the ChiralTag which reflects the actual tetrahedral configuration.
- Parameters:
mol – RDKit Mol object (stereochemistry should already be assigned)
junction_atoms (List[int]) – List of exactly 2 atom indices at ring junction
- Returns:
‘cis’ or ‘trans’, or None if –
Not exactly 2 junction atoms
Missing stereochemistry on junction atoms
- Return type:
str | None
Examples
>>> # cis-decalin: both [C@@H] = same ChiralTag = cis >>> mol = Chem.MolFromSmiles('C1CC[C@@H]2CCCC[C@@H]2C1') >>> bridgeheads = get_bridgehead_atoms(mol) >>> determine_simple_cis_trans(mol, bridgeheads) 'cis' >>> # trans-decalin: [C@@H]...[C@H] = different ChiralTag = trans >>> mol = Chem.MolFromSmiles('C1CC[C@@H]2CCCC[C@H]2C1') >>> bridgeheads = get_bridgehead_atoms(mol) >>> determine_simple_cis_trans(mol, bridgeheads) 'trans'
- orthonym.rules.stereochemistry.get_junction_locants_for_fused_system(mol, bridgehead_atoms, ring_size_1, ring_size_2)#
Generate IUPAC ‘a’ suffix locants for junction atoms in fused systems.
For ortho-fused bicyclics like decahydronaphthalene: - Ring 1 (6-membered): positions 1-4, then 4a - Ring 2 (6-membered): positions 4a-8, then 8a - Junction atoms get ‘a’ suffix locants (4a, 8a)
This is a simplified implementation for common cases.
- Parameters:
mol – RDKit Mol object
bridgehead_atoms (List[int]) – List of atom indices at ring junctions
ring_size_1 (int) – Size of first ring
ring_size_2 (int) – Size of second ring
- Returns:
Dict mapping atom index to locant string (e.g., {3 – ‘4a’, 8: ‘8a’})
- Return type:
Dict[int, str]
Note
This is a simplified mapping. Full IUPAC numbering requires consideration of ring system orientation and heteroatom positions.