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

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.ring_ylidene_bonds(mol)#

The stereo double bonds that _is_true_exocyclic leaves uncited: not a ring bond, a ring atom at EACH end (the bond between a ring and a ring ‘-ylidene’ substituent).

Neither scope of a name can cite such a bond through collect_stereodescriptors: the host skips it because the ylidene end is a ring atom (condition 2 above), and so does the branch. ‘Specification by stereodescriptors E and Z’ (the Blue Book) licenses it all the same: “The stereodescriptors ‘E’ and ‘Z’ are used to specify the configuration of an exo-cyclic double bond.” (:48273), cited by method (a), at the front of the name with the parent locant (:48276, the PIN method,:48279), or by method (b), at the front of the ylidene prefix (:48277). The default paths do not use this licence; the best-effort floor does, through assembly.ring_ylidene_stereo (one scope cites the bond, never both).

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.merge_stereodescriptors(base, extra)#

base (one scope’s collect_stereodescriptors) plus extra descriptors in locant order. An extra whose locant already carries an E/Z descriptor is dropped: one atom has one double bond, so a second claim on its locant is a numbering the name cannot read.

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.locate_stereo_blocks(name)#

[(start, end, text)]: where in name each descriptor block that strip_stereo_blocks() removes sits.

It finds the blocks with the same two matchers as:func:strip_stereo_blocks (_STRIP_STEREO_LEADING_RE and _STRIP_STEREO_NESTED_RE) run to the same fixpoint, and follows every removal back to its offset in name. text is the block as:func:strip_stereo_blocks returns it ('(1r,4r)', 'cis'), and the list is in the order of its blocks. name[start:end] is the block with its following hyphen ('(1r,4r)-'); the enclosing mark of a nested block lies before start and is not part of the span. Removing the spans from name gives strip_stereo_blocks(name)[0].

READ-ONLY, like:func:strip_stereo_blocks: the blocks and their offsets are what the descriptor check read, so a caller that rewrites a block rewrites the one that was checked. A removal that is not contiguous in name (a block cut by an earlier removal) is reported with the span that covers it; name[start:end] == text + '-' is false there, which a caller that edits by span tests.

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 _CIPCode EXCEPT ring bonds whose smallest ring is <8 (ring-strain-fixed geometry — not a free stereogenic unit; the SAME exclusion collect_stereodescriptors applies, 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_complete for 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 _CIPCode whose 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_descriptors cannot count an Ra/Sa token 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 like 7a). Axial Ra/Sa/M/P blocks 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). Axial Ra/Sa tokens 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 as inject_stereo_from_locant_map does. 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_locants map 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.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.