Skip to content

Commit 96e7a6f

Browse files
authored
Merge pull request #1 from AtomFlow-AI/codex/review-code-completeness
Preserve stereochemistry and explicit aromatic '<-->' bonds in Mermaid↔RDKit converters
2 parents 2f04d49 + 983c4a5 commit 96e7a6f

6 files changed

Lines changed: 247 additions & 115 deletions

File tree

molecode/markush/mermaid_to_rdkit.py

Lines changed: 85 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class MermaidMolParser:
2828
'---': Chem.BondType.SINGLE,
2929
'===': Chem.BondType.DOUBLE,
3030
'-.-': Chem.BondType.TRIPLE,
31+
'<-->': Chem.BondType.AROMATIC,
3132
'-->': Chem.BondType.DATIVE, # 配位键
3233
}
3334

@@ -88,7 +89,7 @@ def _parse_line(self, line: str):
8889

8990
# 尝试匹配普通键连接: atom1 bond_type atom2
9091
# 原子ID可能包含手性后缀 (_R 或 _S)
91-
bond_pattern = r'([\w_]+)\s*(---|\===|-\.-|-->)\s*([\w_]+)'
92+
bond_pattern = r'([\w_]+)\s*(<-->|---|\===|-\.-|-->)\s*([\w_]+)'
9293
bond_match = re.search(bond_pattern, line)
9394

9495
if bond_match:
@@ -241,17 +242,10 @@ def _build_mol(self) -> Optional[Chem.Mol]:
241242
atom_obj = mol.GetAtomWithIdx(idx)
242243
atom_obj.SetProp("_abbreviation", self.abbreviations[atom_id])
243244

244-
# 设置手性(如果有)
245-
if atom_id in self.chirality:
246-
chirality_type = self.chirality[atom_id]
247-
atom_obj = mol.GetAtomWithIdx(idx)
248-
249-
if chirality_type == 'R':
250-
atom_obj.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CW)
251-
elif chirality_type == 'S':
252-
atom_obj.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CCW)
253-
254-
# 添加键
245+
# 添加键。双键 E/Z 和四面体 R/S 都需要在完整拓扑存在后设置,
246+
# 因此这里先记录,待 SanitizeMol 后统一恢复。
247+
stereo_bonds = []
248+
aromatic_atom_idxs = set()
255249
for bond_info in self.bonds:
256250
if len(bond_info) == 3:
257251
# 普通键: (atom1_id, atom2_id, bond_type_str)
@@ -270,40 +264,16 @@ def _build_mol(self) -> Optional[Chem.Mol]:
270264

271265
mol.AddBond(idx1, idx2, bond_type)
272266

273-
# 设置立体化学(稍后统一处理,需要先添加所有键)
267+
if bond_type_str == '<-->':
268+
aromatic_atom_idxs.update((idx1, idx2))
269+
bond = mol.GetBondBetweenAtoms(idx1, idx2)
270+
bond.SetIsAromatic(True)
271+
274272
if stereo_type:
275-
# 记录需要设置立体化学的键
276-
if not hasattr(mol, '_stereo_bonds'):
277-
mol._stereo_bonds = []
278-
mol._stereo_bonds.append((idx1, idx2, stereo_type))
279-
280-
# 在转换为不可编辑的Mol之前,设置立体化学
281-
if hasattr(mol, '_stereo_bonds'):
282-
for idx1, idx2, stereo_type in mol._stereo_bonds:
283-
bond = mol.GetBondBetweenAtoms(idx1, idx2)
284-
285-
# 获取双键两端原子的邻接原子(用于定义立体化学)
286-
atom1 = mol.GetAtomWithIdx(idx1)
287-
atom2 = mol.GetAtomWithIdx(idx2)
288-
289-
# 找到idx1的邻居(除了idx2)
290-
neighbors1 = [n.GetIdx() for n in atom1.GetNeighbors() if n.GetIdx() != idx2]
291-
# 找到idx2的邻居(除了idx1)
292-
neighbors2 = [n.GetIdx() for n in atom2.GetNeighbors() if n.GetIdx() != idx1]
293-
294-
# 如果两端都有邻居,设置立体化学
295-
if neighbors1 and neighbors2:
296-
# 使用第一个邻居作为参考原子
297-
bond.SetStereoAtoms(neighbors1[0], neighbors2[0])
298-
299-
if stereo_type == 'E':
300-
bond.SetStereo(Chem.BondStereo.STEREOE)
301-
elif stereo_type == 'Z':
302-
bond.SetStereo(Chem.BondStereo.STEREOZ)
303-
elif stereo_type == 'CIS':
304-
bond.SetStereo(Chem.BondStereo.STEREOCIS)
305-
elif stereo_type == 'TRANS':
306-
bond.SetStereo(Chem.BondStereo.STEREOTRANS)
273+
stereo_bonds.append((idx1, idx2, stereo_type))
274+
275+
for idx in aromatic_atom_idxs:
276+
mol.GetAtomWithIdx(idx).SetIsAromatic(True)
307277

308278
# 转换为不可编辑的Mol对象
309279
mol = mol.GetMol()
@@ -322,9 +292,79 @@ def _build_mol(self) -> Optional[Chem.Mol]:
322292
# 完全失败,返回未清理的版本
323293
pass
324294

295+
self._assign_chirality_from_ids(mol, atom_id_to_idx)
296+
self._assign_double_bond_stereo(mol, stereo_bonds)
297+
325298
return mol
326299

327300

301+
def _assign_chirality_from_ids(self, mol: Chem.Mol, atom_id_to_idx: Dict[str, int]):
302+
"""根据 atom id 的 _R/_S 后缀恢复绝对 CIP 手性。"""
303+
if not self.chirality:
304+
return
305+
306+
for atom_id, desired_cip in self.chirality.items():
307+
idx = atom_id_to_idx.get(atom_id)
308+
if idx is None:
309+
continue
310+
311+
atom = mol.GetAtomWithIdx(idx)
312+
matched = False
313+
314+
for chiral_tag in (
315+
Chem.ChiralType.CHI_TETRAHEDRAL_CW,
316+
Chem.ChiralType.CHI_TETRAHEDRAL_CCW,
317+
):
318+
atom.SetChiralTag(chiral_tag)
319+
try:
320+
Chem.AssignStereochemistry(mol, cleanIt=True, force=True)
321+
except Exception:
322+
continue
323+
324+
if atom.HasProp('_CIPCode') and atom.GetProp('_CIPCode') == desired_cip:
325+
matched = True
326+
break
327+
328+
if not matched:
329+
atom.SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)
330+
331+
try:
332+
Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
333+
except Exception:
334+
pass
335+
336+
def _assign_double_bond_stereo(self, mol: Chem.Mol, stereo_bonds: List[Tuple[int, int, str]]):
337+
"""恢复 ===|E| / ===|Z| 双键构型。"""
338+
for idx1, idx2, stereo_type in stereo_bonds:
339+
bond = mol.GetBondBetweenAtoms(idx1, idx2)
340+
if bond is None:
341+
continue
342+
343+
atom1 = mol.GetAtomWithIdx(idx1)
344+
atom2 = mol.GetAtomWithIdx(idx2)
345+
neighbors1 = [n.GetIdx() for n in atom1.GetNeighbors() if n.GetIdx() != idx2]
346+
neighbors2 = [n.GetIdx() for n in atom2.GetNeighbors() if n.GetIdx() != idx1]
347+
348+
if not neighbors1 or not neighbors2:
349+
continue
350+
351+
bond.SetStereoAtoms(neighbors1[0], neighbors2[0])
352+
353+
if stereo_type == 'E':
354+
bond.SetStereo(Chem.BondStereo.STEREOE)
355+
elif stereo_type == 'Z':
356+
bond.SetStereo(Chem.BondStereo.STEREOZ)
357+
elif stereo_type == 'CIS':
358+
bond.SetStereo(Chem.BondStereo.STEREOCIS)
359+
elif stereo_type == 'TRANS':
360+
bond.SetStereo(Chem.BondStereo.STEREOTRANS)
361+
362+
try:
363+
Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
364+
except Exception:
365+
pass
366+
367+
328368
def has_invalid_atoms(mol: Chem.Mol) -> bool:
329369
"""
330370
检查分子是否包含无效原子(Dummy Atom)

molecode/markush/rdkit_to_mermaid.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -163,18 +163,16 @@ def _generate_atom_id(self, atom: Chem.Atom) -> str:
163163
# 基础ID
164164
base_id = f"{clean_name}_{symbol}_{count}"
165165

166-
# 检测手性并添加后缀
167-
chiral_tag = atom.GetChiralTag()
168-
169-
if chiral_tag == Chem.ChiralType.CHI_TETRAHEDRAL_CW:
170-
# 顺时针 (R构型)
171-
return f"{base_id}_R"
172-
elif chiral_tag == Chem.ChiralType.CHI_TETRAHEDRAL_CCW:
173-
# 逆时针 (S构型)
174-
return f"{base_id}_S"
175-
else:
176-
# 无手性或未指定
177-
return base_id
166+
# 使用 RDKit 计算出的绝对 CIP 构型,而不是直接把
167+
# CHI_TETRAHEDRAL_CW/CCW 当作 R/S。CW/CCW 依赖原子顺序,
168+
# 只有 _CIPCode 才是可序列化的绝对 R/S 标签。
169+
if atom.HasProp('_CIPCode'):
170+
cip_code = atom.GetProp('_CIPCode')
171+
if cip_code in ('R', 'S'):
172+
return f"{base_id}_{cip_code}"
173+
174+
# 无手性或未指定
175+
return base_id
178176

179177
def _generate_atom_label(self, atom: Chem.Atom) -> str:
180178
"""

molecode/molecule/mermaid_to_rdkit.py

Lines changed: 88 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class MermaidMolParser:
2828
'---': Chem.BondType.SINGLE,
2929
'===': Chem.BondType.DOUBLE,
3030
'-.-': Chem.BondType.TRIPLE,
31+
'<-->': Chem.BondType.AROMATIC,
3132
'-->': Chem.BondType.DATIVE, # 配位键
3233
}
3334

@@ -86,7 +87,7 @@ def _parse_line(self, line: str):
8687

8788
# 尝试匹配普通键连接: atom1 bond_type atom2
8889
# 原子ID可能包含手性后缀 (_R 或 _S)
89-
bond_pattern = r'([\w_]+)\s*(---|\===|-\.-|-->)\s*([\w_]+)'
90+
bond_pattern = r'([\w_]+)\s*(<-->|---|\===|-\.-|-->)\s*([\w_]+)'
9091
bond_match = re.search(bond_pattern, line)
9192

9293
if bond_match:
@@ -215,17 +216,10 @@ def _build_mol(self) -> Optional[Chem.Mol]:
215216
idx = mol.AddAtom(atom)
216217
atom_id_to_idx[atom_id] = idx
217218

218-
# 设置手性(如果有)
219-
if atom_id in self.chirality:
220-
chirality_type = self.chirality[atom_id]
221-
atom_obj = mol.GetAtomWithIdx(idx)
222-
223-
if chirality_type == 'R':
224-
atom_obj.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CW)
225-
elif chirality_type == 'S':
226-
atom_obj.SetChiralTag(Chem.ChiralType.CHI_TETRAHEDRAL_CCW)
227-
228-
# 添加键
219+
# 添加键。双键 E/Z 和四面体 R/S 都需要在完整拓扑存在后设置,
220+
# 因此这里先记录,待 SanitizeMol 后统一恢复。
221+
stereo_bonds = []
222+
aromatic_atom_idxs = set()
229223
for bond_info in self.bonds:
230224
if len(bond_info) == 3:
231225
# 普通键: (atom1_id, atom2_id, bond_type_str)
@@ -244,40 +238,16 @@ def _build_mol(self) -> Optional[Chem.Mol]:
244238

245239
mol.AddBond(idx1, idx2, bond_type)
246240

247-
# 设置立体化学(稍后统一处理,需要先添加所有键)
241+
if bond_type_str == '<-->':
242+
aromatic_atom_idxs.update((idx1, idx2))
243+
bond = mol.GetBondBetweenAtoms(idx1, idx2)
244+
bond.SetIsAromatic(True)
245+
248246
if stereo_type:
249-
# 记录需要设置立体化学的键
250-
if not hasattr(mol, '_stereo_bonds'):
251-
mol._stereo_bonds = []
252-
mol._stereo_bonds.append((idx1, idx2, stereo_type))
253-
254-
# 在转换为不可编辑的Mol之前,设置立体化学
255-
if hasattr(mol, '_stereo_bonds'):
256-
for idx1, idx2, stereo_type in mol._stereo_bonds:
257-
bond = mol.GetBondBetweenAtoms(idx1, idx2)
258-
259-
# 获取双键两端原子的邻接原子(用于定义立体化学)
260-
atom1 = mol.GetAtomWithIdx(idx1)
261-
atom2 = mol.GetAtomWithIdx(idx2)
262-
263-
# 找到idx1的邻居(除了idx2)
264-
neighbors1 = [n.GetIdx() for n in atom1.GetNeighbors() if n.GetIdx() != idx2]
265-
# 找到idx2的邻居(除了idx1)
266-
neighbors2 = [n.GetIdx() for n in atom2.GetNeighbors() if n.GetIdx() != idx1]
267-
268-
# 如果两端都有邻居,设置立体化学
269-
if neighbors1 and neighbors2:
270-
# 使用第一个邻居作为参考原子
271-
bond.SetStereoAtoms(neighbors1[0], neighbors2[0])
272-
273-
if stereo_type == 'E':
274-
bond.SetStereo(Chem.BondStereo.STEREOE)
275-
elif stereo_type == 'Z':
276-
bond.SetStereo(Chem.BondStereo.STEREOZ)
277-
elif stereo_type == 'CIS':
278-
bond.SetStereo(Chem.BondStereo.STEREOCIS)
279-
elif stereo_type == 'TRANS':
280-
bond.SetStereo(Chem.BondStereo.STEREOTRANS)
247+
stereo_bonds.append((idx1, idx2, stereo_type))
248+
249+
for idx in aromatic_atom_idxs:
250+
mol.GetAtomWithIdx(idx).SetIsAromatic(True)
281251

282252
# 转换为不可编辑的Mol对象
283253
mol = mol.GetMol()
@@ -296,9 +266,82 @@ def _build_mol(self) -> Optional[Chem.Mol]:
296266
# 完全失败,返回未清理的版本
297267
pass
298268

269+
self._assign_chirality_from_ids(mol, atom_id_to_idx)
270+
self._assign_double_bond_stereo(mol, stereo_bonds)
271+
299272
return mol
300273

301274

275+
def _assign_chirality_from_ids(self, mol: Chem.Mol, atom_id_to_idx: Dict[str, int]):
276+
"""根据 atom id 的 _R/_S 后缀恢复绝对 CIP 手性。"""
277+
if not self.chirality:
278+
return
279+
280+
for atom_id, desired_cip in self.chirality.items():
281+
idx = atom_id_to_idx.get(atom_id)
282+
if idx is None:
283+
continue
284+
285+
atom = mol.GetAtomWithIdx(idx)
286+
matched = False
287+
288+
for chiral_tag in (
289+
Chem.ChiralType.CHI_TETRAHEDRAL_CW,
290+
Chem.ChiralType.CHI_TETRAHEDRAL_CCW,
291+
):
292+
atom.SetChiralTag(chiral_tag)
293+
try:
294+
Chem.AssignStereochemistry(mol, cleanIt=True, force=True)
295+
except Exception:
296+
continue
297+
298+
if atom.HasProp('_CIPCode') and atom.GetProp('_CIPCode') == desired_cip:
299+
matched = True
300+
break
301+
302+
if not matched:
303+
atom.SetChiralTag(Chem.ChiralType.CHI_UNSPECIFIED)
304+
305+
try:
306+
Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
307+
except Exception:
308+
pass
309+
310+
def _assign_double_bond_stereo(self, mol: Chem.Mol, stereo_bonds: List[Tuple[int, int, str]]):
311+
"""恢复 ===|E| / ===|Z| 双键构型。"""
312+
for idx1, idx2, stereo_type in stereo_bonds:
313+
bond = mol.GetBondBetweenAtoms(idx1, idx2)
314+
if bond is None:
315+
continue
316+
317+
# 获取双键两端原子的邻接原子(用于定义立体化学)
318+
atom1 = mol.GetAtomWithIdx(idx1)
319+
atom2 = mol.GetAtomWithIdx(idx2)
320+
321+
neighbors1 = [n.GetIdx() for n in atom1.GetNeighbors() if n.GetIdx() != idx2]
322+
neighbors2 = [n.GetIdx() for n in atom2.GetNeighbors() if n.GetIdx() != idx1]
323+
324+
if not neighbors1 or not neighbors2:
325+
continue
326+
327+
bond.SetStereoAtoms(neighbors1[0], neighbors2[0])
328+
329+
if stereo_type == 'E':
330+
bond.SetStereo(Chem.BondStereo.STEREOE)
331+
elif stereo_type == 'Z':
332+
bond.SetStereo(Chem.BondStereo.STEREOZ)
333+
elif stereo_type == 'CIS':
334+
bond.SetStereo(Chem.BondStereo.STEREOCIS)
335+
elif stereo_type == 'TRANS':
336+
bond.SetStereo(Chem.BondStereo.STEREOTRANS)
337+
338+
try:
339+
# 不使用 cleanIt=True,避免清掉刚刚从 EGL 明确恢复的 E/Z 标记。
340+
Chem.AssignStereochemistry(mol, cleanIt=False, force=True)
341+
except Exception:
342+
pass
343+
344+
302345
def has_invalid_atoms(mol: Chem.Mol) -> bool:
303346
"""
304347
检查分子是否包含无效原子(Dummy Atom)

molecode/molecule/rdkit_to_mermaid.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -158,18 +158,16 @@ def _generate_atom_id(self, atom: Chem.Atom) -> str:
158158
# 基础ID
159159
base_id = f"{clean_name}_{symbol}_{count}"
160160

161-
# 检测手性并添加后缀
162-
chiral_tag = atom.GetChiralTag()
163-
164-
if chiral_tag == Chem.ChiralType.CHI_TETRAHEDRAL_CW:
165-
# 顺时针 (R构型)
166-
return f"{base_id}_R"
167-
elif chiral_tag == Chem.ChiralType.CHI_TETRAHEDRAL_CCW:
168-
# 逆时针 (S构型)
169-
return f"{base_id}_S"
170-
else:
171-
# 无手性或未指定
172-
return base_id
161+
# 使用 RDKit 计算出的绝对 CIP 构型,而不是直接把
162+
# CHI_TETRAHEDRAL_CW/CCW 当作 R/S。CW/CCW 依赖原子顺序,
163+
# 只有 _CIPCode 才是可序列化的绝对 R/S 标签。
164+
if atom.HasProp('_CIPCode'):
165+
cip_code = atom.GetProp('_CIPCode')
166+
if cip_code in ('R', 'S'):
167+
return f"{base_id}_{cip_code}"
168+
169+
# 无手性或未指定
170+
return base_id
173171

174172
def _generate_atom_label(self, atom: Chem.Atom) -> str:
175173
"""

0 commit comments

Comments
 (0)