Skip to content

Commit 2eaf48d

Browse files
YZY-stackclaude
andcommitted
Preserve R/S and E/Z stereochemistry in polymer converters
Extends PR #1's stereochemistry fix (previously molecule + markush only) to the polymer path, which until now silently dropped chirality and double-bond geometry on round-trip. - polymer_to_mermaid: emit absolute CIP R/S (_CIPCode) in atom ids instead of order-dependent CHI_TETRAHEDRAL_CW/CCW (same bug fixed in the molecule converter). - mermaid_to_psmiles: parse _R/_S id suffixes and ===|E|/===|Z| markers, then restore them after sanitize — chirality via brute-forced CIP matching, and double-bond geometry via SetDoubleBondNeighborDirections so SMILES emits / and \. - tests/test_polymer_stereochemistry.py: PLA R/S, cis/trans polybutadiene, combined chiral+E/Z, and plain-polymer regression coverage. Verified: chiral and E/Z PSMILES now round-trip to canonical-identical SMILES, and cis is distinguished from trans. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 970d572 commit 2eaf48d

3 files changed

Lines changed: 148 additions & 9 deletions

File tree

molecode/polymer/mermaid_to_psmiles.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,70 @@ def parse_element(label: str) -> tuple[str, int]:
9393

9494
_NODE_RE = re.compile(r'^\s+(\w+)\[([^\]]+)\]\s*$')
9595

96+
# Absolute CIP chirality suffix on an atom id, e.g. ``B0_C2_R`` → "R".
97+
# The preceding char is always a digit (the per-element counter), so this never
98+
# collides with an element symbol such as sulfur (``B0_S1``).
99+
_CHIRAL_RE = re.compile(r'\d_([RS])$')
100+
101+
102+
# ── Stereochemistry restoration (after sanitize / full topology) ──────────────
103+
104+
def _restore_chirality(mol: RWMol, idx_to_cip: dict[int, str]) -> None:
105+
"""Restore absolute CIP R/S by brute-forcing the chiral tag.
106+
107+
CW/CCW is order-dependent, so we try each tag, recompute CIP, and keep the
108+
one whose ``_CIPCode`` matches the desired absolute label (mirrors the
109+
small-molecule converter).
110+
"""
111+
if not idx_to_cip:
112+
return
113+
for idx, desired in idx_to_cip.items():
114+
atom = mol.GetAtomWithIdx(idx)
115+
matched = False
116+
for tag in (Chem.ChiralType.CHI_TETRAHEDRAL_CW,
117+
Chem.ChiralType.CHI_TETRAHEDRAL_CCW):
118+
atom.SetChiralTag(tag)
119+
try:
120+
Chem.AssignStereochemistry(mol, cleanIt=True, force=True)
121+
except Exception:
122+
continue
123+
if atom.HasProp('_CIPCode') and atom.GetProp('_CIPCode') == desired:
124+
matched = True
125+
break
126+
if not matched:
127+
atom.SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)
128+
try:
129+
Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
130+
except Exception:
131+
pass
132+
133+
134+
def _restore_double_bond_stereo(mol: RWMol, records: list) -> None:
135+
"""Restore ===|E| / ===|Z| double-bond configuration.
136+
137+
Sets the double-bond stereo + reference atoms, then derives the neighbouring
138+
single-bond directions so the SMILES writer actually emits ``/`` and ``\``.
139+
"""
140+
applied = False
141+
for idx1, idx2, stereo in records:
142+
bond = mol.GetBondBetweenAtoms(idx1, idx2)
143+
if bond is None:
144+
continue
145+
n1 = [n.GetIdx() for n in mol.GetAtomWithIdx(idx1).GetNeighbors()
146+
if n.GetIdx() != idx2]
147+
n2 = [n.GetIdx() for n in mol.GetAtomWithIdx(idx2).GetNeighbors()
148+
if n.GetIdx() != idx1]
149+
if n1 and n2:
150+
bond.SetStereoAtoms(n1[0], n2[0])
151+
bond.SetStereo(Chem.BondStereo.STEREOE if stereo == 'E'
152+
else Chem.BondStereo.STEREOZ)
153+
applied = True
154+
if applied:
155+
try:
156+
Chem.SetDoubleBondNeighborDirections(mol)
157+
except Exception:
158+
pass
159+
96160

97161
# ── Main converter ────────────────────────────────────────────────────────────
98162

@@ -107,6 +171,7 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
107171

108172
# 1. Collect node declarations (skip TL / TR terminus nodes)
109173
nodes: dict[str, tuple[str, int]] = {} # nid → (element, formal_charge)
174+
chirality: dict[str, str] = {} # nid → "R" / "S"
110175
for line in lines:
111176
m = _NODE_RE.match(line)
112177
if m:
@@ -117,10 +182,13 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
117182
symbol, charge = parse_element(label)
118183
nodes[nid] = (symbol, charge)
119184
except ValueError:
120-
pass
185+
continue
186+
cm = _CHIRAL_RE.search(nid)
187+
if cm:
188+
chirality[nid] = cm.group(1)
121189

122190
# 2. Collect edges and identify attachment nodes
123-
edges: list[tuple[str, Chem.BondType, str]] = []
191+
edges: list[tuple[str, Chem.BondType, str, Optional[str]]] = []
124192
entry_nodes: set[str] = set()
125193
exit_nodes: set[str] = set()
126194

@@ -130,6 +198,7 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
130198
continue
131199
a, bond_sym, b = m.group(1), m.group(2), m.group(3)
132200
bt = _BOND_MAP.get(bond_sym, Chem.BondType.SINGLE)
201+
ez = 'E' if bond_sym == '===|E|' else ('Z' if bond_sym == '===|Z|' else None)
133202

134203
if a == "TL":
135204
entry_nodes.add(b)
@@ -141,7 +210,7 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
141210
exit_nodes.add(b)
142211
else:
143212
if a in nodes and b in nodes:
144-
edges.append((a, bt, b))
213+
edges.append((a, bt, b, ez))
145214

146215
if not entry_nodes or not exit_nodes:
147216
return None
@@ -166,12 +235,15 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
166235
ap_left = rw.AddAtom(Chem.Atom(0))
167236
ap_right = rw.AddAtom(Chem.Atom(0))
168237

169-
for a, bt, b in edges:
238+
stereo_records: list[tuple[int, int, str]] = [] # (idx1, idx2, 'E'/'Z')
239+
for a, bt, b, ez in edges:
170240
if a in nid_to_idx and b in nid_to_idx:
171241
try:
172242
rw.AddBond(nid_to_idx[a], nid_to_idx[b], bt)
173243
except Exception:
174244
return None
245+
if ez:
246+
stereo_records.append((nid_to_idx[a], nid_to_idx[b], ez))
175247

176248
if entry_nid not in nid_to_idx or exit_nid not in nid_to_idx:
177249
return None
@@ -191,6 +263,12 @@ def mermaid_to_psmiles(mermaid: str) -> Optional[str]:
191263
except Exception:
192264
return None
193265

266+
# 5. Restore stereochemistry (R/S and E/Z) after the full topology exists.
267+
_restore_chirality(
268+
rw, {nid_to_idx[n]: cip for n, cip in chirality.items() if n in nid_to_idx}
269+
)
270+
_restore_double_bond_stereo(rw, stereo_records)
271+
194272
return Chem.MolToSmiles(rw)
195273

196274

molecode/polymer/polymer_to_mermaid.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,15 @@ def _new_node_id(self, atom: Chem.Atom) -> str:
122122
symbol = atom.GetSymbol()
123123
self._element_counter[symbol] += 1
124124
cnt = self._element_counter[symbol]
125-
chiral = atom.GetChiralTag()
125+
# 使用 RDKit 计算的绝对 CIP 构型(_CIPCode),而不是把
126+
# CHI_TETRAHEDRAL_CW/CCW 直接当 R/S —— CW/CCW 依赖原子顺序,
127+
# 只有 _CIPCode 才是可序列化的绝对 R/S 标签。
128+
# AssignStereochemistry 已在 convert() 中调用,确保 _CIPCode 存在。
126129
suffix = ""
127-
if chiral == Chem.ChiralType.CHI_TETRAHEDRAL_CW:
128-
suffix = "_R"
129-
elif chiral == Chem.ChiralType.CHI_TETRAHEDRAL_CCW:
130-
suffix = "_S"
130+
if atom.HasProp("_CIPCode"):
131+
cip = atom.GetProp("_CIPCode")
132+
if cip in ("R", "S"):
133+
suffix = f"_{cip}"
131134
return f"{self.prefix}_{symbol}{cnt}{suffix}"
132135

133136
def convert(self) -> Tuple[List[str], str, str]:
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Polymer round-trips preserve R/S chirality and E/Z double-bond stereo.
2+
3+
Mirrors tests/test_molecule_stereochemistry.py but for the polymer converters,
4+
which encode a repeat unit (PSMILES with two ``*`` attachment points) as a
5+
Mermaid graph and back. Before the CIP fix the polymer path dropped both
6+
chirality and E/Z; these tests pin the corrected behaviour.
7+
"""
8+
9+
from rdkit import Chem
10+
11+
from molecode.polymer import polymer_to_mermaid, mermaid_to_psmiles
12+
13+
14+
def _round_trip(psmiles: str, n: int = 10) -> str:
15+
graph = polymer_to_mermaid(psmiles, n=n, name="Test")
16+
back = mermaid_to_psmiles(graph)
17+
assert back is not None
18+
return Chem.CanonSmiles(back)
19+
20+
21+
def test_r_chirality_round_trip_uses_absolute_cip():
22+
ps = "*OC(=O)[C@@H](C)*" # PLA-like, one stereocentre
23+
graph = polymer_to_mermaid(ps, n=10, name="Test")
24+
assert "_R" in graph or "_S" in graph
25+
assert _round_trip(ps) == Chem.CanonSmiles(ps)
26+
27+
28+
def test_s_chirality_round_trip_uses_absolute_cip():
29+
ps = "*OC(=O)[C@H](C)*"
30+
assert _round_trip(ps) == Chem.CanonSmiles(ps)
31+
32+
33+
def test_trans_double_bond_round_trip():
34+
ps = "*C/C=C/C*"
35+
graph = polymer_to_mermaid(ps, n=5, name="Test")
36+
assert "===|E|" in graph
37+
assert _round_trip(ps, n=5) == Chem.CanonSmiles(ps)
38+
39+
40+
def test_cis_double_bond_round_trip():
41+
ps = "*C/C=C\\C*"
42+
graph = polymer_to_mermaid(ps, n=5, name="Test")
43+
assert "===|Z|" in graph
44+
assert _round_trip(ps, n=5) == Chem.CanonSmiles(ps)
45+
46+
47+
def test_cis_and_trans_are_distinguished():
48+
assert _round_trip("*C/C=C/C*", n=5) != _round_trip("*C/C=C\\C*", n=5)
49+
50+
51+
def test_chirality_and_ez_combined():
52+
ps = "*O[C@@H](C)/C=C/C*"
53+
assert _round_trip(ps) == Chem.CanonSmiles(ps)
54+
55+
56+
def test_plain_polymers_still_round_trip():
57+
for ps in ("*CC*", "*CC(C)*", "*CCO*", "*NCCCCCC(=O)*", "*CC(c1ccccc1)*"):
58+
assert _round_trip(ps) == Chem.CanonSmiles(ps)

0 commit comments

Comments
 (0)