From 2ffc4e70ea1ddfe5ad0a466e7d639f68bc98c08e Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Wed, 13 May 2026 09:16:00 +0200 Subject: [PATCH 1/7] branch --- test_data_query.ipynb | 2302 ----------------------------------------- test_query.ipynb | 324 ------ 2 files changed, 2626 deletions(-) delete mode 100644 test_data_query.ipynb delete mode 100644 test_query.ipynb diff --git a/test_data_query.ipynb b/test_data_query.ipynb deleted file mode 100644 index a70c2bc..0000000 --- a/test_data_query.ipynb +++ /dev/null @@ -1,2302 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "6abb3307", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/homes/biertank/minh/miniconda3/envs/synkit/lib/python3.11/site-packages/rxnmapper/batched_mapper.py:4: UserWarning: pkg_resources is deprecated as an API. See https://setuptools.pypa.io/en/latest/pkg_resources.html. The pkg_resources package is slated for removal as early as 2025-11-30. Refrain from using this package or pin to Setuptools<81.\n", - " import pkg_resources\n", - "/homes/biertank/minh/miniconda3/envs/synkit/lib/python3.11/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", - " from .autonotebook import tqdm as notebook_tqdm\n" - ] - } - ], - "source": [ - "from synkit.CRN.Query.kegg_extract import KEGGExtractor\n", - "\n", - "extractor = KEGGExtractor()\n", - "\n", - "pathway_data = extractor.build_pathway_json(\n", - " \"hsa00010\",\n", - " with_compounds=True,\n", - " with_atom_maps=True,\n", - " save_as='Data/KEGG_Query/hsa00010_raw.json',\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "0fda9a18", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pathway_id': 'hsa00010',\n", - " 'modules': ['M00001', 'M00002', 'M00003', 'M00307'],\n", - " 'by_module': {'M00001': {'module_id': 'M00001',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00002 + C00022 <=> C00008 + C00074',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:8](=[O:9])[OH:10].[P:4](=[O:5])([OH:6])([OH:7])[O:33][P:30]([O:29][P:26]([O:25][CH2:24][C@H:23]1[O:22][C@@H:21]([n:20]2[c:16]3[n:15][cH:14][n:13][c:12]([NH2:11])[c:17]3[n:18][cH:19]2)[C@H:36]([OH:37])[C@@H:34]1[OH:35])(=[O:27])[OH:28])(=[O:31])[OH:32]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[NH2:11][c:12]1[n:13][cH:14][n:15][c:16]2[c:17]1[n:18][cH:19][n:20]2[C@@H:21]1[O:22][C@H:23]([CH2:24][O:25][P:26](=[O:27])([OH:28])[O:29][P:30](=[O:31])([OH:32])[OH:33])[C@@H:34]([OH:35])[C@H:36]1[OH:37]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 <=> C00074 + C00001',\n", - " 'rule': '[CH2:1]([C@@H:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10])[OH:11]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[OH2:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R00756',\n", - " 'reaction': 'C00002 + C00085 <=> C00008 + C00354',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[O:23][P:40](=[O:41])([OH:42])[OH:43])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[P:29]([OH:30])([OH:31])[O:32][CH2:33][C@H:34]1[O:35][C:36]([OH:37])([CH2:38][OH:39])[C@@H:44]([OH:45])[C@@H:46]1[OH:47]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[OH:23])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[P:29]([OH:30])([OH:31])[O:32][CH2:33][C@H:34]1[O:35][C:36]([OH:37])([CH2:38][O:39][P:40](=[O:41])([OH:42])[OH:43])[C@@H:44]([OH:45])[C@@H:46]1[OH:47]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00118 <=> C00111',\n", - " 'rule': '[OH:1][C@@H:2]([CH:3]=[O:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]>>[O:1]=[C:2]([CH2:3][OH:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 <=> C00236 + C00004 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1.[O:45]=[CH:46][C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[OH:47][P:48](=[O:49])([OH:50])[OH:51]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[O:45]=[C:46]([O:47][P:48](=[O:49])([OH:50])[OH:51])[C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[H+:60]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 <=> C00236 + C00005 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[cH:46][cH:47][cH:48]1.[O:49]=[CH:50][C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[OH:51][P:52](=[O:53])([OH:54])[OH:55]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[CH:46]=[CH:47][CH2:48]1.[O:49]=[C:50]([O:51][P:52](=[O:53])([OH:54])[OH:55])[C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[H+:64]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00354 <=> C00111 + C00118',\n", - " 'rule': '[OH:1][C:2]1([CH2:3][O:4][P:7]([OH:6])(=[O:8])[OH:9])[C@@H:5]([OH:10])[C@H:15]([OH:16])[C@@H:13]([CH2:12][O:11][P:17](=[O:18])([OH:19])[OH:20])[O:14]1>>[O:1]=[C:2]([CH2:3][OH:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10].[O:11]=[CH:12][C@H:13]([OH:14])[CH2:15][O:16][P:17](=[O:18])([OH:19])[OH:20]',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00002 + C00197 <=> C00008 + C00236',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[O:23][P:31](=[O:32])([OH:33])[OH:34])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([OH:30])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[OH:23])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([O:30][P:31](=[O:32])([OH:33])[OH:34])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00631 <=> C00197',\n", - " 'rule': '[O:1]=[C:2]([OH:3])[C@H:4]([O:5][P:8](=[O:9])([OH:10])[OH:11])[CH2:6][OH:7]>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01786',\n", - " 'reaction': 'C00002 + C00267 <=> C00008 + C00668',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[O:23][P:29](=[O:28])([OH:30])[OH:31])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[OH:32][CH2:33][C@H:34]1[O:35][C@H:36]([OH:37])[C@H:38]([OH:39])[C@@H:40]([OH:41])[C@@H:42]1[OH:43]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[OH:23])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[P:29]([OH:30])([OH:31])[O:32][CH2:33][C@H:34]1[O:35][C@H:36]([OH:37])[C@H:38]([OH:39])[C@@H:40]([OH:41])[C@@H:42]1[OH:43]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R02189',\n", - " 'reaction': 'C00404 + C00267 <=> C00404 + C00668',\n", - " 'rule': '[O:1]=[P:2]([OH:3])([OH:19])[O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[OH:29].[OH:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16].[O:17]=[P:18]([OH:19])([OH:20])[O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[OH:29]',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OP(=O)(O)OP(=O)(O)O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R05805',\n", - " 'reaction': 'C00008 + C00085 <=> C00020 + C00354',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:36](=[O:37])([OH:38])[OH:39])[C@@H:20]([OH:21])[C@H:22]1[OH:23].[O:24]=[P:25]([OH:26])([OH:27])[O:28][CH2:29][C@H:30]1[O:31][C:32]([OH:33])([CH2:34][OH:35])[C@@H:40]([OH:41])[C@@H:42]1[OH:43]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[OH:19])[C@@H:20]([OH:21])[C@H:22]1[OH:23].[O:24]=[P:25]([OH:26])([OH:27])[O:28][CH2:29][C@H:30]1[O:31][C:32]([OH:33])([CH2:34][O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]([OH:41])[C@@H:42]1[OH:43]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 <=> C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11]>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+]'},\n", - " {'id': 'R09085',\n", - " 'reaction': 'C00267 + C00008 <=> C00668 + C00020',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:25](=[O:24])([OH:26])[OH:27])[C@@H:20]([OH:21])[C@H:22]1[OH:23].[OH:28][CH2:29][C@H:30]1[O:31][C@H:32]([OH:33])[C@H:34]([OH:35])[C@@H:36]([OH:37])[C@@H:38]1[OH:39]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[OH:19])[C@@H:20]([OH:21])[C@H:22]1[OH:23].[O:24]=[P:25]([OH:26])([OH:27])[O:28][CH2:29][C@H:30]1[O:31][C@H:32]([OH:33])[C@H:34]([OH:35])[C@@H:36]([OH:37])[C@@H:38]1[OH:39]',\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O>>O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'R13199',\n", - " 'reaction': 'C00668 <=> C00085',\n", - " 'rule': '[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C:9]([OH:10])([CH2:11][OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00020',\n", - " 'name': 'AMP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': None},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'smiles': None},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00267',\n", - " 'name': 'alpha-D-Glucose',\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00404',\n", - " 'name': 'Polyphosphate',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C00668',\n", - " 'name': 'alpha-D-Glucose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'}],\n", - " 'missing': {'missing_compounds': [{'id': 'C00138',\n", - " 'name': 'Reduced ferredoxin',\n", - " 'reactions': ['R07159']},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'reactions': ['R07159']}],\n", - " 'missing_compound_ids': ['C00138', 'C00139'],\n", - " 'reactions_involving_missing': ['R07159']}},\n", - " 'M00002': {'module_id': 'M00002',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00002 + C00022 <=> C00008 + C00074',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:8](=[O:9])[OH:10].[P:4](=[O:5])([OH:6])([OH:7])[O:33][P:30]([O:29][P:26]([O:25][CH2:24][C@H:23]1[O:22][C@@H:21]([n:20]2[c:16]3[n:15][cH:14][n:13][c:12]([NH2:11])[c:17]3[n:18][cH:19]2)[C@H:36]([OH:37])[C@@H:34]1[OH:35])(=[O:27])[OH:28])(=[O:31])[OH:32]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[NH2:11][c:12]1[n:13][cH:14][n:15][c:16]2[c:17]1[n:18][cH:19][n:20]2[C@@H:21]1[O:22][C@H:23]([CH2:24][O:25][P:26](=[O:27])([OH:28])[O:29][P:30](=[O:31])([OH:32])[OH:33])[C@@H:34]([OH:35])[C@H:36]1[OH:37]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 <=> C00074 + C00001',\n", - " 'rule': '[CH2:1]([C@@H:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10])[OH:11]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[OH2:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00118 <=> C00111',\n", - " 'rule': '[OH:1][C@@H:2]([CH:3]=[O:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]>>[O:1]=[C:2]([CH2:3][OH:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 <=> C00236 + C00004 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1.[O:45]=[CH:46][C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[OH:47][P:48](=[O:49])([OH:50])[OH:51]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[O:45]=[C:46]([O:47][P:48](=[O:49])([OH:50])[OH:51])[C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[H+:60]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 <=> C00236 + C00005 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[cH:46][cH:47][cH:48]1.[O:49]=[CH:50][C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[OH:51][P:52](=[O:53])([OH:54])[OH:55]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[CH:46]=[CH:47][CH2:48]1.[O:49]=[C:50]([O:51][P:52](=[O:53])([OH:54])[OH:55])[C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[H+:64]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00002 + C00197 <=> C00008 + C00236',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[O:23][P:31](=[O:32])([OH:33])[OH:34])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([OH:30])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[OH:23])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([O:30][P:31](=[O:32])([OH:33])[OH:34])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00631 <=> C00197',\n", - " 'rule': '[O:1]=[C:2]([OH:3])[C@H:4]([O:5][P:8](=[O:9])([OH:10])[OH:11])[CH2:6][OH:7]>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 <=> C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11]>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+]'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': None},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'smiles': None},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [{'id': 'C00138',\n", - " 'name': 'Reduced ferredoxin',\n", - " 'reactions': ['R07159']},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'reactions': ['R07159']}],\n", - " 'missing_compound_ids': ['C00138', 'C00139'],\n", - " 'reactions_involving_missing': ['R07159']}},\n", - " 'M00003': {'module_id': 'M00003',\n", - " 'reactions': [{'id': 'R00341',\n", - " 'reaction': 'C00002 + C00036 <=> C00008 + C00074 + C00011',\n", - " 'rule': '[P:4](=[O:5])([OH:6])([OH:7])[O:33][P:30]([O:29][P:26]([O:25][CH2:24][C@H:23]1[O:22][C@@H:21]([n:20]2[c:16]3[n:15][cH:14][n:13][c:12]([NH2:11])[c:17]3[n:18][cH:19]2)[C@H:36]([OH:37])[C@@H:34]1[OH:35])(=[O:27])[OH:28])(=[O:31])[OH:32].[CH2:1]([C:2](=[O:3])[C:8](=[O:9])[OH:10])[C:39](=[O:38])[OH:40]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[NH2:11][c:12]1[n:13][cH:14][n:15][c:16]2[c:17]1[n:18][cH:19][n:20]2[C@@H:21]1[O:22][C@H:23]([CH2:24][O:25][P:26](=[O:27])([OH:28])[O:29][P:30](=[O:31])([OH:32])[OH:33])[C@@H:34]([OH:35])[C@H:36]1[OH:37].[O:38]=[C:39]=[O:40]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00431',\n", - " 'reaction': 'C00044 + C00036 <=> C00035 + C00074 + C00011',\n", - " 'rule': '[P:4](=[O:5])([OH:6])([OH:7])[O:31][P:28]([O:27][P:24]([O:23][CH2:22][C@H:21]1[O:20][C@@H:19]([n:18]2[c:14]3[n:13][c:12]([NH2:11])[nH:38][c:36](=[O:37])[c:15]3[n:16][cH:17]2)[C@H:34]([OH:35])[C@@H:32]1[OH:33])(=[O:25])[OH:26])(=[O:29])[OH:30].[CH2:1]([C:2](=[O:3])[C:8](=[O:9])[OH:10])[C:40](=[O:39])[OH:41]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[NH2:11][c:12]1[n:13][c:14]2[c:15]([n:16][cH:17][n:18]2[C@@H:19]2[O:20][C@H:21]([CH2:22][O:23][P:24](=[O:25])([OH:26])[O:27][P:28](=[O:29])([OH:30])[OH:31])[C@@H:32]([OH:33])[C@H:34]2[OH:35])[c:36](=[O:37])[nH:38]1.[O:39]=[C:40]=[O:41]',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.O=C(O)CC(=O)C(=O)O>>Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 <=> C00074 + C00001',\n", - " 'rule': '[CH2:1]([C@@H:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10])[OH:11]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[OH2:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R00726',\n", - " 'reaction': 'C00081 + C00036 <=> C00104 + C00074 + C00011',\n", - " 'rule': '[CH2:1]([C:2](=[O:3])[C:8](=[O:9])[OH:10])[C:12](=[O:11])[OH:13].[P:4](=[O:5])([OH:6])([OH:7])[O:36][P:33]([O:32][P:29]([O:28][CH2:27][C@H:26]1[O:25][C@@H:24]([n:23]2[c:19]3[n:18][cH:17][nH:16][c:15](=[O:14])[c:20]3[n:21][cH:22]2)[C@H:39]([OH:40])[C@@H:37]1[OH:38])(=[O:30])[OH:31])(=[O:34])[OH:35]>>[CH2:1]=[C:2]([O:3][P:4](=[O:5])([OH:6])[OH:7])[C:8](=[O:9])[OH:10].[O:11]=[C:12]=[O:13].[O:14]=[c:15]1[nH:16][cH:17][n:18][c:19]2[c:20]1[n:21][cH:22][n:23]2[C@@H:24]1[O:25][C@H:26]([CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[C@@H:37]([OH:38])[C@H:39]1[OH:40]',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00762',\n", - " 'reaction': 'C00354 + C00001 <=> C00085 + C00009',\n", - " 'rule': '[OH2:3].[O:1]=[P:2]([OH:4])([OH:5])[O:17][CH2:16][C:14]1([OH:15])[O:13][C@H:12]([CH2:11][O:10][P:7](=[O:6])([OH:8])[OH:9])[C@@H:20]([OH:21])[C@@H:18]1[OH:19]>>[O:1]=[P:2]([OH:3])([OH:4])[OH:5].[O:6]=[P:7]([OH:8])([OH:9])[O:10][CH2:11][C@H:12]1[O:13][C:14]([OH:15])([CH2:16][OH:17])[C@@H:18]([OH:19])[C@@H:20]1[OH:21]',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O.O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O.O=P(O)(O)O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00118 <=> C00111',\n", - " 'rule': '[OH:1][C@@H:2]([CH:3]=[O:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]>>[O:1]=[C:2]([CH2:3][OH:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 <=> C00236 + C00004 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1.[O:45]=[CH:46][C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[OH:47][P:48](=[O:49])([OH:50])[OH:51]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[O:45]=[C:46]([O:47][P:48](=[O:49])([OH:50])[OH:51])[C@H:52]([OH:53])[CH2:54][O:55][P:56](=[O:57])([OH:58])[OH:59].[H+:60]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 <=> C00236 + C00005 + C00080',\n", - " 'rule': '[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[cH:46][cH:47][cH:48]1.[O:49]=[CH:50][C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[OH:51][P:52](=[O:53])([OH:54])[OH:55]>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([O:35][P:36](=[O:37])([OH:38])[OH:39])[C@@H:40]3[OH:41])[C@@H:42]([OH:43])[C@H:44]2[OH:45])[CH:46]=[CH:47][CH2:48]1.[O:49]=[C:50]([O:51][P:52](=[O:53])([OH:54])[OH:55])[C@H:56]([OH:57])[CH2:58][O:59][P:60](=[O:61])([OH:62])[OH:63].[H+:64]',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00354 <=> C00111 + C00118',\n", - " 'rule': '[OH:1][C:2]1([CH2:3][O:4][P:7]([OH:6])(=[O:8])[OH:9])[C@@H:5]([OH:10])[C@H:15]([OH:16])[C@@H:13]([CH2:12][O:11][P:17](=[O:18])([OH:19])[OH:20])[O:14]1>>[O:1]=[C:2]([CH2:3][OH:4])[CH2:5][O:6][P:7](=[O:8])([OH:9])[OH:10].[O:11]=[CH:12][C@H:13]([OH:14])[CH2:15][O:16][P:17](=[O:18])([OH:19])[OH:20]',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00002 + C00197 <=> C00008 + C00236',\n", - " 'rule': '[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[O:23][P:31](=[O:32])([OH:33])[OH:34])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([OH:30])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]>>[NH2:1][c:2]1[n:3][cH:4][n:5][c:6]2[c:7]1[n:8][cH:9][n:10]2[C@@H:11]1[O:12][C@H:13]([CH2:14][O:15][P:16](=[O:17])([OH:18])[O:19][P:20](=[O:21])([OH:22])[OH:23])[C@@H:24]([OH:25])[C@H:26]1[OH:27].[O:28]=[C:29]([O:30][P:31](=[O:32])([OH:33])[OH:34])[C@H:35]([OH:36])[CH2:37][O:38][P:39](=[O:40])([OH:41])[OH:42]',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00631 <=> C00197',\n", - " 'rule': '[O:1]=[C:2]([OH:3])[C@H:4]([O:5][P:8](=[O:9])([OH:10])[OH:11])[CH2:6][OH:7]>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11]',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00035',\n", - " 'name': 'GDP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00036', 'name': 'Oxaloacetate', 'smiles': 'O=C(O)CC(=O)C(=O)O'},\n", - " {'id': 'C00044',\n", - " 'name': 'GTP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00081',\n", - " 'name': 'ITP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00104',\n", - " 'name': 'IDP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00307': {'module_id': 'M00307',\n", - " 'reactions': [{'id': 'R00014',\n", - " 'reaction': 'C00022 + C00068 <=> C05125 + C00011',\n", - " 'rule': '[C:9]([CH3:10])(=[O:11])[C:31](=[O:30])[OH:32].[CH3:1][c:2]1[n:3][cH:4][c:5]([CH2:6][n+:7]2[cH:8][s:12][c:13]([CH2:14][CH2:15][O:16][P:17](=[O:18])([OH:19])[O:20][P:21](=[O:22])([OH:23])[OH:24])[c:25]2[CH3:26])[c:27]([NH2:28])[n:29]1>>[CH3:1][c:2]1[n:3][cH:4][c:5]([CH2:6][n+:7]2[c:8]([CH:9]([CH3:10])[OH:11])[s:12][c:13]([CH2:14][CH2:15][O:16][P:17](=[O:18])([OH:19])[O:20][P:21](=[O:22])([OH:23])[OH:24])[c:25]2[CH3:26])[c:27]([NH2:28])[n:29]1.[O:30]=[C:31]=[O:32]',\n", - " 'smiles': 'CC(=O)C(=O)O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1>>Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.O=C=O'},\n", - " {'id': 'R00209',\n", - " 'reaction': 'C00022 + C00010 + C00003 <=> C00024 + C00011 + C00004 + C00080',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:97]([OH:96])=[O:98].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[c:55]1[cH:56][n+:57]([C@@H:58]2[O:59][C@H:60]([CH2:61][O:62][P:63](=[O:64])([OH:65])[O:66][P:67](=[O:68])([OH:69])[O:70][CH2:71][C@H:72]3[O:73][C@@H:74]([n:75]4[cH:76][n:77][c:78]5[c:79]([NH2:80])[n:81][cH:82][n:83][c:84]45)[C@H:85]([OH:86])[C@@H:87]3[OH:88])[C@@H:89]([OH:90])[C@H:91]2[OH:92])[cH:93][cH:94][cH:95]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[C:55]1=[CH:56][N:57]([C@@H:58]2[O:59][C@H:60]([CH2:61][O:62][P:63](=[O:64])([OH:65])[O:66][P:67](=[O:68])([OH:69])[O:70][CH2:71][C@H:72]3[O:73][C@@H:74]([n:75]4[cH:76][n:77][c:78]5[c:79]([NH2:80])[n:81][cH:82][n:83][c:84]45)[C@H:85]([OH:86])[C@@H:87]3[OH:88])[C@@H:89]([OH:90])[C@H:91]2[OH:92])[CH:93]=[CH:94][CH2:95]1.[O:96]=[C:97]=[O:98].[H+:99]',\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R00210',\n", - " 'reaction': 'C00022 + C00010 + C00006 <=> C00024 + C00011 + C00005 + C00080',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:101]([OH:100])=[O:102].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[c:55]1[cH:56][n+:57]([C@@H:58]2[O:59][C@H:60]([CH2:61][O:62][P:63](=[O:64])([OH:65])[O:66][P:67](=[O:68])([OH:69])[O:70][CH2:71][C@H:72]3[O:73][C@@H:74]([n:75]4[cH:76][n:77][c:78]5[c:79]([NH2:80])[n:81][cH:82][n:83][c:84]45)[C@H:85]([O:86][P:87](=[O:88])([OH:89])[OH:90])[C@@H:91]3[OH:92])[C@@H:93]([OH:94])[C@H:95]2[OH:96])[cH:97][cH:98][cH:99]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[C:55]1=[CH:56][N:57]([C@@H:58]2[O:59][C@H:60]([CH2:61][O:62][P:63](=[O:64])([OH:65])[O:66][P:67](=[O:68])([OH:69])[O:70][CH2:71][C@H:72]3[O:73][C@@H:74]([n:75]4[cH:76][n:77][c:78]5[c:79]([NH2:80])[n:81][cH:82][n:83][c:84]45)[C@H:85]([O:86][P:87](=[O:88])([OH:89])[OH:90])[C@@H:91]3[OH:92])[C@@H:93]([OH:94])[C@H:95]2[OH:96])[CH:97]=[CH:98][CH2:99]1.[O:100]=[C:101]=[O:102].[H+:103]',\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01196',\n", - " 'reaction': '2 C00138 + C00024 + C00011 + 2 C00080 <=> 2 C00139 + C00022 + C00010',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[S:54][CH2:53][CH2:52][NH:51][C:49]([CH2:48][CH2:47][NH:46][C:44]([C@@H:42]([C:8]([CH3:7])([CH3:9])[CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]1[O:22][C@@H:23]([n:24]2[cH:25][n:26][c:27]3[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]23)[C@H:34]([OH:35])[C@@H:36]1[O:37][P:38](=[O:39])([OH:40])[OH:41])[OH:43])=[O:45])=[O:50].[C:4](=[O:5])=[O:6].[H+].[H+]>>[CH3:1][C:2](=[O:3])[C:4](=[O:5])[OH:6].[CH3:7][C:8]([CH3:9])([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]1[O:22][C@@H:23]([n:24]2[cH:25][n:26][c:27]3[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]23)[C@H:34]([OH:35])[C@@H:36]1[O:37][P:38](=[O:39])([OH:40])[OH:41])[C@@H:42]([OH:43])[C:44](=[O:45])[NH:46][CH2:47][CH2:48][C:49](=[O:50])[NH:51][CH2:52][CH2:53][SH:54]',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.[H+].[H+]>>CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS'},\n", - " {'id': 'R02569',\n", - " 'reaction': 'C00024 + C15973 <=> C00010 + C16255',\n", - " 'rule': '[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@@H:9]([SH:10])[CH2:11][CH2:12][SH:13].[C:14]([CH3:15])(=[O:16])[S:64][CH2:63][CH2:62][NH:61][C:59]([CH2:58][CH2:57][NH:56][C:54]([C@@H:52]([C:18]([CH3:17])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[OH:53])=[O:55])=[O:60]>>[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@@H:9]([SH:10])[CH2:11][CH2:12][S:13][C:14]([CH3:15])=[O:16].[CH3:17][C:18]([CH3:19])([CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[C@@H:52]([OH:53])[C:54](=[O:55])[NH:56][CH2:57][CH2:58][C:59](=[O:60])[NH:61][CH2:62][CH2:63][SH:64]',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.*NC(=O)CCCC[C@@H](S)CCS>>CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.*NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'R03270',\n", - " 'reaction': 'C05125 + C15972 <=> C16255 + C00068',\n", - " 'rule': '[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@H:9]1[S:10][S:13][CH2:12][CH2:11]1.[CH:14]([CH3:15])([OH:16])[c:24]1[n+:23]([CH2:22][c:21]2[cH:20][n:19][c:18]([CH3:17])[n:42][c:40]2[NH2:41])[c:38]([CH3:39])[c:26]([CH2:27][CH2:28][O:29][P:30](=[O:31])([OH:32])[O:33][P:34](=[O:35])([OH:36])[OH:37])[s:25]1>>[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@@H:9]([SH:10])[CH2:11][CH2:12][S:13][C:14]([CH3:15])=[O:16].[CH3:17][c:18]1[n:19][cH:20][c:21]([CH2:22][n+:23]2[cH:24][s:25][c:26]([CH2:27][CH2:28][O:29][P:30](=[O:31])([OH:32])[O:33][P:34](=[O:35])([OH:36])[OH:37])[c:38]2[CH3:39])[c:40]([NH2:41])[n:42]1',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.*NC(=O)CCCC[C@@H]1CCSS1>>*NC(=O)CCCC[C@@H](S)CCSC(C)=O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'R07618',\n", - " 'reaction': 'C15973 + C00003 <=> C15972 + C00004 + C00080',\n", - " 'rule': '[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@H:9]([CH2:10][CH2:11][SH:12])[SH:13].[NH2:14][C:15](=[O:16])[c:17]1[cH:18][n+:19]([C@@H:20]2[O:21][C@H:22]([CH2:23][O:24][P:25](=[O:26])([OH:27])[O:28][P:29](=[O:30])([OH:31])[O:32][CH2:33][C@H:34]3[O:35][C@@H:36]([n:37]4[cH:38][n:39][c:40]5[c:41]([NH2:42])[n:43][cH:44][n:45][c:46]45)[C@H:47]([OH:48])[C@@H:49]3[OH:50])[C@@H:51]([OH:52])[C@H:53]2[OH:54])[cH:55][cH:56][cH:57]1>>[*:1][NH:2][C:3](=[O:4])[CH2:5][CH2:6][CH2:7][CH2:8][C@@H:9]1[CH2:10][CH2:11][S:12][S:13]1.[NH2:14][C:15](=[O:16])[C:17]1=[CH:18][N:19]([C@@H:20]2[O:21][C@H:22]([CH2:23][O:24][P:25](=[O:26])([OH:27])[O:28][P:29](=[O:30])([OH:31])[O:32][CH2:33][C@H:34]3[O:35][C@@H:36]([n:37]4[cH:38][n:39][c:40]5[c:41]([NH2:42])[n:43][cH:44][n:45][c:46]45)[C@H:47]([OH:48])[C@@H:49]3[OH:50])[C@@H:51]([OH:52])[C@H:53]2[OH:54])[CH:55]=[CH:56][CH2:57]1.[H+:58]',\n", - " 'smiles': '*NC(=O)CCCC[C@@H](S)CCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>*NC(=O)CCCC[C@@H]1CCSS1.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R10866',\n", - " 'reaction': 'C00022 + C00010 + C02869 <=> C00024 + C00011 + C02745',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:53](=[O:52])[OH:54].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51]>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[O:52]=[C:53]=[O:54]',\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O'}],\n", - " 'molecules': [{'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00010',\n", - " 'name': 'CoA',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00024',\n", - " 'name': 'Acetyl-CoA',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O'},\n", - " {'id': 'C00068',\n", - " 'name': 'Thiamin diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': None},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'smiles': None},\n", - " {'id': 'C02745', 'name': 'Reduced flavodoxin', 'smiles': None},\n", - " {'id': 'C02869', 'name': 'Oxidized flavodoxin', 'smiles': None},\n", - " {'id': 'C05125',\n", - " 'name': '2-(alpha-Hydroxyethyl)thiamine diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': '*NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': '*NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': '*NC(=O)CCCC[C@@H](S)CCSC(C)=O'}],\n", - " 'missing': {'missing_compounds': [{'id': 'C00138',\n", - " 'name': 'Reduced ferredoxin',\n", - " 'reactions': ['R01196']},\n", - " {'id': 'C00139', 'name': 'Oxidized ferredoxin', 'reactions': ['R01196']},\n", - " {'id': 'C02745', 'name': 'Reduced flavodoxin', 'reactions': ['R10866']},\n", - " {'id': 'C02869', 'name': 'Oxidized flavodoxin', 'reactions': ['R10866']}],\n", - " 'missing_compound_ids': ['C00138', 'C00139', 'C02745', 'C02869'],\n", - " 'reactions_involving_missing': ['R01196', 'R10866']}}},\n", - " 'missing': {'missing_compound_ids': ['C00138', 'C00139', 'C02745', 'C02869'],\n", - " 'reactions_involving_missing': ['R01196', 'R07159', 'R10866']}}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "pathway_data" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "a7e0f3fe", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pathway_id': 'hsa00010',\n", - " 'modules': ['M00001', 'M00002', 'M00003', 'M00307'],\n", - " 'by_module': {'M00001': {'module_id': 'M00001',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00008 + C00074 => C00002 + C00022',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 => C00074 + C00001',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R00756',\n", - " 'reaction': 'C00002 + C00085 => C00008 + C00354',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00111 => C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O>>O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 => C00236 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 => C00236 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00354 => C00111 + C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00008 + C00236 => C00002 + C00197',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00197 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R01786',\n", - " 'reaction': 'C00002 + C00267 => C00008 + C00668',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R02189',\n", - " 'reaction': 'C00404 + C00267 => C99999 + C00668',\n", - " 'rule': '[O:1]=[P:2]([OH:3])([OH:4])[O:20][P:18](=[O:17])([OH:19])[O:21][P:22](=[O:23])([OH:24])[OH:25].[OH:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16].[O:17]=[P:18]([OH:19])([OH:20])[O:21][P:22](=[O:23])([OH:24])[OH:25]',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OP(=O)(O)O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R05805',\n", - " 'reaction': 'C00008 + C00085 => C00020 + C00354',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 => C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1'},\n", - " {'id': 'R09085',\n", - " 'reaction': 'C00267 + C00008 => C00668 + C00020',\n", - " 'rule': None,\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O>>O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'R13199',\n", - " 'reaction': 'C00668 => C00085',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00020',\n", - " 'name': 'AMP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00267',\n", - " 'name': 'alpha-D-Glucose',\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00404',\n", - " 'name': 'Polyphosphate',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C00668',\n", - " 'name': 'alpha-D-Glucose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00002': {'module_id': 'M00002',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00008 + C00074 => C00002 + C00022',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 => C00074 + C00001',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00111 => C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O>>O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 => C00236 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 => C00236 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00008 + C00236 => C00002 + C00197',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00197 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 => C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00003': {'module_id': 'M00003',\n", - " 'reactions': [{'id': 'R00341',\n", - " 'reaction': 'C00002 + C00036 => C00008 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00431',\n", - " 'reaction': 'C00044 + C00036 => C00035 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.O=C(O)CC(=O)C(=O)O>>Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00074 + C00001 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O.O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R00726',\n", - " 'reaction': 'C00081 + C00036 => C00104 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00762',\n", - " 'reaction': 'C00354 + C00001 => C00085 + C00009',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O.O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O.O=P(O)(O)O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00118 => C00111',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00236 + C00004 + C00080 => C00118 + C00009 + C00003',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]>>O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00236 + C00005 + C00080 => C00118 + C00009 + C00006',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]>>O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00111 + C00118 => C00354',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O>>O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00002 + C00197 => C00008 + C00236',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00631 => C00197',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00035',\n", - " 'name': 'GDP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00036', 'name': 'Oxaloacetate', 'smiles': 'O=C(O)CC(=O)C(=O)O'},\n", - " {'id': 'C00044',\n", - " 'name': 'GTP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00081',\n", - " 'name': 'ITP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00104',\n", - " 'name': 'IDP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00307': {'module_id': 'M00307',\n", - " 'reactions': [{'id': 'R00014',\n", - " 'reaction': 'C00022 + C00068 => C05125 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1>>Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.O=C=O'},\n", - " {'id': 'R00209',\n", - " 'reaction': 'C00022 + C00010 + C00003 => C00024 + C00011 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R00210',\n", - " 'reaction': 'C00022 + C00010 + C00006 => C00024 + C00011 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01196',\n", - " 'reaction': '2 C00139 + C00022 + C00010 => 2 C00138 + C00024 + C00011 + 2 C00080',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:53]([OH:52])=[O:54].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[S:57]1[Fe+:58][S:59][Fe+:60]1.[S:61]1[Fe+:62][S:63][Fe+:64]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[O:52]=[C:53]=[O:54].[H+:55].[H+:56].[S:57]1[Fe:58][S:59][Fe+:60]1.[S:61]1[Fe:62][S:63][Fe+:64]1',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1.CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS>>S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1.CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.[H+].[H+]'},\n", - " {'id': 'R02569',\n", - " 'reaction': 'C00010 + C16255 => C00024 + C15973',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[S:4][CH2:62][CH2:61][C@@H:59]([CH2:58][CH2:57][CH2:56][CH2:55][C:53]([NH2:52])=[O:54])[SH:60].[CH2:5]([CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[SH:63]>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[CH2:55][CH2:56][CH2:57][CH2:58][C@@H:59]([SH:60])[CH2:61][CH2:62][SH:63]',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)CCCC[C@@H](S)CCSC(C)=O>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'R03270',\n", - " 'reaction': 'C05125 + C15972 => C16255 + C00068',\n", - " 'rule': '[CH3:1][CH:2]([OH:3])[c:23]1[n+:22]([CH2:21][c:20]2[cH:19][n:18][c:17]([CH3:16])[n:41][c:39]2[NH2:40])[c:37]([CH3:38])[c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[s:24]1.[S:4]1[CH2:5][CH2:6][C@@H:7]([CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15])[S:8]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][C@H:7]([SH:8])[CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15].[CH3:16][c:17]1[n:18][cH:19][c:20]([CH2:21][n+:22]2[cH:23][s:24][c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[c:37]2[CH3:38])[c:39]([NH2:40])[n:41]1',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.NC(=O)CCCC[C@@H]1CCSS1>>NC(=O)CCCC[C@@H](S)CCSC(C)=O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'R07618',\n", - " 'reaction': 'C15973 + C00003 => C15972 + C00004 + C00080',\n", - " 'rule': '[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@H:52]([CH2:53][CH2:54][SH:55])[SH:56].[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@@H:52]1[CH2:53][CH2:54][S:55][S:56]1.[H+:57]',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>NC(=O)CCCC[C@@H]1CCSS1.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R10866',\n", - " 'reaction': 'C00022 + C00010 + C02869 => C00024 + C00011 + C02745',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:84]([OH:83])=[O:85].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[n:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[n:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]-1[n:82]2>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[N:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[nH:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]1[NH:82]2.[O:83]=[C:84]=[O:85]',\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'}],\n", - " 'molecules': [{'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00010',\n", - " 'name': 'CoA',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00024',\n", - " 'name': 'Acetyl-CoA',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O'},\n", - " {'id': 'C00068',\n", - " 'name': 'Thiamin diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C02745',\n", - " 'name': 'Reduced flavodoxin',\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': 'Oxidized flavodoxin',\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C05125',\n", - " 'name': '2-(alpha-Hydroxyethyl)thiamine diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}}},\n", - " 'missing': {'missing_compound_ids': [], 'reactions_involving_missing': []}}" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import json\n", - "\n", - "with open('Data/KEGG/hsa00010_fixed_new.json', 'r') as file:\n", - " pathway_data = json.load(file)\n", - "\n", - "pathway_data" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "6175331d", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Query.kegg_impute import KEGGImputer\n", - "\n", - "fixes = [\n", - " {\n", - " \"id\": \"R02189\",\n", - " \"reaction\": \"C00404 + C00267 => C99999 + C00668\",\n", - " },\n", - " {\n", - " \"id\": \"C99999\",\n", - " \"name\": \"Polyphosphate fragment\",\n", - " \"smiles\": \"O=P(O)(O)OP(=O)(O)O\",\n", - " },\n", - " {\n", - " \"id\": \"C00138\",\n", - " \"name\": \"Reduced ferredoxin\",\n", - " \"smiles\": \"S1[Fe]S[Fe+]1\"\n", - " },\n", - " {\n", - " \"id\": \"C00139\",\n", - " \"name\": \"Oxidized ferredoxin\",\n", - " \"smiles\": \"S1[Fe+]S[Fe+]1\"\n", - " },\n", - " {\n", - " \"id\": \"C02745\",\n", - " \"smiles\": \"CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))\"\n", - " },\n", - " {\n", - " \"id\": \"C02869\",\n", - " \"smiles\": \"CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))\"\n", - " },\n", - " {\n", - " \"id\": \"C15972\",\n", - " \"name\": \"Enzyme N6-(lipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H]1CCSS\",\n", - " },\n", - " {\n", - " \"id\": \"C15973\",\n", - " \"name\": \"Enzyme N6-(dihydrolipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCS\",\n", - " },\n", - " {\n", - " \"id\": \"C16255\",\n", - " \"name\": \"[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCSC(C)=O\",\n", - " },\n", - " {\n", - " \"id\": \"C15972\",\n", - " \"name\": \"Enzyme N6-(lipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H]1CCSS1\"\n", - " },\n", - " {\n", - " \"id\": \"C15973\",\n", - " \"name\": \"Enzyme N6-(dihydrolipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCS\"\n", - " },\n", - " {\n", - " \"id\": \"C16255\",\n", - " \"name\": \"[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCSC(C)=O\"\n", - " }\n", - "]\n", - "\n", - "imputer = KEGGImputer()\n", - "imputed_pathway = imputer.impute_pathway(\n", - " pathway_data,\n", - " fixes=fixes,\n", - " save_as='Data/KEGG/hsa00010_fixed_new.json',\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "id": "1f0e9981", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'pathway_id': 'hsa00010',\n", - " 'modules': ['M00001', 'M00002', 'M00003', 'M00307'],\n", - " 'by_module': {'M00001': {'module_id': 'M00001',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00008 + C00074 => C00002 + C00022',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 => C00074 + C00001',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R00756',\n", - " 'reaction': 'C00002 + C00085 => C00008 + C00354',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00111 => C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O>>O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 => C00236 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 => C00236 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00354 => C00111 + C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00008 + C00236 => C00002 + C00197',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00197 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R01786',\n", - " 'reaction': 'C00002 + C00267 => C00008 + C00668',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R02189',\n", - " 'reaction': 'C00404 + C00267 => C99999 + C00668',\n", - " 'rule': '[O:1]=[P:2]([OH:3])([OH:4])[O:20][P:18](=[O:17])([OH:19])[O:21][P:22](=[O:23])([OH:24])[OH:25].[OH:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16].[O:17]=[P:18]([OH:19])([OH:20])[O:21][P:22](=[O:23])([OH:24])[OH:25]',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OP(=O)(O)O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R05805',\n", - " 'reaction': 'C00008 + C00085 => C00020 + C00354',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 => C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1'},\n", - " {'id': 'R09085',\n", - " 'reaction': 'C00267 + C00008 => C00668 + C00020',\n", - " 'rule': None,\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O>>O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'R13199',\n", - " 'reaction': 'C00668 => C00085',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00020',\n", - " 'name': 'AMP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00267',\n", - " 'name': 'alpha-D-Glucose',\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00404',\n", - " 'name': 'Polyphosphate',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C00668',\n", - " 'name': 'alpha-D-Glucose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00002': {'module_id': 'M00002',\n", - " 'reactions': [{'id': 'R00200',\n", - " 'reaction': 'C00008 + C00074 => C00002 + C00022',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00631 => C00074 + C00001',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00111 => C00118',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O>>O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00118 + C00009 + C00003 => C00236 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00118 + C00009 + C00006 => C00236 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00008 + C00236 => C00002 + C00197',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00197 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R07159',\n", - " 'reaction': 'C00118 + C00001 + 2 C00139 => C00197 + 2 C00080 + 2 C00138',\n", - " 'rule': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00003': {'module_id': 'M00003',\n", - " 'reactions': [{'id': 'R00341',\n", - " 'reaction': 'C00002 + C00036 => C00008 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00431',\n", - " 'reaction': 'C00044 + C00036 => C00035 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.O=C(O)CC(=O)C(=O)O>>Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00658',\n", - " 'reaction': 'C00074 + C00001 => C00631',\n", - " 'rule': None,\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O.O>>O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'R00726',\n", - " 'reaction': 'C00081 + C00036 => C00104 + C00074 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O'},\n", - " {'id': 'R00762',\n", - " 'reaction': 'C00354 + C00001 => C00085 + C00009',\n", - " 'rule': None,\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O.O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O.O=P(O)(O)O'},\n", - " {'id': 'R01015',\n", - " 'reaction': 'C00118 => C00111',\n", - " 'rule': None,\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'R01061',\n", - " 'reaction': 'C00236 + C00004 + C00080 => C00118 + C00009 + C00003',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]>>O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'R01063',\n", - " 'reaction': 'C00236 + C00005 + C00080 => C00118 + C00009 + C00006',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]>>O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'R01068',\n", - " 'reaction': 'C00111 + C00118 => C00354',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O>>O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'R01512',\n", - " 'reaction': 'C00002 + C00197 => C00008 + C00236',\n", - " 'rule': None,\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'R01518',\n", - " 'reaction': 'C00631 => C00197',\n", - " 'rule': None,\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O'}],\n", - " 'molecules': [{'id': 'C00001', 'name': 'H2O', 'smiles': 'O'},\n", - " {'id': 'C00002',\n", - " 'name': 'ATP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00008',\n", - " 'name': 'ADP',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00009', 'name': 'Orthophosphate', 'smiles': 'O=P(O)(O)O'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00035',\n", - " 'name': 'GDP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00036', 'name': 'Oxaloacetate', 'smiles': 'O=C(O)CC(=O)C(=O)O'},\n", - " {'id': 'C00044',\n", - " 'name': 'GTP',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1'},\n", - " {'id': 'C00074',\n", - " 'name': 'Phosphoenolpyruvate',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00081',\n", - " 'name': 'ITP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00085',\n", - " 'name': 'D-Fructose 6-phosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00104',\n", - " 'name': 'IDP',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O'},\n", - " {'id': 'C00111',\n", - " 'name': 'Glycerone phosphate',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O'},\n", - " {'id': 'C00118',\n", - " 'name': 'D-Glyceraldehyde 3-phosphate',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00197',\n", - " 'name': '3-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00236',\n", - " 'name': '3-Phospho-D-glyceroyl phosphate',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O'},\n", - " {'id': 'C00354',\n", - " 'name': 'D-Fructose 1,6-bisphosphate',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O'},\n", - " {'id': 'C00631',\n", - " 'name': '2-Phospho-D-glycerate',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C02745',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': None,\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}},\n", - " 'M00307': {'module_id': 'M00307',\n", - " 'reactions': [{'id': 'R00014',\n", - " 'reaction': 'C00022 + C00068 => C05125 + C00011',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1>>Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.O=C=O'},\n", - " {'id': 'R00209',\n", - " 'reaction': 'C00022 + C00010 + C00003 => C00024 + C00011 + C00004 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R00210',\n", - " 'reaction': 'C00022 + C00010 + C00006 => C00024 + C00011 + C00005 + C00080',\n", - " 'rule': None,\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R01196',\n", - " 'reaction': '2 C00139 + C00022 + C00010 => 2 C00138 + C00024 + C00011 + 2 C00080',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:53]([OH:52])=[O:54].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[S:57]1[Fe+:58][S:59][Fe+:60]1.[S:61]1[Fe+:62][S:63][Fe+:64]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[O:52]=[C:53]=[O:54].[H+:55].[H+:56].[S:57]1[Fe:58][S:59][Fe+:60]1.[S:61]1[Fe:62][S:63][Fe+:64]1',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1.CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS>>S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1.CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.[H+].[H+]'},\n", - " {'id': 'R02569',\n", - " 'reaction': 'C00010 + C16255 => C00024 + C15973',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[S:4][CH2:62][CH2:61][C@@H:59]([CH2:58][CH2:57][CH2:56][CH2:55][C:53]([NH2:52])=[O:54])[SH:60].[CH2:5]([CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[SH:63]>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[CH2:55][CH2:56][CH2:57][CH2:58][C@@H:59]([SH:60])[CH2:61][CH2:62][SH:63]',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)CCCC[C@@H](S)CCSC(C)=O>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'R03270',\n", - " 'reaction': 'C05125 + C15972 => C16255 + C00068',\n", - " 'rule': '[CH3:1][CH:2]([OH:3])[c:23]1[n+:22]([CH2:21][c:20]2[cH:19][n:18][c:17]([CH3:16])[n:41][c:39]2[NH2:40])[c:37]([CH3:38])[c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[s:24]1.[S:4]1[CH2:5][CH2:6][C@@H:7]([CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15])[S:8]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][C@H:7]([SH:8])[CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15].[CH3:16][c:17]1[n:18][cH:19][c:20]([CH2:21][n+:22]2[cH:23][s:24][c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[c:37]2[CH3:38])[c:39]([NH2:40])[n:41]1',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.NC(=O)CCCC[C@@H]1CCSS1>>NC(=O)CCCC[C@@H](S)CCSC(C)=O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'R07618',\n", - " 'reaction': 'C15973 + C00003 => C15972 + C00004 + C00080',\n", - " 'rule': '[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@H:52]([CH2:53][CH2:54][SH:55])[SH:56].[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@@H:52]1[CH2:53][CH2:54][S:55][S:56]1.[H+:57]',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>NC(=O)CCCC[C@@H]1CCSS1.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]'},\n", - " {'id': 'R10866',\n", - " 'reaction': 'C00022 + C00010 + C02869 => C00024 + C00011 + C02745',\n", - " 'rule': '[CH3:1][C:2](=[O:3])[C:84]([OH:83])=[O:85].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[n:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[n:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]-1[n:82]2>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[N:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[nH:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]1[NH:82]2.[O:83]=[C:84]=[O:85]',\n", - " 'smiles': 'CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'}],\n", - " 'molecules': [{'id': 'C00003',\n", - " 'name': 'NAD+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00004',\n", - " 'name': 'NADH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00005',\n", - " 'name': 'NADPH',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1'},\n", - " {'id': 'C00006',\n", - " 'name': 'NADP+',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1'},\n", - " {'id': 'C00010',\n", - " 'name': 'CoA',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS'},\n", - " {'id': 'C00011', 'name': 'CO2', 'smiles': 'O=C=O'},\n", - " {'id': 'C00022', 'name': 'Pyruvate', 'smiles': 'CC(=O)C(=O)O'},\n", - " {'id': 'C00024',\n", - " 'name': 'Acetyl-CoA',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O'},\n", - " {'id': 'C00068',\n", - " 'name': 'Thiamin diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C00080', 'name': 'H+', 'smiles': '[H+]'},\n", - " {'id': 'C00138', 'name': 'Reduced ferredoxin', 'smiles': 'S1[Fe]S[Fe+]1'},\n", - " {'id': 'C00139',\n", - " 'name': 'Oxidized ferredoxin',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1'},\n", - " {'id': 'C02745',\n", - " 'name': 'Reduced flavodoxin',\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'},\n", - " {'id': 'C02869',\n", - " 'name': 'Oxidized flavodoxin',\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))'},\n", - " {'id': 'C05125',\n", - " 'name': '2-(alpha-Hydroxyethyl)thiamine diphosphate',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1'},\n", - " {'id': 'C15972',\n", - " 'name': 'Enzyme N6-(lipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1'},\n", - " {'id': 'C15973',\n", - " 'name': 'Enzyme N6-(dihydrolipoyl)lysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS'},\n", - " {'id': 'C16255',\n", - " 'name': '[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O'},\n", - " {'id': 'C99999',\n", - " 'name': 'Polyphosphate fragment',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O'}],\n", - " 'missing': {'missing_compounds': [],\n", - " 'missing_compound_ids': [],\n", - " 'reactions_involving_missing': []}}},\n", - " 'missing': {'missing_compound_ids': [], 'reactions_involving_missing': []}}" - ] - }, - "execution_count": 44, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "imputed_pathway" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "731cabeb", - "metadata": {}, - "outputs": [], - "source": [ - "imputed_pathway = pathway_data" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "9547de51", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "AbstractReactionNetwork(molecule_pool=['Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'C=C(OP(=O)(O)O)C(=O)O', 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'CC(=O)C(=O)O', 'O=C(O)[C@@H](CO)OP(=O)(O)O', 'O', 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O', 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O', 'O=C(CO)COP(=O)(O)O', 'O=C[C@H](O)COP(=O)(O)O', 'O=P(O)(O)O', 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1', 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O', 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1', '[H+]', 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1', 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1', 'O=C(O)[C@H](O)COP(=O)(O)O', 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O', 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O', 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O', 'O=P(O)(O)OP(=O)(O)O', 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O', 'S1[Fe+]S[Fe+]1', 'S1[Fe]S[Fe+]1', 'O=C(O)CC(=O)C(=O)O', 'O=C=O', 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1', 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1', 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1', 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1', 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS', 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O', 'NC(=O)CCCC[C@@H](S)CCSC(C)=O', 'NC(=O)CCCC[C@@H](S)CCS', 'NC(=O)CCCC[C@@H]1CCSS1', 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))', 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'], reactions=['A+B>>C+D', 'E>>B+F', 'C+G>>A+H', 'I>>J', 'J+K+L>>M+N+O', 'J+K+P>>M+Q+O', 'H>>I+J', 'A+M>>C+R', 'R>>E', 'C+S>>A+T', 'U+S>>V+T', 'A+G>>W+H', 'J+F+X+X>>R+O+O+Y+Y', 'S+A>>T+W', 'T>>G', 'C+Z>>A+B+AA', 'AB+Z>>AC+B+AA', 'B+F>>E', 'AD+Z>>AE+B+AA', 'H+F>>G+K', 'J>>I', 'M+N+O>>J+K+L', 'M+Q+O>>J+K+P', 'I+J>>H', 'C+R>>A+M', 'E>>R', 'D+AF>>AG+AA', 'D+AH+L>>AI+AA+N+O', 'D+AH+P>>AI+AA+Q+O', 'X+X+D+AH>>Y+Y+AI+AA+O+O', 'AH+AJ>>AI+AK', 'AG+AL>>AJ+AF', 'AK+L>>AL+N+O', 'D+AH+AM>>AI+AA+AN'], templates={'M00001:R02189': '[O:1]=[P:2]([OH:3])([OH:4])[O:20][P:18](=[O:17])([OH:19])[O:21][P:22](=[O:23])([OH:24])[OH:25].[OH:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16].[O:17]=[P:18]([OH:19])([OH:20])[O:21][P:22](=[O:23])([OH:24])[OH:25]', 'M00001:R07159': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1', 'M00002:R07159': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1', 'M00307:R01196': '[CH3:1][C:2](=[O:3])[C:53]([OH:52])=[O:54].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[S:57]1[Fe+:58][S:59][Fe+:60]1.[S:61]1[Fe+:62][S:63][Fe+:64]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[O:52]=[C:53]=[O:54].[H+:55].[H+:56].[S:57]1[Fe:58][S:59][Fe+:60]1.[S:61]1[Fe:62][S:63][Fe+:64]1', 'M00307:R02569': '[CH3:1][C:2](=[O:3])[S:4][CH2:62][CH2:61][C@@H:59]([CH2:58][CH2:57][CH2:56][CH2:55][C:53]([NH2:52])=[O:54])[SH:60].[CH2:5]([CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[SH:63]>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[CH2:55][CH2:56][CH2:57][CH2:58][C@@H:59]([SH:60])[CH2:61][CH2:62][SH:63]', 'M00307:R03270': '[CH3:1][CH:2]([OH:3])[c:23]1[n+:22]([CH2:21][c:20]2[cH:19][n:18][c:17]([CH3:16])[n:41][c:39]2[NH2:40])[c:37]([CH3:38])[c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[s:24]1.[S:4]1[CH2:5][CH2:6][C@@H:7]([CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15])[S:8]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][C@H:7]([SH:8])[CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15].[CH3:16][c:17]1[n:18][cH:19][c:20]([CH2:21][n+:22]2[cH:23][s:24][c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[c:37]2[CH3:38])[c:39]([NH2:40])[n:41]1', 'M00307:R07618': '[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@H:52]([CH2:53][CH2:54][SH:55])[SH:56].[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@@H:52]1[CH2:53][CH2:54][S:55][S:56]1.[H+:57]', 'M00307:R10866': '[CH3:1][C:2](=[O:3])[C:84]([OH:83])=[O:85].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[n:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[n:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]-1[n:82]2>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[N:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[nH:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]1[NH:82]2.[O:83]=[C:84]=[O:85]'}, label_to_molecule={'A': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'B': 'C=C(OP(=O)(O)O)C(=O)O', 'C': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'D': 'CC(=O)C(=O)O', 'E': 'O=C(O)[C@@H](CO)OP(=O)(O)O', 'F': 'O', 'G': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O', 'H': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O', 'I': 'O=C(CO)COP(=O)(O)O', 'J': 'O=C[C@H](O)COP(=O)(O)O', 'K': 'O=P(O)(O)O', 'L': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1', 'M': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O', 'N': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1', 'O': '[H+]', 'P': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1', 'Q': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1', 'R': 'O=C(O)[C@H](O)COP(=O)(O)O', 'S': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O', 'T': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O', 'U': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O', 'V': 'O=P(O)(O)OP(=O)(O)O', 'W': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O', 'X': 'S1[Fe+]S[Fe+]1', 'Y': 'S1[Fe]S[Fe+]1', 'Z': 'O=C(O)CC(=O)C(=O)O', 'AA': 'O=C=O', 'AB': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1', 'AC': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1', 'AD': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'AE': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O', 'AF': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1', 'AG': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1', 'AH': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS', 'AI': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O', 'AJ': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O', 'AK': 'NC(=O)CCCC[C@@H](S)CCS', 'AL': 'NC(=O)CCCC[C@@H]1CCSS1', 'AM': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))', 'AN': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'})" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from synkit.CRN.Construct.abstract import AbstractReactionExtractor\n", - "\n", - "abs_network = AbstractReactionExtractor().build(\n", - " data=imputed_pathway,\n", - " drop_missing_smiles_reactions=True,\n", - " deduplicate=True,\n", - " order=\"appearance\",\n", - " reactant_join=\"+\",\n", - " product_join=\"+\",\n", - " prefix_module_in_reaction_id=True,\n", - " reaction_id_keys=[\"id\"],\n", - " reaction_smiles_keys=[\"smiles\"],\n", - " template_keys=[\"rule\"],\n", - " save_as=None\n", - ")\n", - "abs_network" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "cc6565fc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'molecule_pool': ['Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'C=C(OP(=O)(O)O)C(=O)O',\n", - " 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'CC(=O)C(=O)O',\n", - " 'O=C(O)[C@@H](CO)OP(=O)(O)O',\n", - " 'O',\n", - " 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O',\n", - " 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O',\n", - " 'O=C(CO)COP(=O)(O)O',\n", - " 'O=C[C@H](O)COP(=O)(O)O',\n", - " 'O=P(O)(O)O',\n", - " 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O',\n", - " 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " '[H+]',\n", - " 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " 'O=C(O)[C@H](O)COP(=O)(O)O',\n", - " 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O',\n", - " 'O=P(O)(O)OP(=O)(O)O',\n", - " 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'S1[Fe+]S[Fe+]1',\n", - " 'S1[Fe]S[Fe+]1',\n", - " 'O=C(O)CC(=O)C(=O)O',\n", - " 'O=C=O',\n", - " 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS',\n", - " 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O',\n", - " 'NC(=O)CCCC[C@@H](S)CCSC(C)=O',\n", - " 'NC(=O)CCCC[C@@H](S)CCS',\n", - " 'NC(=O)CCCC[C@@H]1CCSS1',\n", - " 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))',\n", - " 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'],\n", - " 'reactions': ['A+B>>C+D',\n", - " 'E>>B+F',\n", - " 'C+G>>A+H',\n", - " 'I>>J',\n", - " 'J+K+L>>M+N+O',\n", - " 'J+K+P>>M+Q+O',\n", - " 'H>>I+J',\n", - " 'A+M>>C+R',\n", - " 'R>>E',\n", - " 'C+S>>A+T',\n", - " 'U+S>>V+T',\n", - " 'A+G>>W+H',\n", - " 'J+F+X+X>>R+O+O+Y+Y',\n", - " 'S+A>>T+W',\n", - " 'T>>G',\n", - " 'C+Z>>A+B+AA',\n", - " 'AB+Z>>AC+B+AA',\n", - " 'B+F>>E',\n", - " 'AD+Z>>AE+B+AA',\n", - " 'H+F>>G+K',\n", - " 'J>>I',\n", - " 'M+N+O>>J+K+L',\n", - " 'M+Q+O>>J+K+P',\n", - " 'I+J>>H',\n", - " 'C+R>>A+M',\n", - " 'E>>R',\n", - " 'D+AF>>AG+AA',\n", - " 'D+AH+L>>AI+AA+N+O',\n", - " 'D+AH+P>>AI+AA+Q+O',\n", - " 'X+X+D+AH>>Y+Y+AI+AA+O+O',\n", - " 'AH+AJ>>AI+AK',\n", - " 'AG+AL>>AJ+AF',\n", - " 'AK+L>>AL+N+O',\n", - " 'D+AH+AM>>AI+AA+AN'],\n", - " 'templates': {'M00001:R02189': '[O:1]=[P:2]([OH:3])([OH:4])[O:20][P:18](=[O:17])([OH:19])[O:21][P:22](=[O:23])([OH:24])[OH:25].[OH:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16]>>[O:1]=[P:2]([OH:3])([OH:4])[O:5][CH2:6][C@H:7]1[O:8][C@H:9]([OH:10])[C@H:11]([OH:12])[C@@H:13]([OH:14])[C@@H:15]1[OH:16].[O:17]=[P:18]([OH:19])([OH:20])[O:21][P:22](=[O:23])([OH:24])[OH:25]',\n", - " 'M00001:R07159': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'M00002:R07159': '[OH2:3].[O:1]=[CH:2][C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[S:14]1[Fe+:15][S:16][Fe+:17]1.[S:18]1[Fe+:19][S:20][Fe+:21]1>>[O:1]=[C:2]([OH:3])[C@H:4]([OH:5])[CH2:6][O:7][P:8](=[O:9])([OH:10])[OH:11].[H+:12].[H+:13].[S:14]1[Fe:15][S:16][Fe+:17]1.[S:18]1[Fe:19][S:20][Fe+:21]1',\n", - " 'M00307:R01196': '[CH3:1][C:2](=[O:3])[C:53]([OH:52])=[O:54].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[S:57]1[Fe+:58][S:59][Fe+:60]1.[S:61]1[Fe+:62][S:63][Fe+:64]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[O:52]=[C:53]=[O:54].[H+:55].[H+:56].[S:57]1[Fe:58][S:59][Fe+:60]1.[S:61]1[Fe:62][S:63][Fe+:64]1',\n", - " 'M00307:R02569': '[CH3:1][C:2](=[O:3])[S:4][CH2:62][CH2:61][C@@H:59]([CH2:58][CH2:57][CH2:56][CH2:55][C:53]([NH2:52])=[O:54])[SH:60].[CH2:5]([CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51])[SH:63]>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[NH2:52][C:53](=[O:54])[CH2:55][CH2:56][CH2:57][CH2:58][C@@H:59]([SH:60])[CH2:61][CH2:62][SH:63]',\n", - " 'M00307:R03270': '[CH3:1][CH:2]([OH:3])[c:23]1[n+:22]([CH2:21][c:20]2[cH:19][n:18][c:17]([CH3:16])[n:41][c:39]2[NH2:40])[c:37]([CH3:38])[c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[s:24]1.[S:4]1[CH2:5][CH2:6][C@@H:7]([CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15])[S:8]1>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][C@H:7]([SH:8])[CH2:9][CH2:10][CH2:11][CH2:12][C:13]([NH2:14])=[O:15].[CH3:16][c:17]1[n:18][cH:19][c:20]([CH2:21][n+:22]2[cH:23][s:24][c:25]([CH2:26][CH2:27][O:28][P:29](=[O:30])([OH:31])[O:32][P:33](=[O:34])([OH:35])[OH:36])[c:37]2[CH3:38])[c:39]([NH2:40])[n:41]1',\n", - " 'M00307:R07618': '[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@H:52]([CH2:53][CH2:54][SH:55])[SH:56].[NH2:1][C:2](=[O:3])[c:4]1[cH:5][n+:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[cH:42][cH:43][cH:44]1>>[NH2:1][C:2](=[O:3])[C:4]1=[CH:5][N:6]([C@@H:7]2[O:8][C@H:9]([CH2:10][O:11][P:12](=[O:13])([OH:14])[O:15][P:16](=[O:17])([OH:18])[O:19][CH2:20][C@H:21]3[O:22][C@@H:23]([n:24]4[cH:25][n:26][c:27]5[c:28]([NH2:29])[n:30][cH:31][n:32][c:33]45)[C@H:34]([OH:35])[C@@H:36]3[OH:37])[C@@H:38]([OH:39])[C@H:40]2[OH:41])[CH:42]=[CH:43][CH2:44]1.[NH2:45][C:46](=[O:47])[CH2:48][CH2:49][CH2:50][CH2:51][C@@H:52]1[CH2:53][CH2:54][S:55][S:56]1.[H+:57]',\n", - " 'M00307:R10866': '[CH3:1][C:2](=[O:3])[C:84]([OH:83])=[O:85].[SH:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[n:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[n:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]-1[n:82]2>>[CH3:1][C:2](=[O:3])[S:4][CH2:5][CH2:6][NH:7][C:8](=[O:9])[CH2:10][CH2:11][NH:12][C:13](=[O:14])[C@H:15]([OH:16])[C:17]([CH3:18])([CH3:19])[CH2:20][O:21][P:22](=[O:23])([OH:24])[O:25][P:26](=[O:27])([OH:28])[O:29][CH2:30][C@H:31]1[O:32][C@@H:33]([n:34]2[cH:35][n:36][c:37]3[c:38]([NH2:39])[n:40][cH:41][n:42][c:43]23)[C@H:44]([OH:45])[C@@H:46]1[O:47][P:48](=[O:49])([OH:50])[OH:51].[CH3:52][c:53]1[cH:54][c:55]2[c:56]([cH:57][c:58]1[CH3:59])[N:60]([CH2:61][CH:62]([OH:63])[CH:64]([OH:65])[CH:66]([OH:67])[CH2:68][O:69][P:70](=[O:71])([O-:72])[O-:73])[c:74]1[nH:75][c:76](=[O:77])[nH:78][c:79](=[O:80])[c:81]1[NH:82]2.[O:83]=[C:84]=[O:85]'},\n", - " 'label_to_molecule': {'A': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'B': 'C=C(OP(=O)(O)O)C(=O)O',\n", - " 'C': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'D': 'CC(=O)C(=O)O',\n", - " 'E': 'O=C(O)[C@@H](CO)OP(=O)(O)O',\n", - " 'F': 'O',\n", - " 'G': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O',\n", - " 'H': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O',\n", - " 'I': 'O=C(CO)COP(=O)(O)O',\n", - " 'J': 'O=C[C@H](O)COP(=O)(O)O',\n", - " 'K': 'O=P(O)(O)O',\n", - " 'L': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'M': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O',\n", - " 'N': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " 'O': '[H+]',\n", - " 'P': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'Q': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " 'R': 'O=C(O)[C@H](O)COP(=O)(O)O',\n", - " 'S': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'T': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'U': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O',\n", - " 'V': 'O=P(O)(O)OP(=O)(O)O',\n", - " 'W': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'X': 'S1[Fe+]S[Fe+]1',\n", - " 'Y': 'S1[Fe]S[Fe+]1',\n", - " 'Z': 'O=C(O)CC(=O)C(=O)O',\n", - " 'AA': 'O=C=O',\n", - " 'AB': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'AC': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'AD': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'AE': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'AF': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'AG': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'AH': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS',\n", - " 'AI': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O',\n", - " 'AJ': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O',\n", - " 'AK': 'NC(=O)CCCC[C@@H](S)CCS',\n", - " 'AL': 'NC(=O)CCCC[C@@H]1CCSS1',\n", - " 'AM': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))',\n", - " 'AN': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))'}}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "abs_network_dict = abs_network.to_dict()\n", - "abs_network_dict" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "id": "1bdbbc3d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "40" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "len(abs_network_dict['molecule_pool'])" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "d5cdb557", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['A+B>>C+D',\n", - " 'E>>B+F',\n", - " 'C+G>>A+H',\n", - " 'I>>J',\n", - " 'J+K+L>>M+N+O',\n", - " 'J+K+P>>M+Q+O',\n", - " 'H>>I+J',\n", - " 'A+M>>C+R',\n", - " 'R>>E',\n", - " 'C+S>>A+T',\n", - " 'U+S>>V+T',\n", - " 'A+G>>W+H',\n", - " 'J+F+X+X>>R+O+O+Y+Y',\n", - " 'S+A>>T+W',\n", - " 'T>>G',\n", - " 'C+Z>>A+B+AA',\n", - " 'AB+Z>>AC+B+AA',\n", - " 'B+F>>E',\n", - " 'AD+Z>>AE+B+AA',\n", - " 'H+F>>G+K',\n", - " 'J>>I',\n", - " 'M+N+O>>J+K+L',\n", - " 'M+Q+O>>J+K+P',\n", - " 'I+J>>H',\n", - " 'C+R>>A+M',\n", - " 'E>>R',\n", - " 'D+AF>>AG+AA',\n", - " 'D+AH+L>>AI+AA+N+O',\n", - " 'D+AH+P>>AI+AA+Q+O',\n", - " 'X+X+D+AH>>Y+Y+AI+AA+O+O',\n", - " 'AH+AJ>>AI+AK',\n", - " 'AG+AL>>AJ+AF',\n", - " 'AK+L>>AL+N+O',\n", - " 'D+AH+AM>>AI+AA+AN']" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "abs_network_dict['reactions']" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "526599d4", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "34" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "len(abs_network_dict['reactions'])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a33b5678", - "metadata": {}, - "outputs": [], - "source": [ - "map = {\"r1\": \"A+B>>C+D\",\n", - " \"r2\": \"E>>D+F\",\n", - " \"r3\": \"A+G>>C+H\",\n", - " \"r4\": \"I>>J\",\n", - " \"r5\": \"I+K+L>>M+N+O\",\n", - " \"r27\": \"I+K+P>>M+Q+O\",\n", - " \"r7\": \"H>>J+I\",\n", - " \"r8\": \"A+R>>C+M\",\n", - " \"r9\": \"E>>R\",\n", - " \"r10\": \"A+S>>C+T\",\n", - " \"r11\": \"U+S>>V+T\",\n", - " \"r12\": \"C+G>>W+H\",\n", - " \"r13\": \"I+F+X+X>>R+O+O+Y+Y\",\n", - " \"r14\": \"S+C>>T+W\",\n", - " \"r15\": \"T>>G\",\n", - " \"r16\": \"A+Z>>C+D+AA\",\n", - " \"r17\": \"AB+Z>>AC+D+AA\",\n", - " \"r18\": \"AD+Z>>AE+D+AA\",\n", - " \"r19\": \"H+F>>G+K\",\n", - " \"r20\": \"B+AF>>AG+AA\",\n", - " \"r21\": \"B+AH+L>>AI+AA+N+O\",\n", - " \"r6\": \"B+AH+P>>AI+AA+Q+O\",/\n", - " \"r22\": \"X+X+B+AH>>Y+Y+AI+AA+O+O\",\n", - " \"r23\": \"AI+AJ>>AH+AK\",\n", - " \"r24\": \"AG+AL>>AK+AF\",\n", - " \"r25\": \"AJ+L>>AL+N+O\",\n", - " \"r26\": \"B+AH+AM>>AI+AA+AN\"\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "bd06a176", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O\n", - "O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O\n", - "O=P(O)(O)OP(=O)(O)OP(=O)(O)O.OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OP(=O)(O)O.O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O.O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O\n", - "O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1\n", - "OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O>>O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O.Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O\n", - "O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O\n", - "O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O\n", - "O=C[C@H](O)COP(=O)(O)O.O.S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1>>O=C(O)[C@H](O)COP(=O)(O)O.[H+].[H+].S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O\n", - "Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.O=C(O)CC(=O)C(=O)O>>Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1.C=C(OP(=O)(O)O)C(=O)O.O=C=O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>C=C(OP(=O)(O)O)C(=O)O.O\n", - "O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)CC(=O)C(=O)O>>O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.C=C(OP(=O)(O)O)C(=O)O.O=C=O\n", - "O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O.O>>O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O.O=P(O)(O)O\n", - "O=C[C@H](O)COP(=O)(O)O>>O=C(CO)COP(=O)(O)O\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "O=C[C@H](O)COP(=O)(O)O.O=P(O)(O)O.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O>>O=C(CO)COP(=O)(O)O.O=C[C@H](O)COP(=O)(O)O\n", - "Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(O)[C@H](O)COP(=O)(O)O>>Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O.O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O\n", - "O=C(O)[C@@H](CO)OP(=O)(O)O>>O=C(O)[C@H](O)COP(=O)(O)O\n", - "CC(=O)C(=O)O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1>>Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.O=C=O\n", - "CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "S1[Fe+]S[Fe+]1.S1[Fe+]S[Fe+]1.CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS>>S1[Fe]S[Fe+]1.S1[Fe]S[Fe+]1.CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.[H+].[H+]\n", - "CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.NC(=O)CCCC[C@@H](S)CCS>>CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.NC(=O)CCCC[C@@H](S)CCSC(C)=O\n", - "Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1.NC(=O)CCCC[C@@H]1CCSS1>>NC(=O)CCCC[C@@H](S)CCSC(C)=O.Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1\n", - "NC(=O)CCCC[C@@H](S)CCS.NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1>>NC(=O)CCCC[C@@H]1CCSS1.NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1.[H+]\n", - "CC(=O)C(=O)O.CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS.CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))>>CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O.O=C=O.CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))\n" - ] - } - ], - "source": [ - "reactions = [\n", - " rxn[\"smiles\"]\n", - " for module in pathway_data[\"by_module\"].values()\n", - " for rxn in module.get(\"reactions\", [])\n", - " if \"reaction\" in rxn\n", - "]\n", - "\n", - "for r in reactions:\n", - " print(r)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "a80f4435", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " A | C00008 | ADP | Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O\n", - " B | C00074 | Phosphoenolpyruvate | C=C(OP(=O)(O)O)C(=O)O\n", - " C | C00002 | ATP | Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O\n", - " D | C00022 | Pyruvate | CC(=O)C(=O)O\n", - " E | C00631 | 2-Phospho-D-glycerate | O=C(O)[C@@H](CO)OP(=O)(O)O\n", - " F | C00001 | H2O | O\n", - " G | C00085 | D-Fructose 6-phosphate | O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O\n", - " H | C00354 | D-Fructose 1,6-bisphosphate | O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O\n", - " I | C00111 | Glycerone phosphate | O=C(CO)COP(=O)(O)O\n", - " J | C00118 | D-Glyceraldehyde 3-phosphate | O=C[C@H](O)COP(=O)(O)O\n", - " K | C00009 | Orthophosphate | O=P(O)(O)O\n", - " L | C00003 | NAD+ | NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1\n", - " M | C00236 | 3-Phospho-D-glyceroyl phosphate | O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O\n", - " N | C00004 | NADH | NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1\n", - " O | C00080 | H+ | [H+]\n", - " P | C00006 | NADP+ | NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1\n", - " Q | C00005 | NADPH | NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1\n", - " R | C00197 | 3-Phospho-D-glycerate | O=C(O)[C@H](O)COP(=O)(O)O\n", - " S | C00267 | alpha-D-Glucose | OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O\n", - " T | C00668 | alpha-D-Glucose 6-phosphate | O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O\n", - " U | C00404 | Polyphosphate | O=P(O)(O)OP(=O)(O)OP(=O)(O)O\n", - " V | C99999 | Polyphosphate fragment | O=P(O)(O)OP(=O)(O)O\n", - " W | C00020 | AMP | Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O\n", - " X | C00139 | Oxidized ferredoxin | S1[Fe+]S[Fe+]1\n", - " Y | C00138 | Reduced ferredoxin | S1[Fe]S[Fe+]1\n", - " Z | C00036 | Oxaloacetate | O=C(O)CC(=O)C(=O)O\n", - " AA | C00011 | CO2 | O=C=O\n", - " AB | C00044 | GTP | Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1\n", - " AC | C00035 | GDP | Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1\n", - " AD | C00081 | ITP | O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O\n", - " AE | C00104 | IDP | O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O\n", - " AF | C00068 | Thiamin diphosphate | Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1\n", - " AG | C05125 | 2-(alpha-Hydroxyethyl)thiamine diphosphate | Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1\n", - " AH | C00010 | CoA | CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS\n", - " AI | C00024 | Acetyl-CoA | CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O\n", - " AJ | C16255 | [Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine | NC(=O)CCCC[C@@H](S)CCSC(C)=O\n", - " AK | C15973 | Enzyme N6-(dihydrolipoyl)lysine | NC(=O)CCCC[C@@H](S)CCS\n", - " AL | C15972 | Enzyme N6-(lipoyl)lysine | NC(=O)CCCC[C@@H]1CCSS1\n", - " AM | C02869 | Oxidized flavodoxin | CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))\n", - " AN | C02745 | Reduced flavodoxin | CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))\n", - "\n", - "Saved mapping to: Data/Study/CRN/case_glycolysis/label_to_name_smiles.json\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "[16:04:47] WARNING: not removing hydrogen atom without neighbors\n", - "[16:04:47] WARNING: not removing hydrogen atom without neighbors\n", - "[16:04:47] WARNING: not removing hydrogen atom without neighbors\n", - "[16:04:47] WARNING: not removing hydrogen atom without neighbors\n", - "[16:04:47] WARNING: not removing hydrogen atom without neighbors\n" - ] - } - ], - "source": [ - "from __future__ import annotations\n", - "\n", - "import json\n", - "from collections import defaultdict\n", - "from pathlib import Path\n", - "\n", - "try:\n", - " from rdkit import Chem\n", - "except ImportError:\n", - " Chem = None\n", - "\n", - "\n", - "def canonicalize_smiles(smiles: str) -> str:\n", - " \"\"\"\n", - " Canonicalize a SMILES string with RDKit.\n", - " Falls back to the raw SMILES if RDKit is unavailable or parsing fails.\n", - " \"\"\"\n", - " smiles = smiles.strip()\n", - " if Chem is None:\n", - " return smiles\n", - "\n", - " mol = Chem.MolFromSmiles(smiles)\n", - " if mol is None:\n", - " return smiles\n", - "\n", - " return Chem.MolToSmiles(mol, canonical=True)\n", - "\n", - "\n", - "def collect_full_molecule_index(full_data: dict) -> dict[str, dict]:\n", - " \"\"\"\n", - " Build an index:\n", - " canonical_smiles -> {\n", - " \"ids\": set(...),\n", - " \"names\": set(...),\n", - " \"raw_smiles\": set(...)\n", - " }\n", - " \"\"\"\n", - " index = defaultdict(lambda: {\"ids\": set(), \"names\": set(), \"raw_smiles\": set()})\n", - "\n", - " for module_data in full_data.get(\"by_module\", {}).values():\n", - " for mol in module_data.get(\"molecules\", []):\n", - " smiles = mol.get(\"smiles\")\n", - " if not smiles:\n", - " continue\n", - "\n", - " key = canonicalize_smiles(smiles)\n", - " index[key][\"raw_smiles\"].add(smiles)\n", - "\n", - " mol_id = mol.get(\"id\")\n", - " if mol_id:\n", - " index[key][\"ids\"].add(mol_id)\n", - "\n", - " mol_name = mol.get(\"name\")\n", - " if mol_name:\n", - " index[key][\"names\"].add(mol_name)\n", - "\n", - " return index\n", - "\n", - "\n", - "def map_abstract_labels_to_full_molecules(abstract_data: dict, full_data: dict) -> dict[str, dict]:\n", - " \"\"\"\n", - " Map abstract labels A, B, C, ... to the corresponding full molecule info.\n", - " \"\"\"\n", - " index = collect_full_molecule_index(full_data)\n", - "\n", - " example = abstract_data[\"examples\"][0]\n", - " label_to_molecule = example[\"label_to_molecule\"]\n", - "\n", - " mapped = {}\n", - "\n", - " for label, smiles in label_to_molecule.items():\n", - " key = canonicalize_smiles(smiles)\n", - " hit = index.get(key)\n", - "\n", - " mapped[label] = {\n", - " \"label\": label,\n", - " \"smiles\": smiles,\n", - " \"ids\": sorted(hit[\"ids\"]) if hit else [],\n", - " \"names\": sorted(hit[\"names\"]) if hit else [],\n", - " \"found\": hit is not None,\n", - " }\n", - "\n", - " return mapped\n", - "\n", - "\n", - "\n", - "# Change these to your actual file names\n", - "abstract_path = Path(\"Data/Study/CRN/case_glycolysis/hsa00010_abstract.json\")\n", - "full_path = Path(\"Data/Study/CRN/case_glycolysis/hsa00010_imputed.json\")\n", - "\n", - "abstract_data = json.loads(abstract_path.read_text(encoding=\"utf-8\"))\n", - "full_data = json.loads(full_path.read_text(encoding=\"utf-8\"))\n", - "\n", - "mapping = map_abstract_labels_to_full_molecules(abstract_data, full_data)\n", - "\n", - "# Pretty print all mappings\n", - "for label in sorted(mapping, key=lambda x: (len(x), x)):\n", - " item = mapping[label]\n", - " names = \"; \".join(item[\"names\"]) if item[\"names\"] else \"UNKNOWN\"\n", - " ids = \", \".join(item[\"ids\"]) if item[\"ids\"] else \"UNKNOWN\"\n", - " print(f\"{label:>3} | {ids:20} | {names:60} | {item['smiles']}\")\n", - "\n", - "# Save as JSON\n", - "out_path = Path(\"Data/Study/CRN/case_glycolysis/label_to_name_smiles.json\")\n", - "out_path.write_text(json.dumps(mapping, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n", - "\n", - "print(\"\\nSaved mapping to:\", out_path)\n", - "\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "f42abbb9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'A': {'label': 'A',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'ids': ['C00008'],\n", - " 'names': ['ADP'],\n", - " 'found': True},\n", - " 'B': {'label': 'B',\n", - " 'smiles': 'C=C(OP(=O)(O)O)C(=O)O',\n", - " 'ids': ['C00074'],\n", - " 'names': ['Phosphoenolpyruvate'],\n", - " 'found': True},\n", - " 'C': {'label': 'C',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'ids': ['C00002'],\n", - " 'names': ['ATP'],\n", - " 'found': True},\n", - " 'D': {'label': 'D',\n", - " 'smiles': 'CC(=O)C(=O)O',\n", - " 'ids': ['C00022'],\n", - " 'names': ['Pyruvate'],\n", - " 'found': True},\n", - " 'E': {'label': 'E',\n", - " 'smiles': 'O=C(O)[C@@H](CO)OP(=O)(O)O',\n", - " 'ids': ['C00631'],\n", - " 'names': ['2-Phospho-D-glycerate'],\n", - " 'found': True},\n", - " 'F': {'label': 'F',\n", - " 'smiles': 'O',\n", - " 'ids': ['C00001'],\n", - " 'names': ['H2O'],\n", - " 'found': True},\n", - " 'G': {'label': 'G',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(CO)[C@@H](O)[C@@H]1O',\n", - " 'ids': ['C00085'],\n", - " 'names': ['D-Fructose 6-phosphate'],\n", - " 'found': True},\n", - " 'H': {'label': 'H',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1OC(O)(COP(=O)(O)O)[C@@H](O)[C@@H]1O',\n", - " 'ids': ['C00354'],\n", - " 'names': ['D-Fructose 1,6-bisphosphate'],\n", - " 'found': True},\n", - " 'I': {'label': 'I',\n", - " 'smiles': 'O=C(CO)COP(=O)(O)O',\n", - " 'ids': ['C00111'],\n", - " 'names': ['Glycerone phosphate'],\n", - " 'found': True},\n", - " 'J': {'label': 'J',\n", - " 'smiles': 'O=C[C@H](O)COP(=O)(O)O',\n", - " 'ids': ['C00118'],\n", - " 'names': ['D-Glyceraldehyde 3-phosphate'],\n", - " 'found': True},\n", - " 'K': {'label': 'K',\n", - " 'smiles': 'O=P(O)(O)O',\n", - " 'ids': ['C00009'],\n", - " 'names': ['Orthophosphate'],\n", - " 'found': True},\n", - " 'L': {'label': 'L',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'ids': ['C00003'],\n", - " 'names': ['NAD+'],\n", - " 'found': True},\n", - " 'M': {'label': 'M',\n", - " 'smiles': 'O=C(OP(=O)(O)O)[C@H](O)COP(=O)(O)O',\n", - " 'ids': ['C00236'],\n", - " 'names': ['3-Phospho-D-glyceroyl phosphate'],\n", - " 'found': True},\n", - " 'N': {'label': 'N',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " 'ids': ['C00004'],\n", - " 'names': ['NADH'],\n", - " 'found': True},\n", - " 'O': {'label': 'O',\n", - " 'smiles': '[H+]',\n", - " 'ids': ['C00080'],\n", - " 'names': ['H+'],\n", - " 'found': True},\n", - " 'P': {'label': 'P',\n", - " 'smiles': 'NC(=O)c1ccc[n+]([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)c1',\n", - " 'ids': ['C00006'],\n", - " 'names': ['NADP+'],\n", - " 'found': True},\n", - " 'Q': {'label': 'Q',\n", - " 'smiles': 'NC(=O)C1=CN([C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OC[C@H]3O[C@@H](n4cnc5c(N)ncnc54)[C@H](OP(=O)(O)O)[C@@H]3O)[C@@H](O)[C@H]2O)C=CC1',\n", - " 'ids': ['C00005'],\n", - " 'names': ['NADPH'],\n", - " 'found': True},\n", - " 'R': {'label': 'R',\n", - " 'smiles': 'O=C(O)[C@H](O)COP(=O)(O)O',\n", - " 'ids': ['C00197'],\n", - " 'names': ['3-Phospho-D-glycerate'],\n", - " 'found': True},\n", - " 'S': {'label': 'S',\n", - " 'smiles': 'OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'ids': ['C00267'],\n", - " 'names': ['alpha-D-Glucose'],\n", - " 'found': True},\n", - " 'T': {'label': 'T',\n", - " 'smiles': 'O=P(O)(O)OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O',\n", - " 'ids': ['C00668'],\n", - " 'names': ['alpha-D-Glucose 6-phosphate'],\n", - " 'found': True},\n", - " 'U': {'label': 'U',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)OP(=O)(O)O',\n", - " 'ids': ['C00404'],\n", - " 'names': ['Polyphosphate'],\n", - " 'found': True},\n", - " 'V': {'label': 'V',\n", - " 'smiles': 'O=P(O)(O)OP(=O)(O)O',\n", - " 'ids': ['C99999'],\n", - " 'names': ['Polyphosphate fragment'],\n", - " 'found': True},\n", - " 'W': {'label': 'W',\n", - " 'smiles': 'Nc1ncnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'ids': ['C00020'],\n", - " 'names': ['AMP'],\n", - " 'found': True},\n", - " 'X': {'label': 'X',\n", - " 'smiles': 'S1[Fe+]S[Fe+]1',\n", - " 'ids': ['C00139'],\n", - " 'names': ['Oxidized ferredoxin'],\n", - " 'found': True},\n", - " 'Y': {'label': 'Y',\n", - " 'smiles': 'S1[Fe]S[Fe+]1',\n", - " 'ids': ['C00138'],\n", - " 'names': ['Reduced ferredoxin'],\n", - " 'found': True},\n", - " 'Z': {'label': 'Z',\n", - " 'smiles': 'O=C(O)CC(=O)C(=O)O',\n", - " 'ids': ['C00036'],\n", - " 'names': ['Oxaloacetate'],\n", - " 'found': True},\n", - " 'AA': {'label': 'AA',\n", - " 'smiles': 'O=C=O',\n", - " 'ids': ['C00011'],\n", - " 'names': ['CO2'],\n", - " 'found': True},\n", - " 'AB': {'label': 'AB',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'ids': ['C00044'],\n", - " 'names': ['GTP'],\n", - " 'found': True},\n", - " 'AC': {'label': 'AC',\n", - " 'smiles': 'Nc1nc2c(ncn2[C@@H]2O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]2O)c(=O)[nH]1',\n", - " 'ids': ['C00035'],\n", - " 'names': ['GDP'],\n", - " 'found': True},\n", - " 'AD': {'label': 'AD',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'ids': ['C00081'],\n", - " 'names': ['ITP'],\n", - " 'found': True},\n", - " 'AE': {'label': 'AE',\n", - " 'smiles': 'O=c1[nH]cnc2c1ncn2[C@@H]1O[C@H](COP(=O)(O)OP(=O)(O)O)[C@@H](O)[C@H]1O',\n", - " 'ids': ['C00104'],\n", - " 'names': ['IDP'],\n", - " 'found': True},\n", - " 'AF': {'label': 'AF',\n", - " 'smiles': 'Cc1ncc(C[n+]2csc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'ids': ['C00068'],\n", - " 'names': ['Thiamin diphosphate'],\n", - " 'found': True},\n", - " 'AG': {'label': 'AG',\n", - " 'smiles': 'Cc1ncc(C[n+]2c(C(C)O)sc(CCOP(=O)(O)OP(=O)(O)O)c2C)c(N)n1',\n", - " 'ids': ['C05125'],\n", - " 'names': ['2-(alpha-Hydroxyethyl)thiamine diphosphate'],\n", - " 'found': True},\n", - " 'AH': {'label': 'AH',\n", - " 'smiles': 'CC(C)(COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O)[C@@H](O)C(=O)NCCC(=O)NCCS',\n", - " 'ids': ['C00010'],\n", - " 'names': ['CoA'],\n", - " 'found': True},\n", - " 'AI': {'label': 'AI',\n", - " 'smiles': 'CC(=O)SCCNC(=O)CCNC(=O)[C@H](O)C(C)(C)COP(=O)(O)OP(=O)(O)OC[C@H]1O[C@@H](n2cnc3c(N)ncnc32)[C@H](O)[C@@H]1OP(=O)(O)O',\n", - " 'ids': ['C00024'],\n", - " 'names': ['Acetyl-CoA'],\n", - " 'found': True},\n", - " 'AJ': {'label': 'AJ',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCSC(C)=O',\n", - " 'ids': ['C16255'],\n", - " 'names': ['[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine'],\n", - " 'found': True},\n", - " 'AK': {'label': 'AK',\n", - " 'smiles': 'NC(=O)CCCC[C@@H](S)CCS',\n", - " 'ids': ['C15973'],\n", - " 'names': ['Enzyme N6-(dihydrolipoyl)lysine'],\n", - " 'found': True},\n", - " 'AL': {'label': 'AL',\n", - " 'smiles': 'NC(=O)CCCC[C@@H]1CCSS1',\n", - " 'ids': ['C15972'],\n", - " 'names': ['Enzyme N6-(lipoyl)lysine'],\n", - " 'found': True},\n", - " 'AM': {'label': 'AM',\n", - " 'smiles': 'CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))',\n", - " 'ids': ['C02869'],\n", - " 'names': ['Oxidized flavodoxin'],\n", - " 'found': True},\n", - " 'AN': {'label': 'AN',\n", - " 'smiles': 'CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))',\n", - " 'ids': ['C02745'],\n", - " 'names': ['Reduced flavodoxin'],\n", - " 'found': True}}" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "mapping" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "synkit", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.14" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/test_query.ipynb b/test_query.ipynb deleted file mode 100644 index 63f6065..0000000 --- a/test_query.ipynb +++ /dev/null @@ -1,324 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "c0d495aa", - "metadata": {}, - "source": [ - "# Example: hsa00010 pathway" - ] - }, - { - "cell_type": "markdown", - "id": "7744e38a", - "metadata": {}, - "source": [ - "### Extract reactions and compounds" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2d66d127", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Query.kegg_extract import KEGGExtractor\n", - "\n", - "pathway_data = KEGGExtractor().build_pathway_json(\n", - " \"hsa00010\",\n", - " with_compounds=True,\n", - " with_atom_maps=True,\n", - " save_as=\"Data/KEGG/hsa00010_raw.json\",\n", - ")\n", - "pathway_data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3ce774e5", - "metadata": {}, - "outputs": [], - "source": [ - "import json\n", - "\n", - "with open('Data/Study/CRN/hsa00010_raw.json') as f:\n", - " pathway_data = json.load(f)" - ] - }, - { - "cell_type": "markdown", - "id": "4d9cd763", - "metadata": {}, - "source": [ - "### Impute reactions and compounds, regenerate atom maps" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5f7361d5", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Query.kegg_impute import KEGGImputer\n", - "\n", - "fixes = [\n", - " {\n", - " \"id\": \"R02189\",\n", - " \"reaction\": \"C00404 + C00267 => C99999 + C00668\",\n", - " },\n", - " {\n", - " \"id\": \"C99999\",\n", - " \"name\": \"Polyphosphate fragment\",\n", - " \"smiles\": \"O=P(O)(O)OP(=O)(O)O\",\n", - " },\n", - " {\n", - " \"id\": \"C00138\",\n", - " \"name\": \"Reduced ferredoxin\",\n", - " \"smiles\": \"S1[Fe]S[Fe+]1\"\n", - " },\n", - " {\n", - " \"id\": \"C00139\",\n", - " \"name\": \"Oxidized ferredoxin\",\n", - " \"smiles\": \"S1[Fe+]S[Fe+]1\"\n", - " },\n", - " {\n", - " \"id\": \"C02745\",\n", - " \"smiles\": \"CC2(C=C1(NC3(C(=O)NC(=O)NC(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)=3)))\"\n", - " },\n", - " {\n", - " \"id\": \"C02869\",\n", - " \"smiles\": \"CC2(C=C1(N=C3(C(=O)NC(=O)N=C(N(CC(O)C(O)C(O)COP([O-])(=O)[O-])C1=CC(C)=2)3)))\"\n", - " },\n", - " {\n", - " \"id\": \"C15972\",\n", - " \"name\": \"Enzyme N6-(lipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H]1CCSS\",\n", - " },\n", - " {\n", - " \"id\": \"C15973\",\n", - " \"name\": \"Enzyme N6-(dihydrolipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCS\",\n", - " },\n", - " {\n", - " \"id\": \"C16255\",\n", - " \"name\": \"[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCSC(C)=O\",\n", - " },\n", - " {\n", - " \"id\": \"C15972\",\n", - " \"name\": \"Enzyme N6-(lipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H]1CCSS1\"\n", - " },\n", - " {\n", - " \"id\": \"C15973\",\n", - " \"name\": \"Enzyme N6-(dihydrolipoyl)lysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCS\"\n", - " },\n", - " {\n", - " \"id\": \"C16255\",\n", - " \"name\": \"[Dihydrolipoyllysine-residue acetyltransferase] S-acetyldihydrolipoyllysine\",\n", - " \"smiles\": \"NC(=O)CCCC[C@@H](S)CCSC(C)=O\"\n", - " }\n", - "]\n", - "\n", - "imputer = KEGGImputer()\n", - "imputed_pathway = imputer.impute_pathway(\n", - " pathway_data,\n", - " fixes=fixes,\n", - " save_as='Data/KEGG/hsa00010_imputed.json',\n", - ")\n", - "imputed_pathway" - ] - }, - { - "cell_type": "markdown", - "id": "d5fd04f0", - "metadata": {}, - "source": [ - "### Make abstract RN" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "01f0fbbe", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Construct.abstract import AbstractReactionExtractor\n", - "\n", - "abtract_pathway = AbstractReactionExtractor().build(\n", - " data=imputed_pathway,\n", - " drop_missing_smiles_reactions=True,\n", - " deduplicate=True,\n", - " order=\"appearance\",\n", - " reactant_join=\"+\",\n", - " product_join=\"+\",\n", - " reaction_id_keys=[\"id\"],\n", - " reaction_smiles_keys=[\"smiles\"],\n", - " template_keys=[\"rule\"],\n", - " save_as=\"Data/KEGG/hsa00010_abstract.json\",\n", - ")\n", - "abtract_pathway" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d3a080c4", - "metadata": {}, - "outputs": [], - "source": [ - "abtract_pathway.reactions" - ] - }, - { - "cell_type": "markdown", - "id": "1de18e9c", - "metadata": {}, - "source": [ - "# Example: M00001 module" - ] - }, - { - "cell_type": "markdown", - "id": "6d130e59", - "metadata": {}, - "source": [ - "### Extract reactions and compounds" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "46e5e1a3", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Query.kegg_extract import KEGGExtractor\n", - "\n", - "module_data = KEGGExtractor().build_module_json(\n", - " \"M00001\",\n", - " with_compounds=True,\n", - " with_atom_maps=True,\n", - " save_as=\"Data/KEGG/M00001_raw.json\",\n", - ")\n", - "module_data" - ] - }, - { - "cell_type": "markdown", - "id": "bbd6f451", - "metadata": {}, - "source": [ - "### Impute reactions and compounds, regenerate atom maps" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a20de3b0", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Query.kegg_impute import KEGGImputer\n", - "\n", - "fixes = [\n", - " {\n", - " \"id\": \"R02189\",\n", - " \"reaction\": \"C00404 + C00267 => C99999 + C00668\",\n", - " },\n", - " {\n", - " \"id\": \"C99999\",\n", - " \"name\": \"Polyphosphate fragment\",\n", - " \"smiles\": \"O=P(O)(O)OP(=O)(O)O\",\n", - " },\n", - " {\n", - " \"id\": \"C00138\",\n", - " \"name\": \"Reduced ferredoxin\",\n", - " \"smiles\": \"S1[Fe]S[Fe+]1\"\n", - " },\n", - " {\n", - " \"id\": \"C00139\",\n", - " \"name\": \"Oxidized ferredoxin\",\n", - " \"smiles\": \"S1[Fe+]S[Fe+]1\"\n", - " },\n", - "]\n", - "\n", - "imputer = KEGGImputer()\n", - "imputed_module = imputer.impute_module(\n", - " module_data,\n", - " fixes=fixes,\n", - " save_as='Data/KEGG/M00001_imputed.json',\n", - ")\n", - "imputed_module" - ] - }, - { - "cell_type": "markdown", - "id": "8e4680e7", - "metadata": {}, - "source": [ - "### Make abstract RN" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "35a3801f", - "metadata": {}, - "outputs": [], - "source": [ - "from synkit.CRN.Construct.abstract import AbstractReactionExtractor\n", - "\n", - "abtract_module = AbstractReactionExtractor().build(\n", - " data=imputed_module,\n", - " drop_missing_smiles_reactions=True,\n", - " deduplicate=True,\n", - " order=\"appearance\",\n", - " reactant_join=\"+\",\n", - " product_join=\"+\",\n", - " reaction_id_keys=[\"id\"],\n", - " reaction_smiles_keys=[\"smiles\"],\n", - " template_keys=[\"rule\"],\n", - " save_as=\"Data/KEGG/M00001_abstract.json\",\n", - ")\n", - "abtract_module" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e17956c2", - "metadata": {}, - "outputs": [], - "source": [ - "abtract_module.reactions" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "synkit", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.11.14" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From f18041f8826b549ce14bd2da7618f14273195360 Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Fri, 22 May 2026 15:28:24 +0200 Subject: [PATCH 2/7] refactor --- .gitignore | 5 + Test/CRN/Props/test_dynamics.py | 6 + Test/Graph/FG/__init__.py | 1 + Test/Graph/FG/test_api.py | 24 + Test/Graph/FG/test_audit.py | 21 + Test/Graph/FG/test_detector.py | 288 ++++ Test/Graph/FG/test_ring_system.py | 42 + Test/Graph/ITS/test_electron_aware_its.py | 206 +++ Test/Graph/ITS/test_its_destruction.py | 31 + Test/Graph/MTG/test_group_comp.py | 52 - Test/Graph/MTG/test_groupoid.py | 181 --- Test/Graph/MTG/test_mtg.py | 14 - Test/Graph/MTG/test_mtg_tuple.py | 344 +++++ Test/Graph/Matcher/test_graph_matcher.py | 28 + Test/Graph/Matcher/test_subgraph_matcher.py | 137 +- Test/Graph/Mech/test_conversion.py | 33 + Test/Graph/Mech/test_electron_accounting.py | 170 +++ Test/Graph/test_canon_graph.py | 19 + Test/IO/test_chemical_converter.py | 41 + Test/IO/test_graph_to_mol.py | 19 + Test/IO/test_mol_to_graph.py | 93 ++ Test/Rule/Apply/test_rule_matcher.py | 21 + Test/Rule/test_syn_rule.py | 37 + Test/Synthesis/Reactor/test_imba_engine.py | 16 +- Test/Synthesis/Reactor/test_partial_engine.py | 12 + Test/Synthesis/Reactor/test_rbl_engine.py | 14 + Test/Synthesis/Reactor/test_rule_filter.py | 22 + .../Reactor/test_syn_reactor_bug_cases.py | 51 + .../test_syn_reactor_electron_cases.py | 436 ++++++ .../Reactor/test_syn_reactor_real_cases.py | 149 ++ .../Reactor/test_syn_reactor_rewrite_modes.py | 199 +++ Test/Vis/test_its_drawer.py | 134 ++ Test/Vis/test_molecule_drawer.py | 73 + Test/Vis/test_reaction_drawer.py | 54 + Test/Vis/test_visual_drawer.py | 48 + Test/Vis/test_visual_model.py | 137 ++ doc/api/graph.rst | 8 - doc/api/vis.rst | 39 +- doc/index.rst | 8 +- doc/vis.rst | 201 +++ lint.sh | 5 +- plan.html | 553 +++++++ requirements.txt | 3 +- synkit/Chem/Reaction/tautomerize.py | 57 +- synkit/Graph/Canon/canon_graph.py | 4 +- synkit/Graph/FG/__init__.py | 19 + synkit/Graph/FG/api.py | 39 + synkit/Graph/FG/audit.py | 155 ++ synkit/Graph/FG/catalog.py | 1168 +++++++++++++++ synkit/Graph/FG/detector.py | 288 ++++ synkit/Graph/FG/model.py | 97 ++ synkit/Graph/FG/ring_system.py | 154 ++ synkit/Graph/Hyrogen/_misc.py | 85 +- synkit/Graph/ITS/its_construction.py | 15 +- synkit/Graph/ITS/its_destruction.py | 28 +- synkit/Graph/ITS/its_expand.py | 404 ++++- synkit/Graph/ITS/its_reverter.py | 7 + synkit/Graph/ITS/rc_extractor.py | 26 +- synkit/Graph/MTG/group_comp.py | 157 -- synkit/Graph/MTG/groupoid.py | 358 ----- synkit/Graph/MTG/mtg.py | 416 +++++- synkit/Graph/Matcher/graph_matcher.py | 49 +- synkit/Graph/Matcher/subgraph_matcher.py | 138 +- synkit/Graph/Mech/__init__.py | 0 synkit/Graph/Mech/conversion.py | 1305 +++++++++++++++++ synkit/Graph/Mech/electron_accounting.py | 64 + synkit/Graph/canon_graph.py | 15 +- synkit/Graph/utils.py | 32 +- synkit/IO/chem_converter.py | 95 +- synkit/IO/graph_to_mol.py | 2 + synkit/IO/mol_to_graph.py | 35 +- synkit/Rule/Apply/rule_matcher.py | 29 +- synkit/Rule/syn_rule.py | 163 +- synkit/Synthesis/Reactor/imba_engine.py | 10 + synkit/Synthesis/Reactor/partial_engine.py | 16 +- synkit/Synthesis/Reactor/rbl_engine.py | 21 + synkit/Synthesis/Reactor/rule_filter.py | 12 +- synkit/Synthesis/Reactor/syn_reactor.py | 831 ++++++++++- synkit/Vis/__init__.py | 44 +- synkit/Vis/graph_visualizer.py | 2 +- synkit/Vis/its_drawer.py | 615 ++++++++ synkit/Vis/molecule_drawer.py | 565 +++++++ synkit/Vis/reaction_drawer.py | 285 ++++ synkit/Vis/vis_synedu/Vis/__init__.py | 7 + synkit/Vis/vis_synedu/Vis/dpo.py | 862 +++++++++++ synkit/Vis/vis_synedu/rxn_vis.py | 382 +++++ synkit/Vis/vis_synedu/vis.py | 501 +++++++ synkit/Vis/visual_drawer.py | 215 +++ synkit/Vis/visual_model.py | 520 +++++++ 89 files changed, 13298 insertions(+), 969 deletions(-) create mode 100644 Test/Graph/FG/__init__.py create mode 100644 Test/Graph/FG/test_api.py create mode 100644 Test/Graph/FG/test_audit.py create mode 100644 Test/Graph/FG/test_detector.py create mode 100644 Test/Graph/FG/test_ring_system.py create mode 100644 Test/Graph/ITS/test_electron_aware_its.py create mode 100644 Test/Graph/ITS/test_its_destruction.py delete mode 100644 Test/Graph/MTG/test_group_comp.py delete mode 100644 Test/Graph/MTG/test_groupoid.py create mode 100644 Test/Graph/MTG/test_mtg_tuple.py create mode 100644 Test/Graph/Mech/test_conversion.py create mode 100644 Test/Graph/Mech/test_electron_accounting.py create mode 100644 Test/Synthesis/Reactor/test_rule_filter.py create mode 100644 Test/Synthesis/Reactor/test_syn_reactor_bug_cases.py create mode 100644 Test/Synthesis/Reactor/test_syn_reactor_electron_cases.py create mode 100644 Test/Synthesis/Reactor/test_syn_reactor_real_cases.py create mode 100644 Test/Synthesis/Reactor/test_syn_reactor_rewrite_modes.py create mode 100644 Test/Vis/test_its_drawer.py create mode 100644 Test/Vis/test_molecule_drawer.py create mode 100644 Test/Vis/test_reaction_drawer.py create mode 100644 Test/Vis/test_visual_drawer.py create mode 100644 Test/Vis/test_visual_model.py create mode 100644 doc/vis.rst create mode 100644 plan.html create mode 100644 synkit/Graph/FG/__init__.py create mode 100644 synkit/Graph/FG/api.py create mode 100644 synkit/Graph/FG/audit.py create mode 100644 synkit/Graph/FG/catalog.py create mode 100644 synkit/Graph/FG/detector.py create mode 100644 synkit/Graph/FG/model.py create mode 100644 synkit/Graph/FG/ring_system.py delete mode 100644 synkit/Graph/MTG/group_comp.py delete mode 100644 synkit/Graph/MTG/groupoid.py create mode 100644 synkit/Graph/Mech/__init__.py create mode 100644 synkit/Graph/Mech/conversion.py create mode 100644 synkit/Graph/Mech/electron_accounting.py create mode 100644 synkit/Vis/its_drawer.py create mode 100644 synkit/Vis/molecule_drawer.py create mode 100644 synkit/Vis/reaction_drawer.py create mode 100644 synkit/Vis/vis_synedu/Vis/__init__.py create mode 100644 synkit/Vis/vis_synedu/Vis/dpo.py create mode 100644 synkit/Vis/vis_synedu/rxn_vis.py create mode 100644 synkit/Vis/vis_synedu/vis.py create mode 100644 synkit/Vis/visual_drawer.py create mode 100644 synkit/Vis/visual_model.py diff --git a/.gitignore b/.gitignore index 914dfa2..00269bf 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,8 @@ synkit/Graph/dev/* *.json *.txt *.log +sprint/ +test_syn.py +.gitignore +measure_candidate_stages.py +run_valid_bug_cases.py diff --git a/Test/CRN/Props/test_dynamics.py b/Test/CRN/Props/test_dynamics.py index b01b4b6..ac7ebfa 100644 --- a/Test/CRN/Props/test_dynamics.py +++ b/Test/CRN/Props/test_dynamics.py @@ -206,6 +206,9 @@ def test_jacobian_pattern_bipartite(self) -> None: self.assertTrue(B.has_edge("row:1", "col:1")) +@unittest.skipUnless( + dynamics._SYMPY_AVAILABLE, "sympy is required for symbolic dynamics tests" +) class TestDynamicsMatrices(unittest.TestCase): def test_symbolic_reactivity_matrix_cycle(self) -> None: G = make_cycle_crn() @@ -429,6 +432,9 @@ def test_to_dict_and_str(self) -> None: self.assertIn("classification", text) +@unittest.skipUnless( + dynamics._SYMPY_AVAILABLE, "sympy is required for exact singularity tests" +) class TestStructuralSingularitySummary(unittest.TestCase): def test_single_step_is_singular_by_pattern(self) -> None: G = make_single_step_crn() diff --git a/Test/Graph/FG/__init__.py b/Test/Graph/FG/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Test/Graph/FG/__init__.py @@ -0,0 +1 @@ + diff --git a/Test/Graph/FG/test_api.py b/Test/Graph/FG/test_api.py new file mode 100644 index 0000000..c71f73e --- /dev/null +++ b/Test/Graph/FG/test_api.py @@ -0,0 +1,24 @@ +import pytest + +from synkit.Graph.FG import smiles_to_graph_and_functional_groups + + +def test_smiles_to_graph_and_functional_groups_supports_unmapped_smiles(): + graph, groups = smiles_to_graph_and_functional_groups("CC(=O)O") + + assert tuple(graph.nodes) == (1, 2, 3, 4) + assert groups == [("carboxylic_acid", (2, 3, 4))] + + +def test_smiles_to_graph_and_functional_groups_preserves_atom_map_node_ids(): + graph, groups = smiles_to_graph_and_functional_groups( + "[CH3:10][C:20](=[O:30])[OH:40]" + ) + + assert tuple(graph.nodes) == (10, 20, 30, 40) + assert groups == [("carboxylic_acid", (20, 30, 40))] + + +def test_smiles_to_graph_and_functional_groups_rejects_invalid_smiles(): + with pytest.raises(ValueError): + smiles_to_graph_and_functional_groups("not_smiles") diff --git a/Test/Graph/FG/test_audit.py b/Test/Graph/FG/test_audit.py new file mode 100644 index 0000000..2b25cb9 --- /dev/null +++ b/Test/Graph/FG/test_audit.py @@ -0,0 +1,21 @@ +from synkit.Graph.FG.audit import audit_reaction_smiles + + +def test_audit_reaction_smiles_summarizes_small_corpus(): + report = audit_reaction_smiles( + [ + "CCO>>CC=O", + "c1ncnnc1>>c1ncnnc1", + ] + ) + + assert report.reactions == 2 + assert report.molecules == 4 + assert report.parse_failures == 0 + assert report.label_counts["primary_alcohol"] == 1 + assert report.label_counts["aldehyde"] == 1 + assert report.label_counts["heteroaromatic_ring"] == 2 + assert report.label_counts["triazine"] == 2 + assert report.heteroaromatic_systems == 2 + assert report.named_heteroaromatic_systems == 2 + assert report.unnamed_heteroaromatic_count == 0 diff --git a/Test/Graph/FG/test_detector.py b/Test/Graph/FG/test_detector.py new file mode 100644 index 0000000..5c6777e --- /dev/null +++ b/Test/Graph/FG/test_detector.py @@ -0,0 +1,288 @@ +import pytest +from rdkit import Chem + +from synkit.Graph.FG import FunctionalGroupDetector +from synkit.IO.mol_to_graph import MolToGraph + + +def _detect(smiles: str): + mol = Chem.MolFromSmiles(smiles) + graph = MolToGraph().transform(mol) + return FunctionalGroupDetector().detect(graph) + + +@pytest.mark.parametrize( + "smiles, expected", + [ + ("C=O", [("aldehyde", (1, 2))]), + ("C(=O)N", [("amide", (1, 2, 3))]), + ("CC(=O)O", [("carboxylic_acid", (2, 3, 4))]), + ("COC(C)=O", [("ester", (2, 3, 5))]), + ("NCC(=O)O", [("amine", (1,)), ("carboxylic_acid", (3, 4, 5))]), + ("CCSCC", [("thioether", (3,))]), + ("CSC(=O)c1ccccc1", [("thioester", (2, 3, 4))]), + ( + "O=C(C)Oc1ccccc1C(=O)O", + [("ester", (1, 2, 4)), ("carboxylic_acid", (11, 12, 13))], + ), + ("CC(C)(C)OO", [("peroxide", (5, 6))]), + ("CC(=O)OO", [("peroxy_acid", (2, 3, 4, 5))]), + ("CCO", [("primary_alcohol", (2, 3))]), + ("CC(C)O", [("secondary_alcohol", (2, 4))]), + ("CC(C)(C)O", [("tertiary_alcohol", (2, 5))]), + ("C=CO", [("enol", (1, 2, 3))]), + ("C1CO1", [("epoxide", (1, 2, 3))]), + ("c1ccccc1O", [("phenol", (7,))]), + ("c1ccccc1N", [("aniline", (7,))]), + ("CC(=O)Cl", [("acyl_chloride", (2, 3, 4))]), + ("CC#N", [("nitrile", (2, 3))]), + ("CN=O", [("nitroso", (2, 3))]), + ("C[N+](=O)[O-]", [("nitro", (2, 3, 4))]), + ("COC(=O)N", [("carbamate", (2, 3, 4, 5))]), + ("CC(=O)OC(C)=O", [("anhydride", (2, 3, 4, 5, 7))]), + ("COC(O)(C)C", [("hemiketal", (2, 3, 4))]), + ("CO[CH](O)C", [("hemiacetal", (2, 3, 4))]), + ("COC(OC)(C)C", [("ketal", (2, 3, 4))]), + ("CO[CH](OC)C", [("acetal", (2, 3, 4))]), + ( + "n1ccccc1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6)), + ("pyridine", (1, 2, 3, 4, 5, 6)), + ], + ), + ( + "[nH]1cccc1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("pyrrole", (1, 2, 3, 4, 5)), + ], + ), + ( + "Cn1cccc1", + [ + ("heteroaromatic_ring", (2, 3, 4, 5, 6)), + ("pyrrole", (2, 3, 4, 5, 6)), + ], + ), + ( + "o1cccc1", + [ + ("furan", (1, 2, 3, 4, 5)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ], + ), + ( + "s1cccc1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("thiophene", (1, 2, 3, 4, 5)), + ], + ), + ( + "n1ccncc1", + [ + ("diazine", (1, 2, 3, 4, 5, 6)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6)), + ], + ), + ( + "c1ncc[nH]1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("imidazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1cn[nH]c1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("pyrazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1ccc2[nH]cnc2c1", + [ + ("benzimidazole", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("imidazole", (4, 5, 6, 7, 8)), + ], + ), + ( + "c1ccc2[nH]ccc2c1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("indole", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("pyrrole", (4, 5, 6, 7, 8)), + ], + ), + ( + "c1ccc2[nH]ncc2c1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("indazole", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("pyrazole", (4, 5, 6, 7, 8)), + ], + ), + ( + "c1ccc2ocnc2c1", + [ + ("benzoxazole", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("oxazole", (4, 5, 6, 7, 8)), + ], + ), + ( + "c1ccc2scnc2c1", + [ + ("benzothiazole", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6, 7, 8, 9)), + ("thiazole", (4, 5, 6, 7, 8)), + ], + ), + ( + "c1nccs1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("thiazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1ccns1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("isothiazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1ncco1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("oxazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1ccon1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("isoxazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1n[nH]cn1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("triazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1nocn1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("oxadiazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1nn[nH]n1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("tetrazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1nscn1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5)), + ("thiadiazole", (1, 2, 3, 4, 5)), + ], + ), + ( + "c1ncnnc1", + [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6)), + ("triazine", (1, 2, 3, 4, 5, 6)), + ], + ), + ("CCCl", [("organohalide", (2, 3))]), + ("c1ccccc1Cl", [("aryl_halide", (6, 7))]), + ("CS(=O)C", [("sulfoxide", (2, 3))]), + ("CS(=O)(=O)C", [("sulfone", (2, 3, 4))]), + ("CS(=O)(=O)N", [("sulfonamide", (2, 3, 4, 5))]), + ("OB(O)c1ccccc1", [("boronic_acid", (1, 2, 3))]), + ("B(OC)(OC)c1ccccc1", [("boronate_ester", (1, 2, 4))]), + ("CO[Si](C)(C)C", [("silyl_ether", (2, 3))]), + ("COP(=O)(OC)OC", [("phosphate", (2, 3, 4, 5, 7))]), + ("CP(=O)(OC)OC", [("phosphonate", (1, 2, 3, 4, 6))]), + ("CP(=O)(C)C", [("phosphine_oxide", (1, 2, 3, 4, 5))]), + ("COP(OC)OC", [("phosphite", (2, 3, 4, 6))]), + ("O=C=Nc1ccccc1", [("isocyanate", (1, 2, 3))]), + ("ON=Cc1ccccc1", [("oxime", (1, 2, 3))]), + ("CNN=C(C)C", [("hydrazone", (2, 3, 4))]), + ("CC=NC", [("imine", (2, 3))]), + ("N=C(N)c1ccccc1", [("amidine", (1, 2, 3))]), + ("NC(=NO)c1ccccc1", [("amidoxime", (1, 2, 3, 4))]), + ("CN=[N+]=[N-]", [("azide", (2, 3, 4))]), + ("c1ccccc1N=Nc1ccccc1", [("azo", (7, 8))]), + ("S=C=Nc1ccccc1", [("isothiocyanate", (1, 2, 3))]), + ("NC(=S)N", [("thiourea", (1, 2, 3, 4))]), + ("CC(=S)N", [("thioamide", (2, 3, 4))]), + ], +) +def test_detects_compatibility_groups(smiles, expected): + assert _detect(smiles) == expected + + +def test_raw_matches_keep_parent_before_resolution(): + mol = Chem.MolFromSmiles("CC(=O)O") + graph = MolToGraph().transform(mol) + detector = FunctionalGroupDetector() + + raw_names = {match.name for match in detector.raw_matches(graph)} + assert {"carbonyl", "carboxylic_acid"}.issubset(raw_names) + assert detector.detect(graph) == [("carboxylic_acid", (2, 3, 4))] + + +def test_internal_prerequisite_patterns_do_not_leak_into_public_results(): + mol = Chem.MolFromSmiles("COC") + graph = MolToGraph().transform(mol) + detector = FunctionalGroupDetector() + + assert {match.name for match in detector.raw_matches(graph)} == {"ether"} + assert { + match.name for match in detector.raw_matches(graph, include_internal=True) + } == {"ether", "oxygen_link"} + + +def test_water_is_not_alcohol(): + assert _detect("O") == [] + + +def test_heteroaromatic_ring_suppresses_generic_amine(): + assert _detect("n1ccccc1") == [ + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6)), + ("pyridine", (1, 2, 3, 4, 5, 6)), + ] + + +def test_diazine_keeps_generic_heteroaromatic_coverage(): + assert _detect("n1ccncc1") == [ + ("diazine", (1, 2, 3, 4, 5, 6)), + ("heteroaromatic_ring", (1, 2, 3, 4, 5, 6)), + ] + + +@pytest.mark.parametrize( + "implicit_smiles, explicit_smiles, expected", + [ + ("CCO", "[CH3][CH2][OH]", [("primary_alcohol", (2, 3))]), + ("C=O", "[CH2]=O", [("aldehyde", (1, 2))]), + ("CC(=O)O", "[CH3][C](=O)[OH]", [("carboxylic_acid", (2, 3, 4))]), + ], +) +def test_implicit_and_explicit_hydrogen_inputs_agree( + implicit_smiles, + explicit_smiles, + expected, +): + assert _detect(implicit_smiles) == expected + assert _detect(explicit_smiles) == expected diff --git a/Test/Graph/FG/test_ring_system.py b/Test/Graph/FG/test_ring_system.py new file mode 100644 index 0000000..6af5b9a --- /dev/null +++ b/Test/Graph/FG/test_ring_system.py @@ -0,0 +1,42 @@ +from rdkit import Chem + +from synkit.Graph.FG.ring_system import AromaticRingSystemDetector +from synkit.IO.mol_to_graph import MolToGraph + + +def _systems(smiles: str): + mol = Chem.MolFromSmiles(smiles) + graph = MolToGraph().transform(mol) + return AromaticRingSystemDetector.detect(graph) + + +def test_detects_single_heteroaromatic_ring(): + system = _systems("n1ccccc1")[0] + + assert system.nodes == (1, 2, 3, 4, 5, 6) + assert system.hetero_nodes == (1,) + assert system.element_counts == {"C": 5, "N": 1} + assert system.ring_sizes == (6,) + assert system.is_fused is False + assert system.hetero_pattern == "1N-6ring" + + +def test_detects_multi_hetero_ring(): + system = _systems("n1ccncc1")[0] + + assert system.element_counts == {"C": 4, "N": 2} + assert system.ring_sizes == (6,) + assert system.hetero_pattern == "2N-6ring" + + +def test_detects_fused_aromatic_system(): + system = _systems("c1ccc2ncccc2c1")[0] + + assert system.element_counts == {"C": 9, "N": 1} + assert system.ring_sizes == (6, 6) + assert system.is_fused is True + assert system.hetero_sequence is None + assert tuple(ring.nodes for ring in system.subrings) == ( + (1, 2, 3, 4, 9, 10), + (4, 5, 6, 7, 8, 9), + ) diff --git a/Test/Graph/ITS/test_electron_aware_its.py b/Test/Graph/ITS/test_electron_aware_its.py new file mode 100644 index 0000000..69cbc2d --- /dev/null +++ b/Test/Graph/ITS/test_electron_aware_its.py @@ -0,0 +1,206 @@ +import unittest + +import networkx as nx + +from synkit.Graph.Hyrogen._misc import h_to_explicit, normalize_h_pair_graph +from synkit.Graph.ITS.its_construction import ITSConstruction +from synkit.Graph.ITS.its_reverter import ITSReverter +from synkit.Graph.ITS.rc_extractor import RCExtractor + + +class TestElectronAwareITS(unittest.TestCase): + def setUp(self): + self.reactant = nx.Graph() + self.reactant.add_node( + 1, + element="N", + aromatic=False, + hcount=2, + charge=0, + neighbors=["C"], + lone_pairs=1, + radical=0, + valence_electrons=5, + ) + self.reactant.add_node( + 2, + element="C", + aromatic=False, + hcount=3, + charge=0, + neighbors=["N"], + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + self.reactant.add_edge( + 1, + 2, + order=1.0, + kekule_order=1.0, + sigma_order=1.0, + pi_order=0.0, + ) + + self.product = nx.Graph() + self.product.add_node( + 1, + element="N", + aromatic=False, + hcount=1, + charge=0, + neighbors=["C"], + lone_pairs=1, + radical=1, + valence_electrons=5, + ) + self.product.add_node( + 2, + element="C", + aromatic=False, + hcount=3, + charge=0, + neighbors=["N"], + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + self.product.add_edge( + 1, + 2, + order=2.0, + kekule_order=2.0, + sigma_order=1.0, + pi_order=1.0, + ) + + def test_construct_stores_default_electron_pairs(self): + its = ITSConstruction.construct(self.reactant, self.product) + + self.assertEqual(its.nodes[1]["lone_pairs"], (1, 1)) + self.assertEqual(its.nodes[1]["radical"], (0, 1)) + self.assertEqual(its.nodes[1]["valence_electrons"], (5, 5)) + self.assertEqual(its.edges[1, 2]["sigma_order"], (1.0, 1.0)) + self.assertEqual(its.edges[1, 2]["pi_order"], (0.0, 1.0)) + + def test_rc_extractor_marks_radical_change(self): + its = ITSConstruction.construct(self.reactant, self.product) + + rc = RCExtractor().extract(its) + + self.assertIn(1, rc) + self.assertIn("radical", rc.graph["rc"]["node_reasons"][1]) + self.assertEqual(rc.edges[1, 2]["standard_order"], -1.0) + + def test_reverter_restores_electron_fields(self): + its = ITSConstruction.construct(self.reactant, self.product) + reactant, product = ( + ITSReverter(its).to_reactant_graph(), + ITSReverter(its).to_product_graph(), + ) + + self.assertEqual(reactant.nodes[1]["radical"], 0) + self.assertEqual(product.nodes[1]["radical"], 1) + self.assertEqual(reactant.edges[1, 2]["pi_order"], 0.0) + self.assertEqual(product.edges[1, 2]["pi_order"], 1.0) + + def test_normalize_h_pair_graph_supports_named_pair_storage(self): + its = ITSConstruction.construct(self.reactant, self.product) + + normalized = normalize_h_pair_graph(its) + + self.assertEqual(normalized.nodes[1]["hcount"], (1, 0)) + + def test_rc_extraction_survives_named_hcount_normalization(self): + its = ITSConstruction.construct(self.reactant, self.product) + normalized = normalize_h_pair_graph(its) + + before = RCExtractor().extract(its) + after = RCExtractor().extract(normalized) + + self.assertEqual(before.graph["rc"]["nodes"], after.graph["rc"]["nodes"]) + self.assertEqual( + before.graph["rc"]["node_reasons"], + after.graph["rc"]["node_reasons"], + ) + + def test_preserve_full_attrs_exports_unfiltered_rc_snapshots(self): + its = ITSConstruction.construct(self.reactant, self.product) + its.nodes[1]["custom_marker"] = "kept" + + rc = RCExtractor(preserve_full_attrs=True).extract(its) + + self.assertEqual(rc.graph["rc"]["node_attrs"][1]["custom_marker"], "kept") + + def test_reverter_drops_nodes_absent_on_one_side(self): + self.reactant.add_node( + 3, + element="H", + aromatic=False, + hcount=0, + charge=0, + neighbors=["N"], + lone_pairs=0, + radical=0, + valence_electrons=1, + ) + self.reactant.add_edge( + 1, + 3, + order=1.0, + kekule_order=1.0, + sigma_order=1.0, + pi_order=0.0, + ) + + its = ITSConstruction.construct(self.reactant, self.product) + reactant, product = ( + ITSReverter(its).to_reactant_graph(), + ITSReverter(its).to_product_graph(), + ) + + self.assertIn(3, reactant) + self.assertNotIn(3, product) + + def test_h_to_explicit_expands_named_pair_hcounts_by_side(self): + its = ITSConstruction.construct(self.reactant, self.product) + + expanded = h_to_explicit(its, [1], its=True) + hydrogens = [ + node + for node, attrs in expanded.nodes(data=True) + if attrs.get("element") == ("H", "H") + ] + + self.assertEqual(expanded.nodes[1]["hcount"], (0, 0)) + self.assertEqual(len(hydrogens), 2) + self.assertEqual( + {expanded.nodes[node]["present"] for node in hydrogens}, + {(True, True), (True, False)}, + ) + self.assertEqual( + {expanded.edges[1, node]["order"] for node in hydrogens}, + {(1.0, 1.0), (1.0, 0.0)}, + ) + + def test_explicit_tuple_hydrogens_revert_to_correct_side_graphs(self): + its = ITSConstruction.construct(self.reactant, self.product) + expanded = h_to_explicit(its, [1], its=True) + reactant, product = ( + ITSReverter(expanded).to_reactant_graph(), + ITSReverter(expanded).to_product_graph(), + ) + + reactant_hydrogens = [ + node for node, attrs in reactant.nodes(data=True) if attrs["element"] == "H" + ] + product_hydrogens = [ + node for node, attrs in product.nodes(data=True) if attrs["element"] == "H" + ] + + self.assertEqual(len(reactant_hydrogens), 2) + self.assertEqual(len(product_hydrogens), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Graph/ITS/test_its_destruction.py b/Test/Graph/ITS/test_its_destruction.py new file mode 100644 index 0000000..3c77f9b --- /dev/null +++ b/Test/Graph/ITS/test_its_destruction.py @@ -0,0 +1,31 @@ +import unittest + +from synkit.Graph.ITS.its_construction import ITSConstruction +from synkit.Graph.ITS.its_destruction import ITSDestruction +from synkit.IO.chem_converter import rsmi_to_graph + + +class TestITSDestruction(unittest.TestCase): + def test_direct_tuple_mode_preserves_electron_fields(self): + reactant, product = rsmi_to_graph( + "[NH2:1][CH3:2]>>[NH:1]=[CH2:2]", + ) + reactant.nodes[1]["lone_pairs"] = 1 + reactant.nodes[1]["radical"] = 0 + reactant.nodes[1]["valence_electrons"] = 5 + product.nodes[1]["lone_pairs"] = 1 + product.nodes[1]["radical"] = 1 + product.nodes[1]["valence_electrons"] = 5 + + its = ITSConstruction.construct(reactant, product) + left, right = ITSDestruction(its).decompose() + + self.assertEqual(left.nodes[1]["lone_pairs"], 1) + self.assertEqual(right.nodes[1]["radical"], 1) + self.assertEqual(right.nodes[1]["valence_electrons"], 5) + self.assertEqual(left.edges[1, 2]["sigma_order"], 1.0) + self.assertEqual(right.edges[1, 2]["pi_order"], 1.0) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Graph/MTG/test_group_comp.py b/Test/Graph/MTG/test_group_comp.py deleted file mode 100644 index 90be4bc..0000000 --- a/Test/Graph/MTG/test_group_comp.py +++ /dev/null @@ -1,52 +0,0 @@ -import unittest -from synkit.IO.chem_converter import rsmi_to_its -from synkit.Graph.MTG.groupoid import node_constraint -from synkit.Graph.MTG.group_comp import GroupComp - - -class TestGroupComp(unittest.TestCase): - - def setUp(self) -> None: - test_1 = [ - "[CH:4]([H:7])([H:8])[CH:5]=[O:6]>>[CH:4]([H:8])=[CH:5][O:6]([H:7])", - "[CH3:1][CH:2]=[O:3].[CH:4]([H:8])=[CH:5][O:6]([H:7])>>[CH3:1][CH:2]([O:3][H:7])[CH:4]([H:8])[CH:5]=[O:6]", - ] - self.test_graph_1 = [rsmi_to_its(var) for var in test_1] - test_2 = [ - "[CH2:1]=[CH:2]-[CH2+:3]>>[CH2+:1]-[CH:2]=[CH2:3]", - "[H:1]-[CH2:2]-[CH2+:3]>>[CH2:2]=[CH2:3].[H+:1]", - ] - self.test_graph_2 = [rsmi_to_its(var) for var in test_2] - - def test_get_mapping(self): - g = GroupComp(self.test_graph_1[0], self.test_graph_1[1]) - m = g.get_mapping(include_singleton=False) - self.assertEqual(len(m), 4) - - def test_get_mapping_singleton(self): - g = GroupComp(self.test_graph_1[0], self.test_graph_1[1]) - m = g.get_mapping(include_singleton=True) - self.assertEqual(len(m), 10) - - def test_get_mapping_from_nodes(self): - m0 = node_constraint( - self.test_graph_2[0].nodes(data=True), self.test_graph_2[1].nodes(data=True) - ) - g = GroupComp(self.test_graph_2[0], self.test_graph_2[1]) - m = g.get_mapping_from_nodes( - m0, - self.test_graph_2[0].edges(data=True), - self.test_graph_2[1].edges(data=True), - ) - self.assertEqual(len(m), 1) - - def test_get_mapping_fallback(self): - g = GroupComp(self.test_graph_2[0], self.test_graph_2[1]) - m = g.get_mapping( - include_singleton=False - ) # even False if cannot find candidate will fall back - self.assertEqual(len(m), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/Test/Graph/MTG/test_groupoid.py b/Test/Graph/MTG/test_groupoid.py deleted file mode 100644 index 3f34501..0000000 --- a/Test/Graph/MTG/test_groupoid.py +++ /dev/null @@ -1,181 +0,0 @@ -import unittest -from synkit.IO.chem_converter import rsmi_to_its -from synkit.Graph.MTG.groupoid import charge_tuple, node_constraint, edge_constraint - - -class TestGroupoid(unittest.TestCase): - - def setUp(self) -> None: - test_1 = [ - "[CH:4]([H:7])([H:8])[CH:5]=[O:6]>>[CH:4]([H:8])=[CH:5][O:6]([H:7])", - "[CH3:1][CH:2]=[O:3].[CH:4]([H:8])=[CH:5][O:6]([H:7])>>[CH3:1][CH:2]([O:3][H:7])[CH:4]([H:8])[CH:5]=[O:6]", - ] - self.test_graph_1 = [rsmi_to_its(var) for var in test_1] - test_2 = [ - "[CH2:1]=[CH:2]-[CH2+:3]>>[CH2+:1]-[CH:2]=[CH2:3]", - "[H:1]-[CH2:2]-[CH2+:3]>>[CH2:2]=[CH2:3].[H+:1]", - ] - self.test_graph_2 = [rsmi_to_its(var) for var in test_2] - - def test_direct_charges(self): - attrs = {"charges": (0, 1)} - self.assertEqual(charge_tuple(attrs), (0, 1)) - - def test_both_fields_prioritize_charges(self): - attrs = { - "charges": (1, 2), - "typesGH": ((None, None, None, 3, None), (None, None, None, 4, None)), - } - # 'charges' should take precedence over 'typesGH' - self.assertEqual(charge_tuple(attrs), (1, 2)) - - def test_charges_not_tuple(self): - attrs = { - "charges": [0, 1], # not a tuple - "typesGH": ((None, None, None, 2, None), (None, None, None, 3, None)), - } - # Non-tuple 'charges' should be ignored in favor of typesGH - self.assertEqual(charge_tuple(attrs), (2, 3)) - - def test_charges_wrong_length(self): - attrs = { - "charges": (0,), # length != 2 - "typesGH": ((None, None, None, 5, None), (None, None, None, 6, None)), - } - # Invalid 'charges' length => fallback to typesGH - self.assertEqual(charge_tuple(attrs), (5, 6)) - - def test_typesGH_valid(self): - attrs = {"typesGH": ((None, None, None, 7, None), (None, None, None, 8, None))} - self.assertEqual(charge_tuple(attrs), (7, 8)) - - def test_typesGH_too_short(self): - attrs = {"typesGH": ((None, None, None, 9, None),)} # only one tuple - # Not enough entries => (None, None) - self.assertEqual(charge_tuple(attrs), (None, None)) - - def test_typesGH_inner_exception(self): - attrs = {"typesGH": ((None, None), (None,))} # inner tuples too short - # Should catch exception and return (None, None) - self.assertEqual(charge_tuple(attrs), (None, None)) - - def test_no_fields(self): - # No relevant keys => (None, None) - self.assertEqual(charge_tuple({}), (None, None)) - - def test_node_constraint(self): - m1 = node_constraint( - self.test_graph_1[0].nodes(data=True), self.test_graph_1[1].nodes(data=True) - ) - self.assertEqual(len(m1.keys()), 5) - - m2 = node_constraint( - self.test_graph_2[0].nodes(data=True), self.test_graph_2[1].nodes(data=True) - ) - self.assertEqual(len(m2.keys()), 3) - - def test_edge_constraint_no_map(self): - m1 = edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - algorithm="bt", - ) - self.assertEqual(len(m1), 46) # backtracking - - self.assertEqual( - len( - edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - algorithm="vf2", - ) - ), - 30, - ) # vf2 - - self.assertEqual( - len( - edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - algorithm="vf3", - ) - ), - 30, - ) # vf3 - - m2 = edge_constraint( - self.test_graph_2[0].edges(data=True), self.test_graph_2[1].edges(data=True) - ) - self.assertEqual(len(m2), 2) # backtracking - - self.assertEqual( - len( - edge_constraint( - self.test_graph_2[0].edges(data=True), - self.test_graph_2[1].edges(data=True), - algorithm="vf2", - ) - ), - 2, - ) - - self.assertEqual( - len( - edge_constraint( - self.test_graph_2[0].edges(data=True), - self.test_graph_2[1].edges(data=True), - algorithm="vf3", - ) - ), - 2, - ) - - def test_edge_constraint_map(self): - m0 = node_constraint( - self.test_graph_1[0].nodes(data=True), self.test_graph_1[1].nodes(data=True) - ) - m1 = edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - m0, - ) - self.assertEqual(len(m1), 4) - - self.assertEqual( - len( - edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - m0, - algorithm="vf2", - ) - ), - 1, - ) - - self.assertEqual( - len( - edge_constraint( - self.test_graph_1[0].edges(data=True), - self.test_graph_1[1].edges(data=True), - m0, - algorithm="vf3", - ) - ), - 1, - ) - - m0 = node_constraint( - self.test_graph_2[0].nodes(data=True), self.test_graph_2[1].nodes(data=True) - ) - m2 = edge_constraint( - self.test_graph_2[0].edges(data=True), - self.test_graph_2[1].edges(data=True), - m0, - ) - self.assertEqual(len(m2), 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/Test/Graph/MTG/test_mtg.py b/Test/Graph/MTG/test_mtg.py index 0f8827d..55098b2 100644 --- a/Test/Graph/MTG/test_mtg.py +++ b/Test/Graph/MTG/test_mtg.py @@ -1,7 +1,6 @@ import unittest from synkit.IO.chem_converter import rsmi_to_its from synkit.Graph.ITS.its_decompose import get_rc -from synkit.Graph.MTG.group_comp import GroupComp from synkit.Graph.MTG.mtg import MTG @@ -13,25 +12,12 @@ def setUp(self) -> None: "[CH3:1][CH:2]=[O:3].[CH:4]([H:8])=[CH:5][O:6]([H:7])>>[CH3:1][CH:2]([O:3][H:7])[CH:4]([H:8])[CH:5]=[O:6]", ] self.test_graph_1 = [get_rc(rsmi_to_its(var)) for var in test_1] - test_2 = [ - "[CH2:1]=[CH:2]-[CH2+:3]>>[CH2+:1]-[CH:2]=[CH2:3]", - "[H:1]-[CH2:2]-[CH2+:3]>>[CH2:2]=[CH2:3].[H+:1]", - ] - self.test_graph_2 = [get_rc(rsmi_to_its(var)) for var in test_2] def test_MTG_1(self): mtg = MTG(self.test_graph_1[0:2], mcs_mol=True) self.assertEqual(mtg._graph.number_of_nodes(), 6) self.assertEqual(mtg._graph.number_of_edges(), 7) - def test_MTG_2(self): - grp = GroupComp(self.test_graph_2[0], self.test_graph_2[1]) - candidates = grp.get_mapping() - # print(candidates) - mtg = MTG(self.test_graph_2[0:], candidates) - self.assertEqual(mtg._graph.number_of_nodes(), 5) - self.assertEqual(mtg._graph.number_of_edges(), 4) - if __name__ == "__main__": unittest.main() diff --git a/Test/Graph/MTG/test_mtg_tuple.py b/Test/Graph/MTG/test_mtg_tuple.py new file mode 100644 index 0000000..2fd2c29 --- /dev/null +++ b/Test/Graph/MTG/test_mtg_tuple.py @@ -0,0 +1,344 @@ +import unittest + +import networkx as nx + +from synkit.Graph.ITS.its_reverter import ITSReverter +from synkit.Graph.ITS.its_construction import ITSConstruction +from synkit.Graph.Mech.electron_accounting import refresh_electron_fields +from synkit.Graph.MTG.mtg import MTG +from synkit.IO import load_database, rsmi_to_its + + +class TestTupleMTG(unittest.TestCase): + def setUp(self): + g0 = nx.Graph() + g0.add_node( + 1, + element="N", + aromatic=False, + hcount=2, + charge=0, + lone_pairs=1, + radical=0, + valence_electrons=5, + ) + g0.add_node( + 2, + element="C", + aromatic=False, + hcount=3, + charge=0, + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + g0.add_edge( + 1, + 2, + order=1.0, + kekule_order=1.0, + sigma_order=1.0, + pi_order=0.0, + ) + + g1 = g0.copy() + g1.nodes[1]["hcount"] = 1 + g1.nodes[1]["radical"] = 1 + g1.edges[1, 2].update( + order=2.0, + kekule_order=2.0, + sigma_order=1.0, + pi_order=1.0, + ) + + g2 = g0.copy() + g2.nodes[1]["charge"] = 1 + g2.nodes[1]["lone_pairs"] = 0 + + self.step_1 = ITSConstruction.construct(g0, g1) + self.step_2 = ITSConstruction.construct(g1, g2) + + def test_tuple_its_detection_preserves_electron_fields(self): + mtg = MTG([self.step_1, self.step_2], mappings=[{1: 1, 2: 2}]) + + self.assertTrue(mtg._tuple_its) + self.assertEqual(mtg._graphs[0].nodes[1]["hcount"], (2, 1)) + self.assertEqual(mtg._graphs[0].nodes[1]["radical"], (0, 1)) + self.assertEqual(mtg._graphs[1].nodes[1]["lone_pairs"], (1, 0)) + + def test_tuple_composed_its_keeps_tuple_node_state(self): + mtg = MTG([self.step_1, self.step_2], mappings=[{1: 1, 2: 2}]) + + composed = mtg.get_compose_its() + + self.assertEqual(composed.nodes[1]["hcount"], (2, 2)) + self.assertEqual(composed.nodes[1]["lone_pairs"], (1, 0)) + self.assertEqual(composed.edges[1, 2]["order"], (1.0, 1.0)) + self.assertEqual(composed.edges[1, 2]["kekule_order"], (1.0, 1.0)) + self.assertEqual(composed.edges[1, 2]["sigma_order"], (1.0, 1.0)) + self.assertEqual(composed.edges[1, 2]["pi_order"], (0.0, 0.0)) + + def test_tuple_mtg_round_trips_ordered_its_steps(self): + mtg = MTG([self.step_1, self.step_2]) + + rebuilt = mtg.get_its_steps() + + self.assertEqual(len(rebuilt), 2) + self.assertEqual(rebuilt[0].nodes[1]["hcount"], (2, 1)) + self.assertEqual(rebuilt[0].edges[1, 2]["pi_order"], (0.0, 1.0)) + self.assertEqual(rebuilt[1].nodes[1]["lone_pairs"], (1, 0)) + self.assertEqual(rebuilt[1].edges[1, 2]["pi_order"], (1.0, 0.0)) + + def test_tuple_mtg_keeps_node_timelines(self): + mtg = MTG([self.step_1, self.step_2], mappings=[{1: 1, 2: 2}]) + + graph = mtg.get_mtg() + + self.assertEqual(graph.nodes[1]["element"], "N") + self.assertEqual(graph.nodes[1]["valence_electrons"], 5) + self.assertEqual(graph.nodes[1]["hcount"], (2, 1, 2)) + self.assertEqual(graph.nodes[1]["radical"], (0, 1, 0)) + self.assertEqual(graph.nodes[1]["lone_pairs"], (1, 1, 0)) + self.assertNotIn("typesGH", graph.nodes[1]) + self.assertNotIn("neighbors", graph.nodes[1]) + self.assertNotIn("hcount_history", graph.nodes[1]) + + def test_tuple_mtg_keeps_electron_authoritative_edge_timelines(self): + mtg = MTG([self.step_1, self.step_2], mappings=[{1: 1, 2: 2}]) + + edge = mtg.get_mtg().edges[1, 2] + + self.assertEqual(edge["kekule_order"], (1.0, 2.0, 1.0)) + self.assertEqual(edge["sigma_order"], (1.0, 1.0, 1.0)) + self.assertEqual(edge["pi_order"], (0.0, 1.0, 0.0)) + self.assertNotIn("pi_order_history", edge) + self.assertNotIn("pi_order_step_history", edge) + + +class TestCuratedTupleMTGMechanisms(unittest.TestCase): + @staticmethod + def _atom( + element, + *, + hcount=0, + charge=0, + lone_pairs=0, + radical=0, + valence_electrons=None, + ): + valence = { + "H": 1, + "C": 4, + "N": 5, + "O": 6, + "Cl": 7, + } + return { + "element": element, + "aromatic": False, + "hcount": hcount, + "charge": charge, + "lone_pairs": lone_pairs, + "radical": radical, + "valence_electrons": valence_electrons or valence[element], + } + + @staticmethod + def _add_bond(graph, u, v, sigma=1.0, pi=0.0): + graph.add_edge( + u, + v, + order=sigma + pi, + kekule_order=sigma + pi, + sigma_order=sigma, + pi_order=pi, + ) + + def _graph(self, nodes, edges): + graph = nx.Graph() + for node, attrs in nodes.items(): + graph.add_node(node, **attrs) + for edge in edges: + self._add_bond(graph, *edge) + return refresh_electron_fields(graph) + + def test_lone_pair_donation_history_recomputes_charge_path(self): + g0 = self._graph( + { + 1: self._atom("N", hcount=3, lone_pairs=1), + 2: self._atom("C", hcount=3), + 3: self._atom("Cl", lone_pairs=3), + }, + [(2, 3, 1.0, 0.0)], + ) + g1 = self._graph( + { + 1: self._atom("N", hcount=3, charge=1, lone_pairs=0), + 2: self._atom("C", hcount=3), + 3: self._atom("Cl", charge=-1, lone_pairs=4), + }, + [(1, 2, 1.0, 0.0)], + ) + g2 = self._graph( + { + 1: self._atom("N", hcount=2, lone_pairs=1), + 2: self._atom("C", hcount=3), + 3: self._atom("Cl", hcount=1, lone_pairs=3), + }, + [(1, 2, 1.0, 0.0), (3, 1, 1.0, 0.0)], + ) + + mtg = MTG( + [ITSConstruction.construct(g0, g1), ITSConstruction.construct(g1, g2)], + mappings=[{1: 1, 2: 2, 3: 3}], + ) + + self.assertEqual(mtg.get_mtg().nodes[1]["lone_pairs"], (1, 0, 1)) + self.assertEqual(mtg.get_mtg().nodes[1]["charge"], (0, 1, 0)) + self.assertEqual( + mtg.get_mtg().edges[1, 2]["sigma_order"], + (0.0, 1.0, 1.0), + ) + self.assertEqual(mtg.get_compose_its().edges[1, 2]["sigma_order"], (0.0, 1.0)) + + def test_radical_progression_keeps_unpaired_electron_timeline(self): + g0 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + g1 = self._graph( + { + 1: self._atom("C", hcount=3, radical=1), + 2: self._atom("Cl", radical=1, lone_pairs=3), + }, + [], + ) + g2 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + + mtg = MTG( + [ITSConstruction.construct(g0, g1), ITSConstruction.construct(g1, g2)], + mappings=[{1: 1, 2: 2}], + ) + + self.assertEqual(mtg.get_mtg().nodes[1]["radical"], (0, 1, 0)) + self.assertEqual( + mtg.get_mtg().edges[1, 2]["sigma_order"], + (1.0, 0.0, 1.0), + ) + self.assertEqual(mtg.get_compose_its().edges[1, 2]["sigma_order"], (1.0, 1.0)) + + def test_rsmi_tuple_mtg_composes_back_to_outer_states(self): + step_1 = rsmi_to_its( + "[CH2:1]=[CH2:2].[H:3][H:4]>>[CH3:1][CH2:2][H:4]", + format="tuple", + ) + step_2 = rsmi_to_its( + "[CH3:1][CH2:2][H:4]>>[CH3:1][CH3:2]", + format="tuple", + ) + + mtg = MTG([step_1, step_2], mappings=[{1: 1, 2: 2, 4: 4}]) + composed = mtg.get_compose_its() + + left = ITSReverter(composed).to_reactant_graph() + right = ITSReverter(composed).to_product_graph() + + self.assertTrue(left.has_edge(1, 2)) + self.assertTrue(right.has_edge(1, 2)) + self.assertEqual(composed.edges[1, 2]["pi_order"], (1.0, 0.0)) + self.assertEqual(composed.nodes[2]["hcount"], (2, 3)) + + def test_mech_fixture_round_trips_ordered_tuple_rsmi_steps(self): + data = load_database("./Data/Testcase/mech.json.gz") + mech = data[0]["mechanisms"][1] + steps = [step["smart_string"] for step in mech["steps"]] + its_steps = [rsmi_to_its(step, format="tuple", core=False) for step in steps] + mtg = MTG(its_steps) + rebuilt = mtg.get_its_steps() + + self.assertEqual(len(rebuilt), len(its_steps)) + for original, recovered in zip(its_steps, rebuilt): + self.assertEqual(set(original.nodes()), set(recovered.nodes())) + self.assertEqual( + {tuple(sorted(edge)) for edge in original.edges()}, + {tuple(sorted(edge)) for edge in recovered.edges()}, + ) + + for node in original.nodes(): + for key in ( + "element", + "atom_map", + "hcount", + "charge", + "radical", + "lone_pairs", + "valence_electrons", + ): + self.assertEqual( + recovered.nodes[node].get(key), + original.nodes[node].get(key), + ) + for u, v in original.edges(): + edge = tuple(sorted((u, v))) + for key in ("order", "kekule_order", "sigma_order", "pi_order"): + self.assertEqual( + recovered.edges[edge].get(key), + original.edges[edge].get(key), + ) + + exported = mtg.get_rsmi_steps() + self.assertEqual(len(exported), len(steps)) + self.assertTrue(all(">>" in step for step in exported)) + + def test_mech_fixture_tuple_mtg_automatic_mapping_matches_identity_mapping(self): + data = load_database("./Data/Testcase/mech.json.gz") + mech = data[0]["mechanisms"][1] + its_steps = [ + rsmi_to_its(step["smart_string"], format="tuple", core=False) + for step in mech["steps"] + ] + identity_mappings = [ + {n: n for n in sorted(set(left.nodes()) & set(right.nodes()))} + for left, right in zip(its_steps, its_steps[1:]) + ] + + auto = MTG(its_steps) + explicit = MTG(its_steps, mappings=identity_mappings) + + self.assertEqual(auto.node_mapping, explicit.node_mapping) + self.assertEqual( + set(auto.get_mtg().edges()), + set(explicit.get_mtg().edges()), + ) + + def test_all_mech_fixture_mechanisms_round_trip_ordered_tuple_its(self): + data = load_database("./Data/Testcase/mech.json.gz") + + for mech in data[0]["mechanisms"]: + with self.subTest(mech=mech["mech_name"]): + its_steps = [ + rsmi_to_its(step["smart_string"], format="tuple", core=False) + for step in mech["steps"] + ] + rebuilt = MTG(its_steps).get_its_steps() + + self.assertEqual(len(rebuilt), len(its_steps)) + for original, recovered in zip(its_steps, rebuilt): + self.assertEqual(set(original.nodes()), set(recovered.nodes())) + self.assertEqual( + {tuple(sorted(edge)) for edge in original.edges()}, + {tuple(sorted(edge)) for edge in recovered.edges()}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Graph/Matcher/test_graph_matcher.py b/Test/Graph/Matcher/test_graph_matcher.py index 996b017..6c58d5d 100644 --- a/Test/Graph/Matcher/test_graph_matcher.py +++ b/Test/Graph/Matcher/test_graph_matcher.py @@ -148,6 +148,34 @@ def test_edge_attribute_mismatch(self): g2.nodes["b"]["charge"] = 0 self.assertFalse(self.gm.isomorphic(g1, g2)) + def test_lone_pairs_use_host_greater_or_equal_semantics(self): + host = nx.Graph() + host.add_node(1, element="O", lone_pairs=3, radical=0, hcount=0) + + pattern = nx.Graph() + pattern.add_node(10, element="O", lone_pairs=2, radical=0, hcount=0) + + gm = GraphMatcherEngine( + node_attrs=["element", "lone_pairs", "radical"], + edge_attrs=[], + max_mappings=None, + ) + self.assertEqual(gm.get_mappings(host, pattern), [{10: 1}]) + + def test_radical_requires_exact_match(self): + host = nx.Graph() + host.add_node(1, element="O", lone_pairs=3, radical=1, hcount=0) + + pattern = nx.Graph() + pattern.add_node(10, element="O", lone_pairs=2, radical=0, hcount=0) + + gm = GraphMatcherEngine( + node_attrs=["element", "lone_pairs", "radical"], + edge_attrs=[], + max_mappings=None, + ) + self.assertEqual(gm.get_mappings(host, pattern), []) + def test_available_backends(self): # available_backends should list at least 'nx' backends = GraphMatcherEngine.available_backends() diff --git a/Test/Graph/Matcher/test_subgraph_matcher.py b/Test/Graph/Matcher/test_subgraph_matcher.py index 6f27674..28c9806 100644 --- a/Test/Graph/Matcher/test_subgraph_matcher.py +++ b/Test/Graph/Matcher/test_subgraph_matcher.py @@ -1,8 +1,15 @@ import unittest +import networkx as nx from synkit.IO.data_io import load_from_pickle from synkit.IO.chem_converter import rsmi_to_its from synkit.Graph.ITS.its_decompose import get_rc -from synkit.Graph.Matcher.subgraph_matcher import SubgraphMatch, SubgraphSearchEngine +from synkit.Graph.Matcher.subgraph_matcher import ( + SubgraphMatch, + SubgraphSearchEngine, + diagnose_candidate_node_match, + electron_aware_edge_match, + resolve_template_match_attrs, +) # Determine if the rule backend is available try: @@ -131,6 +138,134 @@ def test_graph_subgraph_morphism_false(self): ) self.assertEqual(len(mapping), 0) + def test_electron_aware_node_matching(self): + host = nx.Graph() + host.add_node(1, element="O", lone_pairs=3, radical=0, hcount=1) + + pattern = nx.Graph() + pattern.add_node(10, element="O", lone_pairs=2, radical=0, hcount=0) + + matches = self.gm.find_subgraph_mappings( + host, + pattern, + node_attrs=["element", "lone_pairs", "radical"], + edge_attrs=[], + ) + self.assertEqual(matches, [{10: 1}]) + + def test_electron_aware_node_matching_rejects_low_lone_pairs(self): + host = nx.Graph() + host.add_node(1, element="O", lone_pairs=1, radical=0, hcount=0) + + pattern = nx.Graph() + pattern.add_node(10, element="O", lone_pairs=2, radical=0, hcount=0) + + matches = self.gm.find_subgraph_mappings( + host, + pattern, + node_attrs=["element", "lone_pairs", "radical"], + edge_attrs=[], + ) + self.assertEqual(matches, []) + + def test_resolve_template_match_attrs_keeps_legacy_template_legacy(self): + pattern = nx.Graph() + pattern.add_node(1, element="O", charge=0) + pattern.add_edge(1, 2, order=1.0) + + node_attrs, edge_attrs = resolve_template_match_attrs(pattern) + + self.assertEqual(node_attrs, ["element", "charge"]) + self.assertEqual(edge_attrs, ["order"]) + + def test_resolve_template_match_attrs_uses_new_template_fields(self): + pattern = nx.Graph() + pattern.add_node( + 1, + element="O", + charge=0, + aromatic=False, + hcount=0, + lone_pairs=2, + radical=0, + ) + pattern.add_node( + 2, + element="C", + charge=0, + aromatic=False, + hcount=3, + lone_pairs=0, + radical=0, + ) + pattern.add_edge(1, 2, order=2.0, sigma_order=1.0, pi_order=1.0) + + node_attrs, edge_attrs = resolve_template_match_attrs(pattern) + + self.assertEqual( + node_attrs, + [ + "element", + "charge", + "aromatic", + "hcount", + "lone_pairs", + "radical", + ], + ) + self.assertEqual(edge_attrs, ["order", "sigma_order", "pi_order"]) + + def test_resolve_template_match_attrs_uses_aromatic_n_pi_role(self): + pattern = nx.Graph() + pattern.add_node( + 1, + element="N", + charge=0, + aromatic=True, + hcount=0, + lone_pairs=1, + radical=0, + aromatic_n_pi_count=1, + ) + + node_attrs, _ = resolve_template_match_attrs(pattern) + + self.assertIn("aromatic_n_pi_count", node_attrs) + + def test_diagnose_candidate_node_match_reports_electron_reason(self): + diagnostic = diagnose_candidate_node_match( + {"element": "O", "lone_pairs": 1, "radical": 0}, + {"element": "O", "lone_pairs": 2, "radical": 1}, + ["element", "lone_pairs", "radical"], + ) + + self.assertFalse(diagnostic["matched"]) + self.assertEqual( + diagnostic["reasons"], + [ + "lone_pairs: host 1 < pattern 2", + "radical: host 0 != pattern 1", + ], + ) + + def test_electron_aware_edge_matching_ignores_aromatic_kekule_phase(self): + self.assertTrue( + electron_aware_edge_match( + {"order": 1.5, "sigma_order": 1.0, "pi_order": 1.0}, + {"order": 1.5, "sigma_order": 1.0, "pi_order": 0.0}, + ["order", "sigma_order", "pi_order"], + ) + ) + + def test_electron_aware_edge_matching_keeps_non_aromatic_sigma_pi_exact(self): + self.assertFalse( + electron_aware_edge_match( + {"order": 2.0, "sigma_order": 1.0, "pi_order": 1.0}, + {"order": 2.0, "sigma_order": 1.0, "pi_order": 0.0}, + ["order", "sigma_order", "pi_order"], + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/Test/Graph/Mech/test_conversion.py b/Test/Graph/Mech/test_conversion.py new file mode 100644 index 0000000..4f86560 --- /dev/null +++ b/Test/Graph/Mech/test_conversion.py @@ -0,0 +1,33 @@ +import networkx as nx + +from synkit.Graph.Mech.conversion import ( + extract_atom_maps_from_smiles, + typed_convert_arrow_code, +) + + +class CountingGraph(nx.Graph): + def __init__(self): + super().__init__() + self.nodes_calls = 0 + + def nodes(self, *args, **kwargs): + self.nodes_calls += 1 + return super().nodes(*args, **kwargs) + + +def test_extract_atom_maps_from_smiles(): + assert extract_atom_maps_from_smiles("[CH:10][N+:61]") == [10, 61] + + +def test_typed_convert_arrow_code_reuses_atom_map_index(): + its = CountingGraph() + its.add_node("a", atom_map=1) + its.add_node("b", atom_map=2) + its.add_edge("a", "b", order=(1.0, 2.0)) + + assert typed_convert_arrow_code("1=1,2;1,2=1", its) == [ + ["LP-/Pi+", [1], [1, 2]], + ["Sigma-/LP+", [1, 2], [1]], + ] + assert its.nodes_calls == 1 diff --git a/Test/Graph/Mech/test_electron_accounting.py b/Test/Graph/Mech/test_electron_accounting.py new file mode 100644 index 0000000..b1614cb --- /dev/null +++ b/Test/Graph/Mech/test_electron_accounting.py @@ -0,0 +1,170 @@ +import unittest + +import networkx as nx +from rdkit import Chem + +from synkit.Graph.Mech.electron_accounting import ( + bond_order_sum, + graph_to_sanitized_kekule_mol, + recompute_charge, + refresh_electron_fields, +) + + +class TestElectronAccounting(unittest.TestCase): + @staticmethod + def _graph_from_kekule_smiles(smiles): + mol = Chem.MolFromSmiles(smiles) + kekule = Chem.Mol(mol) + Chem.Kekulize(kekule, clearAromaticFlags=True) + + valence_electrons = { + "C": 4, + "N": 5, + "O": 6, + } + graph = nx.Graph() + for atom in kekule.GetAtoms(): + graph.add_node( + atom.GetIdx(), + element=atom.GetSymbol(), + charge=atom.GetFormalCharge(), + hcount=atom.GetTotalNumHs(), + lone_pairs=0, + radical=atom.GetNumRadicalElectrons(), + valence_electrons=valence_electrons[atom.GetSymbol()], + ) + for bond in kekule.GetBonds(): + order = bond.GetBondTypeAsDouble() + graph.add_edge( + bond.GetBeginAtomIdx(), + bond.GetEndAtomIdx(), + sigma_order=1.0, + pi_order=order - 1.0, + ) + return graph + + def test_refresh_recomputes_kekule_order_and_charge(self): + graph = nx.Graph() + graph.add_node( + 1, + element="O", + charge=0, + hcount=0, + lone_pairs=2, + radical=0, + valence_electrons=6, + ) + graph.add_node( + 2, + element="C", + charge=0, + hcount=2, + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + graph.add_edge(1, 2, sigma_order=1.0, pi_order=1.0) + + refreshed = refresh_electron_fields(graph) + + self.assertEqual(refreshed.edges[1, 2]["kekule_order"], 2.0) + self.assertEqual(bond_order_sum(refreshed, 1), 2.0) + self.assertEqual(recompute_charge(refreshed, 1), 0.0) + self.assertFalse(refreshed.nodes[1]["charge_mismatch"]) + + def test_refresh_detects_charge_mismatch(self): + graph = nx.Graph() + graph.add_node( + 1, + element="O", + charge=0, + hcount=1, + lone_pairs=3, + radical=0, + valence_electrons=6, + ) + graph.add_node( + 2, + element="H", + charge=0, + hcount=0, + lone_pairs=0, + radical=0, + valence_electrons=1, + ) + graph.add_edge(1, 2, sigma_order=1.0, pi_order=0.0) + + refreshed = refresh_electron_fields(graph) + + self.assertEqual(refreshed.nodes[1]["recomputed_charge"], -2.0) + self.assertTrue(refreshed.nodes[1]["charge_mismatch"]) + + def test_radical_count_prevents_false_charge_on_hydroxyl_radical(self): + graph = nx.Graph() + graph.add_node( + 1, + element="O", + charge=0, + hcount=1, + lone_pairs=2, + radical=1, + valence_electrons=6, + ) + + refreshed = refresh_electron_fields(graph) + + self.assertEqual(refreshed.nodes[1]["recomputed_charge"], 0) + self.assertFalse(refreshed.nodes[1]["charge_mismatch"]) + + def test_radical_count_prevents_false_charge_on_methyl_radical(self): + graph = nx.Graph() + graph.add_node( + 1, + element="C", + charge=0, + hcount=3, + lone_pairs=0, + radical=1, + valence_electrons=4, + ) + + refreshed = refresh_electron_fields(graph) + + self.assertEqual(refreshed.nodes[1]["recomputed_charge"], 0) + self.assertFalse(refreshed.nodes[1]["charge_mismatch"]) + + def test_kekule_reconstruction_reperceives_aromatic_examples(self): + cases = { + "c1ccccc1": "c1ccccc1", + "n1ccccc1": "c1ccncc1", + "[nH]1cccc1": "c1cc[nH]c1", + "o1cccc1": "c1ccoc1", + "[nH+]1ccccc1": "c1cc[nH+]cc1", + } + + for input_smiles, expected_smiles in cases.items(): + with self.subTest(smiles=input_smiles): + graph = self._graph_from_kekule_smiles(input_smiles) + mol = graph_to_sanitized_kekule_mol(graph) + + self.assertEqual(Chem.MolToSmiles(mol), expected_smiles) + self.assertTrue(all(bond.GetIsAromatic() for bond in mol.GetBonds())) + + def test_kekule_reconstruction_does_not_invent_aromaticity(self): + cases = { + "C=CC=C": "C=CC=C", + "C1=CC=CCC1": "C1=CCCC=C1", + } + + for input_smiles, expected_smiles in cases.items(): + with self.subTest(smiles=input_smiles): + graph = self._graph_from_kekule_smiles(input_smiles) + mol = graph_to_sanitized_kekule_mol(graph) + + self.assertEqual(Chem.MolToSmiles(mol), expected_smiles) + self.assertFalse(any(bond.GetIsAromatic() for bond in mol.GetBonds())) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Graph/test_canon_graph.py b/Test/Graph/test_canon_graph.py index 0c0c164..0e6ad21 100644 --- a/Test/Graph/test_canon_graph.py +++ b/Test/Graph/test_canon_graph.py @@ -46,6 +46,25 @@ def test_canonical_signature_difference(self): sigG, sigH, "Different graphs should have different signatures" ) + def test_electron_state_changes_canonical_signature(self): + neutral = nx.Graph() + neutral.add_node(1, element="N", charge=0, lone_pairs=1, radical=0) + + radical = nx.Graph() + radical.add_node(1, element="N", charge=0, lone_pairs=1, radical=1) + + lone_pair_changed = nx.Graph() + lone_pair_changed.add_node(1, element="N", charge=0, lone_pairs=0, radical=0) + + self.assertNotEqual( + self.canon.canonical_signature(neutral), + self.canon.canonical_signature(radical), + ) + self.assertNotEqual( + self.canon.canonical_signature(neutral), + self.canon.canonical_signature(lone_pair_changed), + ) + def test_make_canonical_graph_structure(self): G_can = self.canon.make_canonical_graph(self.G_swapped) # Canonical graph should have nodes labeled 1 and 2 diff --git a/Test/IO/test_chemical_converter.py b/Test/IO/test_chemical_converter.py index 52a75f4..d0f32e5 100644 --- a/Test/IO/test_chemical_converter.py +++ b/Test/IO/test_chemical_converter.py @@ -235,6 +235,14 @@ def test_rsmi_to_rc(self): rc = rsmi_to_its(smart, core=True) self.assertFalse(graph_isomorphism(its, rc)) + def test_tuple_rsmi_to_rc(self): + smart = "[CH3:5][CH:1]=[CH2:2].[H:3][H:4]>>[CH3:5][CH2:1][CH3:2]" + its = rsmi_to_its(smart, format="tuple") + rc = rsmi_to_its(smart, core=True, format="tuple") + + self.assertFalse(graph_isomorphism(its, rc)) + self.assertIn("radical", next(iter(its.nodes(data=True)))[1]) + def test_its_to_rsmi(self): smart = "[CH3:5][CH:1]=[CH2:2].[H:3][H:4]>>[CH3:5][CH:1]([H:3])[CH2:2][H:4]" its = rsmi_to_its(smart) @@ -244,6 +252,39 @@ def test_its_to_rsmi(self): CanonRSMI().canonicalise(new_smart).canonical_rsmi, ) + def test_tuple_its_to_rsmi(self): + smart = "[CH3:5][CH:1]=[CH2:2].[H:3][H:4]>>[CH3:5][CH2:1][CH3:2]" + its = rsmi_to_its(smart, format="tuple") + new_smart = its_to_rsmi(its, format="tuple") + + self.assertEqual( + CanonRSMI().canonicalise(smart).canonical_rsmi, + CanonRSMI().canonicalise(new_smart).canonical_rsmi, + ) + + def test_tuple_its_to_rsmi_reperceives_aromatic_product(self): + smart = "[CH2:1]1[CH:2]=[CH:3][CH:4]=[CH:5][CH2:6]1>>[cH:1]1[cH:2][cH:3][cH:4][cH:5][cH:6]1" + its = rsmi_to_its(smart, format="tuple") + new_smart = its_to_rsmi(its, format="tuple") + + self.assertEqual( + CanonRSMI().canonicalise(smart).canonical_rsmi, + CanonRSMI().canonicalise(new_smart).canonical_rsmi, + ) + + def test_tuple_rsmi_to_its_explicit_hydrogen(self): + smart = "[CH3:1][CH2:2]>>[CH2:1]=[CH2:2]" + its = rsmi_to_its(smart, explicit_hydrogen=True, format="tuple") + + hydrogens = [ + attrs + for _, attrs in its.nodes(data=True) + if attrs.get("element") == ("H", "H") + ] + + self.assertTrue(hydrogens) + self.assertTrue(any(attrs["present"] != (True, True) for attrs in hydrogens)) + def test_rsmi_to_rsmarts_and_back(self): rsmi = "[H:3][O:4].[N:1][C:2]>>[C:2][O:4].[N:1][H:3]" diff --git a/Test/IO/test_graph_to_mol.py b/Test/IO/test_graph_to_mol.py index 5cdf84a..9608a0c 100644 --- a/Test/IO/test_graph_to_mol.py +++ b/Test/IO/test_graph_to_mol.py @@ -54,6 +54,25 @@ def test_molecule_with_charges(self): mol = self.converter.graph_to_mol(graph) self.assertEqual(Chem.CanonSmiles(Chem.MolToSmiles(mol)), "[NH4+]") + def test_molecule_with_radical(self): + graph = nx.Graph() + graph.add_node(0, element="C", charge=0, radical=1) + + mol = self.converter.graph_to_mol(graph, sanitize=False) + + self.assertEqual(mol.GetAtomWithIdx(0).GetNumRadicalElectrons(), 1) + + def test_aromatic_order_is_reperceived_on_sanitized_output(self): + graph = nx.cycle_graph(6) + nx.set_node_attributes(graph, "C", "element") + nx.set_node_attributes(graph, 0, "charge") + nx.set_edge_attributes(graph, 1.5, "order") + + mol = self.converter.graph_to_mol(graph) + + self.assertEqual(Chem.MolToSmiles(mol), "c1ccccc1") + self.assertTrue(all(bond.GetIsAromatic() for bond in mol.GetBonds())) + if __name__ == "__main__": unittest.main() diff --git a/Test/IO/test_mol_to_graph.py b/Test/IO/test_mol_to_graph.py index 8d827f4..c262a8c 100644 --- a/Test/IO/test_mol_to_graph.py +++ b/Test/IO/test_mol_to_graph.py @@ -90,6 +90,7 @@ def test_transform_node_keys_minimal(self): "aromatic", "radical", "lone_pairs", + "valence_electrons", "available_lp", "oxidation_state", ): @@ -112,6 +113,8 @@ def test_transform_edge_keys(self): "bond_type", "aromatic", "kekule_order", + "sigma_order", + "pi_order", "kekule_bond_type", "ez_isomer", "conjugated", @@ -157,6 +160,28 @@ def test_transform_edge_whitelist(self): for _, _, data in g.edges(data=True): self.assertEqual(set(data.keys()), {"order"}) + def test_sigma_pi_order_split_matches_kekule_order(self): + mol = Chem.MolFromSmiles("C#CC=C") + g = MolToGraph().transform(mol) + for _, _, data in g.edges(data=True): + self.assertEqual( + data["kekule_order"], + data["sigma_order"] + data["pi_order"], + ) + + def test_aromatic_bonds_preserve_matching_and_rewrite_views(self): + mol = Chem.MolFromSmiles("c1ccccc1") + g = MolToGraph().transform(mol) + + self.assertEqual({data["order"] for _, _, data in g.edges(data=True)}, {1.5}) + self.assertEqual( + { + (data["kekule_order"], data["sigma_order"], data["pi_order"]) + for _, _, data in g.edges(data=True) + }, + {(1.0, 1.0, 0.0), (2.0, 1.0, 1.0)}, + ) + def test_drop_non_aam_requires_use_index(self): with self.assertRaises(ValueError): self.converter.transform( @@ -239,6 +264,66 @@ def test_radical_in_light_weight_graph(self): for _, data in g.nodes(data=True): self.assertIn("radical", data) + # ------------------------------------------------------------------ + # Lone-pair chemistry audit + # ------------------------------------------------------------------ + + def test_lone_pair_audit_matrix(self): + cases = { + "O": [("O", 2)], + "[OH-]": [("O", 3)], + "N": [("N", 1)], + "[NH4+]": [("N", 0)], + "[Cl-]": [("Cl", 4)], + "n1ccccc1": [("N", 1)], + "[nH]1cccc1": [("N", 1)], + "C=O": [("O", 2)], + "[CH3]": [("C", 0)], + "S": [("S", 2)], + "[SH-]": [("S", 3)], + "[SH3+]": [("S", 1)], + "P": [("P", 1)], + "[PH4+]": [("P", 0)], + "P(=O)(O)(O)O": [("P", 0)], + "S(=O)(=O)(O)O": [("S", 0)], + } + + for smiles, expected_atoms in cases.items(): + with self.subTest(smiles=smiles): + mol = Chem.MolFromSmiles(smiles) + observed = [ + (atom.GetSymbol(), MolToGraph.estimate_lone_pairs(atom)) + for atom in mol.GetAtoms() + if atom.GetSymbol() in {symbol for symbol, _ in expected_atoms} + ] + self.assertEqual(observed[: len(expected_atoms)], expected_atoms) + + def test_lone_pairs_match_for_explicit_and_implicit_hydrogen_forms(self): + equivalent_pairs = [ + ("O", "[OH2]"), + ("N", "[NH3]"), + ("[nH]1cccc1", "[n]1([H])cccc1"), + ("Oc1ccccc1", "[OH]c1ccccc1"), + ("Nc1ccccc1", "[NH2]c1ccccc1"), + ] + + for implicit_smiles, explicit_smiles in equivalent_pairs: + with self.subTest( + implicit_smiles=implicit_smiles, + explicit_smiles=explicit_smiles, + ): + implicit_mol = Chem.MolFromSmiles(implicit_smiles) + explicit_mol = Chem.MolFromSmiles(explicit_smiles) + implicit_values = [ + MolToGraph.estimate_lone_pairs(atom) + for atom in implicit_mol.GetAtoms() + ] + explicit_values = [ + MolToGraph.estimate_lone_pairs(atom) + for atom in explicit_mol.GetAtoms() + ] + self.assertEqual(implicit_values, explicit_values) + # ------------------------------------------------------------------ # Lone-pair estimation # ------------------------------------------------------------------ @@ -255,6 +340,14 @@ def test_estimate_lone_pairs_pyrrolic_n(self): lp = MolToGraph.estimate_lone_pairs(n_atom) self.assertGreater(lp, 0) + def test_estimate_lone_pairs_fused_aromatic_bridgehead_n(self): + mol = Chem.MolFromSmiles("c1nc2cnccn2c1") + n_atoms = [atom for atom in mol.GetAtoms() if atom.GetSymbol() == "N"] + self.assertEqual( + [MolToGraph.estimate_lone_pairs(atom) for atom in n_atoms], + [1, 1, 1], + ) + def test_estimate_available_lone_pairs_pyrrolic_n_zero(self): # [nH] lone pair is conjugated into the ring — not available for donation mol = Chem.MolFromSmiles("c1cc[nH]c1") diff --git a/Test/Rule/Apply/test_rule_matcher.py b/Test/Rule/Apply/test_rule_matcher.py index e9649be..b8a5fae 100644 --- a/Test/Rule/Apply/test_rule_matcher.py +++ b/Test/Rule/Apply/test_rule_matcher.py @@ -31,6 +31,16 @@ def test_rule_match_balance(self): # The returned rule graph should be isomorphic to the input rule self.assertTrue(nx.is_isomorphic(returned_rule, rule)) + def test_tuple_rule_match_balance(self): + input_rsmi = "CC[CH2:3][Cl:1].[NH2:2][H:4]>>CC[CH2:3][NH2:2].[Cl:1][H:4]" + rule = rsmi_to_its(input_rsmi, core=True, format="tuple") + rsmi_std = Standardize().fit(input_rsmi) + matcher = RuleMatcher(rsmi_std, rule) + smarts, returned_rule = matcher.get_result() + + self.assertEqual(Standardize().fit(smarts), rsmi_std) + self.assertIs(returned_rule, rule) + def test_rbl_missing_product(self): """Partial (RBL) match when product fragments are missing in rule.""" rsmi = "CC(Br)C.CB(O)O>>CC(C)C" @@ -89,6 +99,17 @@ def test_help_output(self): self.assertIn("RuleMatcher for RSMI", out) self.assertIn("Candidate SMARTS patterns:", out) + def test_diagnostics_passthrough_is_opt_in(self): + input_rsmi = "CC[CH2:3][Cl:1].[NH2:2][H:4]>>CC[CH2:3][NH2:2].[Cl:1][H:4]" + rule = rsmi_to_its(input_rsmi, core=True) + matcher = RuleMatcher( + Standardize().fit(input_rsmi), + rule, + electron_diagnostics=True, + ) + + self.assertTrue(matcher.diagnostics) + if __name__ == "__main__": unittest.main() diff --git a/Test/Rule/test_syn_rule.py b/Test/Rule/test_syn_rule.py index 497694a..674e906 100644 --- a/Test/Rule/test_syn_rule.py +++ b/Test/Rule/test_syn_rule.py @@ -94,6 +94,43 @@ def test_str_repr(self): self.assertIn("left=(|V|=", r) self.assertIn("right=(|V|=", r) + def test_tuple_rule_preserves_tuple_representation(self): + smart = "[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]" + + rule = SynRule.from_smart( + smart, + canon=False, + implicit_h=False, + format="tuple", + ) + + self.assertEqual(rule._format, "tuple") + self.assertEqual(rule.rc.raw.nodes[1]["element"], ("C", "C")) + self.assertEqual(rule.rc.raw.edges[1, 2]["pi_order"], (0.0, 1.0)) + self.assertEqual(rule.left.raw.edges[1, 2]["pi_order"], 0.0) + self.assertEqual(rule.right.raw.edges[1, 2]["pi_order"], 1.0) + + def test_tuple_rule_implicit_h_strips_removable_explicit_hydrogens(self): + smart = "[CH3:1][Cl:2].[O:3]([H:4])[H:5]>>[CH3:1][O:3][H:4].[Cl:2][H:5]" + rule = SynRule.from_smart( + smart, + canon=False, + implicit_h=True, + format="tuple", + ) + + self.assertFalse( + any(data["element"] == "H" for _, data in rule.left.raw.nodes(data=True)) + ) + self.assertFalse( + any(data["element"] == "H" for _, data in rule.right.raw.nodes(data=True)) + ) + self.assertEqual(rule.rc.raw.nodes[1]["hcount"], (0, 0)) + self.assertEqual(rule.rc.raw.nodes[2]["hcount"], (0, 1)) + self.assertEqual(rule.rc.raw.nodes[3]["hcount"], (2, 1)) + self.assertTrue(rule.rc.raw.nodes[2]["h_pairs"]) + self.assertTrue(rule.rc.raw.nodes[3]["h_pairs"]) + if __name__ == "__main__": unittest.main() diff --git a/Test/Synthesis/Reactor/test_imba_engine.py b/Test/Synthesis/Reactor/test_imba_engine.py index 4e895bf..5ee5962 100644 --- a/Test/Synthesis/Reactor/test_imba_engine.py +++ b/Test/Synthesis/Reactor/test_imba_engine.py @@ -60,7 +60,7 @@ def test_pipeline_backward(self): partial=True, ) out = engine.smarts_list - self.assertEqual(len(out), 3) + self.assertEqual(len(out), 2) out_rsmi = Standardize().fit(out[0], remove_aam=True) self.assertIn("*", out_rsmi) self.assertNotEqual(out_rsmi, self.rsmi) @@ -76,7 +76,7 @@ def test_pipeline_backward(self): ) out_clean = engine_clean.smarts_list - self.assertEqual(len(out_clean), 3) + self.assertEqual(len(out_clean), 2) # outs = [Standardize().fit(o, remove_aam=True) for o in out_clean] # self.assertIn(self.rsmi, outs) @@ -89,6 +89,18 @@ def test_invalid_rsmi(self): wild = WildCard().rsmi_with_wildcards(rsmi) _ = rsmi_to_its(wild, core=True) + def test_diagnostics_passthrough_is_opt_in(self): + engine = ImbaEngine( + "[CH3:1][CH3:2]", + "[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]", + add_wildcard=False, + electron_diagnostics=True, + ) + + self.assertEqual(len(engine.smarts_list), 1) + self.assertEqual(len(engine.diagnostics), 1) + self.assertEqual(engine.diagnostics[0]["mismatch_count"], 0) + if __name__ == "__main__": unittest.main() diff --git a/Test/Synthesis/Reactor/test_partial_engine.py b/Test/Synthesis/Reactor/test_partial_engine.py index b3b1422..3400a5b 100644 --- a/Test/Synthesis/Reactor/test_partial_engine.py +++ b/Test/Synthesis/Reactor/test_partial_engine.py @@ -43,6 +43,18 @@ def test_backward_direction_example(self): ] self.assertEqual(result, expected) + def test_diagnostics_passthrough_is_opt_in(self): + engine = PartialEngine( + "CCC(=O)OC", + "[C:1][O:2].[O:3][H:4]>>[C:1][O:3].[O:2][H:4]", + electron_diagnostics=True, + ) + + result = engine.fit(invert=False) + + self.assertTrue(result) + self.assertTrue(engine.diagnostics) + if __name__ == "__main__": unittest.main() diff --git a/Test/Synthesis/Reactor/test_rbl_engine.py b/Test/Synthesis/Reactor/test_rbl_engine.py index 13bb67a..b8fca4a 100644 --- a/Test/Synthesis/Reactor/test_rbl_engine.py +++ b/Test/Synthesis/Reactor/test_rbl_engine.py @@ -126,6 +126,20 @@ def test_repr_contains_key_info(self) -> None: self.assertIn("wildcard_element='X*'", rep) self.assertIn("reactor_cls=", rep) + def test_diagnostics_are_grouped_by_reactor_stage(self) -> None: + engine = RBLEngine(early_stop=False, electron_diagnostics=True) + engine.process( + "CCC(=O)OC>>CCC(=O)OCC", + "[C:1][O:2].[O:3][H:4]>>[C:1][O:3].[O:2][H:4]", + ) + + self.assertEqual( + set(engine.diagnostics), {"forward", "backward", "quick_check"} + ) + self.assertTrue(engine.diagnostics["forward"]) + self.assertTrue(engine.diagnostics["backward"]) + self.assertIn("diagnostics", engine.result) + def test_quick_check_short_circuits_pipeline(self) -> None: """ When early_stop=True and _quick_check succeeds, process() diff --git a/Test/Synthesis/Reactor/test_rule_filter.py b/Test/Synthesis/Reactor/test_rule_filter.py new file mode 100644 index 0000000..f407412 --- /dev/null +++ b/Test/Synthesis/Reactor/test_rule_filter.py @@ -0,0 +1,22 @@ +import unittest + +from synkit.IO.chem_converter import rsmi_to_graph, rsmi_to_its +from synkit.Synthesis.Reactor.rule_filter import RuleFilter + + +class TestRuleFilter(unittest.TestCase): + def test_tuple_rule_uses_tuple_decomposition(self): + host, _ = rsmi_to_graph("[CH3:1][Cl:2]>>[CH3:1][Cl:2]") + rule = rsmi_to_its( + "[CH3:1][Cl:2]>>[CH3:1].[Cl:2]", + core=True, + format="tuple", + ) + + filtered = RuleFilter(host, [rule], engine="nx") + + self.assertEqual(filtered.new_rules, [rule]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Synthesis/Reactor/test_syn_reactor_bug_cases.py b/Test/Synthesis/Reactor/test_syn_reactor_bug_cases.py new file mode 100644 index 0000000..828a120 --- /dev/null +++ b/Test/Synthesis/Reactor/test_syn_reactor_bug_cases.py @@ -0,0 +1,51 @@ +import unittest + +from synkit.Chem.Reaction.standardize import Standardize +from synkit.IO.chem_converter import rsmi_to_its +from synkit.Synthesis.Reactor.syn_reactor import SynReactor + + +class TestSynReactorBugCases(unittest.TestCase): + def test_tuple_backward_cross_coupling_keeps_aromatic_role_context(self): + smart = ( + "[CH3:10][CH2:11][O:12][C:13](=[O:14])[c:15]1[cH:16][cH:18][cH:19]" + "[c:20]([B:21]([OH:22])[OH:23])[cH:17]1." + "[CH3:1][c:2]1[cH:3][cH:5][cH:6][c:7]([Br:8])[c:4]1[I:9]" + ">>" + "[CH3:1][c:2]1[cH:3][cH:5][cH:6][c:7]([Br:8])[c:4]1-" + "[c:20]1[cH:17][c:15]([C:13]([O:12][CH2:11][CH3:10])=[O:14])" + "[cH:16][cH:18][cH:19]1.[I:9][B:21]([OH:22])[OH:23]" + ) + expected = Standardize().fit(smart) + reactants, products = expected.split(">>") + rc = rsmi_to_its(smart, core=True, format="tuple") + + forward = SynReactor( + substrate=reactants, + template=rc, + implicit_temp=False, + explicit_h=False, + ) + backward = SynReactor( + substrate=products, + template=rc, + implicit_temp=False, + explicit_h=False, + invert=True, + ) + + forward_smis = [ + Standardize().fit(candidate, remove_aam=True) + for candidate in forward.smarts + ] + backward_smis = [ + Standardize().fit(candidate, remove_aam=True) + for candidate in backward.smarts + ] + + self.assertIn(expected, forward_smis) + self.assertIn(expected, backward_smis) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Synthesis/Reactor/test_syn_reactor_electron_cases.py b/Test/Synthesis/Reactor/test_syn_reactor_electron_cases.py new file mode 100644 index 0000000..bddc3cc --- /dev/null +++ b/Test/Synthesis/Reactor/test_syn_reactor_electron_cases.py @@ -0,0 +1,436 @@ +import unittest + +from synkit.Graph.ITS.its_reverter import ITSReverter +from synkit.Chem.Reaction.aam_validator import AAMValidator +from synkit.Chem.Reaction.standardize import Standardize +from synkit.IO.chem_converter import rsmi_to_its +from synkit.Synthesis.Reactor.syn_reactor import SynReactor + + +class TestSynReactorElectronCases(unittest.TestCase): + @staticmethod + def _has_equivalent_candidate(smart: str, candidates: list[str]) -> bool: + return any( + AAMValidator().smiles_check(smart, candidate) for candidate in candidates + ) + + @staticmethod + def _has_standardized_candidate(smart: str, candidates: list[str]) -> bool: + standardizer = Standardize() + expected = standardizer.fit(smart) + return expected in [ + standardizer.fit(candidate, remove_aam=True) for candidate in candidates + ] + + @staticmethod + def _tuple_reactors( + smart: str, + *, + core: bool, + implicit_temp: bool, + explicit_h: bool, + ) -> tuple[SynReactor, SynReactor]: + substrate, product = Standardize().fit(smart, remove_aam=True).split(">>") + rc = rsmi_to_its(smart, core=core, format="tuple") + return ( + SynReactor( + substrate=substrate, + template=rc, + electron_diagnostics=True, + implicit_temp=implicit_temp, + explicit_h=explicit_h, + ), + SynReactor( + substrate=product, + template=rc, + electron_diagnostics=True, + implicit_temp=implicit_temp, + explicit_h=explicit_h, + invert=True, + ), + ) + + def _assert_bidirectional_equivalent( + self, + smart: str, + *, + core: bool, + implicit_temp: bool, + explicit_h: bool, + ) -> tuple[SynReactor, SynReactor]: + forward, backward = self._tuple_reactors( + smart, + core=core, + implicit_temp=implicit_temp, + explicit_h=explicit_h, + ) + self.assertTrue(forward.smarts) + self.assertTrue(backward.smarts) + self.assertTrue(self._has_equivalent_candidate(smart, forward.smarts)) + self.assertTrue(self._has_equivalent_candidate(smart, backward.smarts)) + return forward, backward + + def _assert_bidirectional_standardized( + self, + smart: str, + *, + core: bool, + implicit_temp: bool, + explicit_h: bool, + ) -> tuple[SynReactor, SynReactor]: + forward, backward = self._tuple_reactors( + smart, + core=core, + implicit_temp=implicit_temp, + explicit_h=explicit_h, + ) + self.assertTrue(forward.smarts) + self.assertTrue(backward.smarts) + self.assertTrue(self._has_standardized_candidate(smart, forward.smarts)) + self.assertTrue(self._has_standardized_candidate(smart, backward.smarts)) + return forward, backward + + def test_lone_pair_donation_recomputes_product_charge(self): + smart = "[NH3:1].[CH3:2][Cl:3]>>[NH3+:1][CH3:2].[Cl-:3]" + rc = rsmi_to_its(smart, core=True, format="tuple") + + # The rewrite should not need the product charge labels from the RC. + # Keep the electron-state changes, but erase direct product charge. + rc.nodes[1]["charge"] = (0, 0) + rc.nodes[3]["charge"] = (0, 0) + n_types = list(rc.nodes[1]["typesGH"]) + cl_types = list(rc.nodes[3]["typesGH"]) + rc.nodes[1]["typesGH"] = ( + n_types[0], + n_types[1][:3] + (0,) + n_types[1][4:], + ) + rc.nodes[3]["typesGH"] = ( + cl_types[0], + cl_types[1][:3] + (0,) + cl_types[1][4:], + ) + + reactor = SynReactor( + "[NH3:1].[CH3:2][Cl:3]", + rc, + implicit_temp=False, + explicit_h=False, + ) + product = ITSReverter(reactor.its_list[0]).to_product_graph() + + self.assertEqual(product.nodes[1]["lone_pairs"], 0) + self.assertEqual(product.nodes[3]["lone_pairs"], 4) + self.assertEqual(product.nodes[1]["charge"], 1.0) + self.assertEqual(product.nodes[3]["charge"], -1.0) + self.assertEqual( + reactor.smarts, + ["[CH3:2][Cl:3].[NH3:1]>>[Cl-:3].[NH3+:1][CH3:2]"], + ) + + reverse_rc = rsmi_to_its(smart, core=True, format="tuple") + backward = SynReactor( + "[NH3+:1][CH3:2].[Cl-:3]", + reverse_rc, + implicit_temp=False, + explicit_h=False, + invert=True, + ) + self.assertTrue(self._has_equivalent_candidate(smart, backward.smarts)) + + def test_tuple_reactor_assigns_fresh_atom_maps_for_unmapped_substrate(self): + smart = "[NH3:1].[CH3:2][Cl:3]>>[NH3+:1][CH3:2].[Cl-:3]" + forward, backward = self._assert_bidirectional_equivalent( + smart, + core=False, + implicit_temp=True, + explicit_h=False, + ) + + for reactor in (forward, backward): + self.assertEqual(len(reactor.smarts), 1) + self.assertTrue( + all( + pair[0] > 0 and pair[1] > 0 + for _, data in reactor.its_list[0].nodes(data=True) + for pair in [data["atom_map"]] + ) + ) + + def test_tuple_reactor_assigns_fresh_atom_maps_to_expanded_hydrogens(self): + smart = "[CH3:1][Cl:2].[O:3]([H:4])[H:5]" ">>[CH3:1][O:3][H:4].[Cl:2][H:5]" + forward, backward = self._assert_bidirectional_standardized( + smart, + core=False, + implicit_temp=False, + explicit_h=True, + ) + + for reactor in (forward, backward): + self.assertEqual(len(reactor.smarts), 1) + self.assertTrue(all("[H]" not in smarts for smarts in reactor.smarts)) + hydrogen_maps = [ + data["atom_map"] + for _, data in reactor.its_list[0].nodes(data=True) + if data["element"] == ("H", "H") + ] + self.assertTrue(hydrogen_maps) + self.assertTrue(all(pair[0] > 0 and pair[1] > 0 for pair in hydrogen_maps)) + + def test_tuple_explicit_h_only_reconstructs_template_explicit_hydrogens(self): + smart = "[H:4][NH2:1].[CH3:2][Cl:3]>>[NH2:1][CH3:2].[Cl:3][H:4]" + forward, backward = self._assert_bidirectional_equivalent( + smart, + core=True, + implicit_temp=False, + explicit_h=True, + ) + + for reactor in (forward, backward): + self.assertEqual(len(reactor.smarts), 1) + self.assertIn("[H:4]", reactor.smarts[0]) + + def test_tuple_implicit_output_omits_mapped_explicit_hydrogens(self): + smart = "[H:4][NH2:1].[CH3:2][Cl:3]>>[NH2:1][CH3:2].[Cl:3][H:4]" + forward, backward = self._tuple_reactors( + smart, + core=True, + implicit_temp=False, + explicit_h=False, + ) + + for reactor in (forward, backward): + self.assertEqual(len(reactor.smarts), 1) + self.assertNotIn("[H:", reactor.smarts[0]) + + def test_tuple_reactor_keeps_removable_hydrogens_implicit_when_requested(self): + smart = "[CH3:1][Cl:2].[O:3]([H:4])[H:5]" ">>[CH3:1][O:3][H:4].[Cl:2][H:5]" + forward, backward = self._tuple_reactors( + smart, + core=False, + implicit_temp=False, + explicit_h=False, + ) + + self.assertEqual( + forward.smarts, ["[CH3:1][Cl:2].[OH2:3]>>[CH3:1][OH:3].[ClH:2]"] + ) + self.assertTrue(self._has_standardized_candidate(smart, backward.smarts)) + self.assertTrue(all("[H:" not in candidate for candidate in backward.smarts)) + + def test_tuple_explicit_h_renders_remaining_water_hydrogens(self): + smart = ( + "[cH:1]1[cH:2][cH:3][cH:4][cH:5][c:6]1[C:7]([H:23])=[O:8]." + "[cH:9]1[cH:10][cH:11][cH:12][cH:13][c:14]1[C:15]([H:19])=[O:16]." + "[C-:17]#[N:18].[O:20]([H:21])[H:22]" + ">>" + "[cH:1]1[cH:2][cH:3][cH:4][cH:5][c:6]1[C:7]([H:23])([O:8][H:21])" + "[C:15](=[O:16])[c:14]1[cH:13][cH:12][cH:11][cH:10][cH:9]1." + "[C-:17]#[N:18].[O:20]([H:19])[H:22]" + ) + forward, backward = self._tuple_reactors( + smart, + core=False, + implicit_temp=False, + explicit_h=True, + ) + + self.assertEqual(len(forward.smarts), 1) + self.assertEqual(len(backward.smarts), 1) + for reactor in (forward, backward): + self.assertTrue(all("[OH:1]" in smarts for smarts in reactor.smarts)) + self.assertTrue(all("[cH:" in smarts for smarts in reactor.smarts)) + atom_maps = [ + pair + for _, data in reactor.its_list[0].nodes(data=True) + if data["element"] == ("H", "H") + for pair in [data["atom_map"]] + ] + self.assertEqual(len(atom_maps), len(set(atom_maps))) + + def test_tuple_explicit_h_has_equivalent_candidate_for_aromatic_case(self): + smart = ( + "[cH:1]1[cH:2][cH:3][cH:4][cH:5][c:6]1[CH:7]=[O:8]." + "[cH:9]1[cH:10][cH:11][cH:12][cH:13][c:14]1[C:15]([H:19])=[O:16]." + "[C-:17]#[N:18].[OH:20]([H:21])>>" + "[cH:1]1[cH:2][cH:3][cH:4][cH:5][c:6]1[CH:7]([O:8][H:21])" + "[C:15](=[O:16])[c:14]1[cH:13][cH:12][cH:11][cH:10][cH:9]1." + "[C-:17]#[N:18].[OH:20]([H:19])" + ) + forward, backward = self._assert_bidirectional_equivalent( + smart, + core=False, + implicit_temp=False, + explicit_h=True, + ) + self.assertTrue(forward.smarts) + self.assertTrue(backward.smarts) + + def test_tuple_hh_reaction_keeps_molecular_hydrogen_explicit(self): + smart = ( + "[C:1](#[C:2][CH3:6])[CH3:5].[H:3][H:4]" + ">>[C:1](=[C:2]([H:4])[CH3:6])([H:3])[CH3:5]" + ) + for explicit_h in (False, True): + with self.subTest(explicit_h=explicit_h): + forward, backward = self._assert_bidirectional_equivalent( + smart, + core=True, + implicit_temp=False, + explicit_h=explicit_h, + ) + + for reactor in (forward, backward): + self.assertTrue( + all("[H:" in candidate for candidate in reactor.smarts) + ) + + def test_tuple_implicit_hh_reaction_consumes_hydrogen_into_hcount(self): + smart = ( + "[C:1](#[C:2][CH3:6])[CH3:5].[H:3][H:4]" ">>[CH:1](=[CH:2][CH3:6])[CH3:5]" + ) + forward, backward = self._assert_bidirectional_equivalent( + smart, + core=True, + implicit_temp=True, + explicit_h=False, + ) + + self.assertTrue(forward.smarts) + self.assertTrue(backward.smarts) + + def test_tuple_implicit_hydrogen_permutations_are_pruned_before_rewrite(self): + smart = ( + "[CH3:1][C:2]#[C:3][CH3:4].[H:5][H:6].[H:7][H:8]" + ">>[CH3:1][CH2:2][CH2:3][CH3:4]" + ) + forward, backward = self._tuple_reactors( + smart, + core=True, + implicit_temp=True, + explicit_h=False, + ) + + # Swapping equivalent H atoms within one H2 or swapping two equivalent + # H2 reagent components is provenance only and is pruned. + self.assertEqual(len(forward.mappings), 2) + self.assertEqual(len(forward.its_list), 1) + self.assertTrue(self._has_standardized_candidate(smart, forward.smarts)) + self.assertTrue(self._has_standardized_candidate(smart, backward.smarts)) + + def test_tuple_real_backward_explicit_h_case_is_reproducible(self): + smart = ( + "[CH3:1][CH2:2][C:3]([CH2:4][CH3:7])([c:5]1[cH:8][cH:10][c:11]" + "([O:12][CH2:14][CH:15]([O:16][Si:18]([CH3:19])([CH3:20])[C:21]" + "([CH3:22])([CH3:23])[CH3:24])[C:17]([CH3:25])([CH3:26])[CH3:27])" + "[c:13]([CH3:28])[cH:9]1)[c:6]1[cH:29][c:31]([CH3:32])[c:33]" + "([CH:34]=[O:35])[s:30]1.[CH3:36][O:37][C:38](=[O:39])[CH2:40]" + "[NH:41][H:42].[H:43][H:44]>>[CH3:1][CH2:2][C:3]([CH2:4][CH3:7])" + "([c:5]1[cH:8][cH:10][c:11]([O:12][CH2:14][CH:15]([O:16][Si:18]" + "([CH3:19])([CH3:20])[C:21]([CH3:22])([CH3:23])[CH3:24])[C:17]" + "([CH3:25])([CH3:26])[CH3:27])[c:13]([CH3:28])[cH:9]1)[c:6]1[cH:29]" + "[c:31]([CH3:32])[c:33]([CH:34]([NH:41][CH2:40][C:38]([O:37][CH3:36])" + "=[O:39])[H:43])[s:30]1.[O:35]([H:42])[H:44]" + ) + standardizer = Standardize() + expected = standardizer.fit(smart) + substrate, product = standardizer.fit(smart, remove_aam=True).split(">>") + rc = rsmi_to_its(smart, core=True, format="tuple") + + forward = SynReactor( + substrate=substrate, + template=rc, + electron_diagnostics=True, + implicit_temp=False, + explicit_h=False, + ) + backward = SynReactor( + substrate=product, + template=rc, + electron_diagnostics=True, + implicit_temp=False, + explicit_h=False, + invert=True, + ) + + self.assertIn( + expected, + [ + standardizer.fit(candidate, remove_aam=True) + for candidate in forward.smarts + ], + ) + self.assertIn( + expected, + [ + standardizer.fit(candidate, remove_aam=True) + for candidate in backward.smarts + ], + ) + self.assertTrue(backward.its_list) + product_graph = ITSReverter(backward.its_list[0]).to_product_graph() + self.assertTrue( + any( + attrs.get("element") == "O" and attrs.get("hcount") == -1 + for _, attrs in product_graph.nodes(data=True) + ) + ) + + def test_radical_homolytic_cc_cleavage(self): + self._assert_radicals( + "[CH3:1][CH3:2]>>[CH3:1].[CH3:2]", + expected_product_radicals={1: 1, 2: 1}, + ) + + def test_radical_cc_recombination(self): + self._assert_radicals( + "[CH3:1].[CH3:2]>>[CH3:1][CH3:2]", + expected_product_radicals={1: 0, 2: 0}, + ) + + def test_radical_homolytic_cbr_cleavage(self): + self._assert_radicals( + "[CH3:1][Br:2]>>[CH3:1].[Br:2]", + expected_product_radicals={1: 1, 2: 1}, + ) + + def test_radical_cbr_recombination(self): + self._assert_radicals( + "[CH3:1].[Br:2]>>[CH3:1][Br:2]", + expected_product_radicals={1: 0, 2: 0}, + ) + + def _assert_radicals( + self, + smart: str, + *, + expected_product_radicals: dict[int, int], + ) -> None: + reactants, _ = smart.split(">>") + rc = rsmi_to_its(smart, core=True, format="tuple") + reactor = SynReactor( + reactants, + rc, + implicit_temp=False, + explicit_h=False, + electron_diagnostics=True, + ) + product = ITSReverter(reactor.its_list[0]).to_product_graph() + + for node, radical in expected_product_radicals.items(): + self.assertEqual(product.nodes[node]["radical"], radical) + self.assertEqual(product.nodes[node]["charge"], 0.0) + + _, products = smart.split(">>") + backward = SynReactor( + products, + rc, + implicit_temp=False, + explicit_h=False, + electron_diagnostics=True, + invert=True, + ) + self.assertTrue(self._has_equivalent_candidate(smart, reactor.smarts)) + self.assertTrue(self._has_equivalent_candidate(smart, backward.smarts)) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Synthesis/Reactor/test_syn_reactor_real_cases.py b/Test/Synthesis/Reactor/test_syn_reactor_real_cases.py new file mode 100644 index 0000000..b5c9c86 --- /dev/null +++ b/Test/Synthesis/Reactor/test_syn_reactor_real_cases.py @@ -0,0 +1,149 @@ +import unittest +import logging +from pathlib import Path + +from rdkit import Chem + +from synkit.Chem.Reaction.standardize import Standardize +from synkit.IO import load_database +from synkit.IO.chem_converter import detect_its_format, rsmi_to_its +from synkit.Synthesis.Reactor.syn_reactor import SynReactor + + +class TestSynReactorRealCases(unittest.TestCase): + REAL_CASE_BATCH_SIZE = 34393 + REAL_CASE_BATCH_SIZE = 100 + PROGRESS_STEPS = 10 + ERROR_LOG = Path("error.txt") + + @classmethod + def setUpClass(cls): + cls.data = load_database("./Data/smart.json.gz") + cls.standardizer = Standardize() + + @staticmethod + def _canonical_fragments(side: str) -> list[str]: + fragments = [] + for fragment in side.split("."): + mol = Chem.MolFromSmiles(fragment) + if mol is None: + raise AssertionError(f"Could not parse fragment: {fragment}") + for atom in mol.GetAtoms(): + atom.SetAtomMapNum(0) + fragments.append(Chem.MolToSmiles(Chem.RemoveHs(mol))) + return sorted(fragments) + + def _round_trip_tuple_rc(self, index: int) -> None: + smart = self.data[index]["smart"] + rsmi = self.standardizer.fit(smart) + reactants, products = rsmi.split(">>") + rc = rsmi_to_its(smart, core=True, format="tuple") + + forward = SynReactor( + substrate=reactants, + template=rc, + implicit_temp=False, + explicit_h=False, + ) + backward = SynReactor( + substrate=products, + template=rc, + implicit_temp=False, + explicit_h=False, + invert=True, + ) + + forward_smis = [ + self.standardizer.fit(item, remove_aam=True) for item in forward.smarts + ] + backward_smis = [ + self.standardizer.fit(item, remove_aam=True) for item in backward.smarts + ] + + self.assertEqual(detect_its_format(rc), "tuple") + self.assertIn(rsmi, forward_smis) + self.assertIn(rsmi, backward_smis) + + def _write_case_error(self, index: int, exc: BaseException) -> None: + """Append one reproducible failed real-case record to ``error.txt``.""" + entry = self.data[index] + smart = entry["smart"] + try: + rsmi = self.standardizer.fit(smart) + except Exception as standardize_exc: + rsmi = f"" + + with self.ERROR_LOG.open("a", encoding="utf-8") as handle: + handle.write( + "\n".join( + [ + "=" * 88, + f"index: {index}", + f"reaction_id: {entry.get('R-id')}", + f"error_type: {type(exc).__name__}", + f"error: {exc}", + "smart:", + smart, + "standardized_rsmi:", + rsmi, + "", + ] + ) + ) + + def test_first_fixture_runs_through_tuple_template(self): + smart = self.data[0]["smart"] + substrate, expected_product = smart.split(">>") + + reactor = SynReactor( + substrate, + smart, + explicit_h=False, + template_format="tuple", + ) + + self.assertEqual(detect_its_format(reactor.rule.rc.raw), "tuple") + self.assertTrue(reactor.its_list) + self.assertEqual(len(reactor.smarts), 1) + actual_product = reactor.smarts[0].split(">>")[1] + self.assertEqual( + self._canonical_fragments(actual_product), + self._canonical_fragments(expected_product), + ) + + def test_first_fixture_round_trips_with_tuple_rc(self): + self._round_trip_tuple_rc(0) + + def test_curated_tuple_rc_matrix_round_trips(self): + for index in (0, 1, 2, 10, 25, 100): + with self.subTest(index=index, reaction_id=self.data[index]["R-id"]): + self._round_trip_tuple_rc(index) + + def test_backward_role_regression_from_index_33(self): + self._round_trip_tuple_rc(33) + + def test_tuple_rc_round_trip_batch(self): + logger = logging.getLogger(__name__) + total = self.REAL_CASE_BATCH_SIZE + progress_every = max(1, total // self.PROGRESS_STEPS) + self.ERROR_LOG.unlink(missing_ok=True) + + for index in range(total): + completed = index + 1 + if completed == 1 or completed % progress_every == 0 or completed == total: + logger.info( + "tuple RC real-case progress: %d/%d (%.0f%%)", + completed, + total, + completed / total * 100, + ) + with self.subTest(index=index, reaction_id=self.data[index]["R-id"]): + try: + self._round_trip_tuple_rc(index) + except Exception as exc: + self._write_case_error(index, exc) + raise + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Synthesis/Reactor/test_syn_reactor_rewrite_modes.py b/Test/Synthesis/Reactor/test_syn_reactor_rewrite_modes.py new file mode 100644 index 0000000..41ec7aa --- /dev/null +++ b/Test/Synthesis/Reactor/test_syn_reactor_rewrite_modes.py @@ -0,0 +1,199 @@ +import unittest + +import networkx as nx +from synkit.IO.chem_converter import detect_its_format, rsmi_to_its +from synkit.Synthesis.Reactor.syn_reactor import SynReactor + + +class TestSynReactorRewriteModes(unittest.TestCase): + def test_detects_legacy_template(self): + rc = nx.Graph() + rc.add_edge(1, 2, order=(1.0, 2.0)) + + self.assertFalse(SynReactor._is_electron_aware_template(rc)) + + def test_detects_electron_aware_template(self): + rc = nx.Graph() + rc.add_edge( + 1, + 2, + order=(1.0, 2.0), + sigma_order=(1.0, 1.0), + pi_order=(0.0, 1.0), + ) + + self.assertTrue(SynReactor._is_electron_aware_template(rc)) + + def test_invert_tuple_template_preserves_tuple_representation(self): + template = "[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]" + reactor = SynReactor( + "[CH2:1]=[CH2:2]", + template, + invert=True, + explicit_h=False, + template_format="tuple", + ) + + self.assertEqual(detect_its_format(reactor.rule.rc.raw), "tuple") + self.assertEqual(reactor.rule.rc.raw.edges[1, 2]["pi_order"], (1.0, 0.0)) + + def test_electron_aware_rewrite_refreshes_product_accounting(self): + host = nx.Graph() + host.add_node( + 1, + element="C", + charge=0, + hcount=3, + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + host.add_node( + 2, + element="C", + charge=0, + hcount=3, + lone_pairs=0, + radical=0, + valence_electrons=4, + ) + host.add_edge(1, 2, order=1.0, sigma_order=1.0, pi_order=0.0) + + rc = nx.Graph() + rc.add_node(10, typesGH=(("C", False, 3, 0, []), ("C", False, 2, 0, []))) + rc.add_node(20, typesGH=(("C", False, 3, 0, []), ("C", False, 2, 0, []))) + rc.add_edge( + 10, + 20, + order=(1.0, 2.0), + sigma_order=(1.0, 1.0), + pi_order=(0.0, 1.0), + standard_order=-1.0, + ) + + rewritten = SynReactor._glue_graph(host, rc, {10: 1, 20: 2})[0] + + self.assertEqual(rewritten.edges[1, 2]["sigma_order"], (1.0, 1.0)) + self.assertEqual(rewritten.edges[1, 2]["pi_order"], (0.0, 1.0)) + self.assertEqual(rewritten.edges[1, 2]["kekule_order"][1], 2.0) + self.assertEqual(rewritten.nodes[1]["hcount"], (3, 2)) + self.assertEqual(rewritten.nodes[1]["recomputed_charge"][1], 0.0) + + def test_electron_aware_to_smarts_uses_kekule_product_reconstruction(self): + its = nx.Graph() + for node in range(6): + its.add_node( + node, + element=("C", "C"), + charge=(0, 0), + hcount=(1, 1), + lone_pairs=(0, 0), + radical=(0, 0), + valence_electrons=(4, 4), + present=(True, True), + ) + cycle_edges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)] + for idx, edge in enumerate(cycle_edges): + order = 1.0 if idx % 2 == 0 else 2.0 + its.add_edge( + *edge, + order=(1.5, 1.5), + kekule_order=(order, order), + sigma_order=(1.0, 1.0), + pi_order=(order - 1.0, order - 1.0), + standard_order=0.0, + ) + its.graph["electron_aware_rewrite"] = True + + self.assertEqual(SynReactor._to_smarts(its), "c1ccccc1>>c1ccccc1") + + def test_legacy_output_does_not_switch_modes_from_host_sigma_pi(self): + its = nx.Graph() + its.add_node( + 1, + element="C", + charge=0, + hcount=3, + typesGH=(("C", False, 3, 0, []), ("C", False, 3, 0, [])), + ) + its.add_node( + 2, + element="C", + charge=0, + hcount=3, + typesGH=(("C", False, 3, 0, []), ("C", False, 3, 0, [])), + ) + its.add_edge( + 1, + 2, + order=(1.0, 1.0), + sigma_order=(1.0, 1.0), + pi_order=(0.0, 0.0), + standard_order=0.0, + ) + its.graph["electron_aware_rewrite"] = False + + self.assertEqual( + SynReactor._to_smarts(its), + "[CH3:1][CH3:2]>>[CH3:1][CH3:2]", + ) + + def test_public_tuple_template_reaches_electron_aware_rewrite(self): + reactor = SynReactor( + "[CH3:1][CH3:2]", + "[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]", + explicit_h=False, + template_format="tuple", + ) + + self.assertEqual(detect_its_format(reactor.rule.rc.raw), "tuple") + self.assertTrue(reactor.its_list) + self.assertTrue(reactor.its_list[0].graph["electron_aware_rewrite"]) + self.assertEqual(reactor.smarts, ["[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]"]) + + def test_diagnostics_are_opt_in_and_do_not_change_products(self): + template = "[CH3:1][CH3:2]>>[CH2:1]=[CH2:2]" + baseline = SynReactor( + "[CH3:1][CH3:2]", + template, + explicit_h=False, + template_format="tuple", + ) + diagnosed = SynReactor( + "[CH3:1][CH3:2]", + template, + explicit_h=False, + template_format="tuple", + electron_diagnostics=True, + ) + + self.assertEqual(baseline.diagnostics, []) + self.assertEqual(diagnosed.smarts, baseline.smarts) + self.assertEqual(len(diagnosed.diagnostics), 1) + self.assertTrue(diagnosed.diagnostics[0]["electron_aware_rewrite"]) + self.assertEqual(diagnosed.diagnostics[0]["mismatch_count"], 0) + + def test_diagnostics_report_public_nonzero_mismatch(self): + template = rsmi_to_its( + "[OH:1][CH3:2]>>[O+:1]=[CH2:2]", + format="tuple", + ) + template.nodes[1]["lone_pairs"] = (2, 0) + reactor = SynReactor( + "[OH:1][CH3:2]", + template, + explicit_h=False, + electron_diagnostics=True, + ) + + self.assertEqual(len(reactor.smarts), 1) + self.assertEqual(len(reactor.diagnostics), 1) + self.assertEqual(reactor.diagnostics[0]["mismatch_count"], 1) + self.assertEqual( + reactor.diagnostics[0]["mismatches"][1], + {"charge": 1, "recomputed_charge": 3.0}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Vis/test_its_drawer.py b/Test/Vis/test_its_drawer.py new file mode 100644 index 0000000..78d452c --- /dev/null +++ b/Test/Vis/test_its_drawer.py @@ -0,0 +1,134 @@ +import unittest + +import matplotlib + +matplotlib.use("Agg") + +from synkit.IO import rsmi_to_its # noqa: E402 +from synkit.Vis.its_drawer import ( # noqa: E402 + draw_its_from_rsmi, + draw_its_graph, + draw_its_only, +) + + +class TestITSDrawer(unittest.TestCase): + rsmi = ( + "[Cl:1][Cl:2].[H:9][c:3]1[cH:4][cH:5][cH:6][cH:7][cH:8]1" + ">>" + "[Cl:1][H:9].[Cl:2][c:3]1[cH:4][cH:5][cH:6][cH:7][cH:8]1" + ) + + def test_draw_tuple_its_from_rsmi(self): + fig, axes = draw_its_from_rsmi( + self.rsmi, + format="tuple", + core=False, + title="chlorination ITS", + ) + + self.assertIs(fig, axes[0].figure) + self.assertEqual(len(axes), 1) + self.assertEqual(axes[0].get_title(), "chlorination ITS") + + def test_draw_tuple_its_graph(self): + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + + fig, axes = draw_its_graph(its, title="tuple ITS") + + self.assertIs(fig, axes[0].figure) + self.assertEqual(len(axes), 1) + self.assertEqual(axes[0].get_title(), "tuple ITS") + + def test_draw_its_only_can_show_changed_edge_labels(self): + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + + ax = draw_its_only( + its, + title="pretty ITS", + show_edge_labels=True, + edge_label_mode="kekule", + ) + + self.assertEqual(ax.get_title(), "pretty ITS") + + def test_draw_its_only_supports_sigma_pi_labels(self): + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + + ax = draw_its_only( + its, + title="sigma/pi ITS", + show_edge_labels=True, + edge_label_mode="sigma_pi", + ) + + self.assertEqual(ax.get_title(), "sigma/pi ITS") + + def test_sigma_pi_labels_only_include_changed_components(self): + from synkit.Vis.its_drawer import _its_display_graph + + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + display = _its_display_graph(its) + labels = [ + attrs["its_label_sigma_pi"] + for _, _, attrs in display.edges(data=True) + if attrs["its_state"] != "unchanged" + ] + + self.assertTrue(labels) + self.assertTrue(all(label.startswith("σ") for label in labels)) + self.assertTrue(all("π" not in label for label in labels)) + + def test_electron_labels_capture_charge_and_lone_pair_changes_separately(self): + from synkit.Vis.its_drawer import _its_display_graph + + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + its = rsmi_to_its(rsmi, core=False, format="tuple") + display = _its_display_graph(its) + + self.assertEqual(display.nodes[2]["its_electron_label_charge"], "q0→-1") + self.assertEqual(display.nodes[2]["its_electron_label_lone_pair"], "λ3→4") + self.assertEqual(display.nodes[3]["its_electron_label_charge"], "q0→+1") + self.assertEqual(display.nodes[3]["its_electron_label_lone_pair"], "λ1→0") + + def test_draw_its_only_can_show_electron_labels(self): + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + its = rsmi_to_its(rsmi, core=False, format="tuple") + + ax = draw_its_only( + its, + title="SN2 ITS", + show_electron_labels=True, + electron_label_mode="lone_pair", + ) + + self.assertEqual(ax.get_title(), "SN2 ITS") + + def test_invalid_its_label_modes_raise(self): + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + + with self.assertRaises(ValueError): + draw_its_only(its, edge_label_mode="verbose") + with self.assertRaises(ValueError): + draw_its_only(its, show_electron_labels=True, electron_label_mode="both") + + def test_draw_tuple_its_projection_when_requested(self): + its = rsmi_to_its(self.rsmi, core=False, format="tuple") + + fig, axes = draw_its_graph(its, title="tuple ITS", projection=True) + + self.assertIs(fig, axes[-1].figure) + self.assertGreaterEqual(len(axes), 4) + self.assertEqual(axes[-1].get_title(), "ITS delta") + + def test_draw_legacy_its_graph_without_delta(self): + its = rsmi_to_its(self.rsmi, core=False, format="typesGH") + + fig, axes = draw_its_graph(its, include_delta_panel=False, projection=True) + + self.assertIs(fig, axes[0].figure) + self.assertGreaterEqual(len(axes), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Vis/test_molecule_drawer.py b/Test/Vis/test_molecule_drawer.py new file mode 100644 index 0000000..6f76ae7 --- /dev/null +++ b/Test/Vis/test_molecule_drawer.py @@ -0,0 +1,73 @@ +import unittest + +import matplotlib +import networkx as nx + +matplotlib.use("Agg") + +from synkit.IO.chem_converter import smiles_to_graph # noqa: E402 +from synkit.Vis.molecule_drawer import draw_molecule_graph # noqa: E402 + + +class TestMoleculeDrawer(unittest.TestCase): + def test_draw_molecule_graph_returns_axes_without_mutation(self): + graph = nx.Graph() + graph.add_node(1, element="C", charge=0, atom_map=1) + graph.add_node(2, element="O", charge=0, atom_map=2) + graph.add_edge(1, 2, order=2) + before_nodes = dict(graph.nodes(data=True)) + before_edges = list(graph.edges(data=True)) + + ax = draw_molecule_graph(graph, label_mode="all", show_atom_map=True) + + self.assertEqual(ax.get_aspect(), 1.0) + self.assertEqual(dict(graph.nodes(data=True)), before_nodes) + self.assertEqual(list(graph.edges(data=True)), before_edges) + + def test_draw_aromatic_molecule(self): + graph = nx.cycle_graph(6) + mapping = {node: node + 1 for node in graph.nodes} + graph = nx.relabel_nodes(graph, mapping) + nx.set_node_attributes(graph, "C", "element") + nx.set_node_attributes(graph, 0, "charge") + nx.set_node_attributes(graph, True, "aromatic") + nx.set_edge_attributes(graph, 1.5, "order") + nx.set_edge_attributes(graph, True, "aromatic") + + ax = draw_molecule_graph(graph, aromatic_style="circle") + + self.assertEqual(ax.get_aspect(), 1.0) + + def test_draw_with_rdkit_panel(self): + graph = nx.Graph() + graph.add_node(1, element="N", charge=1, atom_map=1) + graph.add_node(2, element="C", charge=0, atom_map=2) + graph.add_edge(1, 2, order=1) + + fig, axes = draw_molecule_graph(graph, include_rdkit_panel=True) + + self.assertEqual(len(axes), 2) + self.assertIs(fig, axes[0].figure) + + def test_draw_real_smiles_graph_aspirin_like_case(self): + graph = smiles_to_graph( + "CC(=O)OC1=CC=CC=C1C(=O)O", + sanitize=True, + use_index_as_atom_map=True, + ) + + ax = draw_molecule_graph( + graph, + label_mode="hetero", + show_atom_map=True, + aromatic_style="circle", + title="Aspirin-like SMILES graph", + ) + + self.assertEqual(graph.number_of_nodes(), 13) + self.assertEqual(graph.number_of_edges(), 13) + self.assertEqual(ax.get_title(), "Aspirin-like SMILES graph") + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Vis/test_reaction_drawer.py b/Test/Vis/test_reaction_drawer.py new file mode 100644 index 0000000..ed242c1 --- /dev/null +++ b/Test/Vis/test_reaction_drawer.py @@ -0,0 +1,54 @@ +import unittest + +import matplotlib + +matplotlib.use("Agg") + +# flake8: noqa: E402 +from synkit.IO.chem_converter import rsmi_to_graph # noqa: E402 +from synkit.Vis.reaction_drawer import ( # noqa: E402 + draw_reaction_graph, + draw_reaction_graphs, + find_reaction_highlights, +) + + +class TestReactionDrawer(unittest.TestCase): + def test_find_reaction_highlights_detects_broken_and_formed_bonds(self): + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + reactant, product = rsmi_to_graph( + rsmi, + drop_non_aam=False, + use_index_as_atom_map=True, + ) + + highlights = find_reaction_highlights(reactant, product) + + self.assertIn(frozenset({1, 2}), highlights.broken_bonds) + self.assertIn(frozenset({1, 3}), highlights.formed_bonds) + self.assertEqual(highlights.changed_atoms, frozenset({1, 2, 3})) + + def test_draw_reaction_graph_from_rsmi(self): + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + + fig, axes = draw_reaction_graph(rsmi, title="SN2") + + self.assertIs(fig, axes[0].figure) + self.assertEqual(len(axes), 5) + + def test_draw_reaction_graphs_accepts_prebuilt_graphs(self): + rsmi = "[C:1]=[O:2].[O:3]>>[C:1]([O:2])[O:3]" + reactant, product = rsmi_to_graph( + rsmi, + drop_non_aam=False, + use_index_as_atom_map=True, + ) + + fig, axes = draw_reaction_graphs(reactant, product, title="addition") + + self.assertIs(fig, axes[-1].figure) + self.assertEqual(len(axes), 4) + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Vis/test_visual_drawer.py b/Test/Vis/test_visual_drawer.py new file mode 100644 index 0000000..b20e5b6 --- /dev/null +++ b/Test/Vis/test_visual_drawer.py @@ -0,0 +1,48 @@ +import unittest + +import matplotlib +import networkx as nx + +matplotlib.use("Agg") + +from synkit.Vis.visual_drawer import draw_graph # noqa: E402 +from synkit.Vis.visual_model import to_visual_graph # noqa: E402 + + +class TestVisualDrawer(unittest.TestCase): + def test_draw_graph_returns_figure_and_axes_without_mutating_input(self): + graph = nx.Graph() + graph.add_node(1, element="C", atom_map=1, charge=0) + graph.add_node(2, element="O", atom_map=2, charge=0) + graph.add_edge(1, 2, order=(1.0, 2.0)) + before_nodes = dict(graph.nodes(data=True)) + before_edges = list(graph.edges(data=True)) + + fig, ax = draw_graph(graph) + + self.assertIs(fig, ax.figure) + self.assertEqual(dict(graph.nodes(data=True)), before_nodes) + self.assertEqual(list(graph.edges(data=True)), before_edges) + + def test_draw_graph_accepts_visual_graph(self): + graph = nx.Graph() + graph.add_node(1, element="N", atom_map=1, hcount=(2, 1)) + graph.add_node(2, element="C", atom_map=2, hcount=(3, 3)) + graph.add_edge( + 1, + 2, + order=(1.0, 2.0), + kekule_order=(1.0, 2.0), + sigma_order=(1.0, 1.0), + pi_order=(0.0, 1.0), + ) + visual = to_visual_graph(graph, mode="sigma_pi") + + fig, ax = draw_graph(visual, mode="sigma_pi") + + self.assertIs(fig, ax.figure) + self.assertEqual(ax.get_title(), "tuple_its") + + +if __name__ == "__main__": + unittest.main() diff --git a/Test/Vis/test_visual_model.py b/Test/Vis/test_visual_model.py new file mode 100644 index 0000000..f2fdc8c --- /dev/null +++ b/Test/Vis/test_visual_model.py @@ -0,0 +1,137 @@ +import unittest + +import networkx as nx + +from synkit.Graph.ITS.its_construction import ITSConstruction +from synkit.Graph.MTG.mtg import MTG +from synkit.Vis.visual_model import ( + detect_visual_kind, + iter_changed_edges, + iter_changed_nodes, + summarize_visual_graph, + to_visual_graph, +) + + +class TestVisualModel(unittest.TestCase): + @staticmethod + def _atom(element, *, hcount=0, charge=0, lone_pairs=0, radical=0): + return { + "element": element, + "aromatic": False, + "hcount": hcount, + "charge": charge, + "lone_pairs": lone_pairs, + "radical": radical, + "valence_electrons": {"H": 1, "C": 4, "N": 5, "O": 6, "Cl": 7}[element], + } + + @staticmethod + def _bond(graph, u, v, sigma=1.0, pi=0.0): + graph.add_edge( + u, + v, + order=sigma + pi, + kekule_order=sigma + pi, + sigma_order=sigma, + pi_order=pi, + ) + + def _graph(self, nodes, edges): + graph = nx.Graph() + for node, attrs in nodes.items(): + graph.add_node(node, **attrs) + for edge in edges: + self._bond(graph, *edge) + return graph + + def test_detects_molecule(self): + graph = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + + visual = to_visual_graph(graph) + + self.assertEqual(detect_visual_kind(graph), "molecule") + self.assertEqual(visual.kind, "molecule") + self.assertEqual(visual.edges[0].label, "—") + + def test_detects_legacy_its(self): + its = nx.Graph() + its.add_node(1, element="C", atom_map=1) + its.add_node(2, element="O", atom_map=2) + its.add_edge(1, 2, order=(1.0, 2.0), standard_order=-1.0) + + visual = to_visual_graph(its) + + self.assertEqual(detect_visual_kind(its), "legacy_its") + self.assertEqual(visual.edges[0].state, "order_changed") + self.assertEqual(visual.edges[0].label, "—>=") + + def test_detects_tuple_its_and_sigma_pi_labels(self): + reactant = self._graph( + { + 1: self._atom("N", hcount=2, lone_pairs=1), + 2: self._atom("C", hcount=3), + }, + [(1, 2, 1.0, 0.0)], + ) + product = self._graph( + { + 1: self._atom("N", hcount=1, lone_pairs=1, radical=1), + 2: self._atom("C", hcount=3), + }, + [(1, 2, 1.0, 1.0)], + ) + its = ITSConstruction.construct(reactant, product) + + visual = to_visual_graph(its, mode="sigma_pi") + + self.assertEqual(detect_visual_kind(its), "tuple_its") + self.assertIn("π0>1", visual.edges[0].label) + self.assertEqual(visual.edges[0].state, "order_changed") + self.assertEqual([node.node_id for node in iter_changed_nodes(visual)], [1]) + + def test_detects_compact_mtg_and_transient_edges(self): + g0 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + g1 = self._graph( + { + 1: self._atom("C", hcount=3, radical=1), + 2: self._atom("Cl", radical=1, lone_pairs=3), + }, + [], + ) + g2 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + mtg = MTG( + [ITSConstruction.construct(g0, g1), ITSConstruction.construct(g1, g2)], + mappings=[{1: 1, 2: 2}], + ).get_mtg() + + visual = to_visual_graph(mtg, mode="timeline") + changed_edges = list(iter_changed_edges(visual)) + + self.assertEqual(detect_visual_kind(mtg), "compact_mtg") + self.assertEqual(changed_edges[0].state, "transient") + self.assertIn("σ:1-0-1", changed_edges[0].label) + summary = summarize_visual_graph(visual) + self.assertEqual(summary["kind"], "compact_mtg") + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/api/graph.rst b/doc/api/graph.rst index c61e8a0..8024bee 100644 --- a/doc/api/graph.rst +++ b/doc/api/graph.rst @@ -196,14 +196,6 @@ Matcher MTG --- -.. automodule:: synkit.Graph.MTG.group_comp - :members: - :show-inheritance: - -.. automodule:: synkit.Graph.MTG.groupoid - :members: - :show-inheritance: - .. automodule:: synkit.Graph.MTG.mcs_matcher :members: :show-inheritance: diff --git a/doc/api/vis.rst b/doc/api/vis.rst index 1023a17..13b4386 100644 --- a/doc/api/vis.rst +++ b/doc/api/vis.rst @@ -1,8 +1,37 @@ Visualization ============= -Visualization utilities for reactions, rules, graphs, CRNs, embeddings, -and output export helpers. +Visualization utilities for molecule graphs, reactions, ITS graphs, diagnostic +graph adapters, CRNs, embeddings, and output export helpers. + +Modern molecule/reaction/ITS renderers +-------------------------------------- + +.. automodule:: synkit.Vis.molecule_drawer + :members: + :show-inheritance: + +.. automodule:: synkit.Vis.reaction_drawer + :members: + :show-inheritance: + +.. automodule:: synkit.Vis.its_drawer + :members: + :show-inheritance: + +Diagnostic adapter layer +------------------------ + +.. automodule:: synkit.Vis.visual_model + :members: + :show-inheritance: + +.. automodule:: synkit.Vis.visual_drawer + :members: + :show-inheritance: + +Legacy and utility visualizers +------------------------------ .. automodule:: synkit.Vis.rxn_vis :members: @@ -16,15 +45,15 @@ and output export helpers. :members: :show-inheritance: -.. automodule:: synkit.Vis.crn_vis +.. automodule:: synkit.Vis.chemical_space :members: :show-inheritance: -.. automodule:: synkit.Vis.embedding +.. automodule:: synkit.Vis.crn_vis :members: :show-inheritance: -.. automodule:: synkit.Vis.chemical_space +.. automodule:: synkit.Vis.embedding :members: :show-inheritance: diff --git a/doc/index.rst b/doc/index.rst index 32be310..6347f68 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -115,6 +115,12 @@ Documentation quick links ITS/MTG construction, WL hashing, and cluster/search primitives. + .. grid-item-card:: :octicon:`eye` Visualization + :link: vis + :link-type: doc + + Molecule, reaction, ITS, and diagnostic graph rendering. + .. grid-item-card:: :octicon:`terminal` API Reference :link: api/index :link-type: doc @@ -159,7 +165,7 @@ Funded by the European Union Horizon Europe Doctoral Network (Marie-Skłodowska- rule synthesis crn + vis api/index reference changelog - diff --git a/doc/vis.rst b/doc/vis.rst new file mode 100644 index 0000000..e3b859f --- /dev/null +++ b/doc/vis.rst @@ -0,0 +1,201 @@ +.. _vis: + +Visualization +============= + +SynKit's visualization layer has two roles: + +* chemistry-first drawings for molecular graphs, reaction panels, and ITS graphs; +* diagnostic graph drawings for raw NetworkX objects, adapters, and future MTG + development. + +For normal chemistry work, prefer the molecule, reaction, and ITS helpers from +``synkit.Vis``. The generic graph drawer is useful when debugging attributes or +new graph representations, but it is intentionally less polished. + +Molecular Graphs From SMILES +---------------------------- + +Start from a real SMILES, convert it to a SynKit molecular graph, and draw it +with atom-map-aware labels. + +.. code-block:: python + + from synkit.IO.chem_converter import smiles_to_graph + from synkit.Vis import draw_molecule_graph + + smiles = "CC(=O)OC1=CC=CC=C1C(=O)O" + graph = smiles_to_graph( + smiles, + sanitize=True, + use_index_as_atom_map=True, + ) + + ax = draw_molecule_graph( + graph, + title=smiles, + label_mode="hetero", + show_atom_map=True, + aromatic_style="circle", + ) + ax.figure + +Useful molecule options: + +.. list-table:: + :header-rows: 1 + + * - Option + - Use + * - ``label_mode="hetero"`` + - Keep carbon labels compact while showing hetero atoms explicitly. + * - ``show_atom_map=True`` + - Show atom-map numbers when present. + * - ``aromatic_style="circle"`` + - Draw aromatic rings with a compact ring marker instead of cluttered edge labels. + +Reaction Panels +--------------- + +Reaction drawings show reactants and products side by side and highlight atoms +and bonds that change. + +.. code-block:: python + + from synkit.Vis import draw_reaction_graph + + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + + fig, axes = draw_reaction_graph( + rsmi, + title="SN2 reaction", + show_atom_map=True, + ) + +ITS Graphs +---------- + +ITS visualization is centered on the transformation graph itself, not the full +reactant/product panels. By default, changed bonds are shown as compact +``kekule_order`` transitions such as ``1->0`` or ``0->1``. + +.. code-block:: python + + from synkit.Vis import draw_its_from_rsmi + + rsmi = ( + "[Cl:1][Cl:2].[H:9][c:3]1[cH:4][cH:5][cH:6][cH:7][cH:8]1" + ">>" + "[Cl:1][H:9].[Cl:2][c:3]1[cH:4][cH:5][cH:6][cH:7][cH:8]1" + ) + + fig, axes = draw_its_from_rsmi( + rsmi, + format="tuple", + core=False, + title="ITS: chlorine transfer to arene", + edge_label_mode="kekule", + ) + +ITS edge-label modes: + +.. list-table:: + :header-rows: 1 + + * - Mode + - Meaning + * - ``edge_label_mode="kekule"`` + - Show changed ``kekule_order`` only. This is the recommended compact view. + * - ``edge_label_mode="sigma_pi"`` + - Show changed sigma/pi components. Unchanged components are suppressed. + * - ``edge_label_mode="none"`` + - Hide edge labels and use only edge color/style. + +Electron Labels +--------------- + +For tuple/electron-aware ITS graphs, node labels can show charge, lone-pair, or +radical changes. Use one signal at a time for readable figures. + +.. code-block:: python + + from synkit.Vis import draw_its_from_rsmi + + sn2 = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + + fig, axes = draw_its_from_rsmi( + sn2, + format="tuple", + core=False, + title="ITS: SN2 lone-pair changes", + show_electron_labels=True, + electron_label_mode="lone_pair", + ) + +Electron-label modes: + +.. list-table:: + :header-rows: 1 + + * - Mode + - Meaning + * - ``electron_label_mode="charge"`` + - Show charge changes, for example ``q0->+1``. + * - ``electron_label_mode="lone_pair"`` + - Show lone-pair changes, for example ``lambda1->0``. + * - ``electron_label_mode="radical"`` + - Show radical changes. + * - ``electron_label_mode="all"`` + - Show every changed electron attribute. This is useful for debugging but can be busy. + +Reactant/Product Projections +---------------------------- + +ITS helpers can also render reactant and product projections when needed for +debugging. + +.. code-block:: python + + fig, axes = draw_its_from_rsmi( + rsmi, + format="tuple", + core=False, + projection=True, + title="ITS with reactant/product projections", + ) + +Use ``projection=True`` when you need to inspect how an ITS decomposes back into +left and right molecular graphs. Use the default ITS-only view for reports and +notebooks. + +Diagnostic Graph View +--------------------- + +The visual model adapter normalizes molecule, reaction, ITS, and MTG-like graph +objects into a common drawing model. This layer is mainly for development and +debugging. + +.. code-block:: python + + from synkit.Vis import detect_visual_kind, summarize_visual_graph, to_visual_graph + from synkit.Vis import draw_graph + + kind = detect_visual_kind(graph) + visual_graph = to_visual_graph(graph) + summary = summarize_visual_graph(visual_graph) + + ax = draw_graph(graph, title=f"{kind}: {summary.kind}") + +Legacy Helpers +-------------- + +The older visualization classes are still exported for compatibility: + +* ``RXNVis`` for reaction image grids; +* ``RuleVis`` for rule/ITS style drawings; +* ``GraphVisualizer`` for general NetworkX graph visualization. + +New code should use ``draw_molecule_graph``, ``draw_reaction_graph``, and +``draw_its_from_rsmi`` unless a legacy workflow specifically depends on the +class-based API. + diff --git a/lint.sh b/lint.sh index 4b0e23b..2ee614a 100755 --- a/lint.sh +++ b/lint.sh @@ -25,12 +25,13 @@ its_destruction.py:C901, conversion.py:C901, injectivity.py:C901, deficiency.py:C901, -mol_to_graph.py:C901" \ +mol_to_graph.py:C901, +detector.py:C901, +mtg.py:C901" \ --exclude=venv,\ core_engine.py,\ rule_apply.py,\ reactor_engine.py,\ -groupoid.py,\ syn_rule.py,\ __init__.py,\ wl_mapper.py,\ diff --git a/plan.html b/plan.html new file mode 100644 index 0000000..3079cdd --- /dev/null +++ b/plan.html @@ -0,0 +1,553 @@ + + + + + Electron-Aware Reactor Design Plan + + + +

Electron-Aware Reactor Design Plan

+ +
+

What We Are Trying To Do

+

+ The current SynKit reactor can apply structural reaction rules, but its + main matching logic is still mostly blind to electron state. Molecular + graphs already know about lone pairs and radicals, but that information is + not yet consistently exposed or used by the reactor. +

+ +

+ The goal is to make the reactor gradually become electron-aware: +

+ +
    +
  • lone pairs should become visible and usable,
  • +
  • radicals should become visible and usable,
  • +
  • charge should eventually be checked from Lewis-style bookkeeping,
  • +
  • aromatic matching and electron accounting should stop being forced into the same bond attribute.
  • +
+ +

+ We do not want to break old templates immediately. The correct shape is a + staged migration: expose the right data first, add observability next, + enforce later. +

+
+ +
+

Current State In The Repository

+ +

What already exists

+
    +
  • + mol_to_graph.py already computes: + lone_pairs, + available_lp, + radical, + and richer bookkeeping fields. +
  • +
  • + The newer ITS path already supports named paired attributes such as: + lone_pairs=(2,1). +
  • +
  • + kekule_order already exists on graph edges and can represent + a Kekule form separately from aromatic bond order. +
  • +
  • + Some older mechanism code already recomputes charge from local electron + inventory, which strongly suggests the intended chemistry model. +
  • +
+ +

What is missing

+
    +
  • + Default graph conversion currently does not expose the small electron-state + surface by default. +
  • +
  • + SynReactor currently matches mostly on + element and charge. +
  • +
  • + Lone pairs are not part of reactor matching semantics. +
  • +
  • + Radicals are handled mostly by wildcard decoration after the fact, not as + true state in the reactor core. +
  • +
  • + GraphToMol does not yet write radical state back into RDKit atoms, + so radical round-tripping is incomplete. +
  • +
  • + Reaction-center extraction knows about lone-pair changes, but not yet + radical changes. +
  • +
+
+ +
+

Step 1: Expose The Small Electron-State Surface

+ +
+ Decision: + Add these to the default public node attributes: + lone_pairs, + available_lp, + radical. +
+ +

+ This is the minimum useful set for electron-aware reaction behavior: +

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldMeaningWhy reactor needs it
lone_pairsNumber of lone pairs on the atomNeeded to know whether an atom can serve as an electron-pair donor
available_lpWhether at least one lone pair is locally available for donationUseful as a compact chemistry-facing flag
radicalNumber of unpaired electronsNeeded to distinguish radical from closed-shell matching
+ +
+ Why not expose everything? + We should not push all detailed bookkeeping into every default graph yet. + Fields like valence_electrons, oxidation state, and LP diagnostics + are useful internally, but they are a larger public surface than v1 needs. +
+
+ +
+

Step 2: Expose Two Bond Views Instead Of One

+ +
+ Decision: + Default edge attributes should include both + order and kekule_order. +
+ +

+ These two fields answer different questions: +

+ + + + + + + + + + + + + + + + + + + + + +
AttributeUseExample
orderGraph identity / aromatic matchingAromatic edge can stay represented as 1.5
kekule_orderElectron bookkeeping / mechanistic bond changesSame aromatic system represented as alternating single/double bonds
+ +

+ This separation prevents a recurring confusion: +

+ +
    +
  • For matching, aromatic systems are often better treated aromatically.
  • +
  • For electron movement, sigma and pi changes are easier to reason about in Kekule form.
  • +
+ +
+ Related decision: + Do not store separate sigma and pi fields in v1. + Derive them from kekule_order when needed. +
+ +
0 -> 1  means new sigma bond
+1 -> 2  means added pi bond
+2 -> 1  means lost pi bond
+
+ +
+

Step 3: Use Named Paired ITS Attributes For Electron State

+ +
+ Decision: + The electron-aware reactor should target the newer named paired ITS + representation, not extend the old positional typesGH tuple. +
+ +

+ Good electron-aware ITS state should look like this: +

+ +
element      = ("O", "O")
+lone_pairs   = (2, 1)
+radical      = (0, 0)
+charge       = (0, 1)
+hcount       = (1, 0)
+ +

+ This is preferable to adding more positions into a legacy tuple like: +

+ +
(element, aromatic, hcount, charge, neighbors, ...)
+ +

+ because named attributes: +

+ +
    +
  • are easier to read,
  • +
  • are harder to misuse,
  • +
  • allow lone pairs and radicals to evolve independently,
  • +
  • make validation code much clearer.
  • +
+ +
+ Important: + This does not mean all old typesGH code must disappear immediately. + It means the new electron-aware path should be built on the named paired form, + and any legacy adapters should be explicit. +
+
+ +
+

Step 4: Make Radical Changes First-Class Reaction-Center Events

+ +
+ Decision: + Reaction-center extraction should treat radical change the same way it + already treats lone-pair change. +
+ +

+ Today a node can enter the reaction center because: +

+ +
    +
  • its element changes,
  • +
  • its hydrogen count changes,
  • +
  • its charge changes,
  • +
  • its lone-pair count changes,
  • +
  • its valence-electron count changes.
  • +
+ +

+ Add: +

+ +
    +
  • radical changes.
  • +
+ +

+ Example: +

+ +
radical = (1, 0)
+ +

+ should be a reason that the atom belongs to the reaction center. +

+
+ +
+

Step 5: Define Matching Semantics Clearly

+ +
+ Decision: + Lone pairs and radicals do not use the same matching rule. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldMatching RuleReason
hcounthost >= templateAlready existing behavior
lone_pairshost >= templateIf a template needs one LP donor, an atom with two LPs still satisfies that requirement
radicalhost == templateOne radical electron is chemically different from zero or two
+ +

+ This means: +

+ +
template lone_pairs = 1
+host lone_pairs     = 2
+=> allowed
+
+template radical = 1
+host radical     = 0
+=> rejected in strict mode
+
+ +
+

Step 6: Add A Compatibility Mode To SynReactor

+ +
+ Decision: + Add an explicit electron_mode parameter. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ModeBehaviorPurpose
legacyCurrent matching behavior onlyPreserve old workflows exactly
warnRun current behavior but report electron-aware mismatchesAudit templates and datasets safely
strictReject mappings that violate electron constraintsFuture chemically stricter mode
+ +
+ Initial default: + electron_mode="warn" +
+ +

+ This gives us visibility before enforcement. Old templates may accidentally + depend on broad matching behavior; we should learn where before changing + results. +

+
+ +
+

Step 7: Change The Meaning Of Charge

+ +
+ Decision: + Charge should move toward “derived plus validated,” not remain the primary + electron-state source of truth. +
+ +

+ For a Lewis-style graph, charge can be recomputed locally from: +

+ +
charge =
+    valence_electrons
+    - (2 * lone_pairs + hcount + bond_order_sum)
+ +

+ In the first implementation: +

+ +
    +
  • do not auto-correct charge yet,
  • +
  • recompute it after rewrite,
  • +
  • compare recomputed charge with represented charge,
  • +
  • warn in warn mode,
  • +
  • reject in strict mode.
  • +
+ +
+ Why not auto-overwrite immediately? + Because auto-correction can hide bad transformations and make debugging + much harder. First we want validation signal; later we can decide whether + canonical recomputation should become authoritative. +
+
+ +
+

Step 8: Preserve Radical State During Output

+ +
+ Decision: + Radical state must survive graph -> RDKit -> SMILES conversion. +
+ +

+ Today the read direction exists: +

+ +
RDKit atom -> graph node["radical"]
+ +

+ But the write direction is missing: +

+ +
graph node["radical"] -> RDKit atom
+ +

+ Without that second half, the reactor can carry correct radical state + internally and still lose it when serializing products. +

+ +

+ So GraphToMol must set RDKit radical electrons from the graph + node attribute during reconstruction. +

+
+ +
+

Step 9: Keep Existing Wildcard Radical Tools As Transitional Support

+ +
\ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 296d0a5..8d0f010 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,8 @@ scikit-learn>=1.4.0 seaborn>=0.13.2 -fgutils rdkit>=2025.3.1 pandas>=2.2.0 requests>=2.32.3 numpy>=2.2.0 regex>=2024.11.6 -sympy>=1.13.3 \ No newline at end of file +sympy>=1.13.3 diff --git a/synkit/Chem/Reaction/tautomerize.py b/synkit/Chem/Reaction/tautomerize.py index 2da5b2d..0518180 100644 --- a/synkit/Chem/Reaction/tautomerize.py +++ b/synkit/Chem/Reaction/tautomerize.py @@ -1,8 +1,9 @@ from typing import List, Dict, Optional from rdkit import Chem -from fgutils import FGQuery from joblib import Parallel, delayed +from synkit.Graph.FG import smiles_to_graph_and_functional_groups + class Tautomerize: """Standardize molecules by converting enol and hemiketal tautomers into @@ -103,18 +104,60 @@ def fix_smiles(smiles: str) -> str: :returns: Canonical SMILES of the standardized molecule. :rtype: str """ - query = FGQuery() - fg = query.get(smiles) - for item in fg: - label, indices = item + while True: + targets = Tautomerize._tautomer_targets(smiles) + if not targets: + break + label, indices = targets[0] if label == "hemiketal": smiles = Tautomerize.standardize_hemiketal(smiles, indices) - fg = query.get(smiles) elif label == "enol": smiles = Tautomerize.standardize_enol(smiles, indices) - fg = query.get(smiles) return Chem.CanonSmiles(smiles) + @staticmethod + def _tautomer_targets(smiles: str) -> list[tuple[str, List[int]]]: + """Return RDKit-index targets used by the tautomer repair helpers.""" + mol = Chem.MolFromSmiles(smiles) + if mol is None: + return [] + graph, groups = smiles_to_graph_and_functional_groups(smiles) + node_to_idx = { + ( + atom.GetAtomMapNum() if atom.GetAtomMapNum() else atom.GetIdx() + 1 + ): atom.GetIdx() + for atom in mol.GetAtoms() + } + + targets = [ + (label, [node_to_idx[node] for node in nodes]) + for label, nodes in groups + if label in {"hemiketal", "enol"} + ] + targets.extend( + ("hemiketal", [node_to_idx[node] for node in nodes]) + for nodes in Tautomerize._geminal_diol_nodes(graph) + ) + return targets + + @staticmethod + def _geminal_diol_nodes(graph) -> list[tuple[int, ...]]: + """Legacy tautomerization compatibility for hydrated carbonyls.""" + targets: list[tuple[int, ...]] = [] + for carbon, data in graph.nodes(data=True): + if data.get("element") != "C": + continue + hydroxyls = [ + neighbor + for neighbor in graph.neighbors(carbon) + if graph.nodes[neighbor].get("element") == "O" + and graph.nodes[neighbor].get("hcount", 0) >= 1 + and graph.edges[carbon, neighbor].get("order") == 1.0 + ] + if len(hydroxyls) >= 2: + targets.append((carbon, hydroxyls[0], hydroxyls[1])) + return targets + @staticmethod def fix_dict(data: Dict[str, str], reaction_column: str) -> Dict[str, str]: """Standardize the reactant and product SMILES in a reaction diff --git a/synkit/Graph/Canon/canon_graph.py b/synkit/Graph/Canon/canon_graph.py index 53e19a7..eb8d8a0 100644 --- a/synkit/Graph/Canon/canon_graph.py +++ b/synkit/Graph/Canon/canon_graph.py @@ -316,8 +316,8 @@ def _serialise(self, g: nx.Graph) -> str: nodes = sorted(g.nodes(data=True), key=lambda x: self._node_key(*x)) edges = sorted(g.edges(data=True), key=lambda x: self._edge_key(*x)) - node_str = ";".join(f"{n}:{self._node_key(n,d)}" for n, d in nodes) - edge_str = ";".join(f"{(u,v)}:{self._edge_key(u,v,d)}" for u, v, d in edges) + node_str = ";".join(f"{n}:{self._node_key(n, d)}" for n, d in nodes) + edge_str = ";".join(f"{(u, v)}:{self._edge_key(u, v, d)}" for u, v, d in edges) return f"N[{node_str}]|E[{edge_str}]" # ------------------------------------------------------------------ # diff --git a/synkit/Graph/FG/__init__.py b/synkit/Graph/FG/__init__.py new file mode 100644 index 0000000..bf415bf --- /dev/null +++ b/synkit/Graph/FG/__init__.py @@ -0,0 +1,19 @@ +"""Functional-group detection on SynKit molecular graphs.""" + +from .catalog import default_registry +from .audit import FunctionalGroupAudit, audit_reaction_smiles +from .api import FunctionalGroupLabels, smiles_to_graph_and_functional_groups +from .detector import FunctionalGroupDetector +from .model import FunctionalGroupMatch, FunctionalGroupPattern, FunctionalGroupRegistry + +__all__ = [ + "FunctionalGroupDetector", + "FunctionalGroupLabels", + "FunctionalGroupAudit", + "FunctionalGroupMatch", + "FunctionalGroupPattern", + "FunctionalGroupRegistry", + "default_registry", + "audit_reaction_smiles", + "smiles_to_graph_and_functional_groups", +] diff --git a/synkit/Graph/FG/api.py b/synkit/Graph/FG/api.py new file mode 100644 index 0000000..2ae5086 --- /dev/null +++ b/synkit/Graph/FG/api.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import networkx as nx + +from synkit.IO.chem_converter import smiles_to_graph + +from .detector import FunctionalGroupDetector + +FunctionalGroupLabels = list[tuple[str, tuple[int, ...]]] + + +def smiles_to_graph_and_functional_groups( + smiles: str, + *, + sanitize: bool = True, +) -> tuple[nx.Graph, FunctionalGroupLabels]: + """Convert SMILES to a molecular graph and detect functional groups. + + Atom-mapped SMILES keep their non-zero atom-map numbers as graph node IDs. + Unmapped atoms use their 1-based atom order as node IDs, so both mapped and + unmapped SMILES can be passed to the same API. + + :param smiles: Input SMILES, with or without atom-map labels. + :type smiles: str + :param sanitize: If ``True``, sanitize the RDKit molecule during conversion. + :type sanitize: bool + :return: Molecular graph and detected ``(name, node_ids)`` FG labels. + :rtype: tuple[nx.Graph, list[tuple[str, tuple[int, ...]]]] + :raises ValueError: If the SMILES cannot be converted to a molecular graph. + """ + graph = smiles_to_graph( + smiles, + drop_non_aam=False, + sanitize=sanitize, + use_index_as_atom_map=True, + ) + if graph is None: + raise ValueError(f"Could not convert SMILES to molecular graph: {smiles!r}") + return graph, FunctionalGroupDetector().detect(graph) diff --git a/synkit/Graph/FG/audit.py b/synkit/Graph/FG/audit.py new file mode 100644 index 0000000..5e3088c --- /dev/null +++ b/synkit/Graph/FG/audit.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from time import perf_counter +from typing import Iterable + +import networkx as nx + +from synkit.Chem.Reaction.standardize import Standardize +from synkit.IO.chem_converter import smiles_to_graph + +from .detector import FunctionalGroupDetector +from .ring_system import AromaticRingSystemDetector + + +@dataclass(frozen=True) +class FunctionalGroupAudit: + """Aggregated detector coverage over a reaction-SMILES corpus.""" + + reactions: int + molecules: int + parse_failures: int + elapsed_seconds: float + label_counts: Counter[str] + heteroaromatic_systems: int + named_heteroaromatic_systems: int + unnamed_heteroaromatic_systems: Counter[tuple] + uncovered_atom_signatures: Counter[tuple] + uncovered_edge_signatures: Counter[tuple] + + @property + def unnamed_heteroaromatic_count(self) -> int: + return self.heteroaromatic_systems - self.named_heteroaromatic_systems + + +def audit_reaction_smiles( + reactions: Iterable[str], + *, + standardizer: Standardize | None = None, +) -> FunctionalGroupAudit: + """Audit FG coverage for an iterable of reaction SMILES strings.""" + std = Standardize() if standardizer is None else standardizer + detector = FunctionalGroupDetector() + + reaction_count = 0 + molecule_count = 0 + parse_failures = 0 + heteroaromatic_systems = 0 + named_heteroaromatic_systems = 0 + label_counts: Counter[str] = Counter() + unnamed_systems: Counter[tuple] = Counter() + uncovered_atoms: Counter[tuple] = Counter() + uncovered_edges: Counter[tuple] = Counter() + + started = perf_counter() + for reaction in reactions: + reaction_count += 1 + standardized = std.fit(reaction, remove_aam=True) + for side in standardized.split(">>"): + for smiles in side.split("."): + graph = smiles_to_graph( + smiles, + drop_non_aam=False, + use_index_as_atom_map=True, + ) + if graph is None: + parse_failures += 1 + continue + molecule_count += 1 + matches = detector.matches(graph) + label_counts.update(match.name for match in matches) + covered = {node for match in matches for node in match.group_nodes} + _count_uncovered_signatures( + graph, + covered, + uncovered_atoms, + uncovered_edges, + ) + + named_ring_nodes = { + match.group_nodes + for match in matches + if match.name != "heteroaromatic_ring" + and match.pattern.priority == 70 + } + for system in AromaticRingSystemDetector.detect(graph): + if not system.hetero_nodes: + continue + heteroaromatic_systems += 1 + has_named_subring = any( + set(nodes).issubset(system.nodes) for nodes in named_ring_nodes + ) + if has_named_subring: + named_heteroaromatic_systems += 1 + continue + unnamed_systems[ + ( + system.hetero_pattern, + system.is_fused, + system.ring_sizes, + tuple(sorted(system.element_counts.items())), + ) + ] += 1 + + return FunctionalGroupAudit( + reactions=reaction_count, + molecules=molecule_count, + parse_failures=parse_failures, + elapsed_seconds=perf_counter() - started, + label_counts=label_counts, + heteroaromatic_systems=heteroaromatic_systems, + named_heteroaromatic_systems=named_heteroaromatic_systems, + unnamed_heteroaromatic_systems=unnamed_systems, + uncovered_atom_signatures=uncovered_atoms, + uncovered_edge_signatures=uncovered_edges, + ) + + +def _count_uncovered_signatures( + graph: nx.Graph, + covered: set[int], + atom_counts: Counter[tuple], + edge_counts: Counter[tuple], +) -> None: + for node, data in graph.nodes(data=True): + if data.get("element") == "H" or node in covered: + continue + neighbors = tuple( + sorted( + graph.nodes[neighbor].get("element") + for neighbor in graph.neighbors(node) + if graph.nodes[neighbor].get("element") != "H" + ) + ) + atom_counts[ + ( + data.get("element"), + data.get("aromatic", False), + data.get("hcount", 0), + neighbors, + ) + ] += 1 + + for left, right, data in graph.edges(data=True): + if left in covered or right in covered: + continue + left_element = graph.nodes[left].get("element") + right_element = graph.nodes[right].get("element") + if "H" in {left_element, right_element}: + continue + edge_counts[ + tuple(sorted((left_element, right_element))) + + (data.get("order"), data.get("aromatic", False)) + ] += 1 diff --git a/synkit/Graph/FG/catalog.py b/synkit/Graph/FG/catalog.py new file mode 100644 index 0000000..1bf3196 --- /dev/null +++ b/synkit/Graph/FG/catalog.py @@ -0,0 +1,1168 @@ +from __future__ import annotations + +from collections.abc import Iterable + +import networkx as nx + +from .model import FunctionalGroupPattern, FunctionalGroupRegistry, Mapping +from .model import FunctionalGroupMatch + + +def _graph( + nodes: Iterable[tuple[int, dict]], + edges: Iterable[tuple[int, int, dict]], +) -> nx.Graph: + graph = nx.Graph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edges) + return graph + + +def _single_heavy_neighbors( + graph: nx.Graph, node: int, *, exclude: set[int] +) -> list[int]: + return [ + neighbor + for neighbor in graph.neighbors(node) + if neighbor not in exclude and graph.nodes[neighbor].get("element") != "H" + ] + + +def _alcohol_carbon_heavy_degree(expected: int): + def validator(graph: nx.Graph, mapping: Mapping) -> bool: + carbon, oxygen = mapping[1], mapping[2] + if not _alcohol_like(graph, mapping): + return False + return len(_single_heavy_neighbors(graph, carbon, exclude={oxygen})) == expected + + return validator + + +def _alcohol_like(graph: nx.Graph, mapping: Mapping) -> bool: + carbon, oxygen = mapping[1], mapping[2] + if graph.nodes[carbon].get("aromatic"): + return False + if graph.nodes[oxygen].get("hcount", 0) < 1: + return False + return all( + graph.edges[carbon, neighbor].get("order") == 1.0 + for neighbor in graph.neighbors(carbon) + ) + + +def _aldehyde(graph: nx.Graph, mapping: Mapping) -> bool: + carbon, oxygen = mapping[1], mapping[2] + if graph.nodes[carbon].get("hcount", 0) < 1: + return False + others = _single_heavy_neighbors(graph, carbon, exclude={oxygen}) + return all(graph.nodes[node].get("element") == "C" for node in others) + + +def _amine(graph: nx.Graph, mapping: Mapping) -> bool: + nitrogen = mapping[1] + if graph.nodes[nitrogen].get("aromatic"): + return False + return all( + graph.edges[nitrogen, neighbor].get("order") == 1.0 + for neighbor in graph.neighbors(nitrogen) + ) + + +def _phenol(graph: nx.Graph, mapping: Mapping) -> bool: + carbon, oxygen = mapping[1], mapping[2] + return ( + graph.nodes[carbon].get("aromatic") is True + and graph.nodes[oxygen].get("hcount", 0) >= 1 + ) + + +def _enol(graph: nx.Graph, mapping: Mapping) -> bool: + return graph.nodes[mapping[3]].get("hcount", 0) >= 1 + + +def _epoxide(graph: nx.Graph, mapping: Mapping) -> bool: + return all(graph.nodes[mapping[node]].get("in_ring") for node in (1, 2, 3)) + + +def _phosphite(graph: nx.Graph, mapping: Mapping) -> bool: + phosphorus = mapping[1] + return all( + not ( + graph.nodes[neighbor].get("element") == "O" + and graph.edges[phosphorus, neighbor].get("order") == 2.0 + ) + for neighbor in graph.neighbors(phosphorus) + ) + + +def _azide(graph: nx.Graph, mapping: Mapping) -> bool: + middle, terminal = mapping[2], mapping[3] + return ( + graph.nodes[middle].get("charge") == 1 + and graph.nodes[terminal].get("charge") == -1 + ) + + +def _hydrazone(graph: nx.Graph, mapping: Mapping) -> bool: + imine_nitrogen = mapping[2] + hydrazine_nitrogen = mapping[3] + return not ( + graph.nodes[imine_nitrogen].get("charge") == 1 + and graph.nodes[hydrazine_nitrogen].get("charge") == -1 + ) + + +def _amidine(graph: nx.Graph, mapping: Mapping) -> bool: + carbon = mapping[1] + imine_nitrogen = mapping[2] + amino_nitrogen = mapping[3] + if any( + graph.nodes[neighbor].get("element") == "O" + for neighbor in graph.neighbors(imine_nitrogen) + if neighbor != carbon + ): + return False + if any( + graph.nodes[neighbor].get("element") == "O" + for neighbor in graph.neighbors(amino_nitrogen) + if neighbor != carbon + ): + return False + return True + + +def _imine(graph: nx.Graph, mapping: Mapping) -> bool: + carbon = mapping[1] + nitrogen = mapping[2] + if graph.nodes[carbon].get("aromatic") or graph.nodes[nitrogen].get("aromatic"): + return False + carbon_neighbors = { + graph.nodes[neighbor].get("element") + for neighbor in graph.neighbors(carbon) + if neighbor != nitrogen + } + nitrogen_neighbors = { + graph.nodes[neighbor].get("element") + for neighbor in graph.neighbors(nitrogen) + if neighbor != carbon + } + if carbon_neighbors & {"O", "S", "N"}: + return False + if nitrogen_neighbors & {"O", "N"}: + return False + return True + + +def _aniline(graph: nx.Graph, mapping: Mapping) -> bool: + return graph.nodes[mapping[1]].get("aromatic") is True and _amine( + graph, {1: mapping[2]} + ) + + +def _aryl_halide(graph: nx.Graph, mapping: Mapping) -> bool: + return graph.nodes[mapping[1]].get("aromatic") is True + + +def _oxygen_has_h(graph: nx.Graph, node: int) -> bool: + return graph.nodes[node].get("hcount", 0) >= 1 + + +def _carbon_substituent_count(graph: nx.Graph, carbon: int, excluded: set[int]) -> int: + return len(_single_heavy_neighbors(graph, carbon, exclude=excluded)) + + +def _aromatic_ring_nodes(graph: nx.Graph, mapping: Mapping) -> set[int]: + return {mapping[node] for node in mapping} + + +def _all_aromatic(graph: nx.Graph, nodes: set[int]) -> bool: + return all(graph.nodes[node].get("aromatic") for node in nodes) + + +def _single_node_recognizer(element: str): + def recognize( + graph: nx.Graph, pattern: FunctionalGroupPattern + ) -> list[FunctionalGroupMatch]: + matches: list[FunctionalGroupMatch] = [] + for node, data in graph.nodes(data=True): + if data.get("element") != element: + continue + mapping = {1: node} + if pattern.validator is not None and not pattern.validator(graph, mapping): + continue + matches.append( + FunctionalGroupMatch( + name=pattern.name, + group_nodes=(node,), + mapping=mapping, + pattern=pattern, + ) + ) + return matches + + return recognize + + +def _two_node_bond_recognizer( + left_element: str, + right_elements: tuple[str, ...], + order: float, +): + def recognize( + graph: nx.Graph, pattern: FunctionalGroupPattern + ) -> list[FunctionalGroupMatch]: + matches: list[FunctionalGroupMatch] = [] + seen: set[tuple[int, ...]] = set() + for left, right, data in graph.edges(data=True): + if data.get("order") != order: + continue + pairs = ((left, right), (right, left)) + for first, second in pairs: + if graph.nodes[first].get("element") != left_element: + continue + if graph.nodes[second].get("element") not in right_elements: + continue + mapping = {1: first, 2: second} + if pattern.validator is not None and not pattern.validator( + graph, mapping + ): + continue + group_nodes = tuple( + sorted(mapping[node] for node in pattern.group_nodes) + ) + if group_nodes in seen: + continue + seen.add(group_nodes) + matches.append( + FunctionalGroupMatch( + name=pattern.name, + group_nodes=group_nodes, + mapping=mapping, + pattern=pattern, + ) + ) + return matches + + return recognize + + +def _symmetric_two_node_bond_recognizer(element: str, order: float): + def recognize( + graph: nx.Graph, pattern: FunctionalGroupPattern + ) -> list[FunctionalGroupMatch]: + matches: list[FunctionalGroupMatch] = [] + for left, right, data in graph.edges(data=True): + if data.get("order") != order: + continue + if graph.nodes[left].get("element") != element: + continue + if graph.nodes[right].get("element") != element: + continue + mapping = {1: left, 2: right} + if pattern.validator is not None and not pattern.validator(graph, mapping): + continue + matches.append( + FunctionalGroupMatch( + name=pattern.name, + group_nodes=tuple(sorted((left, right))), + mapping=mapping, + pattern=pattern, + ) + ) + return matches + + return recognize + + +def default_registry() -> FunctionalGroupRegistry: + """Build the default graph-native functional-group registry.""" + patterns = [ + FunctionalGroupPattern( + "carbonyl", + _graph( + [(1, {"element": "C"}), (2, {"element": "O"})], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + anchor_node=2, + priority=10, + recognizer=_two_node_bond_recognizer("C", ("O",), 2.0), + ), + FunctionalGroupPattern( + "aldehyde", + _graph( + [(1, {"element": "C", "hcount_min": 1}), (2, {"element": "O"})], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + parents=("carbonyl",), + requires=("carbonyl",), + anchor_node=2, + priority=30, + validator=_aldehyde, + recognizer=_two_node_bond_recognizer("C", ("O",), 2.0), + ), + FunctionalGroupPattern( + "ketone", + _graph( + [(1, {"element": "C"}), (2, {"element": "O"})], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + parents=("carbonyl",), + requires=("carbonyl",), + anchor_node=2, + priority=20, + validator=lambda graph, mapping: graph.nodes[mapping[1]].get("hcount", 0) + == 0, + recognizer=_two_node_bond_recognizer("C", ("O",), 2.0), + ), + FunctionalGroupPattern( + "carboxylic_acid", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "O", "hcount_min": 1}), + ], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + parents=("ester",), + requires=("carbonyl",), + anchor_node=3, + priority=60, + ), + FunctionalGroupPattern( + "amide", + _graph( + [(1, {"element": "C"}), (2, {"element": "O"}), (3, {"element": "N"})], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + parents=("ketone", "amine"), + requires=("carbonyl",), + anchor_node=3, + priority=50, + ), + FunctionalGroupPattern( + "carbamate", + _graph( + [ + (1, {"element": "O"}), + (2, {"element": "C"}), + (3, {"element": "O"}), + (4, {"element": "N"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 2.0}), + (2, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + parents=("amide", "ester"), + requires=("carbonyl",), + anchor_node=4, + priority=60, + ), + FunctionalGroupPattern( + "ester", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "C"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (3, 4, {"order": 1.0}), + ], + ), + (1, 2, 3), + parents=("ketone", "ether"), + requires=("carbonyl",), + anchor_node=3, + priority=50, + ), + FunctionalGroupPattern( + "alcohol", + _graph( + [(1, {"element": "C"}), (2, {"element": "O", "hcount_min": 1})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("ether",), + anchor_node=2, + priority=20, + validator=_alcohol_like, + recognizer=_two_node_bond_recognizer("C", ("O",), 1.0), + ), + FunctionalGroupPattern( + "primary_alcohol", + _graph( + [(1, {"element": "C"}), (2, {"element": "O", "hcount_min": 1})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("alcohol",), + anchor_node=2, + priority=30, + validator=_alcohol_carbon_heavy_degree(1), + recognizer=_two_node_bond_recognizer("C", ("O",), 1.0), + ), + FunctionalGroupPattern( + "secondary_alcohol", + _graph( + [(1, {"element": "C"}), (2, {"element": "O", "hcount_min": 1})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("primary_alcohol",), + requires=("alcohol",), + anchor_node=2, + priority=40, + validator=_alcohol_carbon_heavy_degree(2), + recognizer=_two_node_bond_recognizer("C", ("O",), 1.0), + ), + FunctionalGroupPattern( + "tertiary_alcohol", + _graph( + [(1, {"element": "C"}), (2, {"element": "O", "hcount_min": 1})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("secondary_alcohol",), + requires=("alcohol",), + anchor_node=2, + priority=50, + validator=_alcohol_carbon_heavy_degree(3), + recognizer=_two_node_bond_recognizer("C", ("O",), 1.0), + ), + FunctionalGroupPattern( + "oxygen_link", + _graph( + [(1, {"element": "O"}), (2, {"element": "C"})], + [(1, 2, {"order": 1.0})], + ), + (1,), + priority=0, + recognizer=_two_node_bond_recognizer("O", ("C",), 1.0), + public=False, + ), + FunctionalGroupPattern( + "ether", + _graph( + [(1, {"element": "O"}), (2, {"element": "C"}), (3, {"element": "C"})], + [(1, 2, {"order": 1.0}), (1, 3, {"order": 1.0})], + ), + (1,), + requires=("oxygen_link",), + anchor_node=1, + priority=10, + ), + FunctionalGroupPattern( + "acetal", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "O"}), + (5, {"element": "C"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (4, 5, {"order": 1.0}), + ], + ), + (1, 2, 4), + parents=("ketal",), + requires=("oxygen_link",), + anchor_node=1, + priority=50, + validator=lambda graph, mapping: graph.nodes[mapping[1]].get("hcount", 0) + >= 1, + ), + FunctionalGroupPattern( + "ketal", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "O"}), + (5, {"element": "C"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (4, 5, {"order": 1.0}), + ], + ), + (1, 2, 4), + parents=("ether",), + requires=("oxygen_link",), + anchor_node=1, + priority=40, + validator=lambda graph, mapping: graph.nodes[mapping[1]].get("hcount", 0) + == 0 + and _carbon_substituent_count(graph, mapping[1], {mapping[2], mapping[4]}) + >= 2, + ), + FunctionalGroupPattern( + "hemiacetal", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "O"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + ], + ), + (1, 2, 4), + parents=("hemiketal",), + requires=("oxygen_link",), + suppresses=( + "alcohol", + "primary_alcohol", + "secondary_alcohol", + "tertiary_alcohol", + ), + anchor_node=1, + priority=60, + validator=lambda graph, mapping: graph.nodes[mapping[1]].get("hcount", 0) + >= 1 + and _oxygen_has_h(graph, mapping[4]), + ), + FunctionalGroupPattern( + "hemiketal", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "O"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + ], + ), + (1, 2, 4), + parents=("ketal", "alcohol"), + requires=("oxygen_link",), + suppresses=( + "alcohol", + "primary_alcohol", + "secondary_alcohol", + "tertiary_alcohol", + ), + anchor_node=1, + priority=60, + validator=lambda graph, mapping: _oxygen_has_h(graph, mapping[4]) + and graph.nodes[mapping[1]].get("hcount", 0) == 0 + and _carbon_substituent_count(graph, mapping[1], {mapping[2], mapping[4]}) + >= 2, + ), + FunctionalGroupPattern( + "amine", + _graph([(1, {"element": "N"})], []), + (1,), + anchor_node=1, + priority=10, + validator=_amine, + recognizer=_single_node_recognizer("N"), + ), + FunctionalGroupPattern( + "aniline", + _graph( + [(1, {"element": "C", "aromatic": True}), (2, {"element": "N"})], + [(1, 2, {"order": 1.0})], + ), + (2,), + parents=("amine",), + requires=("amine",), + anchor_node=2, + priority=30, + validator=_aniline, + recognizer=_two_node_bond_recognizer("C", ("N",), 1.0), + ), + FunctionalGroupPattern( + "nitrile", + _graph( + [(1, {"element": "C"}), (2, {"element": "N"})], + [(1, 2, {"order": 3.0})], + ), + (1, 2), + anchor_node=2, + priority=30, + recognizer=_two_node_bond_recognizer("C", ("N",), 3.0), + ), + FunctionalGroupPattern( + "nitroso", + _graph( + [(1, {"element": "N"}), (2, {"element": "O"})], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + anchor_node=1, + priority=30, + recognizer=_two_node_bond_recognizer("N", ("O",), 2.0), + ), + FunctionalGroupPattern( + "nitro", + _graph( + [(1, {"element": "N"}), (2, {"element": "O"}), (3, {"element": "O"})], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + parents=("nitroso",), + requires=("nitroso",), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "thioether", + _graph( + [(1, {"element": "S"}), (2, {"element": "C"}), (3, {"element": "C"})], + [(1, 2, {"order": 1.0}), (1, 3, {"order": 1.0})], + ), + (1,), + anchor_node=1, + priority=20, + ), + FunctionalGroupPattern( + "sulfoxide", + _graph( + [ + (1, {"element": "S"}), + (2, {"element": "O"}), + ], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + suppresses=("thioether",), + anchor_node=1, + priority=30, + ), + FunctionalGroupPattern( + "sulfone", + _graph( + [ + (1, {"element": "S"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + ], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 2.0})], + ), + (1, 2, 3), + parents=("sulfoxide",), + requires=("sulfoxide",), + suppresses=("thioether",), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "sulfonamide", + _graph( + [ + (1, {"element": "S"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "N"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 2.0}), + (1, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + parents=("sulfone", "amine"), + requires=("sulfone",), + suppresses=("thioether",), + anchor_node=1, + priority=50, + ), + FunctionalGroupPattern( + "thioester", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "S"}), + (4, {"element": "C"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (3, 4, {"order": 1.0}), + ], + ), + (1, 2, 3), + parents=("ketone", "thioether"), + requires=("carbonyl",), + anchor_node=3, + priority=50, + ), + FunctionalGroupPattern( + "phenol", + _graph( + [(1, {"element": "C", "aromatic": True}), (2, {"element": "O"})], + [(1, 2, {"order": 1.0})], + ), + (2,), + parents=("alcohol",), + anchor_node=2, + priority=50, + validator=_phenol, + recognizer=_two_node_bond_recognizer("C", ("O",), 1.0), + ), + FunctionalGroupPattern( + "enol", + _graph( + [(1, {"element": "C"}), (2, {"element": "C"}), (3, {"element": "O"})], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 1.0})], + ), + (1, 2, 3), + parents=("alcohol",), + anchor_node=3, + priority=40, + validator=_enol, + ), + FunctionalGroupPattern( + "peroxide", + _graph( + [(1, {"element": "O"}), (2, {"element": "O"})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("ether",), + anchor_node=1, + priority=30, + recognizer=_symmetric_two_node_bond_recognizer("O", 1.0), + ), + FunctionalGroupPattern( + "peroxy_acid", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "O", "hcount_min": 1}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (3, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + parents=("ester", "peroxide"), + requires=("carbonyl",), + anchor_node=3, + priority=60, + ), + FunctionalGroupPattern( + "anhydride", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "C"}), + (5, {"element": "O"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (3, 4, {"order": 1.0}), + (4, 5, {"order": 2.0}), + ], + ), + (1, 2, 3, 4, 5), + parents=("ester",), + requires=("carbonyl",), + anchor_node=3, + priority=60, + ), + FunctionalGroupPattern( + "acyl_chloride", + _graph( + [(1, {"element": "C"}), (2, {"element": "O"}), (3, {"element": "Cl"})], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + parents=("ketone",), + requires=("carbonyl",), + suppresses=("organohalide",), + anchor_node=3, + priority=50, + ), + FunctionalGroupPattern( + "epoxide", + _graph( + [(1, {"element": "C"}), (2, {"element": "C"}), (3, {"element": "O"})], + [ + (1, 2, {"order": 1.0}), + (1, 3, {"order": 1.0}), + (2, 3, {"order": 1.0}), + ], + ), + (1, 2, 3), + parents=("ether",), + requires=("oxygen_link",), + anchor_node=3, + priority=40, + validator=_epoxide, + ), + FunctionalGroupPattern( + "boronic_acid", + _graph( + [ + (1, {"element": "B"}), + (2, {"element": "O", "hcount_min": 1}), + (3, {"element": "O", "hcount_min": 1}), + ], + [(1, 2, {"order": 1.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "boronate_ester", + _graph( + [ + (1, {"element": "B"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "O"}), + (5, {"element": "C"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (4, 5, {"order": 1.0}), + ], + ), + (1, 2, 4), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "silyl_ether", + _graph( + [(1, {"element": "O"}), (2, {"element": "Si"})], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + anchor_node=2, + priority=30, + recognizer=_two_node_bond_recognizer("O", ("Si",), 1.0), + ), + FunctionalGroupPattern( + "phosphate", + _graph( + [ + (1, {"element": "P"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "O"}), + (5, {"element": "O"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (1, 5, {"order": 1.0}), + ], + ), + (1, 2, 3, 4, 5), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "phosphonate", + _graph( + [ + (1, {"element": "P"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "O"}), + (5, {"element": "C"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (1, 5, {"order": 1.0}), + ], + ), + (1, 2, 3, 4, 5), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "phosphine_oxide", + _graph( + [ + (1, {"element": "P"}), + (2, {"element": "O"}), + (3, {"element": "C"}), + (4, {"element": "C"}), + (5, {"element": "C"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + (1, 5, {"order": 1.0}), + ], + ), + (1, 2, 3, 4, 5), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "phosphite", + _graph( + [ + (1, {"element": "P"}), + (2, {"element": "O"}), + (3, {"element": "O"}), + (4, {"element": "O"}), + ], + [ + (1, 2, {"order": 1.0}), + (1, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + anchor_node=1, + priority=30, + validator=_phosphite, + ), + FunctionalGroupPattern( + "isocyanate", + _graph( + [ + (1, {"element": "O"}), + (2, {"element": "C"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 2.0})], + ), + (1, 2, 3), + suppresses=("carbonyl", "ketone"), + anchor_node=2, + priority=40, + ), + FunctionalGroupPattern( + "oxime", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "N"}), + (3, {"element": "O"}), + ], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 1.0})], + ), + (1, 2, 3), + anchor_node=2, + priority=40, + ), + FunctionalGroupPattern( + "hydrazone", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "N"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 1.0})], + ), + (1, 2, 3), + suppresses=("amine",), + anchor_node=2, + priority=40, + validator=_hydrazone, + ), + FunctionalGroupPattern( + "imine", + _graph( + [(1, {"element": "C"}), (2, {"element": "N"})], + [(1, 2, {"order": 2.0})], + ), + (1, 2), + anchor_node=2, + priority=20, + validator=_imine, + recognizer=_two_node_bond_recognizer("C", ("N",), 2.0), + ), + FunctionalGroupPattern( + "amidine", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "N"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + suppresses=("amine",), + anchor_node=1, + priority=40, + validator=_amidine, + ), + FunctionalGroupPattern( + "amidoxime", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "N"}), + (3, {"element": "N"}), + (4, {"element": "O"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (2, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + suppresses=("amine", "oxime"), + anchor_node=1, + priority=50, + ), + FunctionalGroupPattern( + "azide", + _graph( + [ + (1, {"element": "N"}), + (2, {"element": "N"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 2.0})], + ), + (1, 2, 3), + anchor_node=2, + priority=40, + validator=_azide, + ), + FunctionalGroupPattern( + "azo", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "N"}), + (3, {"element": "N"}), + (4, {"element": "C"}), + ], + [ + (1, 2, {"order": 1.0}), + (2, 3, {"order": 2.0}), + (3, 4, {"order": 1.0}), + ], + ), + (2, 3), + anchor_node=2, + priority=40, + ), + FunctionalGroupPattern( + "isothiocyanate", + _graph( + [ + (1, {"element": "S"}), + (2, {"element": "C"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (2, 3, {"order": 2.0})], + ), + (1, 2, 3), + anchor_node=2, + priority=50, + ), + FunctionalGroupPattern( + "thiourea", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "S"}), + (3, {"element": "N"}), + (4, {"element": "N"}), + ], + [ + (1, 2, {"order": 2.0}), + (1, 3, {"order": 1.0}), + (1, 4, {"order": 1.0}), + ], + ), + (1, 2, 3, 4), + suppresses=("amine", "thioamide"), + anchor_node=1, + priority=50, + ), + FunctionalGroupPattern( + "thioamide", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": "S"}), + (3, {"element": "N"}), + ], + [(1, 2, {"order": 2.0}), (1, 3, {"order": 1.0})], + ), + (1, 2, 3), + suppresses=("amine",), + anchor_node=1, + priority=40, + ), + FunctionalGroupPattern( + "organohalide", + _graph( + [ + (1, {"element": "C"}), + (2, {"element": ("F", "Cl", "Br", "I")}), + ], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + anchor_node=2, + priority=20, + recognizer=_two_node_bond_recognizer("C", ("F", "Cl", "Br", "I"), 1.0), + ), + FunctionalGroupPattern( + "aryl_halide", + _graph( + [ + (1, {"element": "C", "aromatic": True}), + (2, {"element": ("F", "Cl", "Br", "I")}), + ], + [(1, 2, {"order": 1.0})], + ), + (1, 2), + parents=("organohalide",), + requires=("organohalide",), + anchor_node=2, + priority=30, + validator=_aryl_halide, + recognizer=_two_node_bond_recognizer("C", ("F", "Cl", "Br", "I"), 1.0), + ), + ] + return FunctionalGroupRegistry(patterns) diff --git a/synkit/Graph/FG/detector.py b/synkit/Graph/FG/detector.py new file mode 100644 index 0000000..ddfe197 --- /dev/null +++ b/synkit/Graph/FG/detector.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from collections import Counter +from typing import Any + +import networkx as nx +from networkx.algorithms.isomorphism import GraphMatcher + +from .catalog import default_registry +from .model import FunctionalGroupMatch, FunctionalGroupPattern, FunctionalGroupRegistry +from .ring_system import AromaticRingSystemDetector + + +def _node_match(host: dict[str, Any], pattern: dict[str, Any]) -> bool: + element = pattern.get("element") + if element is not None: + allowed = element if isinstance(element, tuple) else (element,) + if host.get("element") not in allowed: + return False + + for attr in ("aromatic", "charge", "radical", "in_ring"): + if attr in pattern and host.get(attr) != pattern[attr]: + return False + + hcount = host.get("hcount", 0) + if "hcount_min" in pattern and hcount < pattern["hcount_min"]: + return False + if "hcount_max" in pattern and hcount > pattern["hcount_max"]: + return False + return True + + +def _edge_match(host: dict[str, Any], pattern: dict[str, Any]) -> bool: + if "order" in pattern and host.get("order") != pattern["order"]: + return False + if "aromatic" in pattern and host.get("aromatic") != pattern["aromatic"]: + return False + return True + + +class FunctionalGroupDetector: + """Detect functional groups from an input molecular ``nx.Graph``.""" + + def __init__(self, registry: FunctionalGroupRegistry | None = None) -> None: + self.registry = default_registry() if registry is None else registry + self.profile_counts: Counter[str] = Counter() + + def raw_matches( + self, + graph: nx.Graph, + *, + include_internal: bool = False, + ) -> list[FunctionalGroupMatch]: + """Return raw public matches before hierarchy resolution.""" + matches: list[FunctionalGroupMatch] = [] + matched_names: set[str] = set() + for pattern in self.registry.execution_order(): + if pattern.requires and not any( + required in matched_names for required in pattern.requires + ): + self.profile_counts[f"skip:{pattern.name}"] += 1 + continue + self.profile_counts[f"attempt:{pattern.name}"] += 1 + if pattern.recognizer is not None: + found = pattern.recognizer(graph, pattern) + matches.extend(found) + if found: + matched_names.add(pattern.name) + continue + if pattern.anchor_node is not None: + anchor_data = pattern.graph.nodes[pattern.anchor_node] + anchor_candidates = [ + node + for node, host_data in graph.nodes(data=True) + if _node_match(host_data, anchor_data) + ] + if not anchor_candidates: + continue + matcher = GraphMatcher( + graph, + pattern.graph, + node_match=_node_match, + edge_match=_edge_match, + ) + seen: set[tuple[int, ...]] = set() + for host_to_pattern in matcher.subgraph_monomorphisms_iter(): + mapping = { + pattern_node: host_node + for host_node, pattern_node in host_to_pattern.items() + } + if pattern.validator is not None and not pattern.validator( + graph, mapping + ): + continue + group_nodes = tuple( + sorted(mapping[node] for node in pattern.group_nodes) + ) + if group_nodes in seen: + continue + seen.add(group_nodes) + matches.append( + FunctionalGroupMatch( + name=pattern.name, + group_nodes=group_nodes, + mapping=mapping, + pattern=pattern, + ) + ) + if any(match.name == pattern.name for match in matches): + matched_names.add(pattern.name) + matches.extend(self._heteroaromatic_matches(graph)) + if include_internal: + return matches + return [match for match in matches if match.pattern.public] + + def matches(self, graph: nx.Graph) -> list[FunctionalGroupMatch]: + """Return hierarchy-resolved matches.""" + raw = self.raw_matches(graph) + raw.sort( + key=lambda match: ( + match.pattern.priority, + len(match.group_nodes), + match.name, + match.group_nodes, + ), + reverse=True, + ) + + accepted: list[FunctionalGroupMatch] = [] + for candidate in raw: + if any(self._suppressed_by(candidate, chosen) for chosen in accepted): + continue + accepted.append(candidate) + return sorted(accepted, key=lambda match: (match.group_nodes, match.name)) + + def detect(self, graph: nx.Graph) -> list[tuple[str, tuple[int, ...]]]: + """Return simple ``(name, node_ids)`` functional-group labels.""" + return [(match.name, match.group_nodes) for match in self.matches(graph)] + + def _suppressed_by( + self, + candidate: FunctionalGroupMatch, + chosen: FunctionalGroupMatch, + ) -> bool: + if not self.registry.is_ancestor(candidate.name, chosen.name): + if candidate.name not in chosen.pattern.suppresses: + return False + return set(candidate.group_nodes).issubset(chosen.group_nodes) + + @staticmethod + def _heteroaromatic_matches(graph: nx.Graph) -> list[FunctionalGroupMatch]: + matches: list[FunctionalGroupMatch] = [] + for system in AromaticRingSystemDetector.detect(graph): + if not system.hetero_nodes: + continue + pattern_graph = nx.Graph() + pattern = FunctionalGroupPattern( + name="heteroaromatic_ring", + graph=pattern_graph, + group_nodes=(), + suppresses=("amine",), + priority=60, + ) + matches.append( + FunctionalGroupMatch( + name="heteroaromatic_ring", + group_nodes=system.nodes, + mapping={}, + pattern=pattern, + ) + ) + for ( + classifier_name, + group_nodes, + ) in FunctionalGroupDetector._classify_ring_system( + graph, + system, + ): + named_pattern = FunctionalGroupPattern( + name=classifier_name, + graph=nx.Graph(), + group_nodes=(), + priority=70, + ) + matches.append( + FunctionalGroupMatch( + name=classifier_name, + group_nodes=group_nodes, + mapping={}, + pattern=named_pattern, + ) + ) + return matches + + @staticmethod + def _classify_ring_system( + graph: nx.Graph, system + ) -> list[tuple[str, tuple[int, ...]]]: + labels: list[tuple[str, tuple[int, ...]]] = [] + for ring in system.subrings: + ring_size = len(ring.nodes) + counts = ring.element_counts + sequence = ring.hetero_sequence + if counts == {"C": 5, "N": 1} and ring_size == 6: + labels.append(("pyridine", ring.nodes)) + elif counts == {"C": 4, "N": 2} and ring_size == 6: + labels.append(("diazine", ring.nodes)) + elif counts == {"C": 4, "N": 1} and ring_size == 5: + labels.append(("pyrrole", ring.nodes)) + elif counts == {"C": 4, "O": 1} and ring_size == 5: + labels.append(("furan", ring.nodes)) + elif counts == {"C": 4, "S": 1} and ring_size == 5: + labels.append(("thiophene", ring.nodes)) + elif counts == {"C": 3, "N": 2} and ring_size == 5: + if sequence == ("C", "C", "C", "N", "N"): + labels.append(("pyrazole", ring.nodes)) + elif sequence == ("C", "C", "N", "C", "N"): + labels.append(("imidazole", ring.nodes)) + elif counts == {"C": 3, "N": 1, "S": 1} and ring_size == 5: + if sequence == ("C", "C", "C", "N", "S"): + labels.append(("isothiazole", ring.nodes)) + elif sequence == ("C", "C", "N", "C", "S"): + labels.append(("thiazole", ring.nodes)) + elif counts == {"C": 3, "N": 1, "O": 1} and ring_size == 5: + if sequence == ("C", "C", "C", "N", "O"): + labels.append(("isoxazole", ring.nodes)) + elif sequence == ("C", "C", "N", "C", "O"): + labels.append(("oxazole", ring.nodes)) + elif counts == {"C": 2, "N": 3} and ring_size == 5: + labels.append(("triazole", ring.nodes)) + elif counts == {"C": 2, "N": 2, "O": 1} and ring_size == 5: + labels.append(("oxadiazole", ring.nodes)) + elif counts == {"C": 1, "N": 4} and ring_size == 5: + labels.append(("tetrazole", ring.nodes)) + elif counts == {"C": 2, "N": 2, "S": 1} and ring_size == 5: + labels.append(("thiadiazole", ring.nodes)) + elif counts == {"C": 3, "N": 3} and ring_size == 6: + labels.append(("triazine", ring.nodes)) + labels.extend(FunctionalGroupDetector._classify_fused_ring_system(system)) + return labels + + @staticmethod + def _classify_fused_ring_system(system) -> list[tuple[str, tuple[int, ...]]]: + if not system.is_fused or system.ring_sizes != (5, 6): + return [] + sequences = {ring.hetero_sequence for ring in system.subrings} + if ( + system.element_counts == {"C": 8, "N": 1} + and ( + "C", + "C", + "C", + "C", + "N", + ) + in sequences + ): + return [("indole", system.nodes)] + if system.element_counts == {"C": 7, "N": 2}: + if ("C", "C", "N", "C", "N") in sequences: + return [("benzimidazole", system.nodes)] + if ("C", "C", "C", "N", "N") in sequences: + return [("indazole", system.nodes)] + if ( + system.element_counts == {"C": 7, "N": 1, "O": 1} + and ( + "C", + "C", + "N", + "C", + "O", + ) + in sequences + ): + return [("benzoxazole", system.nodes)] + if ( + system.element_counts == {"C": 7, "N": 1, "S": 1} + and ( + "C", + "C", + "N", + "C", + "S", + ) + in sequences + ): + return [("benzothiazole", system.nodes)] + return [] diff --git a/synkit/Graph/FG/model.py b/synkit/Graph/FG/model.py new file mode 100644 index 0000000..c012328 --- /dev/null +++ b/synkit/Graph/FG/model.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Iterable + +import networkx as nx + +Mapping = dict[int, int] +Validator = Callable[[nx.Graph, Mapping], bool] +Recognizer = Callable[ + [nx.Graph, "FunctionalGroupPattern"], list["FunctionalGroupMatch"] +] + + +@dataclass(frozen=True) +class FunctionalGroupPattern: + """Graph-native functional-group definition.""" + + name: str + graph: nx.Graph + group_nodes: tuple[int, ...] + parents: tuple[str, ...] = () + suppresses: tuple[str, ...] = () + requires: tuple[str, ...] = () + anchor_node: int | None = None + priority: int = 0 + validator: Validator | None = None + recognizer: Recognizer | None = None + public: bool = True + + +@dataclass(frozen=True) +class FunctionalGroupMatch: + """One matched functional group in a host graph.""" + + name: str + group_nodes: tuple[int, ...] + mapping: Mapping + pattern: FunctionalGroupPattern + + +@dataclass +class FunctionalGroupRegistry: + """Container for functional-group patterns and hierarchy metadata.""" + + patterns: list[FunctionalGroupPattern] = field(default_factory=list) + + def add(self, pattern: FunctionalGroupPattern) -> None: + self.patterns.append(pattern) + + def extend(self, patterns: Iterable[FunctionalGroupPattern]) -> None: + self.patterns.extend(patterns) + + def by_name(self, name: str) -> FunctionalGroupPattern: + for pattern in self.patterns: + if pattern.name == name: + return pattern + raise KeyError(name) + + def is_ancestor(self, ancestor: str, child: str) -> bool: + """Return whether ``ancestor`` is an ancestor of ``child``.""" + seen: set[str] = set() + stack = [child] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + try: + parents = self.by_name(current).parents + except KeyError: + parents = () + for parent in parents: + if parent == ancestor: + return True + stack.append(parent) + return False + + def execution_order(self) -> list[FunctionalGroupPattern]: + """Return patterns in prerequisite-respecting order.""" + by_name = {pattern.name: pattern for pattern in self.patterns} + visited: set[str] = set() + ordered: list[FunctionalGroupPattern] = [] + + def visit(name: str) -> None: + if name in visited: + return + visited.add(name) + pattern = by_name[name] + for required in pattern.requires: + if required in by_name: + visit(required) + ordered.append(pattern) + + for pattern in self.patterns: + visit(pattern.name) + return ordered diff --git a/synkit/Graph/FG/ring_system.py b/synkit/Graph/FG/ring_system.py new file mode 100644 index 0000000..e357808 --- /dev/null +++ b/synkit/Graph/FG/ring_system.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass + +import networkx as nx + + +@dataclass(frozen=True) +class AromaticSubring: + """One minimal aromatic cycle inside an aromatic ring system.""" + + nodes: tuple[int, ...] + element_counts: dict[str, int] + hetero_sequence: tuple[str, ...] + + +@dataclass(frozen=True) +class AromaticRingSystem: + """One connected aromatic ring system from a molecular graph.""" + + nodes: tuple[int, ...] + edges: tuple[tuple[int, int], ...] + hetero_nodes: tuple[int, ...] + element_counts: dict[str, int] + ring_sizes: tuple[int, ...] + subrings: tuple[AromaticSubring, ...] + is_fused: bool + hetero_sequence: tuple[str, ...] | None + hetero_pattern: str + + +class AromaticRingSystemDetector: + """Extract aromatic connected components and characterize their rings.""" + + @staticmethod + def detect(graph: nx.Graph) -> list[AromaticRingSystem]: + aromatic = nx.Graph() + for node, data in graph.nodes(data=True): + if data.get("aromatic"): + aromatic.add_node(node, **data) + for left, right, data in graph.edges(data=True): + if left not in aromatic or right not in aromatic: + continue + if data.get("aromatic") or data.get("order") == 1.5: + aromatic.add_edge(left, right, **data) + + systems: list[AromaticRingSystem] = [] + for component_nodes in nx.connected_components(aromatic): + component = aromatic.subgraph(component_nodes).copy() + cycles = nx.minimum_cycle_basis(component) + if not cycles: + continue + nodes = tuple(sorted(component.nodes())) + edges = tuple(sorted(tuple(sorted(edge)) for edge in component.edges())) + hetero_nodes = tuple( + sorted( + node + for node in component.nodes() + if component.nodes[node].get("element") not in {"C", "H"} + ) + ) + element_counts = dict( + sorted( + Counter( + component.nodes[node].get("element") for node in component + ).items() + ) + ) + ring_sizes = tuple(sorted(len(cycle) for cycle in cycles)) + subrings = tuple( + sorted( + ( + AromaticSubring( + nodes=tuple(sorted(cycle)), + element_counts=dict( + sorted( + Counter( + component.nodes[node].get("element") + for node in cycle + ).items() + ) + ), + hetero_sequence=AromaticRingSystemDetector._canonical_cycle_sequence( + component, + cycle, + ), + ) + for cycle in cycles + ), + key=lambda ring: ring.nodes, + ) + ) + hetero_sequence = None + if len(cycles) == 1: + hetero_sequence = subrings[0].hetero_sequence + systems.append( + AromaticRingSystem( + nodes=nodes, + edges=edges, + hetero_nodes=hetero_nodes, + element_counts=element_counts, + ring_sizes=ring_sizes, + subrings=subrings, + is_fused=len(cycles) > 1, + hetero_sequence=hetero_sequence, + hetero_pattern=AromaticRingSystemDetector._pattern( + element_counts, + ring_sizes, + ), + ) + ) + return sorted(systems, key=lambda system: system.nodes) + + @staticmethod + def _canonical_cycle_sequence( + graph: nx.Graph, + cycle_nodes: list[int], + ) -> tuple[str, ...]: + cycle = list(cycle_nodes) + subgraph = graph.subgraph(cycle) + start = min(cycle) + neighbors = sorted(subgraph.neighbors(start)) + if len(neighbors) != 2: + return tuple(graph.nodes[node].get("element") for node in sorted(cycle)) + + candidates: list[tuple[str, ...]] = [] + for first in neighbors: + order = [start, first] + while len(order) < len(cycle): + current = order[-1] + previous = order[-2] + nxt = [node for node in subgraph.neighbors(current) if node != previous] + if not nxt: + break + order.append(nxt[0]) + seq = tuple(graph.nodes[node].get("element") for node in order) + candidates.append(seq) + rotations: list[tuple[str, ...]] = [] + for seq in candidates: + for index in range(len(seq)): + rotations.append(seq[index:] + seq[:index]) + return min(rotations) + + @staticmethod + def _pattern(element_counts: dict[str, int], ring_sizes: tuple[int, ...]) -> str: + hetero = [ + f"{count}{element}" + for element, count in element_counts.items() + if element not in {"C", "H"} + ] + prefix = "-".join(hetero) if hetero else "carbocycle" + sizes = "-".join(str(size) for size in ring_sizes) + return f"{prefix}-{sizes}ring" diff --git a/synkit/Graph/Hyrogen/_misc.py b/synkit/Graph/Hyrogen/_misc.py index b84ad5c..2568a01 100644 --- a/synkit/Graph/Hyrogen/_misc.py +++ b/synkit/Graph/Hyrogen/_misc.py @@ -134,6 +134,9 @@ def h_to_explicit(G: nx.Graph, nodes: List[int] = None, its: bool = False) -> nx if heavy not in H2: continue count = H2.nodes[heavy].get("hcount", 0) + if its and _is_pair_hcount(count): + max_node = _expand_paired_its_hydrogens(H2, heavy, max_node) + continue if count <= 0: continue @@ -164,6 +167,65 @@ def h_to_explicit(G: nx.Graph, nodes: List[int] = None, its: bool = False) -> nx return H2 +def _is_pair_hcount(value: Any) -> bool: + """Return whether a value is a 2-item integer hydrogen-count pair.""" + return ( + isinstance(value, (list, tuple)) + and len(value) == 2 + and all(isinstance(item, int) for item in value) + ) + + +def _paired_hydrogen_node_attrs(present: Tuple[bool, bool]) -> dict: + """Build named paired attrs for a tuple-style ITS hydrogen node.""" + return { + "element": ("H", "H"), + "aromatic": (False, False), + "hcount": (0, 0), + "charge": (0, 0), + "radical": (0, 0), + "lone_pairs": (0, 0), + "valence_electrons": (1, 1), + "neighbors": ([], []), + "atom_map": (0, 0), + "present": present, + } + + +def _expand_paired_its_hydrogens( + graph: nx.Graph, + heavy: int, + max_node: int, +) -> int: + """Expand paired ITS hydrogen counts into shared and side-only H nodes.""" + react_h, prod_h = graph.nodes[heavy]["hcount"] + shared = min(react_h, prod_h) + react_only = react_h - shared + prod_only = prod_h - shared + + expansion_plan = ( + [((True, True), (1.0, 1.0))] * shared + + [((True, False), (1.0, 0.0))] * react_only + + [((False, True), (0.0, 1.0))] * prod_only + ) + + for present, bond_pair in expansion_plan: + max_node += 1 + graph.add_node(max_node, **_paired_hydrogen_node_attrs(present)) + graph.add_edge( + heavy, + max_node, + order=bond_pair, + kekule_order=bond_pair, + sigma_order=bond_pair, + pi_order=(0.0, 0.0), + standard_order=bond_pair[0] - bond_pair[1], + ) + + graph.nodes[heavy]["hcount"] = (0, 0) + return max_node + + def implicit_hydrogen( graph: nx.Graph, preserve_atom_maps: Set[int], reindex: bool = False ) -> nx.Graph: @@ -464,16 +526,17 @@ def _normalize_h_pair(h_react: int, h_prod: int) -> Tuple[int, int]: def normalize_h_pair_graph(rc_graph: nx.Graph, inplace: bool = False) -> nx.Graph: """ - Normalize the hydrogen-count field inside ``typesGH`` for all nodes. + Normalize paired hydrogen counts for all ITS nodes. - Assumption: - ``typesGH`` is a 2-tuple: - (reactant_attr, product_attr) + New-style ITS nodes may store ``hcount`` directly as + ``(reactant_hcount, product_hcount)``. Legacy ITS nodes may instead store + hydrogen counts inside ``typesGH``: - and each attr tuple has the form: - (element, aromatic, hydrogen_count, charge, neighbors) + ``typesGH = (reactant_attr, product_attr)`` - Only the hydrogen_count field at index 2 is normalized. + where each side tuple has the form + ``(element, aromatic, hydrogen_count, charge, neighbors)``. + Both representations are normalized when present. :param rc_graph: Reaction-center graph. :type rc_graph: nx.Graph @@ -485,6 +548,14 @@ def normalize_h_pair_graph(rc_graph: nx.Graph, inplace: bool = False) -> nx.Grap graph = rc_graph if inplace else deepcopy(rc_graph) for node, data in graph.nodes(data=True): + hcount = data.get("hcount") + if ( + isinstance(hcount, (list, tuple)) + and len(hcount) == 2 + and all(isinstance(value, int) for value in hcount) + ): + data["hcount"] = _normalize_h_pair(hcount[0], hcount[1]) + typesgh = data.get("typesGH") if typesgh is None: continue diff --git a/synkit/Graph/ITS/its_construction.py b/synkit/Graph/ITS/its_construction.py index 0934776..8f00d65 100644 --- a/synkit/Graph/ITS/its_construction.py +++ b/synkit/Graph/ITS/its_construction.py @@ -24,11 +24,15 @@ class ITSConstruction: "partial_charge": 0, "hybridization": "", "lone_pairs": 0, + "radical": 0, "valence_electrons": 0, } CORE_EDGE_DEFAULTS: Dict[str, Any] = { "order": 0.0, + "kekule_order": 0.0, + "sigma_order": 0.0, + "pi_order": 0.0, "ez_isomer": "", "bond_type": "", "conjugated": False, @@ -235,6 +239,7 @@ def _populate_node_attributes( ) its.nodes[n]["typesGH"] = (g_tuple, h_tuple) + its.nodes[n]["present"] = (n in G, n in H) for i, attr in enumerate(node_attrs): its.nodes[n][attr] = (g_tuple[i], h_tuple[i]) if store else g_tuple[i] @@ -446,8 +451,16 @@ def construct( "hcount", "charge", "neighbors", + "lone_pairs", + "radical", + "valence_electrons", + ] + edge_attrs = edge_attrs or [ + "order", + "kekule_order", + "sigma_order", + "pi_order", ] - edge_attrs = edge_attrs or ["order"] node_defaults = ITSConstruction._resolve_defaults( attributes_defaults, ITSConstruction.CORE_NODE_DEFAULTS diff --git a/synkit/Graph/ITS/its_destruction.py b/synkit/Graph/ITS/its_destruction.py index 274a969..3e99ffc 100644 --- a/synkit/Graph/ITS/its_destruction.py +++ b/synkit/Graph/ITS/its_destruction.py @@ -35,13 +35,26 @@ def __init__( its_graph: nx.Graph, node_attrs: Optional[List[str]] = None, edge_share: str = "order", + edge_attrs: Optional[List[str]] = None, clean_wildcard: bool = False, ): if node_attrs is None: - node_attrs = ["element", "charge", "hcount", "aromatic", "atom_map"] + node_attrs = [ + "element", + "charge", + "hcount", + "aromatic", + "radical", + "lone_pairs", + "valence_electrons", + "atom_map", + ] + if edge_attrs is None: + edge_attrs = [edge_share, "kekule_order", "sigma_order", "pi_order"] self._its = its_graph self.node_attrs = node_attrs self.edge_share = edge_share + self.edge_attrs = edge_attrs self.clean_wildcard = clean_wildcard self._G: Optional[nx.Graph] = None self._H: Optional[nx.Graph] = None @@ -143,10 +156,19 @@ def _has_real(side_dict: Dict[str, Any]) -> bool: order_g, order_h = order_tuple else: order_g = order_h = 0.0 + g_edge_attrs: Dict[str, Any] = {} + h_edge_attrs: Dict[str, Any] = {} + for attr in self.edge_attrs: + value = data.get(attr) + if isinstance(value, tuple) and len(value) == 2: + g_edge_attrs[attr], h_edge_attrs[attr] = value + elif value is not None: + g_edge_attrs[attr] = value + h_edge_attrs[attr] = value if isinstance(order_g, (int, float)) and order_g > 0: - G.add_edge(u, v, order=order_g) + G.add_edge(u, v, **g_edge_attrs) if isinstance(order_h, (int, float)) and order_h > 0: - H.add_edge(u, v, order=order_h) + H.add_edge(u, v, **h_edge_attrs) # Apply wildcard cleaning if requested (without neighbor contraction) if self.clean_wildcard: diff --git a/synkit/Graph/ITS/its_expand.py b/synkit/Graph/ITS/its_expand.py index 0a23438..d8c5bf6 100644 --- a/synkit/Graph/ITS/its_expand.py +++ b/synkit/Graph/ITS/its_expand.py @@ -16,9 +16,17 @@ class ITSExpand: reaction center graph. This class identifies the reaction center from an RSMI, builds and - reconstructs the ITS graph, decomposes it back into reactants and - products, and standardizes atom mappings to produce a fully mapped - AAM RSMI. + reconstructs the ITS graph, decomposes it back into reactants and products, + and standardizes atom mappings to produce a fully mapped AAM RSMI. + + The optional ``preserve_older_map`` mode keeps existing atom-map numbers + from the input RSMI by reindexing the side graph before ITS reconstruction. + + Notes + ----- + ``preserve_older_map=True`` is intended for the ITS expansion path only. + It should not be combined with ``relabel=True``, because ``ITSRelabel`` + globally renumbers atom maps. :cvar std: Standardize instance for reaction SMILES standardization. :type std: Standardize @@ -31,56 +39,406 @@ def __init__(self) -> None: """ pass + @staticmethod + def _split_rsmi(rsmi: str) -> tuple[str, str]: + """Split a reaction SMILES into reactant and product sides. + + :param rsmi: Reaction SMILES string in ``reactant>>product`` format. + :type rsmi: str + :returns: Reactant-side SMILES and product-side SMILES. + :rtype: tuple[str, str] + :raises ValueError: If the input is not a valid two-sided RSMI. + """ + try: + react_smi, prod_smi = rsmi.split(">>") + except ValueError as e: + raise ValueError("Input RSMI must be 'reactant>>product'") from e + + if not react_smi or not prod_smi: + raise ValueError("Input RSMI must contain both reactant and product sides") + + return react_smi, prod_smi + + @staticmethod + def _atom_map(data: dict) -> int: + """Safely extract an atom-map number from node attributes. + + :param data: Node attribute dictionary. + :type data: dict + :returns: Atom-map number. Returns ``0`` if absent or falsy. + :rtype: int + """ + return int(data.get("atom_map", 0) or 0) + + @staticmethod + def _nonzero_atom_maps(graph) -> list[int]: + """Collect all nonzero atom-map numbers from a graph. + + :param graph: Molecular graph. + :type graph: networkx.Graph + :returns: List of nonzero atom-map numbers. + :rtype: list[int] + """ + return [ + ITSExpand._atom_map(data) + for _, data in graph.nodes(data=True) + if ITSExpand._atom_map(data) != 0 + ] + + @staticmethod + def _validate_unique_atom_maps(atom_maps: list[int]) -> None: + """Validate that all nonzero atom-map numbers are unique. + + :param atom_maps: Nonzero atom-map numbers. + :type atom_maps: list[int] + :raises ValueError: If duplicate nonzero atom-map numbers are found. + """ + if len(atom_maps) != len(set(atom_maps)): + raise ValueError( + "Duplicate nonzero atom_map values found in side graph. " + "Cannot safely reindex graph by atom_map." + ) + + @staticmethod + def _validate_atom_maps_within_range( + atom_maps: list[int], + n_nodes: int, + ) -> None: + """Validate that atom-map numbers can be used as contiguous node IDs. + + In the side graph, we want final node IDs to remain exactly ``1..N``. + Therefore, a mapped atom can only be moved to its atom-map number if + that number is within ``1..N``. + + :param atom_maps: Nonzero atom-map numbers. + :type atom_maps: list[int] + :param n_nodes: Number of nodes in the side graph. + :type n_nodes: int + :raises ValueError: If any atom-map number is outside ``1..N``. + """ + bad_targets = [ + atom_map for atom_map in atom_maps if atom_map < 1 or atom_map > n_nodes + ] + + if bad_targets: + raise ValueError( + "Cannot keep side graph node ids contiguous from 1..N while " + f"also using atom_map as node id. The following atom maps are " + f"outside 1..{n_nodes}: {bad_targets}" + ) + + @staticmethod + def _assign_mapped_nodes(graph) -> tuple[dict, set[int]]: + """Assign mapped atoms to node IDs equal to their atom-map numbers. + + For example, if a node has ``atom_map=20``, the returned mapping assigns + that old node to new node ID ``20``. + + :param graph: Molecular side graph. + :type graph: networkx.Graph + :returns: A partial old-node to new-node mapping and the used node IDs. + :rtype: tuple[dict, set[int]] + """ + mapping = {} + used_ids = set() + + for node, data in graph.nodes(data=True): + atom_map = ITSExpand._atom_map(data) + + if atom_map == 0: + continue + + mapping[node] = atom_map + used_ids.add(atom_map) + + return mapping, used_ids + + @staticmethod + def _assign_unmapped_nodes( + graph, + mapping: dict, + used_ids: set[int], + ) -> dict: + """Assign unmapped atoms while preserving contiguous node IDs. + + Unmapped atoms keep their original node ID when possible. If an + unmapped atom's node ID conflicts with a mapped atom's target ID, it is + moved into one of the remaining free IDs inside ``1..N``. + + :param graph: Molecular side graph. + :type graph: networkx.Graph + :param mapping: Existing mapping from mapped atoms. + :type mapping: dict + :param used_ids: Node IDs already occupied by mapped atoms. + :type used_ids: set[int] + :returns: Complete old-node to new-node mapping. + :rtype: dict + """ + n_nodes = graph.number_of_nodes() + free_ids = set(range(1, n_nodes + 1)) - used_ids + pending_unmapped = [] + + for node, data in graph.nodes(data=True): + atom_map = ITSExpand._atom_map(data) + + if atom_map != 0: + continue + + if isinstance(node, int) and node in free_ids: + mapping[node] = node + free_ids.remove(node) + else: + pending_unmapped.append(node) + + for old_node, new_node in zip(pending_unmapped, sorted(free_ids)): + mapping[old_node] = new_node + + return mapping + + @staticmethod + def _validate_contiguous_mapping(mapping: dict, n_nodes: int) -> None: + """Validate that a mapping produces exactly node IDs ``1..N``. + + :param mapping: Old-node to new-node mapping. + :type mapping: dict + :param n_nodes: Number of nodes in the graph. + :type n_nodes: int + :raises RuntimeError: If the mapped node IDs are not exactly ``1..N``. + """ + expected_ids = set(range(1, n_nodes + 1)) + actual_ids = set(mapping.values()) + + if actual_ids != expected_ids: + missing = sorted(expected_ids - actual_ids) + extra = sorted(actual_ids - expected_ids) + raise RuntimeError( + f"Reindexing failed. Missing node ids: {missing}; " + f"extra node ids: {extra}" + ) + + @staticmethod + def _build_side_graph_reindex_mapping(graph) -> dict: + """Build an old-node to new-node mapping for a side graph. + + The mapping satisfies two conditions: + + 1. Every atom with ``atom_map != 0`` is assigned to node ID + ``atom_map``. + 2. The final node IDs are exactly contiguous from ``1..N``. + + :param graph: Molecular side graph. + :type graph: networkx.Graph + :returns: Old-node to new-node mapping. + :rtype: dict + :raises ValueError: If atom-map values are duplicated or incompatible + with contiguous node IDs. + """ + n_nodes = graph.number_of_nodes() + atom_maps = ITSExpand._nonzero_atom_maps(graph) + + ITSExpand._validate_unique_atom_maps(atom_maps) + ITSExpand._validate_atom_maps_within_range(atom_maps, n_nodes) + + mapping, used_ids = ITSExpand._assign_mapped_nodes(graph) + mapping = ITSExpand._assign_unmapped_nodes(graph, mapping, used_ids) + + ITSExpand._validate_contiguous_mapping(mapping, n_nodes) + + return mapping + + @staticmethod + def _copy_nodes_with_mapping(graph, new_graph, mapping: dict) -> None: + """Copy graph nodes into a new graph using a node mapping. + + :param graph: Source graph. + :type graph: networkx.Graph + :param new_graph: Destination graph. + :type new_graph: networkx.Graph + :param mapping: Old-node to new-node mapping. + :type mapping: dict + """ + for old_node, new_node in mapping.items(): + attrs = dict(graph.nodes[old_node]) + new_graph.add_node(new_node, **attrs) + + @staticmethod + def _copy_edges_with_mapping(graph, new_graph, mapping: dict) -> None: + """Copy graph edges into a new graph using a node mapping. + + Supports both simple graphs and multigraphs. + + :param graph: Source graph. + :type graph: networkx.Graph + :param new_graph: Destination graph. + :type new_graph: networkx.Graph + :param mapping: Old-node to new-node mapping. + :type mapping: dict + """ + if graph.is_multigraph(): + for u, v, key, attrs in graph.edges(keys=True, data=True): + new_graph.add_edge( + mapping[u], + mapping[v], + key=key, + **dict(attrs), + ) + return + + for u, v, attrs in graph.edges(data=True): + new_graph.add_edge( + mapping[u], + mapping[v], + **dict(attrs), + ) + + @staticmethod + def _rebuild_graph_with_mapping(graph, mapping: dict): + """Rebuild a graph with remapped node IDs. + + This avoids in-place relabeling collisions, for example when node ``27`` + must become node ``20`` while old node ``20`` must move elsewhere. + + :param graph: Source graph. + :type graph: networkx.Graph + :param mapping: Old-node to new-node mapping. + :type mapping: dict + :returns: Rebuilt graph with remapped node IDs. + :rtype: networkx.Graph + """ + new_graph = graph.__class__() + new_graph.graph.update(graph.graph) + + ITSExpand._copy_nodes_with_mapping(graph, new_graph, mapping) + ITSExpand._copy_edges_with_mapping(graph, new_graph, mapping) + + return new_graph + + @staticmethod + def reindex_side_graph_by_atom_map(graph): + """Reindex a side graph so mapped atoms use ``atom_map`` as node ID. + + The returned graph keeps node IDs contiguous from ``1..N``. + + This is useful because the reaction-center graph produced by + ``ITSConstruction().ITSGraph(...)`` uses atom-map numbers as node IDs, + whereas the side graph produced by ``smiles_to_graph(...)`` may use + RDKit-style atom indices as node IDs. + + Example + ------- + Before reindexing: + + .. code-block:: text + + Node 20: atom_map = 0 + Node 27: atom_map = 20 + + After reindexing: + + .. code-block:: text + + Node 20: atom_map = 20 + Node 27: atom_map = 0 + + or another unmapped atom may be moved into the freed node position. + + :param graph: Molecular side graph. + :type graph: networkx.Graph + :returns: Reindexed side graph with contiguous node IDs. + :rtype: networkx.Graph + :raises ValueError: If atom-map numbers cannot be safely used as node + IDs while preserving ``1..N`` indexing. + """ + mapping = ITSExpand._build_side_graph_reindex_mapping(graph) + return ITSExpand._rebuild_graph_with_mapping(graph, mapping) + @staticmethod def expand_aam_with_its( rsmi: str, relabel: bool = False, use_G: bool = True, + preserve_older_map: bool = False, ) -> str: """Expand a partial reaction SMILES to a full AAM RSMI using ITS reconstruction. - :param rsmi: Reaction SMILES string in the format 'reactant>>product'. + :param rsmi: Reaction SMILES string in the format + ``reactant>>product``. :type rsmi: str - :param use_G: If True, expand using the reactant side; otherwise use the product side. + :param relabel: If True, directly apply ``ITSRelabel().fit(rsmi)``. + This globally renumbers atom maps. + :type relabel: bool + :param use_G: If True, expand using the reactant side. If False, + expand using the product side. :type use_G: bool - :param light_weight: Flag indicating whether to apply a lighter-weight standardization. - :type light_weight: bool - :returns: Fully atom-mapped reaction SMILES after ITS expansion and standardization. + :param preserve_older_map: If True, preserve existing nonzero atom-map + numbers by reindexing the side graph before ITS reconstruction. + This keeps old maps such as ``:20`` attached to the same atom. + This option is incompatible with ``relabel=True``. + :type preserve_older_map: bool + :returns: Fully atom-mapped reaction SMILES after ITS expansion and + standardization. :rtype: str - :raises ValueError: If input RSMI format is invalid or ITS reconstruction fails. + :raises ValueError: If input RSMI format is invalid, if incompatible + options are used, or if side-graph reindexing is unsafe. :example: >>> expander = ITSExpand() - >>> expander.expand_aam_with_its("CC[CH2:3][Cl:1].[N:2]>>CC[CH2:3][N:2].[Cl:1]") + >>> expander.expand_aam_with_its( + ... "CC[CH2:3][Cl:1].[N:2]>>CC[CH2:3][N:2].[Cl:1]", + ... preserve_older_map=True, + ... ) '[CH3:1][CH2:2][CH2:3][Cl:4].[N:5]>>[CH3:1][CH2:2][CH2:3][N:5].[Cl:4]' """ + if relabel and preserve_older_map: + raise ValueError( + "preserve_older_map=True cannot be combined with relabel=True. " + "ITSRelabel globally renumbers atom maps. Use relabel=False " + "with preserve_older_map=True." + ) + if relabel: return ITSRelabel().fit(rsmi) - # Validate and split reaction SMILES - try: - react_smi, prod_smi = rsmi.split(">>") - except ValueError as e: - raise ValueError("Input RSMI must be 'reactant>>product'") from e - # Build graphs for reactants and products + react_smi, prod_smi = ITSExpand._split_rsmi(rsmi) + + # Build graphs for reactants and products. react_graph, prod_graph = rsmi_to_graph(rsmi) - # Construct the ITS reaction center graph + # Construct the ITS reaction-center graph. + # + # Do NOT reindex rc_graph here. + # The reaction-center graph already uses atom-map numbers as node IDs, + # for example nodes 10, 11, 12, and 20. rc_graph = ITSConstruction().ITSGraph(react_graph, prod_graph) - # Choose which side to expand + # Choose which side to expand. smi_side = react_smi if use_G else prod_smi + side_graph = smiles_to_graph( - smi_side, sanitize=True, drop_non_aam=False, use_index_as_atom_map=False + smi_side, + sanitize=True, + drop_non_aam=False, + use_index_as_atom_map=False, ) - # Reconstruct the full ITS graph + # Node IDs remain contiguous from 1..N. + if preserve_older_map: + side_graph = ITSExpand.reindex_side_graph_by_atom_map(side_graph) + + # Reconstruct the full ITS graph. its_graph = ITSBuilder().ITSGraph(side_graph, rc_graph) - # Decompose ITS back into reactant and product graphs + # Decompose ITS back into reactant and product graphs. new_react, new_prod = its_decompose(its_graph) - # Convert graphs back to RSMI and standardize atom mappings - expanded_rsmi = graph_to_rsmi(new_react, new_prod, its_graph, True, False) + # Convert graphs back to RSMI and standardize atom mappings. + expanded_rsmi = graph_to_rsmi( + new_react, + new_prod, + its_graph, + True, + False, + ) + return std.fit(expanded_rsmi, remove_aam=False) diff --git a/synkit/Graph/ITS/its_reverter.py b/synkit/Graph/ITS/its_reverter.py index c69b5be..a8d6842 100644 --- a/synkit/Graph/ITS/its_reverter.py +++ b/synkit/Graph/ITS/its_reverter.py @@ -57,12 +57,15 @@ class ITSReverter: "neighbors", "atom_map", "lone_pairs", + "radical", "valence_electrons", ) #: edge attributes commonly stored in ITS and worth restoring DEFAULT_EDGE_ATTRS = ( "kekule_order", + "sigma_order", + "pi_order", "order", "bond_type", "conjugated", @@ -126,6 +129,10 @@ def _node_exists_on_side(cls, attrs: dict[str, Any], idx: int) -> bool: :returns: Whether the node exists on that side. :rtype: bool """ + present = attrs.get("present") + if isinstance(present, tuple) and len(present) == 2: + return bool(present[idx]) + element = cls._pick_side_value(attrs.get("element"), idx) return element not in (None, "") diff --git a/synkit/Graph/ITS/rc_extractor.py b/synkit/Graph/ITS/rc_extractor.py index 29466aa..98b309f 100644 --- a/synkit/Graph/ITS/rc_extractor.py +++ b/synkit/Graph/ITS/rc_extractor.py @@ -30,6 +30,7 @@ class RCExtractor: - ``hcount`` (after hydrogen-pair normalization) - ``charge`` - ``lone_pairs`` (or alias ``lp``) + - ``radical`` - ``valence_electrons`` Default exported attribute sets @@ -47,12 +48,15 @@ class RCExtractor: - ``hybridization`` - ``atom_map`` - ``lone_pairs`` + - ``radical`` - ``valence_electrons`` - ``partial_charge`` Default edge attributes: - ``kekule_order`` + - ``sigma_order`` + - ``pi_order`` - ``order`` - ``bond_type`` - ``conjugated`` @@ -123,6 +127,7 @@ class RCExtractor: "hcount", "charge", "lone_pairs", + "radical", "valence_electrons", ) LP_ALIASES = ("lone_pairs", "lp") @@ -136,12 +141,15 @@ class RCExtractor: "hybridization", "atom_map", "lone_pairs", + "radical", "valence_electrons", "partial_charge", ) DEFAULT_EDGE_ATTRS = ( "kekule_order", + "sigma_order", + "pi_order", "order", "bond_type", "conjugated", @@ -152,6 +160,7 @@ def __init__( self, node_attrs: Iterable[str] | None = None, edge_attrs: Iterable[str] | None = None, + preserve_full_attrs: bool = False, ) -> None: """ Initialize the reaction-center extractor. @@ -164,9 +173,14 @@ def __init__( ``graph.graph["rc"]["edge_attrs"]``. If ``None``, the class defaults are used. :type edge_attrs: Iterable[str] | None + :param preserve_full_attrs: If ``True``, export complete node and edge + attribute dictionaries in the RC metadata snapshots instead of the + configured attribute subset. + :type preserve_full_attrs: bool """ self._node_attrs = tuple(node_attrs or self.DEFAULT_NODE_ATTRS) self._edge_attrs = tuple(edge_attrs or self.DEFAULT_EDGE_ATTRS) + self.preserve_full_attrs = preserve_full_attrs def __repr__(self) -> str: """ @@ -421,7 +435,11 @@ def _collect_node_attrs( """ collected: dict[int, dict[str, Any]] = {} for node in nodes: - selected = self._select_attrs(graph.nodes[node], self.node_attrs) + selected = ( + dict(graph.nodes[node]) + if self.preserve_full_attrs + else self._select_attrs(graph.nodes[node], self.node_attrs) + ) if selected: collected[node] = selected return collected @@ -445,7 +463,11 @@ def _collect_edge_attrs( collected: dict[tuple[int, int], dict[str, Any]] = {} for u, v in edges: edge_key = self._edge_key(u, v) - selected = self._select_attrs(graph.edges[u, v], self.edge_attrs) + selected = ( + dict(graph.edges[u, v]) + if self.preserve_full_attrs + else self._select_attrs(graph.edges[u, v], self.edge_attrs) + ) if selected: collected[edge_key] = selected return collected diff --git a/synkit/Graph/MTG/group_comp.py b/synkit/Graph/MTG/group_comp.py deleted file mode 100644 index dc83c53..0000000 --- a/synkit/Graph/MTG/group_comp.py +++ /dev/null @@ -1,157 +0,0 @@ -"""groupcomp.py -~~~~~~~~~~~~~~~~ -Orchestration utilities to discover *groupoid‑compatible* merge candidates between two -`networkx` graphs, mirroring the MTG public API style. - -* Single orchestration class – :class:`GroupComp` – instantiated with two graphs. -* Exposes high‑level methods to get **node candidates**, **edge candidates**, and a **mapping**. -* Lean – core node/edge logic lives in `groupoid.py`; this module coordinates and presents a clean API. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from collections import defaultdict -from typing import Any, Dict, List, Iterable, Tuple - -import networkx as nx - -from synkit.Graph.MTG.groupoid import ( - node_constraint, - edge_constraint, -) - -# ============================================================================== -# Type Aliases -# ============================================================================== - -NodeId = int -Node = Tuple[NodeId, Dict[str, Any]] -Edge = Tuple[NodeId, NodeId, Dict[str, Any]] # (u, v, attribute-dict) -MappingList = List[Dict[NodeId, NodeId]] - -# ============================================================================== -# Public orchestration class -# ============================================================================== - - -@dataclass(slots=True) -class GroupComp: - """Compute node/edge merge mappings that respect the *groupoid* rule. - - Parameters - ---------- - G1, G2 : networkx.Graph or networkx.DiGraph - Graphs between which to find compatible merge candidates. - """ - - G1: nx.Graph - G2: nx.Graph - - # ................................................................. - # SINGLE‑NODE MAPPING (FALLBACK) - # ................................................................. - @staticmethod - def get_mapping_from_nodes( - node_mapping: Dict[NodeId, List[NodeId]], - edges1: Iterable[Edge], - edges2: Iterable[Edge], - ) -> MappingList: - """Return *single‑node* mappings ``[{v₁: v₂}, …]`` that obey the - groupoid order rule w.r.t **all** incident edges on each side.""" - # Index incident edges once – O(|E|) - inc1: Dict[NodeId, List[Edge]] = defaultdict(list) - inc2: Dict[NodeId, List[Edge]] = defaultdict(list) - for u, v, a in edges1: - inc1[u].append((u, v, a)) - inc1[v].append((u, v, a)) - for u, v, a in edges2: - inc2[u].append((u, v, a)) - inc2[v].append((u, v, a)) - - res: MappingList = [] - for v1, cand in node_mapping.items(): - E1 = inc1.get(v1, []) - for v2 in cand: - E2 = inc2.get(v2, []) - if not E1 and not E2: # isolate nodes – always compatible - res.append({v1: v2}) - continue - # Forward check: every e1 has partner e2 - fwd_ok = all( - any( - a1.get("order", (None, None))[1] - == a2.get("order", (None, None))[0] - for _, _, a2 in E2 - ) - for _, _, a1 in E1 - ) - if not fwd_ok: - continue - # Reverse check: every e2 has partner e1 - rev_ok = all( - any( - a2.get("order", (None, None))[0] - == a1.get("order", (None, None))[1] - for _, _, a1 in E1 - ) - for _, _, a2 in E2 - ) - if rev_ok: - res.append({v1: v2}) - return res - - def get_mapping( - self, - *, - include_singleton: bool = False, - algorithm: str = "bt", - mcs: bool = False, - ) -> MappingList: - """Return all *groupoid‑legal* node‑mappings between G1 and G2. - - Steps: - 1. :func:`node_constraint` – filter by element/charge. - 2. :func:`edge_constraint` – structural filter (pairwise edges). - 3. Optionally fallback to :func:`get_mapping_from_nodes` for isolated nodes - or if *include_singleton* is *True*. - """ - # 1. node candidates - node_map = node_constraint(self.G1.nodes(data=True), self.G2.nodes(data=True)) - # 2. edge‑based candidates - mappings = edge_constraint( - self.G1.edges(data=True), - self.G2.edges(data=True), - node_map, - algorithm=algorithm, - mcs=mcs, - ) - # 3. fallback single‑node mappings - if include_singleton or not mappings: - singletons = self.get_mapping_from_nodes( - node_map, self.G1.edges(data=True), self.G2.edges(data=True) - ) - mappings.extend(singletons) - return mappings - - def help(self) -> None: - """Print the class docstring and all public methods.""" - print(self.__class__.__doc__) - for name in dir(self): - if not name.startswith("_"): - print(name) - - def __repr__(self) -> str: - """Compact summary: GroupComp(|V|1_V2, |E|1_E2, |M|).""" - try: - v1 = self.G1.number_of_nodes() - v2 = self.G2.number_of_nodes() - e1 = self.G1.number_of_edges() - e2 = self.G2.number_of_edges() - m = len(self.get_mapping()) - except Exception: - v1 = v2 = e1 = e2 = m = 0 # type: ignore - return f"GroupComp(|V|={v1}_{v2}, |E|={e1}_{e2}, |M|={m})" - - -__all__ = ["GroupComp", "NodeId", "Node", "Edge", "MappingList"] diff --git a/synkit/Graph/MTG/groupoid.py b/synkit/Graph/MTG/groupoid.py deleted file mode 100644 index b5d473b..0000000 --- a/synkit/Graph/MTG/groupoid.py +++ /dev/null @@ -1,358 +0,0 @@ -from __future__ import annotations -import networkx as nx -from collections import defaultdict -from typing import Iterable, Mapping, List, Dict, Any, Tuple, Optional, Set, FrozenSet - -# ============================================================================== -# Type Aliases -# ============================================================================== - -NodeId = int -ChargeTuple = Tuple[int | None, int | None] -Node = Tuple[NodeId, Dict[str, Any]] # (id, attribute-dict) -Edge = Tuple[NodeId, NodeId, Dict[str, Any]] # (u, v, attribute-dict) -MappingList = List[Dict[NodeId, NodeId]] - -# ============================================================================== -# Public Groupoid Operations -# ============================================================================== - - -def charge_tuple(attrs: Mapping[str, Any]) -> ChargeTuple: - """Extract the 2-tuple charge signature from node attributes. - - Supports both: - - attrs['charges'] as a tuple of two ints - - attrs['typesGH'] as an iterable of two tuples where the 3rd element - in each is an int charge. - - Returns - ------- - (charge0, charge1) or (None, None) if unavailable - """ - # Case 1: direct 'charges' field - ch = attrs.get("charges") - if isinstance(ch, tuple) and len(ch) == 2: - return ch[0], ch[1] - - # Case 2: 'typesGH' field - tg = attrs.get("typesGH") - if isinstance(tg, (list, tuple)) and len(tg) >= 2: - try: - return tg[0][3], tg[1][3] - except Exception: - pass - - return None, None - - -def node_constraint( - nodes1: Iterable[Node], - nodes2: Iterable[Node], -) -> Dict[NodeId, List[NodeId]]: - """Compute candidate node mappings based on element and groupoid charge - rule. - - For each node v1 in nodes1 and v2 in nodes2, v2 is a candidate if: - 1. v1.attrs['element'] == v2.attrs['element'], and - 2. charge_tuple(v1)[1] == charge_tuple(v2)[0]. - - Returns - ------- - mapping : dict mapping each G1 node_id to a list of G2 node_ids - """ - # Index G2 by (element, first_charge) - idx_g2: Dict[Tuple[Any, Any], List[NodeId]] = defaultdict(list) - for n2_id, attrs2 in nodes2: - elem2 = attrs2.get("element") - first_charge, _ = charge_tuple(attrs2) - if elem2 is not None: - idx_g2[(elem2, first_charge)].append(n2_id) - - # Build mapping for G1 - mapping: Dict[NodeId, List[NodeId]] = {} - for n1_id, attrs1 in nodes1: - elem1 = attrs1.get("element") - _, second_charge = charge_tuple(attrs1) - mapping[n1_id] = idx_g2.get((elem1, second_charge), []) - - return mapping - - -# --------------------------------------------------------------------------- -# Back‑tracking implementation (legacy / fallback) -# --------------------------------------------------------------------------- - - -def _edge_constraint_backtracking( - edges1: Iterable[Edge], - edges2: Iterable[Edge], - node_mapping: Optional[Mapping[NodeId, List[NodeId]]] = None, - *, - mcs: bool = True, -) -> MappingList: - """Explicit set‑packing search. - - Parameters - ---------- - mcs : bool, default ``True`` - If ``True`` return **only** mappings that maximise the number of matched - edges (MCS). If ``False`` return *all* disjoint edge‑set mappings. - """ - # 1. candidate edge pairs ------------------------------------------------ - candidates: List[Tuple[Edge, Edge]] = [] - for u1, v1, a1 in edges1: - o1 = a1.get("order", (None, None)) - if len(o1) < 2: - continue - needed = o1[1] - for u2, v2, a2 in edges2: - o2 = a2.get("order", (None, None)) - if len(o2) < 2 or o2[0] != needed: - continue - if node_mapping and ( - u2 not in node_mapping.get(u1, []) or v2 not in node_mapping.get(v1, []) - ): - continue - candidates.append(((u1, v1, a1), (u2, v2, a2))) - - # 2. DFS to enumerate *all* disjoint edge‑pair sets ---------------------- - pair_sets: List[List[Tuple[Edge, Edge]]] = [] - - def _dfs(chosen: List[Tuple[Edge, Edge]], rem: List[Tuple[Edge, Edge]]): - if not rem: - if chosen: - pair_sets.append(chosen.copy()) - return - first, *rest = rem - (u1, v1, _), (u2, v2, _) = first - # include if disjoint on both graphs - filt = [ - p - for p in rest - if p[0][0] not in (u1, v1) - and p[0][1] not in (u1, v1) - and p[1][0] not in (u2, v2) - and p[1][1] not in (u2, v2) - ] - _dfs(chosen + [first], filt) # include - _dfs(chosen, rest) # exclude - - _dfs([], candidates) - - # 3. select MCS (optional) ---------------------------------------------- - if mcs: - max_sz = max((len(s) for s in pair_sets), default=0) - pair_sets = [s for s in pair_sets if len(s) == max_sz] - - # 4. convert → mapping list & dedupe ------------------------------------ - mappings: MappingList = [] - seen: Set[FrozenSet] = set() - for match_set in pair_sets: - m: Dict[NodeId, NodeId] = {} - for (u1, v1, _), (u2, v2, _) in match_set: - m[u1] = u2 - m[v1] = v2 - key = frozenset(m.items()) - if key not in seen: - seen.add(key) - mappings.append(m) - return mappings - - -# --------------------------------------------------------------------------- -# VF2 -# --------------------------------------------------------------------------- - - -def _edge_constraint_vf2( - edges1: Iterable[Edge], - edges2: Iterable[Edge], - node_mapping: Optional[Mapping[NodeId, List[NodeId]]] = None, -) -> MappingList: - """VF2‐style routine, fully in Python (no NetworkX), seeded like VF3 but - relaxed so it returns the same maximal‐common‐subgraph mappings. - - The returned dicts will always have their keys sorted ascending. - """ - # --- build adjacency lists with valid 'order' tuples --- - adj1: Dict[NodeId, Dict[NodeId, Tuple[int, int]]] = {} - for u, v, data in edges1: - o = data.get("order", ()) - if isinstance(o, tuple) and len(o) >= 2: - adj1.setdefault(u, {})[v] = o - adj1.setdefault(v, {})[u] = (o[1], o[0]) - adj2: Dict[NodeId, Dict[NodeId, Tuple[int, int]]] = {} - for u, v, data in edges2: - o = data.get("order", ()) - if isinstance(o, tuple) and len(o) >= 2: - adj2.setdefault(u, {})[v] = o - adj2.setdefault(v, {})[u] = (o[1], o[0]) - - # --- seed exactly as VF3 does --- - seeds: List[Dict[NodeId, NodeId]] = [] - for u1, v1, d1 in edges1: - o1 = d1.get("order", ()) - if not (isinstance(o1, tuple) and len(o1) >= 2): - continue - need = o1[1] - for u2, v2, d2 in edges2: - o2 = d2.get("order", ()) - if not (isinstance(o2, tuple) and len(o2) >= 2) or o2[0] != need: - continue - if node_mapping and ( - u2 not in node_mapping.get(u1, []) or v2 not in node_mapping.get(v1, []) - ): - continue - seeds.append({u1: u2, v1: v2}) - if not seeds: - return [] - - # --- DFS grouping by using state dict --- - state: Dict[str, Any] = {"best": [], "max_edges": 0} - - def _dfs( - idx: int, - current: Dict[NodeId, NodeId], - mapped1: Set[NodeId], - mapped2: Set[NodeId], - edge_count: int, - ): - # mutate state - if idx == len(seeds): - if edge_count > state["max_edges"]: - state["max_edges"] = edge_count - state["best"] = [current.copy()] - elif edge_count == state["max_edges"]: - state["best"].append(current.copy()) - return - - cand = seeds[idx] - # try including if no node-ID conflict - if not (set(cand.keys()) & mapped1 or set(cand.values()) & mapped2): - _dfs( - idx + 1, - {**current, **cand}, - mapped1 | set(cand.keys()), - mapped2 | set(cand.values()), - edge_count + 1, - ) - # try skipping this seed - _dfs(idx + 1, current, mapped1, mapped2, edge_count) - - # kick off DFS from each seed - for i, seed in enumerate(seeds): - _dfs(i, seed.copy(), set(seed.keys()), set(seed.values()), 1) - - # --- dedupe automorphisms & sort keys --- - uniq: MappingList = [] - seen: Set[FrozenSet] = set() - for m in state["best"]: - key = frozenset(m.items()) - if key in seen: - continue - seen.add(key) - # rebuild with keys in ascending order - sorted_map = {u: m[u] for u in sorted(m.keys())} - uniq.append(sorted_map) - - return uniq - - -# --------------------------------------------------------------------------- -# VF3 – pairwise → grouped matching (hybrid) -# --------------------------------------------------------------------------- - - -def _edge_constraint_vf3( - edges1: Iterable[Edge], - edges2: Iterable[Edge], - node_mapping: Optional[Mapping[NodeId, List[NodeId]]] = None, -) -> MappingList: - """Hybrid strategy: single‑edge matches seeded, then grouped via DFS.""" - # 1. seed list - seeds: List[Dict[NodeId, NodeId]] = [] - for u1, v1, a1 in edges1: - o1 = a1.get("order", (None, None)) - if len(o1) < 2: - continue - need = o1[1] - for u2, v2, a2 in edges2: - o2 = a2.get("order", (None, None)) - if len(o2) < 2 or o2[0] != need: - continue - if node_mapping and ( - u2 not in node_mapping.get(u1, []) or v2 not in node_mapping.get(v1, []) - ): - continue - seeds.append({u1: u2, v1: v2}) - if not seeds: - return [] - - # 2. DFS grouping by using a state dict - state: Dict[str, Any] = {"best": [], "max_edges": 0} - - def _dfs(idx: int, current: Dict[NodeId, NodeId]): - if idx == len(seeds): - edges = len(current) // 2 - if edges == 0: - return - if edges > state["max_edges"]: - state["max_edges"] = edges - state["best"] = [current.copy()] - elif edges == state["max_edges"]: - state["best"].append(current.copy()) - return - - cand = seeds[idx] - # include this seed if no conflicts - if not ( - set(cand.keys()) & current.keys() - or set(cand.values()) & set(current.values()) - ): - _dfs(idx + 1, {**current, **cand}) - # always try skipping - _dfs(idx + 1, current) - - _dfs(0, {}) - - # 3. dedupe - uniq: MappingList = [] - seen: Set[FrozenSet] = set() - for m in state["best"]: - key = frozenset(m.items()) - if key not in seen: - seen.add(key) - uniq.append(m) - return uniq - - -# --------------------------------------------------------------------------- -# Public wrapper -# --------------------------------------------------------------------------- - - -def edge_constraint( - edges1: Iterable[Edge], - edges2: Iterable[Edge], - node_mapping: Optional[Mapping[NodeId, List[NodeId]]] = None, - *, - algorithm: str = "bt", - mcs: bool = False, -) -> MappingList: - """Return node‑mappings under the groupoid order rule. - - Parameters - ---------- - algorithm : {'vf2', 'vf3', 'bt'}, default 'bt' - Which internal strategy to use. - mcs : bool, default True - Only for ``algorithm='bt'`` – if ``True`` keep maximum‑edge mappings, else - return *all* disjoint mappings. - """ - alg = algorithm.lower() - if alg == "vf3": - return _edge_constraint_vf3(edges1, edges2, node_mapping) - if alg == "bt" or alg == "backtracking": - return _edge_constraint_backtracking(edges1, edges2, node_mapping, mcs=mcs) - return _edge_constraint_vf2(edges1, edges2, node_mapping) diff --git a/synkit/Graph/MTG/mtg.py b/synkit/Graph/MTG/mtg.py index 6cc773d..f033d00 100644 --- a/synkit/Graph/MTG/mtg.py +++ b/synkit/Graph/MTG/mtg.py @@ -37,12 +37,21 @@ from synkit.IO import its_to_rsmi, rsmi_to_its NodeID = int -OrderPair = Tuple[float, float] MissingOrder = Tuple[Set[float], Set[float]] GraphMapping = Dict[NodeID, NodeID] _PLACEHOLDER: MissingOrder = (set(), set()) _PLACEHOLDER_TYPESGH = (set(), set(), set(), set(), set()) +_TUPLE_EDGE_ATTRS = ("order", "kekule_order", "sigma_order", "pi_order") +_TUPLE_NODE_SCALAR_ATTRS = ("element", "atom_map", "valence_electrons") +_TUPLE_NODE_TIMELINE_ATTRS = ( + "aromatic", + "hcount", + "charge", + "radical", + "lone_pairs", + "present", +) __all__ = ["MTG"] @@ -78,6 +87,7 @@ def __init__( self._graphs = self._prepare_graph_sequence(sequences) self._k = len(self._graphs) + self._tuple_its = all(self._is_tuple_its(g) for g in self._graphs) self._mappings = ( mappings if mappings is not None else self._compute_mappings(self._graphs) @@ -116,11 +126,41 @@ def describe() -> str: def get_mtg(self, *, directed: bool = False) -> nx.Graph: return self._graph.to_directed() if directed else self._graph + def get_its_steps(self, *, directed: bool = False) -> List[nx.Graph]: + """Reconstruct the ordered list of per-step ITS graphs from the MTG.""" + if not self._tuple_its: + return [graph.copy() for graph in self._graphs] + graph = self.get_mtg(directed=directed) + return [self._tuple_step_its(graph, step) for step in range(self._k)] + + def get_rsmi_steps( + self, + *, + directed: bool = False, + explicit_hydrogen: bool = False, + sanitize: bool = True, + ) -> List[str]: + """Serialize reconstructed per-step ITS graphs to reaction SMILES.""" + fmt = "tuple" if self._tuple_its else "typesGH" + return [ + its_to_rsmi( + its, + format=fmt, + explicit_hydrogen=explicit_hydrogen, + sanitize=sanitize, + ) + for its in self.get_its_steps(directed=directed) + ] + def get_compose_its(self, *, directed: bool = False) -> nx.Graph: g = self.get_mtg(directed=directed) - g = label_mtg_edges(g, inplace=False) - g = normalize_order(g) - g = normalize_hcount_and_typesGH(g) + if self._tuple_its: + g = self._compose_tuple_node_attrs(g) + g = self._compose_tuple_edge_attrs(g) + else: + g = label_mtg_edges(g, inplace=False) + g = normalize_order(g) + g = normalize_hcount_and_typesGH(g) return compute_standard_order(g) def get_aam(self, *, directed: bool = False, explicit_h: bool = False) -> str: @@ -144,6 +184,10 @@ def _merge_attrs(lhs: MutableMapping[str, Any], rhs: Mapping[str, Any]) -> None: lhs[k] = v def _build_node_map_and_attributes(self) -> None: + if self._tuple_its and self._has_tuple_atom_maps(self._graphs): + self._build_tuple_node_map_and_attributes() + return + prod, node_map = {}, {} last = self._graphs[-1] for nid, attrs in last.nodes(data=True): @@ -174,62 +218,226 @@ def _build_node_map_and_attributes(self) -> None: else: first_idx[p] = gi - for p, attrs in prod.items(): - hist: List[Any] = [] - fi = first_idx[p] - for i in range(self._k): - if i < fi: - hist.append(_PLACEHOLDER_TYPESGH) - elif i == fi: - val = ( - self._graphs[i] - .nodes[ - next( - n - for (gi, n), pp in node_map.items() - if gi == i and pp == p - ) - ] - .get("typesGH", (_PLACEHOLDER_TYPESGH, _PLACEHOLDER_TYPESGH)) - ) - hist.append(val) - else: - originals = [ - n for (gi, n), pp in node_map.items() if gi == i and pp == p - ] - if originals: + if self._tuple_its: + self._simplify_tuple_node_attrs(prod, node_map) + else: + for p, attrs in prod.items(): + hist: List[Any] = [] + fi = first_idx[p] + for i in range(self._k): + if i < fi: + hist.append(_PLACEHOLDER_TYPESGH) + elif i == fi: val = ( self._graphs[i] - .nodes[originals[0]] + .nodes[ + next( + n + for (gi, n), pp in node_map.items() + if gi == i and pp == p + ) + ] .get( "typesGH", (_PLACEHOLDER_TYPESGH, _PLACEHOLDER_TYPESGH) - )[-1] + ) ) hist.append(val) else: - hist.append(_PLACEHOLDER_TYPESGH) - attrs["typesGH_history"] = tuple(hist) - attrs["typesGH"] = attrs["typesGH_history"] + originals = [ + n for (gi, n), pp in node_map.items() if gi == i and pp == p + ] + if originals: + val = ( + self._graphs[i] + .nodes[originals[0]] + .get( + "typesGH", + (_PLACEHOLDER_TYPESGH, _PLACEHOLDER_TYPESGH), + )[-1] + ) + hist.append(val) + else: + hist.append(_PLACEHOLDER_TYPESGH) + attrs["typesGH_history"] = tuple(hist) + attrs["typesGH"] = attrs["typesGH_history"] + + self._prod_nodes = prod + self._node_map = node_map + def _build_tuple_node_map_and_attributes(self) -> None: + prod: Dict[int, Dict[str, Any]] = {} + node_map: Dict[Tuple[int, NodeID], int] = {} + pid_counter = 0 + + for gi, graph in enumerate(self._graphs): + used_in_graph: Set[int] = set() + for nid, attrs in graph.nodes(data=True): + pid = self._tuple_node_pid(attrs) + if pid is None or pid in used_in_graph: + while pid_counter in prod: + pid_counter += 1 + pid = pid_counter + pid_counter += 1 + prod.setdefault(pid, {}) + node_map[(gi, nid)] = pid + used_in_graph.add(pid) + + self._simplify_tuple_node_attrs(prod, node_map) self._prod_nodes = prod self._node_map = node_map def _build_edge_history_and_graph(self) -> None: - hist: Dict[Tuple[int, int], List[MissingOrder]] = {} + hist: Dict[Tuple[int, int], Dict[str, List[MissingOrder]]] = {} for i, G in enumerate(self._graphs): for u, v, a in G.edges(data=True): pu, pv = self._node_map[(i, u)], self._node_map[(i, v)] key = tuple(sorted((pu, pv))) - lst = hist.setdefault(key, [_PLACEHOLDER] * self._k) - lst[i] = a.get("order", _PLACEHOLDER) + attr_hist = hist.setdefault( + key, + {name: [_PLACEHOLDER] * self._k for name in _TUPLE_EDGE_ATTRS}, + ) + for name in _TUPLE_EDGE_ATTRS: + attr_hist[name][i] = a.get(name, _PLACEHOLDER) g = nx.Graph() g.add_nodes_from(self._prod_nodes.items()) - for (u, v), lst in hist.items(): - g.add_edge(u, v, order=tuple(lst)) + for (u, v), attr_hist in hist.items(): + attrs: Dict[str, Any] = {"order": tuple(attr_hist["order"])} + if self._tuple_its: + attrs = {} + for name, values in attr_hist.items(): + attrs[name] = self._edge_pair_history_to_timeline( + tuple(values), + g.nodes[u].get("present"), + g.nodes[v].get("present"), + ) + attrs["steps"] = tuple( + i + for i, value in enumerate(attr_hist["order"]) + if self._is_observed_pair(value) + ) + g.add_edge(u, v, **attrs) if g.number_of_nodes() != len(self._prod_nodes): raise RuntimeError("Node count mismatch.") self._graph = g + def _simplify_tuple_node_attrs( + self, + prod: Dict[int, Dict[str, Any]], + node_map: Dict[Tuple[int, NodeID], int], + ) -> None: + """ + Replace tuple-ITS node attrs with compact MTG attrs. + + A path of ``k`` ITS steps has ``k + 1`` mechanism states: the first + step's left side followed by each step's right side. + """ + refs_by_pid: Dict[int, Dict[int, NodeID]] = {} + for (gi, nid), pid in node_map.items(): + refs_by_pid.setdefault(pid, {})[gi] = nid + + for pid, refs in refs_by_pid.items(): + simplified: Dict[str, Any] = {} + for key in _TUPLE_NODE_SCALAR_ATTRS: + timeline = self._node_attr_timeline(refs, key) + simplified[key] = next( + (value for value in timeline if value is not None), + None, + ) + + for key in _TUPLE_NODE_TIMELINE_ATTRS: + simplified[key] = self._node_attr_timeline(refs, key) + + simplified["steps"] = tuple(sorted(refs)) + prod[pid] = simplified + + def _node_attr_timeline( + self, + refs: Dict[int, NodeID], + key: str, + ) -> Tuple[Any, ...]: + timeline: List[Any] = [None] * (self._k + 1) + for gi in range(self._k): + nid = refs.get(gi) + if nid is None: + continue + value = self._graphs[gi].nodes[nid].get(key) + if self._is_pair(value): + timeline[gi] = value[0] + timeline[gi + 1] = value[1] + return tuple(timeline) + + def _compose_tuple_node_attrs(self, graph: nx.Graph) -> nx.Graph: + """ + Collapse tuple-ITS node histories to the outermost observed states. + + The fused MTG node attrs are initially copied from the last ITS step. + For a composed ITS we instead need the first available left-side value + and the last available right-side value across the whole trajectory. + """ + out = graph.copy() + for _, attrs in out.nodes(data=True): + for key in _TUPLE_NODE_SCALAR_ATTRS: + value = attrs.get(key) + attrs[key] = (value, value) + for key in _TUPLE_NODE_TIMELINE_ATTRS: + timeline = attrs.get(key) + if isinstance(timeline, tuple) and timeline: + attrs[key] = (timeline[0], timeline[-1]) + return out + + def _compose_tuple_edge_attrs(self, graph: nx.Graph) -> nx.Graph: + """Collapse tuple edge timelines to first-state / final-state pairs.""" + out = graph.copy() + for _, _, attrs in out.edges(data=True): + for name in _TUPLE_EDGE_ATTRS: + timeline = attrs.get(name) + if not isinstance(timeline, tuple): + continue + if timeline: + attrs[name] = (timeline[0], timeline[-1]) + return out + + def _tuple_step_its(self, graph: nx.Graph, step: int) -> nx.Graph: + """Extract one paired tuple ITS step from compact tuple-MTG timelines.""" + its = nx.Graph() + for node, attrs in graph.nodes(data=True): + node_attrs: Dict[str, Any] = {} + if step not in attrs.get("steps", ()): + continue + present_pair = self._timeline_pair(attrs.get("present"), step) + if present_pair[0] is None or present_pair[1] is None: + continue + for key in _TUPLE_NODE_SCALAR_ATTRS: + value = attrs.get(key) + node_attrs[key] = (value, value) + for key in _TUPLE_NODE_TIMELINE_ATTRS: + value = self._timeline_pair(attrs.get(key), step) + if value != (None, None): + node_attrs[key] = value + its.add_node(node, **node_attrs) + + for u, v, attrs in graph.edges(data=True): + if step not in attrs.get("steps", ()): + continue + edge_attrs: Dict[str, Any] = {} + has_edge = False + for key in _TUPLE_EDGE_ATTRS: + value = self._timeline_pair(attrs.get(key), step) + if value == (None, None): + continue + edge_attrs[key] = value + if ( + key == "order" + and value[0] is not None + and value[1] is not None + and value != (0, 0) + and value != (0.0, 0.0) + ): + has_edge = True + if has_edge and u in its and v in its: + its.add_edge(u, v, **edge_attrs) + return compute_standard_order(its) + def _prepare_graph_sequence( self, seq: List[nx.Graph] | List[str] ) -> List[nx.Graph]: @@ -238,12 +446,100 @@ def _prepare_graph_sequence( g = rsmi_to_its(item, core=False) if isinstance(item, str) else item if self._canonicaliser: g = self._canonicaliser.canonicalise_graph(g).canonical_graph + if self._is_tuple_its(g): + out.append(g) + continue g = h_to_explicit(g, its=True) - # out.append(g) out.append(normalize_hcount_and_typesGH(g)) return out + @staticmethod + def _is_tuple_its(graph: nx.Graph) -> bool: + """ + Detect paired-attribute ITS graphs produced by the newer tuple format. + + Tuple ITS nodes carry side-specific attributes directly, such as + ``element=("C", "C")`` and ``lone_pairs=(0, 0)``. Legacy ITS graphs + instead keep the paired state primarily in ``typesGH``. + """ + if graph.number_of_nodes() == 0: + return False + _, attrs = next(iter(graph.nodes(data=True))) + element = attrs.get("element") + return isinstance(element, tuple) and len(element) == 2 + + @staticmethod + def _is_pair(value: Any) -> bool: + return isinstance(value, tuple) and len(value) == 2 + + @classmethod + def _is_observed_pair(cls, value: Any) -> bool: + return cls._is_pair(value) and not ( + isinstance(value[0], set) and isinstance(value[1], set) + ) + + @staticmethod + def _timeline_pair(timeline: Any, step: int) -> Tuple[Any, Any]: + if not isinstance(timeline, tuple) or len(timeline) <= step + 1: + return (None, None) + return (timeline[step], timeline[step + 1]) + + @classmethod + def _edge_pair_history_to_timeline( + cls, + history: Tuple[Any, ...], + u_present: Any, + v_present: Any, + ) -> Tuple[Any, ...]: + """ + Convert ITS step-pair history into mechanism-state timeline. + + Example: ``((2, 1), (1, 2))`` becomes ``(2, 1, 2)``. + Missing edge states are ``0`` when both endpoint atoms exist and + ``None`` when an endpoint is absent. + """ + if not history: + return () + + timeline: List[Any] = [None] * (len(history) + 1) + for idx, value in enumerate(history): + if cls._is_pair(value) and not ( + isinstance(value[0], set) and isinstance(value[1], set) + ): + timeline[idx] = value[0] + timeline[idx + 1] = value[1] + return tuple( + cls._fill_missing_edge_state(value, idx, u_present, v_present) + for idx, value in enumerate(timeline) + ) + + @staticmethod + def _fill_missing_edge_state( + value: Any, + idx: int, + u_present: Any, + v_present: Any, + ) -> Any: + if value is not None: + return value + if ( + isinstance(u_present, tuple) + and isinstance(v_present, tuple) + and len(u_present) > idx + and len(v_present) > idx + and u_present[idx] is True + and v_present[idx] is True + ): + return 0.0 + return None + def _compute_mappings(self, graphs: List[nx.Graph]) -> List[GraphMapping]: + if self._tuple_its: + return [ + self._compute_tuple_mapping(graphs[i], graphs[i + 1]) + for i in range(len(graphs) - 1) + ] + mappings: List[GraphMapping] = [] for i in range(len(graphs) - 1): m = MCSMatcher(node_label_names=self._node_label_names) @@ -255,6 +551,48 @@ def _compute_mappings(self, graphs: List[nx.Graph]) -> List[GraphMapping]: mappings.append(m._mappings[0]) return mappings + @classmethod + def _compute_tuple_mapping(cls, left: nx.Graph, right: nx.Graph) -> GraphMapping: + left_by_map = cls._nodes_by_atom_map(left) + right_by_map = cls._nodes_by_atom_map(right) + common_maps = sorted(set(left_by_map) & set(right_by_map)) + mapping = {left_by_map[amap]: right_by_map[amap] for amap in common_maps} + + if mapping: + return mapping + + common_nodes = sorted(set(left.nodes()) & set(right.nodes())) + return {node: node for node in common_nodes} + + @classmethod + def _has_tuple_atom_maps(cls, graphs: List[nx.Graph]) -> bool: + return any( + cls._tuple_node_pid(attrs) is not None + for graph in graphs + for _, attrs in graph.nodes(data=True) + ) + + @staticmethod + def _tuple_node_pid(attrs: Mapping[str, Any]) -> int | None: + atom_map = attrs.get("atom_map") + if isinstance(atom_map, tuple) and len(atom_map) == 2: + atom_map = atom_map[1] if atom_map[1] not in (None, 0, "") else atom_map[0] + if atom_map in (None, 0, ""): + return None + return int(atom_map) + + @staticmethod + def _nodes_by_atom_map(graph: nx.Graph) -> Dict[int, NodeID]: + by_map: Dict[int, NodeID] = {} + for node, attrs in graph.nodes(data=True): + atom_map = MTG._tuple_node_pid(attrs) + if atom_map is None: + continue + if atom_map in by_map: + continue + by_map[atom_map] = node + return by_map + @property def node_mapping(self) -> Dict[Tuple[int, NodeID], int]: return dict(self._node_map) diff --git a/synkit/Graph/Matcher/graph_matcher.py b/synkit/Graph/Matcher/graph_matcher.py index 2fece56..8ee767a 100644 --- a/synkit/Graph/Matcher/graph_matcher.py +++ b/synkit/Graph/Matcher/graph_matcher.py @@ -77,9 +77,10 @@ class GraphMatcherEngine: :class:`~networkx.algorithms.isomorphism.GraphMatcher`. * ``"rule"`` – optional, requires the third‑party *mod* package. node_attrs, edge_attrs: - Lists of attribute keys that must match exactly between candidate - nodes/edges. ``hcount`` is treated specially – the host must be **≥** - the pattern (to allow aggregated counts). + Lists of attribute keys used for matching. ``hcount`` and + ``lone_pairs`` are treated specially: the host must be **≥** the + pattern. Other requested attributes, including ``radical``, match + exactly. wl1_filter: If *True*, a fast WL‑based colour refinement pre‑filter discards host graphs that cannot possibly contain the pattern. @@ -162,11 +163,15 @@ def nm(nh, np): # noqa: ANN001 – external signature return nm def nm(nh, np, _attrs=attrs): # noqa: ANN001 – external signature - # Strict equality for selected attributes … for k in _attrs: - if nh.get(k) != np.get(k): + host_value = nh.get(k, 0 if k in {"hcount", "lone_pairs"} else None) + pattern_value = np.get(k, 0 if k in {"hcount", "lone_pairs"} else None) + if k in {"hcount", "lone_pairs"}: + if host_value < pattern_value: + return False + continue + if host_value != pattern_value: return False - # … plus host‑≥‑pattern for "hcount" if present. return nh.get("hcount", 0) >= np.get("hcount", 0) return nm @@ -230,17 +235,19 @@ def _isomorphic_nx( if not isinstance(g1, nx.Graph) or not isinstance(g2, nx.Graph): raise TypeError("NX backend expects `networkx.Graph` objects.") - # Put the *smaller* graph first – helps GraphMatcher. - if g1.number_of_nodes() > g2.number_of_nodes(): - g1, g2 = g2, g1 # type: ignore[misc] + # Treat the larger graph as host so comparator semantics remain + # host-first for subgraph checks. + host, pattern = (g1, g2) + if g1.number_of_nodes() < g2.number_of_nodes(): + host, pattern = g2, g1 - if not self._pre_check(g2, g1): # g2 is the (larger) host + if not self._pre_check(host, pattern): return False - gm = _NXGraphMatcher(g1, g2, node_match=self._nm, edge_match=self._em) + gm = _NXGraphMatcher(host, pattern, node_match=self._nm, edge_match=self._em) return ( gm.is_isomorphic() - if g1.number_of_nodes() == g2.number_of_nodes() + if host.number_of_nodes() == pattern.number_of_nodes() else gm.subgraph_is_isomorphic() ) @@ -253,7 +260,7 @@ def _get_mappings_nx( if not self._pre_check(host, pattern): return [] - gm = _NXGraphMatcher(pattern, host, node_match=self._nm, edge_match=self._em) + gm = _NXGraphMatcher(host, pattern, node_match=self._nm, edge_match=self._em) # Full blow isomorphism (same #nodes / #edges)? Then a single call tells # us everything and is much faster than iterating via *isomorphisms_iter*. @@ -261,7 +268,16 @@ def _get_mappings_nx( pattern.number_of_nodes() == host.number_of_nodes() and pattern.number_of_edges() == host.number_of_edges() ): - return [gm.mapping] if gm.is_isomorphic() else [] + return ( + [ + { + pattern_node: host_node + for host_node, pattern_node in gm.mapping.items() + } + ] + if gm.is_isomorphic() + else [] + ) # Sub‑isomorphisms. iso_iter = gm.subgraph_isomorphisms_iter() @@ -271,7 +287,10 @@ def _get_mappings_nx( ) # local import – cheap and avoids polluting global namespace iso_iter = islice(iso_iter, self.max_mappings) - return list(iso_iter) + return [ + {pattern_node: host_node for host_node, pattern_node in mapping.items()} + for mapping in iso_iter + ] # ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――――—— # Rule (GML) backend – thin wrappers around ``mod.ruleGMLString`` diff --git a/synkit/Graph/Matcher/subgraph_matcher.py b/synkit/Graph/Matcher/subgraph_matcher.py index 8b78c00..8de9505 100644 --- a/synkit/Graph/Matcher/subgraph_matcher.py +++ b/synkit/Graph/Matcher/subgraph_matcher.py @@ -105,6 +105,126 @@ ] +def electron_aware_node_match( + host_data: EdgeAttr, + pattern_data: EdgeAttr, + node_attrs: Sequence[str], +) -> bool: + """Compare node attributes with chemistry-aware cardinality semantics. + + Attributes in ``node_attrs`` are exact matches except: + + - ``hcount``: host must be greater than or equal to pattern + - ``lone_pairs``: host must be greater than or equal to pattern + - ``aromatic_n_pi_count``: exact aromatic-N role label when present + ``radical`` therefore remains exact whenever the caller includes it in + ``node_attrs``. + """ + for attr in node_attrs: + host_value = host_data.get( + attr, 0 if attr in {"hcount", "lone_pairs"} else None + ) + pattern_value = pattern_data.get( + attr, 0 if attr in {"hcount", "lone_pairs"} else None + ) + if attr in {"hcount", "lone_pairs"}: + if host_value < pattern_value: + return False + continue + if host_value != pattern_value: + return False + return True + + +def electron_aware_edge_match( + host_data: EdgeAttr, + pattern_data: EdgeAttr, + edge_attrs: Sequence[str], +) -> bool: + """Compare edge attrs while treating aromatic Kekule phase as non-semantic. + + Aromatic presentation bonds are matched by ``order == 1.5``. Their + particular ``sigma_order`` / ``pi_order`` split depends on the chosen + Kekule form and is not stable across independently parsed graphs. + """ + host_is_aromatic = host_data.get("order") == 1.5 + pattern_is_aromatic = pattern_data.get("order") == 1.5 + for attr in edge_attrs: + if ( + attr in {"sigma_order", "pi_order"} + and host_is_aromatic + and pattern_is_aromatic + ): + continue + if host_data.get(attr) != pattern_data.get(attr): + return False + return True + + +def explain_node_mismatch( + host_data: EdgeAttr, + pattern_data: EdgeAttr, + node_attrs: Sequence[str], +) -> list[str]: + """Return node-level mismatch reasons using matcher semantics.""" + reasons: list[str] = [] + for attr in node_attrs: + host_value = host_data.get( + attr, 0 if attr in {"hcount", "lone_pairs"} else None + ) + pattern_value = pattern_data.get( + attr, 0 if attr in {"hcount", "lone_pairs"} else None + ) + if attr in {"hcount", "lone_pairs"}: + if host_value < pattern_value: + reasons.append(f"{attr}: host {host_value} < pattern {pattern_value}") + continue + if host_value != pattern_value: + reasons.append(f"{attr}: host {host_value!r} != pattern {pattern_value!r}") + return reasons + + +def resolve_template_match_attrs( + pattern: nx.Graph, + *, + legacy_node_attrs: Sequence[str] = ("element", "charge"), + legacy_edge_attrs: Sequence[str] = ("order",), +) -> tuple[list[str], list[str]]: + """Choose match attrs from what the template actually carries. + + Legacy templates keep the legacy attribute set. Electron-aware templates opt + into extra constraints only when those attrs are present on the template. + """ + node_attrs = list(legacy_node_attrs) + edge_attrs = list(legacy_edge_attrs) + + for attr in ( + "aromatic", + "hcount", + "lone_pairs", + "radical", + "aromatic_n_pi_count", + ): + if any(attr in data for _, data in pattern.nodes(data=True)): + node_attrs.append(attr) + + for attr in ("sigma_order", "pi_order"): + if any(attr in data for _, _, data in pattern.edges(data=True)): + edge_attrs.append(attr) + + return node_attrs, edge_attrs + + +def diagnose_candidate_node_match( + host_data: EdgeAttr, + pattern_data: EdgeAttr, + node_attrs: Sequence[str], +) -> dict[str, Any]: + """Return a compact node-match diagnostic payload.""" + reasons = explain_node_mismatch(host_data, pattern_data, node_attrs) + return {"matched": not reasons, "reasons": reasons} + + # --------------------------------------------------------------------------- # Core engine class # --------------------------------------------------------------------------- @@ -315,8 +435,7 @@ def _quick_pre_filter( count = sum( 1 for _, host_data in host.nodes(data=True) - if all(host_data.get(a) == pat_data.get(a) for a in node_attrs) - and host_data.get("hcount", 0) >= pat_data.get("hcount", 0) + if electron_aware_node_match(host_data, pat_data, node_attrs) and host.degree(_) >= pat_deg ) # if no candidates; impossible match @@ -347,7 +466,8 @@ def find_subgraph_mappings( host, pattern NetworkX graphs (host ≥ pattern). node_attrs, edge_attrs - Keys of attributes to match exactly (plus `hcount` ≥). + Keys of attributes to match; ``hcount`` and ``lone_pairs`` use + host-greater-or-equal semantics, while the rest are exact. strategy Matching strategy code or enum ("all", "comp", "bt"). max_results @@ -426,12 +546,10 @@ def _find_all_subgraph_mappings( """Classic VF2 over the whole host graph.""" def node_match(nh: EdgeAttr, np: EdgeAttr) -> bool: - return all(nh.get(k) == np.get(k) for k in node_attrs) and nh.get( - "hcount", 0 - ) >= np.get("hcount", 0) + return electron_aware_node_match(nh, np, node_attrs) def edge_match(eh: EdgeAttr, ep: EdgeAttr) -> bool: - return all(eh.get(k) == ep.get(k) for k in edge_attrs) + return electron_aware_edge_match(eh, ep, edge_attrs) gm = GraphMatcher(host, pattern, node_match=node_match, edge_match=edge_match) results: List[MappingDict] = [] @@ -467,12 +585,10 @@ def _find_component_aware_subgraph_mappings( return [] def node_match(nh: EdgeAttr, np: EdgeAttr) -> bool: - if any(nh.get(a) != np.get(a) for a in node_attrs): - return False - return nh.get("hcount", 0) >= np.get("hcount", 0) + return electron_aware_node_match(nh, np, node_attrs) def edge_match(eh: EdgeAttr, ep: EdgeAttr) -> bool: - return all(eh.get(a) == ep.get(a) for a in edge_attrs) + return electron_aware_edge_match(eh, ep, edge_attrs) per_cc: List[List[Tuple[int, MappingDict]]] = [] for pc in pat_ccs: diff --git a/synkit/Graph/Mech/__init__.py b/synkit/Graph/Mech/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/synkit/Graph/Mech/conversion.py b/synkit/Graph/Mech/conversion.py new file mode 100644 index 0000000..3c1d846 --- /dev/null +++ b/synkit/Graph/Mech/conversion.py @@ -0,0 +1,1305 @@ +from __future__ import annotations + +import re +from collections import Counter +from typing import Any, Optional + +# ============================================================ +# Optional SynKit imports +# ============================================================ + +try: + from synkit.Chem.Reaction.canon_rsmi import CanonRSMI + from synkit.Graph.ITS.its_expand import ITSExpand + from synkit.IO import rsmi_to_its +except ImportError: + CanonRSMI = None + rsmi_to_its = None + ITSExpand = None + + +# ============================================================ +# Regex helpers +# ============================================================ + +BRACKET_ATOM_RE = re.compile(r"\[[^\[\]]+\]") +ATOM_MAP_RE = re.compile(r":(\d+)(?=\])") + + +# ============================================================ +# Arrow-code parsing +# ============================================================ + + +def parse_atom_list(text: str) -> list[int]: + """Parse a comma-separated atom-map list. + + :param text: Atom-map text such as ``"10"`` or ``"10,11"``. + :type text: str + :returns: Parsed atom-map numbers. + :rtype: list[int] + """ + return [int(x.strip()) for x in text.split(",") if x.strip()] + + +def parse_arrow_step(step: str) -> tuple[list[int], list[int]]: + """ + Convert one arrow-code step. + + For example, ``"10=20"`` becomes ``([10], [20])`` and + ``"12=11,12"`` becomes ``([12], [11, 12])``. + + :param step: One arrow-code step containing a left and right side. + :type step: str + :returns: Parsed left-side and right-side atom-map lists. + :rtype: tuple[list[int], list[int]] + :raises ValueError: If ``step`` does not contain ``"="``. + """ + if "=" not in step: + raise ValueError(f"Invalid arrow step without '=': {step}") + + lhs, rhs = step.split("=", 1) + return parse_atom_list(lhs), parse_atom_list(rhs) + + +def split_arrow_code(arrow_code: str) -> list[str]: + """Split an arrow code into non-empty steps. + + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :returns: Individual stripped arrow-code steps. + :rtype: list[str] + """ + return [s.strip() for s in arrow_code.split(";") if s.strip()] + + +def arrow_atom_maps(arrow_code: str) -> set[int]: + """Return all atom maps used in an arrow code. + + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :returns: Atom-map numbers referenced by the code. + :rtype: set[int] + """ + maps: set[int] = set() + + for step in split_arrow_code(arrow_code): + lhs, rhs = parse_arrow_step(step) + maps.update(lhs) + maps.update(rhs) + + return maps + + +def classify_arrow_shape(step: str) -> str: + """Classify one arrow-code step. + + :param step: One arrow-code step. + :type step: str + :returns: Shape label such as ``"a=b"`` or ``"a,b=c,d"``. + :rtype: str + """ + lhs, rhs = parse_arrow_step(step) + + if len(lhs) == 1 and len(rhs) == 1: + return "a=b" + + if len(lhs) == 1 and len(rhs) == 2: + return "a=b,c" + + if len(lhs) == 2 and len(rhs) == 1: + return "a,b=c" + + if len(lhs) == 2 and len(rhs) == 2: + return "a,b=c,d" + + return f"unsupported:{len(lhs)},{len(rhs)}" + + +def check_arrow_code_coverage(arrow_codes: list[str]) -> dict[str, Any]: + """Check which arrow-code shapes appear in a dataset. + + :param arrow_codes: Arrow codes to inspect. + :type arrow_codes: list[str] + :returns: Shape counts, unsupported steps, and an all-supported flag. + :rtype: dict[str, Any] + """ + shape_counter = Counter() + unsupported = [] + + for row_idx, arrow_code in enumerate(arrow_codes, start=1): + for step in split_arrow_code(arrow_code): + shape = classify_arrow_shape(step) + shape_counter[shape] += 1 + + if shape.startswith("unsupported"): + unsupported.append( + { + "row_index": row_idx, + "arrow_code": arrow_code, + "step": step, + "shape": shape, + } + ) + + return { + "shape_counts": dict(shape_counter), + "unsupported": unsupported, + "all_supported": len(unsupported) == 0, + } + + +# ============================================================ +# Atom-map preprocessing +# ============================================================ + + +def extract_atom_maps_from_smiles(smiles: str) -> list[int]: + """ + Extract atom-map numbers from bracket atoms. + + For example, ``"[CH:10]"`` yields ``10`` and ``"[N+:61]"`` yields ``61``. + + :param smiles: SMILES or SMIRKS fragment. + :type smiles: str + :returns: Atom-map numbers found in bracket atoms. + :rtype: list[int] + """ + return [int(x) for x in ATOM_MAP_RE.findall(smiles)] + + +def duplicate_atom_maps_in_side(smiles: str) -> dict[int, int]: + """Find duplicated atom maps in one side of a reaction. + + :param smiles: Reactant-side or product-side SMILES text. + :type smiles: str + :returns: Mapping of duplicated atom-map numbers to occurrence counts. + :rtype: dict[int, int] + """ + counts = Counter(extract_atom_maps_from_smiles(smiles)) + return {atom_map: count for atom_map, count in counts.items() if count > 1} + + +def validate_arrow_maps( + rsmi: str, + arrow_code: str, + raise_on_arrow_duplicates: bool = True, + raise_on_missing_arrow_maps: bool = True, +) -> dict[str, Any]: + """ + Validate atom maps before SynKit. + + Rules + ----- + 1. Duplicated atom maps used by arrow_code are fatal. + 2. Missing atom maps used by arrow_code are fatal. + 3. Duplicated non-arrow atom maps are warnings only, because they + can be removed before SynKit expansion. + + :param rsmi: Reaction SMILES in ``reactants>>products`` format. + :type rsmi: str + :param arrow_code: Arrow code whose atom maps must be validated. + :type arrow_code: str + :param raise_on_arrow_duplicates: Whether duplicated arrow atom maps are fatal. + :type raise_on_arrow_duplicates: bool + :param raise_on_missing_arrow_maps: Whether missing arrow atom maps are fatal. + :type raise_on_missing_arrow_maps: bool + :returns: Validation diagnostics. + :rtype: dict[str, Any] + :raises ValueError: If the RSMI is malformed or enabled validation fails. + """ + if ">>" not in rsmi: + raise ValueError("RSMI must contain '>>'") + + reactants, products = rsmi.split(">>", 1) + + arrow_maps = arrow_atom_maps(arrow_code) + + reactant_maps = extract_atom_maps_from_smiles(reactants) + product_maps = extract_atom_maps_from_smiles(products) + + all_raw_maps = set(reactant_maps) | set(product_maps) + + missing_arrow_maps = sorted(m for m in arrow_maps if m not in all_raw_maps) + + r_dupes = duplicate_atom_maps_in_side(reactants) + p_dupes = duplicate_atom_maps_in_side(products) + + arrow_dupes = { + "reactants": {m: c for m, c in r_dupes.items() if m in arrow_maps}, + "products": {m: c for m, c in p_dupes.items() if m in arrow_maps}, + } + + non_arrow_dupes = { + "reactants": {m: c for m, c in r_dupes.items() if m not in arrow_maps}, + "products": {m: c for m, c in p_dupes.items() if m not in arrow_maps}, + } + + diagnostics = { + "arrow_maps": sorted(arrow_maps), + "missing_arrow_maps": missing_arrow_maps, + "arrow_duplicate_maps": arrow_dupes, + "non_arrow_duplicate_maps": non_arrow_dupes, + "has_missing_arrow_maps": bool(missing_arrow_maps), + "has_arrow_duplicate_maps": bool( + arrow_dupes["reactants"] or arrow_dupes["products"] + ), + "has_non_arrow_duplicate_maps": bool( + non_arrow_dupes["reactants"] or non_arrow_dupes["products"] + ), + } + + if raise_on_missing_arrow_maps and diagnostics["has_missing_arrow_maps"]: + raise ValueError( + "Some atom maps used in arrow_code are missing from the reaction SMILES. " + f"Diagnostics: {diagnostics}" + ) + + if raise_on_arrow_duplicates and diagnostics["has_arrow_duplicate_maps"]: + raise ValueError( + "Some atom maps used in arrow_code are duplicated in the reaction SMILES. " + f"Diagnostics: {diagnostics}" + ) + + return diagnostics + + +def remove_non_arrow_atom_maps(rsmi: str, arrow_code: str) -> str: + """ + Keep only atom maps involved in arrow_code. + Remove every other atom map. + + This is important because some source SMIRKS have duplicated + non-arrow atom maps, e.g. + + [N+:61]2=[CH:61] + + If 61 is not used by arrow_code, we remove it and let SynKit + CanonRSMI().expand_aam(...) generate clean full maps. + + :param rsmi: Reaction SMILES to clean. + :type rsmi: str + :param arrow_code: Arrow code whose atom maps should be preserved. + :type arrow_code: str + :returns: Reaction SMILES with non-arrow atom maps removed. + :rtype: str + """ + keep_maps = arrow_atom_maps(arrow_code) + + def clean_bracket_atom(match: re.Match) -> str: + token = match.group(0) + + map_match = ATOM_MAP_RE.search(token) + if map_match is None: + return token + + atom_map = int(map_match.group(1)) + + if atom_map in keep_maps: + return token + + return ATOM_MAP_RE.sub("", token) + + return BRACKET_ATOM_RE.sub(clean_bracket_atom, rsmi) + + +# ============================================================ +# Generic LP/B conversion +# ============================================================ + + +def generic_convert_step(step: str) -> list[Any]: + """ + Generic graph-independent conversion. + + Supported grammar + ----------------- + a=b + LP(a) forms bond a-b + -> ["LP-/B+", [a], [a, b]] + + a=b,c + LP(a) forms/increases bond b-c + -> ["LP-/B+", [a], [b, c]] + + a,b=c + bond a-b breaks; electrons end as LP on c + -> ["B-/LP+", [a, b], [c]] + + a,b=c,d + bond a-b becomes bond c-d + -> ["B-/B+", [a, b], [c, d]] + + :param step: One arrow-code step. + :type step: str + :returns: Generic LP/B conversion record. + :rtype: list[Any] + :raises ValueError: If the step shape is unsupported. + """ + lhs, rhs = parse_arrow_step(step) + + # a=b + if len(lhs) == 1 and len(rhs) == 1: + a = lhs[0] + b = rhs[0] + return ["LP-/B+", [a], [a, b]] + + # a=b,c + if len(lhs) == 1 and len(rhs) == 2: + return ["LP-/B+", lhs, rhs] + + # a,b=c + if len(lhs) == 2 and len(rhs) == 1: + return ["B-/LP+", lhs, rhs] + + # a,b=c,d + if len(lhs) == 2 and len(rhs) == 2: + return ["B-/B+", lhs, rhs] + + raise ValueError(f"Unsupported arrow step: {step}") + + +def generic_convert_arrow_code(arrow_code: str) -> list[list[Any]]: + """Convert every step in an arrow code to generic LP/B form. + + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :returns: Generic conversion records for each step. + :rtype: list[list[Any]] + """ + return [generic_convert_step(step) for step in split_arrow_code(arrow_code)] + + +# ============================================================ +# SynKit ITS construction +# ============================================================ + + +def build_its_from_rsmi( + rsmi: str, + arrow_code: str, + expand_aam: bool = True, + remove_non_arrow_maps: bool = True, +): + """ + Build SynKit ITS graph from reaction SMILES. + + Pipeline + -------- + raw SMIRKS + -> validate arrow atom maps + -> remove non-arrow atom maps + -> CanonRSMI().expand_aam(...) + -> rsmi_to_its(...) + + :param rsmi: Reaction SMILES in ``reactants>>products`` format. + :type rsmi: str + :param arrow_code: Arrow code used to preserve relevant atom maps. + :type arrow_code: str + :param expand_aam: Whether to expand atom mapping before ITS construction. + :type expand_aam: bool + :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code. + :type remove_non_arrow_maps: bool + :returns: ITS graph, expanded RSMI, cleaned RSMI, and validation diagnostics. + :rtype: tuple + :raises ImportError: If required SynKit conversion helpers are unavailable. + """ + if CanonRSMI is None or rsmi_to_its is None: + raise ImportError( + "SynKit is not available. Run this code inside your SynKit environment." + ) + + diagnostics = validate_arrow_maps( + rsmi=rsmi, + arrow_code=arrow_code, + raise_on_arrow_duplicates=True, + raise_on_missing_arrow_maps=True, + ) + + if remove_non_arrow_maps: + rsmi_for_its = remove_non_arrow_atom_maps(rsmi, arrow_code) + else: + rsmi_for_its = rsmi + + expanded_rsmi = ( + ITSExpand().expand_aam_with_its( + rsmi_for_its, relabel=False, preserve_older_map=True + ) + if expand_aam + else rsmi_for_its + ) + + its = rsmi_to_its(expanded_rsmi) + + return its, expanded_rsmi, rsmi_for_its, diagnostics + + +# ============================================================ +# ITS graph helpers +# ============================================================ + + +def atom_map_to_nodes(its) -> dict[int, list[Any]]: + """ + Build atom-map-number -> list of ITS node ids. + + This catches ambiguous duplicated atom maps after ITS construction. + + :param its: ITS graph. + :type its: networkx.Graph + :returns: Mapping from atom-map number to ITS node IDs. + :rtype: dict[int, list[Any]] + """ + mapping: dict[int, list[Any]] = {} + + for node, data in its.nodes(data=True): + atom_map = int(data.get("atom_map", node)) + mapping.setdefault(atom_map, []).append(node) + + return mapping + + +def get_unique_node_for_atom_map( + its, + atom_map: int, + strict: bool = True, + atom_map_nodes: Optional[dict[int, list[Any]]] = None, +) -> Optional[Any]: + """Get the unique ITS node corresponding to an atom map. + + :param its: ITS graph. + :type its: networkx.Graph + :param atom_map: Atom-map number to resolve. + :type atom_map: int + :param strict: Whether a missing atom map should raise. + :type strict: bool + :param atom_map_nodes: Optional precomputed atom-map to node index. + :type atom_map_nodes: Optional[dict[int, list[Any]]] + :returns: Unique ITS node ID, or ``None`` when missing and ``strict`` is false. + :rtype: Optional[Any] + :raises ValueError: If the atom map is missing in strict mode or is ambiguous. + """ + mapping = atom_map_nodes if atom_map_nodes is not None else atom_map_to_nodes(its) + nodes = mapping.get(int(atom_map), []) + + if len(nodes) == 0: + if strict: + raise ValueError(f"Atom map {atom_map} is missing from ITS graph.") + return None + + if len(nodes) == 1: + return nodes[0] + + raise ValueError( + f"Atom map {atom_map} maps to multiple ITS nodes: {nodes}. " + "This means atom mapping is ambiguous." + ) + + +def extract_order_from_edge_data(edge_data: Any) -> tuple[float, float]: + """ + Extract SynKit ITS edge order. + + Expected normal edge format: + {"order": (reactant_order, product_order)} + + MultiGraph-like fallback: + {0: {"order": (reactant_order, product_order)}} + + :param edge_data: ITS edge attributes. + :type edge_data: Any + :returns: Reactant-side and product-side bond orders. + :rtype: tuple[float, float] + """ + if edge_data is None: + return 0.0, 0.0 + + if isinstance(edge_data, dict) and "order" in edge_data: + order = edge_data["order"] + return float(order[0]), float(order[1]) + + if isinstance(edge_data, dict): + for value in edge_data.values(): + if isinstance(value, dict) and "order" in value: + order = value["order"] + return float(order[0]), float(order[1]) + + return 0.0, 0.0 + + +def get_its_bond_order( + its, + atom_a: int, + atom_b: int, + strict: bool = True, + context: str = "", + atom_map_nodes: Optional[dict[int, list[Any]]] = None, +) -> tuple[float, float]: + """ + Return ITS bond order for atom-map pair. + + For example, an edge with order ``(0.0, 1.0)`` represents new bond + formation from reactants to products. + + :param its: ITS graph. + :type its: networkx.Graph + :param atom_a: First atom-map number. + :type atom_a: int + :param atom_b: Second atom-map number. + :type atom_b: int + :param strict: Whether missing nodes or edges should raise. + :type strict: bool + :param context: Optional context appended to strict-mode edge errors. + :type context: str + :param atom_map_nodes: Optional precomputed atom-map to node index. + :type atom_map_nodes: Optional[dict[int, list[Any]]] + :returns: Reactant-side and product-side bond orders. + :rtype: tuple[float, float] + :raises ValueError: If strict lookup fails. + """ + node_a = get_unique_node_for_atom_map( + its, + atom_a, + strict=strict, + atom_map_nodes=atom_map_nodes, + ) + node_b = get_unique_node_for_atom_map( + its, + atom_b, + strict=strict, + atom_map_nodes=atom_map_nodes, + ) + + if node_a is None or node_b is None: + return 0.0, 0.0 + + if not its.has_edge(node_a, node_b): + if strict: + extra = f" Context: {context}" if context else "" + raise ValueError( + f"ITS graph has no edge for atom maps {atom_a}-{atom_b}." f"{extra}" + ) + return 0.0, 0.0 + + edge_data = its.get_edge_data(node_a, node_b) + return extract_order_from_edge_data(edge_data) + + +# ============================================================ +# Sigma/Pi typing +# ============================================================ + + +def is_zero(x: float, tol: float = 1e-6) -> bool: + """Return whether a value is approximately zero. + + :param x: Value to compare. + :type x: float + :param tol: Absolute tolerance. + :type tol: float + :returns: Whether ``x`` is within tolerance of zero. + :rtype: bool + """ + return abs(x) < tol + + +def is_one(x: float, tol: float = 1e-6) -> bool: + """Return whether a value is approximately one. + + :param x: Value to compare. + :type x: float + :param tol: Absolute tolerance. + :type tol: float + :returns: Whether ``x`` is within tolerance of one. + :rtype: bool + """ + return abs(x - 1.0) < tol + + +def bond_minus_type(reactant_order: float) -> str: + """ + Type consumed bond/electron-pair source. + + Rules + ----- + reactant_order == 1.0 -> Sigma- + reactant_order > 1.0 -> Pi- + includes double, triple, aromatic 1.5 + + unknown -> B- + + :param reactant_order: Bond order on the reactant side. + :type reactant_order: float + :returns: Typed consumed-bond label. + :rtype: str + """ + if is_one(reactant_order): + return "Sigma-" + + if reactant_order > 1.0: + return "Pi-" + + return "B-" + + +def bond_plus_type( + reactant_order: float, + product_order: float, +) -> str: + """ + Type formed/increased bond destination. + + Rules + ----- + 0 -> 1 : Sigma+ + 0 -> 1.5 : Sigma+, because new connectivity starts as sigma + 0 -> 2 : Sigma+, because new connectivity starts as sigma + 1 -> 2 : Pi+ + 1.5 -> 2 : Pi+ + 2 -> 3 : Pi+ + + :param reactant_order: Bond order on the reactant side. + :type reactant_order: float + :param product_order: Bond order on the product side. + :type product_order: float + :returns: Typed formed-bond label. + :rtype: str + """ + if product_order <= 0: + return "B+" + + # New bond formation. First new connectivity is sigma. + if is_zero(reactant_order) and product_order > 0: + return "Sigma+" + + # Existing bond order increases. Added component is pi. + if product_order > reactant_order: + return "Pi+" + + # Fallbacks. + if is_one(product_order): + return "Sigma+" + + if product_order > 1.0: + return "Pi+" + + return "B+" + + +# ============================================================ +# Typed LP/Sigma/Pi conversion +# ============================================================ + + +def typed_convert_step( + step: str, + its, + strict_bond_lookup: bool = True, + atom_map_nodes: Optional[dict[int, list[Any]]] = None, +) -> list[Any]: + """ + Convert one arrow-code step into typed LP/Sigma/Pi format. + + Important + --------- + This function does NOT globally force Sigma/Pi from orbital_class. + Each step is typed from local ITS bond-order changes. + + :param step: One arrow-code step. + :type step: str + :param its: ITS graph used for local bond-order lookup. + :type its: networkx.Graph + :param strict_bond_lookup: Whether missing bond lookups should raise. + :type strict_bond_lookup: bool + :param atom_map_nodes: Optional precomputed atom-map to node index. + :type atom_map_nodes: Optional[dict[int, list[Any]]] + :returns: Typed LP/Sigma/Pi conversion record. + :rtype: list[Any] + :raises ValueError: If the step shape is unsupported or strict lookup fails. + """ + lhs, rhs = parse_arrow_step(step) + + # -------------------------------------------------------- + # Case 1: a=b + # LP(a) forms bond a-b + # -------------------------------------------------------- + if len(lhs) == 1 and len(rhs) == 1: + a = lhs[0] + b = rhs[0] + + r_order, p_order = get_its_bond_order( + its, + a, + b, + strict=strict_bond_lookup, + context=step, + atom_map_nodes=atom_map_nodes, + ) + plus = bond_plus_type(r_order, p_order) + + return [f"LP-/{plus}", [a], [a, b]] + + # -------------------------------------------------------- + # Case 2: a=b,c + # LP(a) forms/increases bond b-c + # + # Example: + # 12=11,12 + # LP on 12 forms/increases 11-12 bond + # -------------------------------------------------------- + if len(lhs) == 1 and len(rhs) == 2: + a = lhs[0] + b, c = rhs + + r_order, p_order = get_its_bond_order( + its, + b, + c, + strict=strict_bond_lookup, + context=step, + atom_map_nodes=atom_map_nodes, + ) + plus = bond_plus_type(r_order, p_order) + + return [f"LP-/{plus}", [a], [b, c]] + + # -------------------------------------------------------- + # Case 3: a,b=c + # bond a-b breaks; electrons become LP on c + # -------------------------------------------------------- + if len(lhs) == 2 and len(rhs) == 1: + a, b = lhs + c = rhs[0] + + r_order, _p_order = get_its_bond_order( + its, + a, + b, + strict=strict_bond_lookup, + context=step, + atom_map_nodes=atom_map_nodes, + ) + minus = bond_minus_type(r_order) + + return [f"{minus}/LP+", [a, b], [c]] + + # -------------------------------------------------------- + # Case 4: a,b=c,d + # bond a-b becomes bond c-d + # -------------------------------------------------------- + if len(lhs) == 2 and len(rhs) == 2: + a, b = lhs + c, d = rhs + + src_r_order, _src_p_order = get_its_bond_order( + its, + a, + b, + strict=strict_bond_lookup, + context=f"source of {step}", + atom_map_nodes=atom_map_nodes, + ) + + dst_r_order, dst_p_order = get_its_bond_order( + its, + c, + d, + strict=strict_bond_lookup, + context=f"destination of {step}", + atom_map_nodes=atom_map_nodes, + ) + + minus = bond_minus_type(src_r_order) + plus = bond_plus_type(dst_r_order, dst_p_order) + + return [f"{minus}/{plus}", [a, b], [c, d]] + + raise ValueError(f"Unsupported arrow step: {step}") + + +def typed_convert_arrow_code( + arrow_code: str, + its, + strict_bond_lookup: bool = True, +) -> list[list[Any]]: + """Convert every step in an arrow code to typed LP/Sigma/Pi form. + + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :param its: ITS graph used for local bond-order lookup. + :type its: networkx.Graph + :param strict_bond_lookup: Whether missing bond lookups should raise. + :type strict_bond_lookup: bool + :returns: Typed conversion records for each step. + :rtype: list[list[Any]] + """ + atom_map_nodes = atom_map_to_nodes(its) + + return [ + typed_convert_step( + step=step, + its=its, + strict_bond_lookup=strict_bond_lookup, + atom_map_nodes=atom_map_nodes, + ) + for step in split_arrow_code(arrow_code) + ] + + +# ============================================================ +# Main public conversion functions +# ============================================================ + + +def convert_arrow_code( + arrow_code: str, + its=None, + strict_bond_lookup: bool = True, +) -> dict[str, Any]: + """ + Convert arrow code into generic and typed formats. + + If ``its`` is ``None``, ``typed_converted`` is ``None``. + + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :param its: Optional ITS graph for typed conversion. + :type its: Optional[networkx.Graph] + :param strict_bond_lookup: Whether missing typed bond lookups should raise. + :type strict_bond_lookup: bool + :returns: Arrow code with generic and optional typed conversions. + :rtype: dict[str, Any] + """ + converted = generic_convert_arrow_code(arrow_code) + + if its is None: + typed_converted = None + else: + typed_converted = typed_convert_arrow_code( + arrow_code=arrow_code, + its=its, + strict_bond_lookup=strict_bond_lookup, + ) + + return { + "arrow_code": arrow_code, + "converted": converted, + "typed_converted": typed_converted, + } + + +def convert_reaction_arrow( + reaction_smiles: str, + arrow_code: str, + orbital_class: Optional[str] = None, + expand_aam: bool = True, + remove_non_arrow_maps: bool = True, + strict_bond_lookup: bool = True, +) -> dict[str, Any]: + """ + Complete wrapper. + + reaction SMILES + arrow code + -> clean non-arrow maps + -> expand AAM with SynKit + -> ITS graph + -> generic converted + -> typed converted + + orbital_class is stored as metadata only. + It is not used to force Sigma/Pi typing. + + :param reaction_smiles: Reaction SMILES in ``reactants>>products`` format. + :type reaction_smiles: str + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :param orbital_class: Optional source-dataset orbital classification metadata. + :type orbital_class: Optional[str] + :param expand_aam: Whether to expand atom mapping before ITS construction. + :type expand_aam: bool + :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code. + :type remove_non_arrow_maps: bool + :param strict_bond_lookup: Whether missing typed bond lookups should raise. + :type strict_bond_lookup: bool + :returns: Conversion result and ITS preparation metadata. + :rtype: dict[str, Any] + """ + its, expanded_rsmi, rsmi_for_its, diagnostics = build_its_from_rsmi( + rsmi=reaction_smiles, + arrow_code=arrow_code, + expand_aam=expand_aam, + remove_non_arrow_maps=remove_non_arrow_maps, + ) + + result = convert_arrow_code( + arrow_code=arrow_code, + its=its, + strict_bond_lookup=strict_bond_lookup, + ) + + result["reaction_smiles"] = reaction_smiles + result["rsmi_for_its"] = rsmi_for_its + result["expanded_rsmi"] = expanded_rsmi + result["orbital_class"] = orbital_class + result["diagnostics"] = diagnostics + + return result + + +def convert_record( + record: dict[str, Any], + reaction_key: str = "SMIRKS", + arrow_key: str = "arrow_code", + orbital_key: str = "orbital pair classification", + expand_aam: bool = True, + remove_non_arrow_maps: bool = True, + strict_bond_lookup: bool = True, +) -> dict[str, Any]: + """ + Convert one dictionary record. + + Expected input keys + ------------------- + { + "SMIRKS": "...>>...", + "arrow_code": "...", + "orbital pair classification": "pi_empty" + } + + :param record: Source record to convert. + :type record: dict[str, Any] + :param reaction_key: Key containing reaction SMILES. + :type reaction_key: str + :param arrow_key: Key containing arrow code. + :type arrow_key: str + :param orbital_key: Key containing optional orbital classification metadata. + :type orbital_key: str + :param expand_aam: Whether to expand atom mapping before ITS construction. + :type expand_aam: bool + :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code. + :type remove_non_arrow_maps: bool + :param strict_bond_lookup: Whether missing typed bond lookups should raise. + :type strict_bond_lookup: bool + :returns: Converted record with original metadata preserved. + :rtype: dict[str, Any] + """ + reaction_smiles = record[reaction_key] + arrow_code = record[arrow_key] + orbital_class = record.get(orbital_key) + + result = convert_reaction_arrow( + reaction_smiles=reaction_smiles, + arrow_code=arrow_code, + orbital_class=orbital_class, + expand_aam=expand_aam, + remove_non_arrow_maps=remove_non_arrow_maps, + strict_bond_lookup=strict_bond_lookup, + ) + + # Preserve original metadata. + for key, value in record.items(): + if key not in result: + result[key] = value + + return result + + +def convert_records( + records: list[dict[str, Any]], + reaction_key: str = "SMIRKS", + arrow_key: str = "arrow_code", + orbital_key: str = "orbital pair classification", + expand_aam: bool = True, + remove_non_arrow_maps: bool = True, + strict_bond_lookup: bool = True, + keep_errors: bool = False, +) -> list[dict[str, Any]]: + """ + Batch conversion. + + keep_errors=False: + raise immediately on first error. + + keep_errors=True: + collect errors into result dictionaries. + + :param records: Source records to convert. + :type records: list[dict[str, Any]] + :param reaction_key: Key containing reaction SMILES. + :type reaction_key: str + :param arrow_key: Key containing arrow code. + :type arrow_key: str + :param orbital_key: Key containing optional orbital classification metadata. + :type orbital_key: str + :param expand_aam: Whether to expand atom mapping before ITS construction. + :type expand_aam: bool + :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code. + :type remove_non_arrow_maps: bool + :param strict_bond_lookup: Whether missing typed bond lookups should raise. + :type strict_bond_lookup: bool + :param keep_errors: Whether to collect conversion failures instead of raising. + :type keep_errors: bool + :returns: Converted records, including failures when ``keep_errors`` is true. + :rtype: list[dict[str, Any]] + """ + results = [] + + for idx, record in enumerate(records, start=1): + try: + result = convert_record( + record=record, + reaction_key=reaction_key, + arrow_key=arrow_key, + orbital_key=orbital_key, + expand_aam=expand_aam, + remove_non_arrow_maps=remove_non_arrow_maps, + strict_bond_lookup=strict_bond_lookup, + ) + result["row_index"] = idx + results.append(result) + + except Exception as e: + if not keep_errors: + print("=" * 100) + print(f"FAILED ROW {idx}") + print("=" * 100) + print("orbital_class:", record.get(orbital_key)) + print("arrow_code:", record.get(arrow_key)) + print("error:", repr(e)) + print("=" * 100) + raise + + failed = dict(record) + failed["row_index"] = idx + failed["error"] = repr(e) + results.append(failed) + + return results + + +# ============================================================ +# Debug helpers +# ============================================================ + + +def debug_arrow_bond_orders( + reaction_smiles: str, + arrow_code: str, + expand_aam: bool = True, + remove_non_arrow_maps: bool = True, + strict_bond_lookup: bool = True, +) -> None: + """Print the ITS bond orders used by each arrow step. + + :param reaction_smiles: Reaction SMILES in ``reactants>>products`` format. + :type reaction_smiles: str + :param arrow_code: Semicolon-separated arrow code. + :type arrow_code: str + :param expand_aam: Whether to expand atom mapping before ITS construction. + :type expand_aam: bool + :param remove_non_arrow_maps: Whether to remove atom maps not used by the arrow code. + :type remove_non_arrow_maps: bool + :param strict_bond_lookup: Whether missing typed bond lookups should raise. + :type strict_bond_lookup: bool + :returns: ``None``. + :rtype: None + """ + its, expanded_rsmi, rsmi_for_its, diagnostics = build_its_from_rsmi( + rsmi=reaction_smiles, + arrow_code=arrow_code, + expand_aam=expand_aam, + remove_non_arrow_maps=remove_non_arrow_maps, + ) + + print("Diagnostics:") + print(diagnostics) + print() + + print("RSMI used for ITS:") + print(rsmi_for_its) + print() + + print("Expanded RSMI:") + print(expanded_rsmi) + print() + + for step in split_arrow_code(arrow_code): + lhs, rhs = parse_arrow_step(step) + + print(f"Step: {step}") + print(f" shape: {classify_arrow_shape(step)}") + + if len(lhs) == 1 and len(rhs) == 1: + a = lhs[0] + b = rhs[0] + print( + f" destination bond {a}-{b}: " + f"{get_its_bond_order(its, a, b, strict=strict_bond_lookup, context=step)}" + ) + + elif len(lhs) == 1 and len(rhs) == 2: + a = lhs[0] + b, c = rhs + print(f" LP source atom {a}") + print( + f" destination bond {b}-{c}: " + f"{get_its_bond_order(its, b, c, strict=strict_bond_lookup, context=step)}" + ) + + elif len(lhs) == 2 and len(rhs) == 1: + a, b = lhs + c = rhs[0] + print( + f" source bond {a}-{b}: " + f"{get_its_bond_order(its, a, b, strict=strict_bond_lookup, context=step)}" + ) + print(f" LP destination atom {c}") + + elif len(lhs) == 2 and len(rhs) == 2: + a, b = lhs + c, d = rhs + print( + f" source bond {a}-{b}: " + f"{get_its_bond_order(its, a, b, strict=strict_bond_lookup, context=step)}" + ) + print( + f" destination bond {c}-{d}: " + f"{get_its_bond_order(its, c, d, strict=strict_bond_lookup, context=step)}" + ) + + print() + + +def debug_record( + record: dict[str, Any], + reaction_key: str = "SMIRKS", + arrow_key: str = "arrow_code", + orbital_key: str = "orbital pair classification", +) -> dict[str, Any]: + """Print full debug output for one record. + + :param record: Source record to inspect. + :type record: dict[str, Any] + :param reaction_key: Key containing reaction SMILES. + :type reaction_key: str + :param arrow_key: Key containing arrow code. + :type arrow_key: str + :param orbital_key: Key containing optional orbital classification metadata. + :type orbital_key: str + :returns: Converted record. + :rtype: dict[str, Any] + """ + from pprint import pprint + + rsmi = record[reaction_key] + arrow_code = record[arrow_key] + orbital_class = record.get(orbital_key) + + print("=" * 100) + print("DEBUG RECORD") + print("=" * 100) + + print("orbital_class:") + print(orbital_class) + print() + + print("arrow_code:") + print(arrow_code) + print() + + print("arrow shapes:") + for step in split_arrow_code(arrow_code): + print(f" {step:25s} -> {classify_arrow_shape(step)}") + print() + + print("raw map diagnostics:") + diagnostics = validate_arrow_maps( + rsmi=rsmi, + arrow_code=arrow_code, + raise_on_arrow_duplicates=False, + raise_on_missing_arrow_maps=False, + ) + pprint(diagnostics, width=160) + print() + + print("RSMI after removing non-arrow maps:") + cleaned = remove_non_arrow_atom_maps(rsmi, arrow_code) + print(cleaned) + print() + + print("Arrow bond orders:") + debug_arrow_bond_orders( + reaction_smiles=rsmi, + arrow_code=arrow_code, + expand_aam=True, + remove_non_arrow_maps=True, + strict_bond_lookup=True, + ) + + result = convert_reaction_arrow( + reaction_smiles=rsmi, + arrow_code=arrow_code, + orbital_class=orbital_class, + expand_aam=True, + remove_non_arrow_maps=True, + strict_bond_lookup=True, + ) + + print("converted:") + pprint(result["converted"], width=120) + print() + + print("typed_converted:") + pprint(result["typed_converted"], width=120) + print() + + return result + + +def check_typed_conversion_quality(results: list[dict[str, Any]]) -> dict[str, Any]: + """Check whether typed conversions still contain generic B-/B+ labels. + + :param results: Conversion results to inspect. + :type results: list[dict[str, Any]] + :returns: Error and untyped-step diagnostics. + :rtype: dict[str, Any] + """ + errors = [] + untyped = [] + + for result in results: + if "error" in result: + errors.append(result) + continue + + typed = result.get("typed_converted") + + if typed is None: + untyped.append( + { + "row_index": result.get("row_index"), + "reason": "typed_converted is None", + } + ) + continue + + for step in typed: + label = step[0] + if "B-" in label or "B+" in label: + untyped.append( + { + "row_index": result.get("row_index"), + "orbital_class": result.get("orbital_class"), + "arrow_code": result.get("arrow_code"), + "step": step, + } + ) + + return { + "n_results": len(results), + "n_errors": len(errors), + "n_untyped_steps": len(untyped), + "all_fully_typed": len(errors) == 0 and len(untyped) == 0, + "errors": errors, + "untyped": untyped, + } diff --git a/synkit/Graph/Mech/electron_accounting.py b/synkit/Graph/Mech/electron_accounting.py new file mode 100644 index 0000000..42a1d0a --- /dev/null +++ b/synkit/Graph/Mech/electron_accounting.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +import networkx as nx +from rdkit import Chem + +from synkit.IO.graph_to_mol import GraphToMol + + +def bond_order_sum(graph: nx.Graph, node: Any) -> float: + """Return the sigma-plus-pi bond-order sum around one node.""" + total = 0.0 + for _, _, data in graph.edges(node, data=True): + total += float(data.get("sigma_order", 0.0)) + float(data.get("pi_order", 0.0)) + return total + + +def recompute_charge(graph: nx.Graph, node: Any) -> int | float: + """Recompute formal charge from stored electron-state fields.""" + attrs = graph.nodes[node] + charge = float(attrs["valence_electrons"]) - ( + 2 * float(attrs.get("lone_pairs", 0)) + + float(attrs.get("radical", 0)) + + float(attrs.get("hcount", 0)) + + bond_order_sum(graph, node) + ) + return int(charge) if charge.is_integer() else charge + + +def refresh_electron_fields(graph: nx.Graph, *, in_place: bool = False) -> nx.Graph: + """Refresh derived electron bookkeeping on a molecular graph. + + The graph is expected to store scalar ``sigma_order`` and ``pi_order`` edge + fields plus node-level electron state. Presentation-facing ``order`` is not + rewritten here; RDKit reconstruction remains responsible for aromatic + re-perception at the product boundary. + """ + target = graph if in_place else graph.copy() + + for _, _, data in target.edges(data=True): + sigma = float(data.get("sigma_order", 0.0)) + pi = float(data.get("pi_order", 0.0)) + data["kekule_order"] = sigma + pi + + for node, attrs in target.nodes(data=True): + attrs["bond_order_sum"] = bond_order_sum(target, node) + if "valence_electrons" not in attrs: + continue + attrs["recomputed_charge"] = recompute_charge(target, node) + represented_charge = float(attrs.get("charge", 0)) + attrs["charge_mismatch"] = represented_charge != attrs["recomputed_charge"] + + return target + + +def graph_to_sanitized_kekule_mol(graph: nx.Graph) -> Chem.Mol: + """Reconstruct a product from ``kekule_order`` and let RDKit sanitize it.""" + refreshed = refresh_electron_fields(graph) + return GraphToMol(edge_attributes={"order": "kekule_order"}).graph_to_mol( + refreshed, + sanitize=True, + use_h_count=True, + ) diff --git a/synkit/Graph/canon_graph.py b/synkit/Graph/canon_graph.py index f134d88..5c49f62 100644 --- a/synkit/Graph/canon_graph.py +++ b/synkit/Graph/canon_graph.py @@ -85,6 +85,8 @@ def _default_node_key(node_id: NodeId, data: NodeData) -> Tuple[Any, ...]: return ( data.get("element", ""), data.get("charge", 0), + data.get("lone_pairs", 0), + data.get("radical", 0), data.get("aromatic", False), # data.get("atom_map", 0), data.get("hcount", 0), @@ -149,7 +151,14 @@ def __init__( backend: Literal["generic", "wl", "morgan", "nauty"] = "generic", wl_iterations: int = 3, morgan_radius: int = 3, - node_attrs: List[str] = ["element", "aromatic", "charge", "hcount"], + node_attrs: List[str] = [ + "element", + "aromatic", + "charge", + "lone_pairs", + "radical", + "hcount", + ], node_sort_key: T_NodeSortKey = _default_node_key, edge_sort_key: T_EdgeSortKey = _default_edge_key, ) -> None: @@ -316,8 +325,8 @@ def _serialise(self, g: nx.Graph) -> str: nodes = sorted(g.nodes(data=True), key=lambda x: self._node_key(*x)) edges = sorted(g.edges(data=True), key=lambda x: self._edge_key(*x)) - node_str = ";".join(f"{n}:{self._node_key(n,d)}" for n, d in nodes) - edge_str = ";".join(f"{(u,v)}:{self._edge_key(u,v,d)}" for u, v, d in edges) + node_str = ";".join(f"{n}:{self._node_key(n, d)}" for n, d in nodes) + edge_str = ";".join(f"{(u, v)}:{self._edge_key(u, v, d)}" for u, v, d in edges) return f"N[{node_str}]|E[{edge_str}]" # ------------------------------------------------------------------ # diff --git a/synkit/Graph/utils.py b/synkit/Graph/utils.py index be44e69..51e7f8f 100644 --- a/synkit/Graph/utils.py +++ b/synkit/Graph/utils.py @@ -81,6 +81,7 @@ def add_wildcard_subgraph_for_unmapped( mapping: Dict[Any, Any], edge_keys: List[str] = ["order"], inplace: bool = False, + tuple_mode: bool = False, ) -> Tuple[nx.Graph, Dict[Any, Any]]: """Extend G with wildcard nodes/edges for every L-node not already mapped, preserving original L->G mapping and returning the full mapping. @@ -97,6 +98,9 @@ def add_wildcard_subgraph_for_unmapped( Edge attributes to copy (first element if list/tuple). Default ['order']. inplace : bool, optional If True, modify G in place; otherwise modify a copy. + tuple_mode : bool, optional + If True, scalarize tuple ITS node attrs onto the left side before + adding wildcard placeholders to the host graph. Returns ------- @@ -116,12 +120,38 @@ def add_wildcard_subgraph_for_unmapped( # Prepare new node IDs next_id = max(G_ext.nodes, default=-1) + 1 + used_atom_maps = { + data.get("atom_map") + for _, data in G_ext.nodes(data=True) + if data.get("atom_map") not in (None, 0) + } + + def _next_unused_atom_map(start: int) -> int: + candidate = start + while candidate in used_atom_maps: + candidate += 1 + return candidate # Add wildcard nodes for each unmapped L node for l_node in unmapped: attrs = L.nodes[l_node].copy() + if tuple_mode: + attrs = { + key: ( + value[0] if isinstance(value, tuple) and len(value) == 2 else value + ) + for key, value in attrs.items() + if key != "typesGH" + } + left_types = L.nodes[l_node].get("typesGH", (None, None))[0] + if left_types is not None: + attrs["typesGH"] = (left_types, left_types) attrs["element"] = "*" - attrs.setdefault("atom_map", next_id) + atom_map = attrs.get("atom_map") + if atom_map in (None, 0) or atom_map in used_atom_maps: + atom_map = _next_unused_atom_map(next_id) + attrs["atom_map"] = atom_map + used_atom_maps.add(atom_map) G_ext.add_node(next_id, **attrs) L_to_G[l_node] = next_id next_id += 1 diff --git a/synkit/IO/chem_converter.py b/synkit/IO/chem_converter.py index 5b4c32c..73d5c96 100644 --- a/synkit/IO/chem_converter.py +++ b/synkit/IO/chem_converter.py @@ -16,6 +16,7 @@ from synkit.Graph.ITS.its_decompose import get_rc, its_decompose from synkit.Graph.ITS.rc_extractor import RCExtractor from synkit.Graph.ITS.its_reverter import ITSReverter +from synkit.Graph.Mech.electron_accounting import graph_to_sanitized_kekule_mol _BRACKET_DIGIT_PATTERN: Pattern[str] = re.compile(r"\[([^\]]*?)\](\d+)") _BRACKET_MAP_PATTERN: Pattern[str] = re.compile(r"\[([^\]]+):(\d+)\]") @@ -27,11 +28,14 @@ "aromatic", "hcount", "charge", + "radical", + "lone_pairs", + "valence_electrons", "neighbors", "atom_map", ) -DEFAULT_EDGE_ATTRS = ("order",) +DEFAULT_EDGE_ATTRS = ("order", "kekule_order", "sigma_order", "pi_order") logger = setup_logging() @@ -73,6 +77,42 @@ def _validate_its_format(format: str) -> ITSFormat: return format +def detect_its_format(graph: nx.Graph) -> ITSFormat: + """ + Detect the ITS storage representation used by a graph. + + Legacy ITS graphs keep scalar node attributes and store side-specific + values only in ``typesGH``. Tuple ITS graphs store direct paired node and + edge attributes such as ``element=("C", "C")`` or + ``sigma_order=(1.0, 1.0)``. + + :param graph: ITS-like graph to inspect. + :type graph: nx.Graph + :return: Detected ITS format. + :rtype: ITSFormat + """ + tuple_node_keys = ( + "element", + "aromatic", + "hcount", + "charge", + "radical", + "lone_pairs", + "valence_electrons", + ) + tuple_edge_keys = ("kekule_order", "sigma_order", "pi_order") + + for _, attrs in graph.nodes(data=True): + if any(_is_pair(attrs.get(key)) for key in tuple_node_keys): + return "tuple" + + for _, _, attrs in graph.edges(data=True): + if any(_is_pair(attrs.get(key)) for key in tuple_edge_keys): + return "tuple" + + return "typesGH" + + def _split_rsmi(rsmi: str) -> tuple[str, str]: """ Split a reaction SMILES string into reactant and product parts. @@ -525,8 +565,8 @@ def rsmi_to_its( :type node_attrs: Optional[Sequence[str]] :param edge_attrs: Edge attributes to include in graph construction. :type edge_attrs: Optional[Sequence[str]] - :param explicit_hydrogen: If ``True`` and ``format="typesGH"``, - convert implicit hydrogens to explicit nodes. + :param explicit_hydrogen: If ``True``, convert implicit hydrogens to + explicit nodes for the selected ITS format. :type explicit_hydrogen: bool :param format: ITS format. :type format: ITSFormat @@ -568,6 +608,10 @@ def rsmi_to_its( node_attrs=resolved_node_attrs, edge_attrs=resolved_edge_attrs, ) + if explicit_hydrogen: + from synkit.Graph.Hyrogen._misc import h_to_explicit + + its_graph = h_to_explicit(its_graph, None, True) if core: its_graph = RCExtractor( node_attrs=resolved_node_attrs, @@ -611,13 +655,44 @@ def its_to_rsmi( validated_format = _validate_its_format(format) reactant_graph, product_graph = _decompose_its(its, validated_format) - rsmi = graph_to_rsmi( - reactant_graph, - product_graph, - its, - sanitize, - explicit_hydrogen, - ) + if validated_format == "tuple": + preserved_hydrogens = ( + [] + if explicit_hydrogen + else _get_preserved_hydrogen_maps(its, validated_format) + ) + reactant_smiles = graph_to_smi( + reactant_graph, + sanitize=sanitize, + preserve_atom_maps=preserved_hydrogens, + ) + try: + if explicit_hydrogen: + product = product_graph + else: + from synkit.Graph.Hyrogen._misc import implicit_hydrogen + + product = implicit_hydrogen( + product_graph, + set(preserved_hydrogens), + ) + product_smiles = Chem.MolToSmiles(graph_to_sanitized_kekule_mol(product)) + except Exception as exc: + logger.debug("Error generating tuple product SMILES: %s", exc) + product_smiles = None + rsmi = ( + f"{reactant_smiles}>>{product_smiles}" + if reactant_smiles is not None and product_smiles is not None + else None + ) + else: + rsmi = graph_to_rsmi( + reactant_graph, + product_graph, + its, + sanitize, + explicit_hydrogen, + ) if rsmi is None: raise ValueError("Failed to convert ITS graph to reaction SMILES.") diff --git a/synkit/IO/graph_to_mol.py b/synkit/IO/graph_to_mol.py index ddcf048..a0d946d 100644 --- a/synkit/IO/graph_to_mol.py +++ b/synkit/IO/graph_to_mol.py @@ -76,6 +76,7 @@ def graph_to_mol( for node, data in graph.nodes(data=True): element = data.get(self.node_attributes["element"], "*") charge = data.get(self.node_attributes["charge"], 0) + radical = data.get(self.node_attributes.get("radical", "radical"), 0) atom_map = ( data.get(self.node_attributes["atom_map"], 0) if "atom_map" in data.keys() @@ -89,6 +90,7 @@ def graph_to_mol( atom = Chem.Atom(element) atom.SetFormalCharge(charge) + atom.SetNumRadicalElectrons(int(radical)) if atom_map is not None: atom.SetAtomMapNum(atom_map) if hcount is not None: diff --git a/synkit/IO/mol_to_graph.py b/synkit/IO/mol_to_graph.py index 54bd839..a96d6e2 100644 --- a/synkit/IO/mol_to_graph.py +++ b/synkit/IO/mol_to_graph.py @@ -558,6 +558,11 @@ def _bond_order_sum_for_lone_pairs(cls, atom: Chem.Atom) -> float: :rtype: float """ try: + if atom.GetIsAromatic(): + # Lone-pair bookkeeping needs the Kekule heavy-atom valence, + # not presentation bond orders such as three aromatic 1.5 bonds. + return float(atom.GetTotalValence() - cls._non_neighbor_h_count(atom)) + aromatic_lp_donor = cls._is_aromatic_lone_pair_donor(atom) total = 0.0 @@ -936,13 +941,13 @@ def _augment_atom_properties( new_props["available_lp"] = available_lone_pairs > 0 # Backward-compatible field used by SynEltra. new_props["lone_pairs"] = estimated_lone_pairs + new_props["valence_electrons"] = cls._safe_valence_electrons(atom) if profile == "full": new_props["bond_order_sum"] = round(cls._safe_bond_order_sum(atom), 3) new_props["lp_bond_order_sum"] = round( cls._bond_order_sum_for_lone_pairs(atom), 3 ) - new_props["valence_electrons"] = cls._safe_valence_electrons(atom) new_props["estimated_lone_pairs"] = estimated_lone_pairs new_props["available_lone_pairs"] = available_lone_pairs @@ -960,11 +965,12 @@ def _gather_atom_properties( Minimal profile keys: ``element``, ``aromatic``, ``hcount``, ``charge``, ``radical``, ``isomer``, ``partial_charge``, ``hybridization``, ``in_ring``, ``neighbors``, ``atom_map``, - ``oxidation_state``, ``available_lp``, ``lone_pairs``. + ``oxidation_state``, ``available_lp``, ``lone_pairs``, + ``valence_electrons``. Full profile additionally includes ``bond_order_sum``, - ``lp_bond_order_sum``, ``valence_electrons``, - ``estimated_lone_pairs``, ``available_lone_pairs``. + ``lp_bond_order_sum``, ``estimated_lone_pairs``, + ``available_lone_pairs``. :param atom: RDKit atom. :type atom: Chem.Atom @@ -1010,6 +1016,7 @@ def _gather_atom_properties( ), "available_lp": available_lone_pairs > 0, "lone_pairs": estimated_lone_pairs, + "valence_electrons": MolToGraph._safe_valence_electrons(atom), } if profile == "full": @@ -1017,7 +1024,6 @@ def _gather_atom_properties( props["lp_bond_order_sum"] = round( MolToGraph._bond_order_sum_for_lone_pairs(atom), 3 ) - props["valence_electrons"] = MolToGraph._safe_valence_electrons(atom) props["estimated_lone_pairs"] = estimated_lone_pairs props["available_lone_pairs"] = available_lone_pairs @@ -1081,17 +1087,29 @@ def _gather_bond_properties( except Exception: kekule_bond_type = bond_type + sigma_order, pi_order = MolToGraph._split_sigma_pi_order(kekule_order) + return { "order": order, "bond_type": bond_type, "aromatic": aromatic, "kekule_order": kekule_order, + "sigma_order": sigma_order, + "pi_order": pi_order, "kekule_bond_type": kekule_bond_type, "ez_isomer": ez, "conjugated": conjugated, "in_ring": in_ring, } + @staticmethod + def _split_sigma_pi_order(kekule_order: float) -> tuple[float, float]: + """Split a Kekule bond order into sigma and pi contributions.""" + order = max(0.0, float(kekule_order)) + if order <= 0: + return 0.0, 0.0 + return 1.0, max(0.0, order - 1.0) + # ------------------------------------------------------------------ # Stereochemistry helpers # ------------------------------------------------------------------ @@ -1225,8 +1243,10 @@ def _create_light_weight_graph( Node attributes: ``element``, ``aromatic``, ``hcount``, ``charge``, ``radical``, ``neighbors``, ``atom_map``, ``oxidation_state``, - ``available_lp``, ``lone_pairs``. Edge attributes: ``order``, - ``bond_type``, ``aromatic``, ``kekule_order``, ``kekule_bond_type``. + ``available_lp``, ``lone_pairs``, ``valence_electrons``. + Edge attributes: ``order``, ``bond_type``, ``aromatic``, + ``kekule_order``, ``sigma_order``, ``pi_order``, + ``kekule_bond_type``. :param mol: RDKit molecule. :type mol: Chem.Mol @@ -1276,6 +1296,7 @@ def _create_light_weight_graph( ), available_lp=available_lone_pairs > 0, lone_pairs=estimated_lone_pairs, + valence_electrons=cls._safe_valence_electrons(atom), ) for bond in mol.GetBonds(): diff --git a/synkit/Rule/Apply/rule_matcher.py b/synkit/Rule/Apply/rule_matcher.py index b587fd9..5fac36f 100644 --- a/synkit/Rule/Apply/rule_matcher.py +++ b/synkit/Rule/Apply/rule_matcher.py @@ -55,7 +55,11 @@ class RuleMatcher: """ def __init__( - self, rsmi: str, rule: Union[str, nx.Graph], explicit_h: bool = True + self, + rsmi: str, + rule: Union[str, nx.Graph], + explicit_h: bool = True, + electron_diagnostics: bool = False, ) -> None: """Initialize the matcher by standardizing the RSMI, building graphs, checking balance, and computing the match. @@ -74,6 +78,8 @@ def __init__( rule = rsmi_to_its(rule, core=True) self.rule = rule self.explicit_h = explicit_h + self.electron_diagnostics = electron_diagnostics + self._diagnostics: list[dict] = [] self.balanced = BalanceReactionCheck(n_jobs=1).rsmi_balance_check(self.rsmi) # Compute and store the match result @@ -103,9 +109,14 @@ def _match_valid(self) -> Optional[Tuple[str, nx.Graph]]: None. :rtype: Optional[tuple[str, nx.Graph]] """ - reactor = SynReactor(substrate=self.r_graph, template=self.rule) + reactor = SynReactor( + substrate=self.r_graph, + template=self.rule, + electron_diagnostics=self.electron_diagnostics, + ) for smarts in reactor.smarts_list: if self.std.fit(smarts) == self.rsmi: + self._diagnostics = reactor.diagnostics return smarts, self.rule return None @@ -120,12 +131,17 @@ def _match_reverse(self) -> Optional[Tuple[str, nx.Graph]]: :rtype: Optional[tuple[str, nx.Graph]] """ # Product‑side fragments - reactor = SynReactor(substrate=self.r_graph, template=self.rule) + reactor = SynReactor( + substrate=self.r_graph, + template=self.rule, + electron_diagnostics=self.electron_diagnostics, + ) for smarts in reactor.smarts_list: std_r = self.std.fit(smarts) if self.all_in( self.rsmi.split(">>")[1].split("."), std_r.split(">>")[1].split(".") ): + self._diagnostics = reactor.diagnostics return smarts, self.rule # Reactant‑side with inverted template @@ -134,12 +150,14 @@ def _match_reverse(self) -> Optional[Tuple[str, nx.Graph]]: template=self.rule, invert=True, explicit_h=self.explicit_h, + electron_diagnostics=self.electron_diagnostics, ) for smarts in reactor.smarts_list: std_r = self.std.fit(smarts) if self.all_in( self.rsmi.split(">>")[0].split("."), std_r.split(">>")[0].split(".") ): + self._diagnostics = reactor.diagnostics return smarts, self.rule return None @@ -157,6 +175,11 @@ def all_in(a: List[str], b: List[str]) -> bool: """ return set(a).issubset(b) + @property + def diagnostics(self) -> list[dict]: + """Electron diagnostics from the reactor that produced the match.""" + return list(self._diagnostics) + def help(self) -> None: """Print internal state and candidate SMARTS patterns for debugging. diff --git a/synkit/Rule/syn_rule.py b/synkit/Rule/syn_rule.py index 733b628..168f764 100644 --- a/synkit/Rule/syn_rule.py +++ b/synkit/Rule/syn_rule.py @@ -27,8 +27,14 @@ from synkit.Graph.syn_graph import SynGraph from synkit.Graph.canon_graph import GraphCanonicaliser from synkit.Graph.ITS.its_decompose import its_decompose +from synkit.Graph.ITS.its_reverter import ITSReverter from synkit.Graph.Hyrogen._misc import normalize_h_pair_graph -from synkit.IO.chem_converter import rsmi_to_its, gml_to_its +from synkit.IO.chem_converter import ( + ITSFormat, + detect_its_format, + rsmi_to_its, + gml_to_its, +) __all__ = ["SynRule"] @@ -76,14 +82,16 @@ def from_smart( *, canon: bool = True, implicit_h: bool = True, + format: ITSFormat = "typesGH", ) -> "SynRule": """Instantiate from a SMARTS string.""" return cls( - rsmi_to_its(smart), + rsmi_to_its(smart, format=format), name=name, canonicaliser=canonicaliser, canon=canon, implicit_h=implicit_h, + format=format, ) @classmethod @@ -116,6 +124,7 @@ def __init__( *, canon: bool = True, implicit_h: bool = True, + format: Optional[ITSFormat] = None, ) -> None: self._name = name self._canon_enabled = canon @@ -124,38 +133,37 @@ def __init__( # Fragment decomposition rc_graph = rc.copy() + self._format = format or detect_its_format(rc_graph) if self._implicit_h: rc_graph = normalize_h_pair_graph(rc_graph) - left_graph, right_graph = its_decompose(rc_graph) + left_graph, right_graph = self._decompose(rc_graph, self._format) # Optional H-stripping - if self._implicit_h: + if self._implicit_h and self._format == "typesGH": self._strip_explicit_h(rc_graph, left_graph, right_graph) - # Update typesGH tuples with new hcount - for node, att in rc_graph.nodes(data=True): - # unpack the old tuples - t0, t1 = att["typesGH"] - - # build new versions with the updated hcount at position 2 - new_t0 = ( - t0[0], - t0[1], - left_graph.nodes[node]["hcount"] + t0[2], - t0[3], - t0[4], - ) - new_t1 = ( - t1[0], - t1[1], - right_graph.nodes[node]["hcount"] + t1[2], - t1[3], - t1[4], - ) - - # reassign the attribute to a fresh tuple-of-tuples - att["typesGH"] = (new_t0, new_t1) - left_graph, right_graph = its_decompose(rc_graph) + # Update typesGH tuples with new hcount. + for node, att in rc_graph.nodes(data=True): + t0, t1 = att["typesGH"] + new_t0 = ( + t0[0], + t0[1], + left_graph.nodes[node]["hcount"] + t0[2], + t0[3], + t0[4], + ) + new_t1 = ( + t1[0], + t1[1], + right_graph.nodes[node]["hcount"] + t1[2], + t1[3], + t1[4], + ) + att["typesGH"] = (new_t0, new_t1) + left_graph, right_graph = self._decompose(rc_graph, self._format) + elif self._implicit_h and self._format == "tuple": + self._strip_explicit_h_tuple(rc_graph, left_graph, right_graph) + left_graph, right_graph = self._decompose(rc_graph, self._format) # ---------- wrap graphs ---------------------------------------- # self.rc = SynGraph(rc_graph, self._canonicaliser, canon=canon) self.left = SynGraph(left_graph, self._canonicaliser, canon=canon) @@ -168,6 +176,14 @@ def __init__( # ================================================================== # # Private utilities # # ================================================================== # + @staticmethod + def _decompose(rc: nx.Graph, format: ITSFormat) -> tuple[nx.Graph, nx.Graph]: + """Return left/right fragments for either supported ITS representation.""" + if format == "tuple": + reverter = ITSReverter(rc) + return reverter.to_reactant_graph(), reverter.to_product_graph() + return its_decompose(rc) + @staticmethod def _strip_explicit_h( rc: nx.Graph, @@ -234,6 +250,97 @@ def _fully_removable(h: str) -> bool: g.nodes[nbr]["hcount"] += 1 g.remove_node(h) + @staticmethod + def _strip_explicit_h_tuple( + rc: nx.Graph, + left: nx.Graph, + right: nx.Graph, + ) -> None: + """Tuple-style equivalent of legacy explicit-H stripping.""" + + def _removable_on(graph: nx.Graph, h: int) -> bool: + if not graph.has_node(h): + return False + nbrs = list(graph.neighbors(h)) + if not nbrs: + return False + return not all(graph.nodes[n].get("element") == "H" for n in nbrs) + + def _fully_removable(h: int) -> bool: + return _removable_on(left, h) and _removable_on(right, h) + + for graph in (left, right): + for _, data in graph.nodes(data=True): + if data.get("element") != "H": + data.setdefault("h_pairs", []) + data.setdefault("h_pairs_left", []) + data.setdefault("h_pairs_right", []) + data.setdefault("h_pair_atom_maps", {}) + + for _, data in rc.nodes(data=True): + element = data.get("element") + is_h = ( + isinstance(element, tuple) + and len(element) == 2 + and all(value == "H" for value in element) + ) + if not is_h: + data.setdefault("h_pairs", []) + data.setdefault("h_pairs_left", []) + data.setdefault("h_pairs_right", []) + data.setdefault("h_pair_atom_maps", {}) + + removable = sorted( + node + for node, attrs in left.nodes(data=True) + if attrs.get("element") == "H" + and right.has_node(node) + and _fully_removable(node) + ) + + for pair_id, h in enumerate(removable, start=1): + atom_map = left.nodes[h].get("atom_map", h) + for side, graph in (("left", left), ("right", right)): + for nbr in list(graph.neighbors(h)): + if graph.nodes[nbr].get("element") != "H": + graph.nodes[nbr]["hcount"] += 1 + graph.nodes[nbr].setdefault("h_pairs", []).append(pair_id) + graph.nodes[nbr].setdefault(f"h_pairs_{side}", []).append( + pair_id + ) + graph.nodes[nbr].setdefault("h_pair_atom_maps", {})[ + pair_id + ] = atom_map + graph.remove_node(h) + if rc.has_node(h): + rc.remove_node(h) + + for node, attrs in rc.nodes(data=True): + if node not in left or node not in right: + continue + if attrs.get("element") == ("H", "H"): + continue + left_h = left.nodes[node].get("hcount", 0) + right_h = right.nodes[node].get("hcount", 0) + attrs["hcount"] = (left_h, right_h) + attrs["h_pairs"] = sorted( + set(left.nodes[node].get("h_pairs", [])) + | set(right.nodes[node].get("h_pairs", [])) + ) + attrs["h_pairs_left"] = sorted(left.nodes[node].get("h_pairs_left", [])) + attrs["h_pairs_right"] = sorted(right.nodes[node].get("h_pairs_right", [])) + attrs["h_pair_atom_maps"] = { + **left.nodes[node].get("h_pair_atom_maps", {}), + **right.nodes[node].get("h_pair_atom_maps", {}), + } + typesgh = attrs.get("typesGH") + if typesgh and len(typesgh) == 2: + react_attr, prod_attr = typesgh + attrs["typesGH"] = ( + tuple(list(react_attr[:2]) + [left_h] + list(react_attr[3:])), + tuple(list(prod_attr[:2]) + [right_h] + list(prod_attr[3:])), + ) + # ================================================================== # # Dunder methods # # ================================================================== # diff --git a/synkit/Synthesis/Reactor/imba_engine.py b/synkit/Synthesis/Reactor/imba_engine.py index 3a64dd3..d98e29d 100644 --- a/synkit/Synthesis/Reactor/imba_engine.py +++ b/synkit/Synthesis/Reactor/imba_engine.py @@ -47,6 +47,7 @@ def __init__( partial: bool = False, embed_threshold: float = None, embed_pre_filter: bool = False, + electron_diagnostics: bool = False, ) -> None: # Assign parameters self.substrate = substrate @@ -60,8 +61,10 @@ def __init__( self.partial = partial self.embed_threshold = embed_threshold self.embed_pre_filter = embed_pre_filter + self.electron_diagnostics = electron_diagnostics # Internal state self._results: List[str] = [] + self._diagnostics = [] # Auto-run fit on init self.fit() @@ -111,8 +114,10 @@ def fit(self) -> "ImbaEngine": canonicaliser=self.canonicaliser, embed_threshold=self.embed_threshold, embed_pre_filter=self.embed_pre_filter, + electron_diagnostics=self.electron_diagnostics, ) raw_smarts: List[str] = reactor.smarts_list + self._diagnostics = reactor.diagnostics # Add radical wildcards if requested if self.add_wildcard: @@ -145,6 +150,11 @@ def smarts_list(self) -> List[str]: """ return self._results.copy() + @property + def diagnostics(self) -> list[dict]: + """Electron diagnostics from the last underlying reactor run.""" + return list(self._diagnostics) + def __len__(self) -> int: """ Number of product SMARTS results. diff --git a/synkit/Synthesis/Reactor/partial_engine.py b/synkit/Synthesis/Reactor/partial_engine.py index d1330e0..b839bd1 100644 --- a/synkit/Synthesis/Reactor/partial_engine.py +++ b/synkit/Synthesis/Reactor/partial_engine.py @@ -18,7 +18,12 @@ class PartialEngine: :type template: str """ - def __init__(self, smi: str, template: str) -> None: + def __init__( + self, + smi: str, + template: str, + electron_diagnostics: bool = False, + ) -> None: """Initialize the PartialEngine. - Removes explicit hydrogens from the given template SMARTS. @@ -39,6 +44,8 @@ def __init__(self, smi: str, template: str) -> None: # Build host graph from the provided SMILES or rsmi self.host = smiles_to_graph(smi) + self.electron_diagnostics = electron_diagnostics + self._diagnostics = [] def fit(self, invert: bool = False) -> list[str]: """Apply the template in one direction to generate radical‐wildcarded @@ -63,8 +70,15 @@ def fit(self, invert: bool = False) -> list[str]: implicit_temp=True, explicit_h=False, invert=invert, + electron_diagnostics=self.electron_diagnostics, ) # Generate SMARTS, then inject radical wildcards smarts_list = reactor.smarts_list + self._diagnostics = reactor.diagnostics wildcarded = [RadicalWildcardAdder().transform(rxn) for rxn in smarts_list] return wildcarded + + @property + def diagnostics(self) -> list[dict]: + """Electron diagnostics from the last reactor run.""" + return list(self._diagnostics) diff --git a/synkit/Synthesis/Reactor/rbl_engine.py b/synkit/Synthesis/Reactor/rbl_engine.py index 4517d31..90b70d5 100644 --- a/synkit/Synthesis/Reactor/rbl_engine.py +++ b/synkit/Synthesis/Reactor/rbl_engine.py @@ -302,6 +302,7 @@ def __init__( max_mappings_per_pair: int = 1, implicit_temp: bool = True, explicit_h: bool = False, + electron_diagnostics: bool = False, embed_threshold: int = 10_000, reactor_cls: type = SynReactor, wildcard_adder_cls: type = RadicalWildcardAdder, @@ -338,6 +339,7 @@ def __init__( # Reactor behaviour flags self.implicit_temp: bool = bool(implicit_temp) self.explicit_h: bool = bool(explicit_h) + self.electron_diagnostics: bool = bool(electron_diagnostics) self.embed_threshold: int = int(embed_threshold) # Dependencies (DI) @@ -369,6 +371,11 @@ def __init__( self._backward_its: List[ITSLike] = [] self._fused_its: List[ITSLike] = [] self._fused_rsmis: List[str] = [] + self._diagnostics: Dict[str, List[Dict[str, Any]]] = { + "forward": [], + "backward": [], + "quick_check": [], + } # Result / termination bookkeeping self._last_stop_mode: str = "not_run" @@ -441,6 +448,7 @@ def _reset_run_state(self) -> None: self._backward_its = [] self._fused_its = [] self._fused_rsmis = [] + self._diagnostics = {"forward": [], "backward": [], "quick_check": []} self._last_stop_mode = "not_run" self._last_stop_reason = "not_run" self._last_stop_metadata = {} @@ -561,8 +569,14 @@ def result(self) -> Dict[str, Any]: "n_forward_its": len(self._forward_its), "n_backward_its": len(self._backward_its), "n_fused_its": len(self._fused_its), + "diagnostics": self.diagnostics, } + @property + def diagnostics(self) -> Dict[str, List[Dict[str, Any]]]: + """Electron diagnostics grouped by reactor stage.""" + return {stage: list(reports) for stage, reports in self._diagnostics.items()} + # ------------------------------------------------------------------ # Template preparation # ------------------------------------------------------------------ @@ -713,7 +727,10 @@ def _run_reaction( automorphism=False, invert=invert, embed_threshold=self.embed_threshold, + electron_diagnostics=self.electron_diagnostics, ) + stage = "backward" if invert else "forward" + self._diagnostics[stage].extend(getattr(reactor, "diagnostics", []) or []) out: List[ITSLike] = [] its_list: Sequence[ITSLike] = getattr(reactor, "its", []) or [] @@ -934,6 +951,10 @@ def _quick_check( automorphism=False, invert=False, embed_threshold=self.embed_threshold, + electron_diagnostics=self.electron_diagnostics, + ) + self._diagnostics["quick_check"].extend( + getattr(reactor, "diagnostics", []) or [] ) sols: Sequence[str] = getattr(reactor, "smarts", []) or [] diff --git a/synkit/Synthesis/Reactor/rule_filter.py b/synkit/Synthesis/Reactor/rule_filter.py index 6e174ad..03a3d80 100644 --- a/synkit/Synthesis/Reactor/rule_filter.py +++ b/synkit/Synthesis/Reactor/rule_filter.py @@ -3,8 +3,10 @@ from synkit.Graph.Matcher.turbo_iso import TurboISO from synkit.Graph.Matcher.sing import SING from synkit.Graph.ITS import its_decompose +from synkit.Graph.ITS.its_reverter import ITSReverter from synkit.Graph.Matcher.subgraph_matcher import SubgraphMatch from synkit.Graph.Hyrogen._misc import h_to_explicit +from synkit.IO.chem_converter import detect_its_format class RuleFilter: @@ -76,7 +78,7 @@ def __init__( # Decompose patterns via ITS self._patterns = [ - its_decompose(r)[1] if self._invert else its_decompose(r)[0] + self._decompose_rule(r)[1] if self._invert else self._decompose_rule(r)[0] for r in self._rules ] @@ -99,6 +101,14 @@ def __init__( self._matches = [self._match(p) for p in self._patterns] self._new_rules = [r for r, m in zip(self._rules, self._matches) if m] + @staticmethod + def _decompose_rule(rule: nx.Graph) -> tuple[nx.Graph, nx.Graph]: + """Return left/right rule fragments for either ITS representation.""" + if detect_its_format(rule) == "tuple": + reverter = ITSReverter(rule) + return reverter.to_reactant_graph(), reverter.to_product_graph() + return its_decompose(rule) + def _match(self, pattern: nx.Graph) -> bool: """Test whether the given pattern occurs as a subgraph in the host. diff --git a/synkit/Synthesis/Reactor/syn_reactor.py b/synkit/Synthesis/Reactor/syn_reactor.py index ef228cd..4c49028 100644 --- a/synkit/Synthesis/Reactor/syn_reactor.py +++ b/synkit/Synthesis/Reactor/syn_reactor.py @@ -6,9 +6,18 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple, Union import networkx as nx +from rdkit import Chem +from networkx.algorithms.isomorphism import ( + GraphMatcher, + categorical_edge_match, + categorical_node_match, +) from synkit.IO.chem_converter import ( + ITSFormat, + _get_preserved_hydrogen_maps, + detect_its_format, smiles_to_graph, rsmi_to_its, graph_to_smi, @@ -20,18 +29,29 @@ from synkit.Graph.syn_graph import SynGraph from synkit.Graph.canon_graph import GraphCanonicaliser from synkit.Graph.ITS.its_decompose import its_decompose +from synkit.Graph.ITS.its_reverter import ITSReverter from synkit.Graph.ITS.its_construction import ITSConstruction from synkit.Graph.Matcher.automorphism import ( Automorphism, ) from synkit.Graph.Matcher.dedup_matches import deduplicate_matches_with_anchor from synkit.Graph.Matcher.auto_est import AutoEst +from synkit.Graph.Matcher.graph_cluster import GraphCluster from synkit.Graph.Matcher.partial_matcher import PartialMatcher from synkit.Graph.Matcher.subgraph_matcher import SubgraphSearchEngine +from synkit.Graph.Matcher.subgraph_matcher import resolve_template_match_attrs +from synkit.Graph.Feature.wl_hash import WLHash +from synkit.Graph.Mech.electron_accounting import ( + graph_to_sanitized_kekule_mol, + refresh_electron_fields, +) +from synkit.IO.graph_to_mol import GraphToMol +from synkit.IO.mol_to_graph import MolToGraph from synkit.Graph.Hyrogen._misc import ( h_to_implicit, h_to_explicit, has_XH, + implicit_hydrogen, ) from synkit.Graph import ( remove_wildcard_nodes, @@ -53,6 +73,18 @@ log = setup_logging(task_type="synreactor") +ITS_STRUCTURAL_NODE_ATTRS = [ + "element", + "aromatic", + "hcount", + "charge", + "radical", + "lone_pairs", + "valence_electrons", + "present", +] +ITS_STRUCTURAL_EDGE_ATTRS = ["order", "kekule_order", "sigma_order", "pi_order"] + # ────────────────────────────────────────────────────────────────────────────── # SynReactor core @@ -88,6 +120,12 @@ class SynReactor: :param partial: If True, use a partial matching fallback. Defaults to False. :type partial: bool + :param template_format: ITS representation used when ``template`` is a + reaction string. Defaults to ``"typesGH"`` for compatibility. + :type template_format: ITSFormat + :param electron_diagnostics: If True, expose per-result electron-accounting + diagnostics without changing generated products. + :type electron_diagnostics: bool :ivar _graph: Cached SynGraph for the substrate. :vartype _graph: Optional[SynGraph] :ivar _rule: Cached SynRule for the template. @@ -111,6 +149,8 @@ class SynReactor: implicit_temp: bool = False strategy: Strategy | str = Strategy.ALL partial: bool = False + template_format: ITSFormat = "typesGH" + electron_diagnostics: bool = False embed_threshold: Optional[int] = None embed_pre_filter: bool = False automorphism: bool = True @@ -121,6 +161,7 @@ class SynReactor: _mappings: List[MappingDict] | None = field(init=False, default=None, repr=False) _its: List[nx.Graph] | None = field(init=False, default=None, repr=False) _smarts: List[str] | None = field(init=False, default=None, repr=False) + _host_for_matching: nx.Graph | None = field(init=False, default=None, repr=False) _flag_pattern_has_explicit_H: bool = field(init=False, default=False, repr=False) def __post_init__(self) -> None: @@ -149,6 +190,8 @@ def from_smiles( implicit_temp: bool = False, automorphism: bool = False, strategy: Strategy | str = Strategy.ALL, + template_format: ITSFormat = "typesGH", + electron_diagnostics: bool = False, ) -> "SynReactor": """ Alternate constructor: build a SynReactor directly from SMILES. @@ -168,6 +211,12 @@ def from_smiles( :type implicit_temp: bool :param strategy: Matching strategy: ALL, 'comp', or 'bt'. Defaults to ALL. :type strategy: Strategy or str + :param template_format: ITS representation used when ``template`` is a + reaction string. Defaults to ``"typesGH"``. + :type template_format: ITSFormat + :param electron_diagnostics: If True, expose per-result electron + diagnostics without changing products. + :type electron_diagnostics: bool :returns: A new `SynReactor` instance. :rtype: SynReactor """ @@ -180,6 +229,8 @@ def from_smiles( implicit_temp=implicit_temp, strategy=strategy, automorphism=automorphism, + template_format=template_format, + electron_diagnostics=electron_diagnostics, ) # ------------------------------------------------------------------ @@ -224,16 +275,20 @@ def mappings(self) -> List[MappingDict]: if has_wildcard_node(pattern_graph): pattern_graph = remove_wildcard_nodes(pattern_graph) + pattern_graph = self._with_aromatic_n_pi_roles(pattern_graph) + matching_host = self._with_aromatic_n_pi_roles(self._matching_host_graph()) + node_attrs, edge_attrs = resolve_template_match_attrs(pattern_graph) + # --- Choose matcher ------------------------------------------------ if self.partial: max_results = ( self.embed_threshold / 100 if self.embed_threshold else None ) matcher = PartialMatcher( - host=self.graph.raw, + host=matching_host, pattern=pattern_graph, - node_attrs=["element", "charge"], - edge_attrs=["order"], + node_attrs=node_attrs, + edge_attrs=edge_attrs, strategy=Strategy.from_string(self.strategy), threshold=self.embed_threshold, pre_filter=self.embed_pre_filter, @@ -243,10 +298,10 @@ def mappings(self) -> List[MappingDict]: raw_maps = matcher.get_mappings() else: raw_maps = SubgraphSearchEngine.find_subgraph_mappings( - host=self.graph.raw, + host=matching_host, pattern=pattern_graph, - node_attrs=["element", "charge"], - edge_attrs=["order"], + node_attrs=node_attrs, + edge_attrs=edge_attrs, strategy=Strategy.from_string(self.strategy), threshold=self.embed_threshold, pre_filter=self.embed_pre_filter, @@ -254,11 +309,36 @@ def mappings(self) -> List[MappingDict]: # --- Automorphism pruning ---------------------------------------- if self.automorphism and raw_maps: - auto = Automorphism(pattern_graph) + automorphism_pattern = self._automorphism_pattern_graph(pattern_graph) + auto = Automorphism( + automorphism_pattern, + node_attr_keys=self._automorphism_node_attrs( + automorphism_pattern, + node_attrs, + ), + edge_attr_keys=edge_attrs, + ) + host_auto = Automorphism( + self._matching_host_graph(), + node_attr_keys=node_attrs, + edge_attr_keys=edge_attrs, + ) self._mappings = deduplicate_matches_with_anchor( raw_maps, pattern_orbits=auto.orbits, pattern_anchor=auto.anchor_component, + host_orbits=host_auto.orbits, + host_anchor=host_auto.anchor_component, + ) + self._mappings = self._deduplicate_equivalent_free_components( + self._mappings, + automorphism_pattern, + auto.anchor_component, + self._automorphism_node_attrs( + automorphism_pattern, + node_attrs, + ), + edge_attrs, ) log.debug( "Automorphism pruning: %d → %d unique mapping(s)", @@ -282,6 +362,20 @@ def mappings(self) -> List[MappingDict]: log.info("%d mapping(s) discovered", len(self._mappings)) return self._mappings + @staticmethod + def _with_aromatic_n_pi_roles(graph: nx.Graph) -> nx.Graph: + """Label aromatic nitrogens by incident aromatic pi-bond count.""" + decorated = graph.copy() + for node, attrs in decorated.nodes(data=True): + if attrs.get("element") != "N" or not attrs.get("aromatic", False): + continue + attrs["aromatic_n_pi_count"] = sum( + 1 + for _, _, edge in decorated.edges(node, data=True) + if edge.get("order") == 1.5 and edge.get("pi_order") == 1.0 + ) + return decorated + @property def its_list(self) -> List[nx.Graph]: """Build ITS graphs for each subgraph mapping. @@ -291,7 +385,7 @@ def its_list(self) -> List[nx.Graph]: """ if self._its is None: # Build ITS for each mapping ------------------------------- - host_raw = self.graph.raw + host_raw = self._matching_host_graph() rc_raw = self.rule.rc.raw self._its = [] for m in self.mappings: @@ -309,6 +403,7 @@ def its_list(self) -> List[nx.Graph]: if self.explicit_h: self._its = [self._explicit_h(g) for g in self._its] + self._its = self._deduplicate_structural_its(self._its) log.debug("Built %d ITS graph(s)", len(self._its)) return self._its @@ -324,8 +419,67 @@ def smarts_list(self) -> List[str]: self._smarts = [value for value in self._smarts if value] if self.invert: self._smarts = [reverse_reaction(rsmi) for rsmi in self._smarts] + self._smarts = list(dict.fromkeys(self._smarts)) return self._smarts + @property + def diagnostics(self) -> List[Dict[str, Any]]: + """Return optional electron-accounting diagnostics for built ITS graphs.""" + if not self.electron_diagnostics: + return [] + + reports: List[Dict[str, Any]] = [] + for index, its in enumerate(self.its_list): + if its.graph.get("electron_aware_rewrite", False): + mismatches = {} + for node, attrs in its.nodes(data=True): + charge_mismatch = attrs.get("charge_mismatch") + mismatch = ( + charge_mismatch[1] + if isinstance(charge_mismatch, tuple) + and len(charge_mismatch) == 2 + else charge_mismatch + ) + if mismatch: + template_charge = attrs.get("template_charge") + recomputed_charge = attrs.get("recomputed_charge") + mismatches[node] = { + "charge": ( + template_charge[1] + if isinstance(template_charge, tuple) + and len(template_charge) == 2 + else template_charge + ), + "recomputed_charge": ( + recomputed_charge[1] + if isinstance(recomputed_charge, tuple) + and len(recomputed_charge) == 2 + else recomputed_charge + ), + } + else: + product = self._product_graph_for_diagnostics(its) + refreshed = refresh_electron_fields(product) + mismatches = { + node: { + "charge": attrs.get("charge"), + "recomputed_charge": attrs.get("recomputed_charge"), + } + for node, attrs in refreshed.nodes(data=True) + if attrs.get("charge_mismatch") + } + reports.append( + { + "index": index, + "electron_aware_rewrite": bool( + its.graph.get("electron_aware_rewrite", False) + ), + "mismatch_count": len(mismatches), + "mismatches": mismatches, + } + ) + return reports + # Backward‑compat aliases (original attribute names) ---------------- smarts = property(lambda self: self.smarts_list) its = property(lambda self: self.its_list) @@ -392,36 +546,99 @@ def _wrap_template(self, tpl: Union[str, nx.Graph, SynRule]) -> SynRule: elif isinstance(tpl, nx.Graph): graph = tpl elif isinstance(tpl, str): - graph = rsmi_to_its(tpl) + graph = rsmi_to_its(tpl, format=self.template_format) else: # pragma: no cover raise TypeError(f"Unsupported template type: {type(tpl)}") # graph = normalize_h_pair_graph(graph) + format = detect_its_format(graph) + # Invert if asked ----------------------------------------------------- if self.invert: if self.implicit_temp: - graph = self._invert_template(graph, balance_its=True) + graph = self._invert_template( + graph, + balance_its=True, + format=format, + ) return SynRule( graph, canonicaliser=self.canonicaliser or GraphCanonicaliser(), + format=format, ) else: - graph = self._invert_template(graph, balance_its=False) + graph = self._invert_template( + graph, + balance_its=False, + format=format, + ) return SynRule( - graph, canonicaliser=self.canonicaliser or GraphCanonicaliser() + graph, + canonicaliser=self.canonicaliser or GraphCanonicaliser(), + format=format, ) else: if self.implicit_temp: return SynRule( graph, canonicaliser=self.canonicaliser or GraphCanonicaliser(), + format=format, ) return SynRule( - graph, canonicaliser=self.canonicaliser or GraphCanonicaliser() + graph, + canonicaliser=self.canonicaliser or GraphCanonicaliser(), + format=format, ) + def _matching_host_graph(self) -> nx.Graph: + """Return the host graph normalized to the active rule representation.""" + if self._host_for_matching is None: + host = self.graph.raw + if getattr(self.rule, "_format", None) == "tuple": + host = self._implicit_heavy_hydrogens(host) + self._host_for_matching = host + return self._host_for_matching + + @staticmethod + def _implicit_heavy_hydrogens(graph: nx.Graph) -> nx.Graph: + """Convert ordinary heavy-atom-bound explicit H nodes into hcount.""" + normalized = graph.copy() + removable = [] + for node, attrs in normalized.nodes(data=True): + if attrs.get("element") != "H": + continue + neighbors = list(normalized.neighbors(node)) + heavy_neighbors = [ + nbr for nbr in neighbors if normalized.nodes[nbr].get("element") != "H" + ] + if heavy_neighbors and len(heavy_neighbors) == len(neighbors): + removable.append((node, heavy_neighbors)) + + for h, heavy_neighbors in removable: + if not normalized.has_node(h): + continue + for heavy in heavy_neighbors: + normalized.nodes[heavy]["hcount"] = ( + normalized.nodes[heavy].get("hcount", 0) + 1 + ) + normalized.remove_node(h) + return normalized + @staticmethod - def _invert_template(tpl: nx.Graph, balance_its: bool = True) -> nx.Graph: + def _invert_template( + tpl: nx.Graph, + balance_its: bool = True, + format: ITSFormat | None = None, + ) -> nx.Graph: + resolved_format = format or detect_its_format(tpl) + if resolved_format == "tuple": + reverter = ITSReverter(tpl) + l, r = reverter.to_reactant_graph(), reverter.to_product_graph() + return ITSConstruction().construct( + r, + l, + balance_its=balance_its, + ) l, r = its_decompose(tpl) return ITSConstruction().ITSGraph(r, l, balance_its=balance_its) @@ -455,8 +672,9 @@ def _node_glue( # host_p[0] = '*' host_n[key] = (new_r, new_p) - if "h_pairs" in pat_n: - host_n["h_pairs"] = pat_n["h_pairs"] + for key in ("h_pairs", "h_pairs_left", "h_pairs_right", "h_pair_atom_maps"): + if key in pat_n: + host_n[key] = pat_n[key] @staticmethod def _get_explicit_map( @@ -493,6 +711,7 @@ def _glue_graph( ) -> List[nx.Graph]: list_its: List[nx.Graph] = [] host_g = deepcopy(host) + electron_aware = SynReactor._is_electron_aware_template(rc) def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: tpl = ( @@ -506,6 +725,8 @@ def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: for _, data in host_g.nodes(data=True): data.setdefault("typesGH", _default_tg(data)) + if electron_aware: + SynReactor._ensure_host_atom_maps(host_g) if pattern_has_explicit_H: mappings, host_g = SynReactor._get_explicit_map( @@ -516,6 +737,8 @@ def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: embed_threshold, embed_pre_filter, ) + if electron_aware: + SynReactor._ensure_host_atom_maps(host_g) else: mappings = [mapping] @@ -525,11 +748,21 @@ def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: its = deepcopy(host_g) # This should only work for implict cases if len(m.keys()) < rc.number_of_nodes(): - its, m = add_wildcard_subgraph_for_unmapped(its, rc, m) + its, m = add_wildcard_subgraph_for_unmapped( + its, + rc, + m, + tuple_mode=electron_aware, + ) for _, _, data in its.edges(data=True): o = data.get("order", 1.0) data["order"] = (o, o) + if electron_aware: + sigma = data.get("sigma_order", 1.0 if o else 0.0) + pi = data.get("pi_order", max(0.0, float(o) - 1.0)) + data["sigma_order"] = (sigma, sigma) + data["pi_order"] = (pi, pi) data.setdefault("standard_order", 0.0) for _, data in rc.nodes(data=True): @@ -539,6 +772,11 @@ def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: for rc_n, host_n in m.items(): if its.has_node(host_n): SynReactor._node_glue(its.nodes[host_n], rc.nodes[rc_n]) + if electron_aware: + SynReactor._pair_electron_aware_node_attrs( + its.nodes[host_n], + rc.nodes[rc_n], + ) # merge edges (additive order) --------------------------- for u, v, rc_attr in rc.edges(data=True): @@ -553,17 +791,423 @@ def _default_tg(a: Dict[str, Any]) -> Tuple[Tuple[Any, ...], Tuple[Any, ...]]: if rc_order[0] == 0: # additive only on product side ho = host_attr["order"] host_attr["order"] = (ho[0], round(ho[1] + rc_order[1])) + if electron_aware: + host_sigma = host_attr.get("sigma_order", (0.0, 0.0)) + host_pi = host_attr.get("pi_order", (0.0, 0.0)) + rc_sigma = rc_attr.get("sigma_order", (0.0, 0.0)) + rc_pi = rc_attr.get("pi_order", (0.0, 0.0)) + host_attr["sigma_order"] = ( + host_sigma[0], + host_sigma[1] + rc_sigma[1], + ) + host_attr["pi_order"] = ( + host_pi[0], + host_pi[1] + rc_pi[1], + ) host_attr["standard_order"] += rc_attr.get( "standard_order", 0.0 ) else: host_attr.update(rc_attr) + if electron_aware: + SynReactor._refresh_product_electron_fields(its) + its.graph["electron_aware_rewrite"] = electron_aware list_its.append(its) return list_its + @staticmethod + def _is_electron_aware_template(rc: nx.Graph) -> bool: + """Return whether an RC carries sigma/pi rewrite state.""" + return any( + "sigma_order" in data and "pi_order" in data + for _, _, data in rc.edges(data=True) + ) + + @staticmethod + def _automorphism_node_attrs( + pattern: nx.Graph, + node_attrs: List[str], + ) -> List[str]: + """Keep pruning at least as role-aware as the stored template data.""" + attrs = list(node_attrs) + for attr in ("aromatic", "neighbors", "_rewrite_role"): + if attr not in attrs and any( + attr in data for _, data in pattern.nodes(data=True) + ): + attrs.append(attr) + return attrs + + def _automorphism_pattern_graph(self, pattern: nx.Graph) -> nx.Graph: + """Decorate tuple patterns with product-side rewrite roles for pruning.""" + if getattr(self.rule, "_format", None) != "tuple": + return pattern + + decorated = pattern.copy() + rc = self.rule.rc.raw + for node, attrs in decorated.nodes(data=True): + rc_attrs = rc.nodes.get(node) + if not rc_attrs: + continue + types = rc_attrs.get("typesGH") + if isinstance(types, tuple) and len(types) == 2: + attrs["_rewrite_role"] = self._chemical_rewrite_role(types[1]) + return decorated + + @staticmethod + def _chemical_rewrite_role(role: Any) -> Any: + """Drop provenance-only atom-map identity from tuple rewrite roles.""" + if isinstance(role, tuple) and len(role) >= 9: + chemical_role = role[:-1] + if chemical_role[0] == "H": + return chemical_role[:-1] + ((),) + return chemical_role + return role + + @staticmethod + def _prepare_its_for_structural_cluster(its: nx.Graph) -> nx.Graph: + """Attach one combined edge signature for exact ITS clustering.""" + prepared = deepcopy(its) + if prepared.graph.get("electron_aware_rewrite", False): + SynReactor._refresh_product_electron_fields(prepared) + aromatic_nodes = { + node + for u, v, attrs in prepared.edges(data=True) + if attrs.get("order") == (1.5, 1.5) + for node in (u, v) + } + for node in aromatic_nodes: + template_charge = prepared.nodes[node].get("template_charge") + if isinstance(template_charge, tuple) and len(template_charge) == 2: + prepared.nodes[node]["charge"] = template_charge + for _, _, attrs in prepared.edges(data=True): + edge_values = [] + aromatic_unchanged = attrs.get("order") == (1.5, 1.5) + for name in ITS_STRUCTURAL_EDGE_ATTRS: + value = attrs.get(name) + if aromatic_unchanged and name in { + "kekule_order", + "sigma_order", + "pi_order", + }: + value = "aromatic_phase" + edge_values.append(value) + attrs["_its_edge_sig"] = tuple(edge_values) + return prepared + + @staticmethod + def _deduplicate_structural_its(its_graphs: List[nx.Graph]) -> List[nx.Graph]: + """Keep one representative for each structurally unique ITS graph.""" + if len(its_graphs) < 2: + return its_graphs + + hasher = WLHash( + node=ITS_STRUCTURAL_NODE_ATTRS, + edge="_its_edge_sig", + ) + buckets: Dict[str, List[Tuple[int, nx.Graph]]] = defaultdict(list) + for index, its in enumerate(its_graphs): + prepared = SynReactor._prepare_its_for_structural_cluster(its) + signature = hasher.weisfeiler_lehman_graph_hash(prepared) + buckets[signature].append((index, prepared)) + + cluster = GraphCluster( + node_label_names=ITS_STRUCTURAL_NODE_ATTRS, + node_label_default=["*", False, 0, 0, 0, 0, 0, ()], + edge_attribute="_its_edge_sig", + ) + representative_indices: List[int] = [] + for bucket in buckets.values(): + if len(bucket) == 1: + representative_indices.append(bucket[0][0]) + continue + prepared = [prepared for _, prepared in bucket] + classes, _ = cluster.iterative_cluster(prepared) + for cls in classes: + representative_indices.append(bucket[min(cls)][0]) + + representative_indices.sort() + return [its_graphs[index] for index in representative_indices] + + def _deduplicate_equivalent_free_components( + self, + mappings: List[MappingDict], + pattern: nx.Graph, + anchor: Optional[frozenset[NodeId]], + node_attrs: List[str], + edge_attrs: List[str], + ) -> List[MappingDict]: + """Collapse swaps of equivalent disconnected non-anchor tuple components.""" + if getattr(self.rule, "_format", None) != "tuple" or len(mappings) < 2: + return mappings + + anchor = anchor or frozenset() + free_components = [ + frozenset(component) + for component in nx.connected_components(pattern) + if not set(component) & set(anchor) + ] + if len(free_components) < 2: + return mappings + + component_groups: List[List[frozenset[NodeId]]] = [] + for component in free_components: + for group in component_groups: + if self._components_are_equivalent( + pattern, + component, + group[0], + node_attrs, + edge_attrs, + ): + group.append(component) + break + else: + component_groups.append([component]) + + swappable_groups = [group for group in component_groups if len(group) > 1] + if not swappable_groups: + return mappings + + swappable_nodes = set().union( + *[set().union(*group) for group in swappable_groups] + ) + seen = set() + unique: List[MappingDict] = [] + for mapping in mappings: + fixed = tuple( + sorted( + (node, host) + for node, host in mapping.items() + if node not in swappable_nodes + ) + ) + component_bags = tuple( + tuple( + sorted( + tuple(sorted(mapping[node] for node in component)) + for component in group + ) + ) + for group in swappable_groups + ) + signature = (fixed, component_bags) + if signature in seen: + continue + seen.add(signature) + unique.append(mapping) + return unique + + @staticmethod + def _components_are_equivalent( + pattern: nx.Graph, + left: frozenset[NodeId], + right: frozenset[NodeId], + node_attrs: List[str], + edge_attrs: List[str], + ) -> bool: + """Return whether two disconnected pattern components have one role shape.""" + left_graph = pattern.subgraph(left) + right_graph = pattern.subgraph(right) + node_defaults = [0 if attr == "charge" else "*" for attr in node_attrs] + edge_defaults = [1.0 for _ in edge_attrs] + matcher = GraphMatcher( + left_graph, + right_graph, + node_match=categorical_node_match(node_attrs, node_defaults), + edge_match=categorical_edge_match(edge_attrs, edge_defaults), + ) + return matcher.is_isomorphic() + + @staticmethod + def _pair_electron_aware_node_attrs( + host_n: Dict[str, Any], + rc_n: Dict[str, Any], + ) -> None: + """Store direct paired node attrs after legacy-compatible node glue.""" + _, product_types = host_n["typesGH"] + rc_present = rc_n.get("present") + reactant_is_absent = ( + isinstance(rc_present, tuple) and len(rc_present) == 2 and not rc_present[0] + ) + legacy_product_values = { + "element": product_types[0], + "aromatic": product_types[1], + "hcount": product_types[2], + "neighbors": product_types[4], + } + + for key, product_value in legacy_product_values.items(): + left_value = host_n.get(key) + product_is_absent = ( + isinstance(rc_present, tuple) + and len(rc_present) == 2 + and not rc_present[1] + ) + rc_value = rc_n.get(key) + if ( + reactant_is_absent + and isinstance(rc_value, tuple) + and len(rc_value) == 2 + ): + product_value = rc_value[1] + if key == "element" and product_value == "*" and not product_is_absent: + product_value = left_value + host_n[key] = (left_value, product_value) + + for key in ("radical", "lone_pairs", "valence_electrons"): + rc_value = rc_n.get(key) + if isinstance(rc_value, tuple) and len(rc_value) == 2: + left_value = host_n.get(key) + if left_value is None: + left_value = rc_value[0] + host_n[key] = (left_value, rc_value[1]) + + host_n["template_charge"] = (host_n.get("charge"), product_types[3]) + + # Electron-authoritative RCs derive charge at the product boundary. + # Keep the reactant-side value temporarily so mutation does not copy + # the RC's product charge label. + host_n["charge"] = (host_n.get("charge"), host_n.get("charge")) + + if "atom_map" in host_n: + host_n["atom_map"] = (host_n["atom_map"], host_n["atom_map"]) + if isinstance(rc_present, tuple) and len(rc_present) == 2: + host_n["present"] = (bool(host_n.get("present", True)), rc_present[1]) + else: + host_n["present"] = (True, True) + + @staticmethod + def _ensure_host_atom_maps(host: nx.Graph) -> None: + """Assign stable fresh atom maps to unmapped host atoms.""" + for node, attrs in host.nodes(data=True): + if attrs.get("atom_map") in (None, 0): + attrs["atom_map"] = node + + @staticmethod + def _refresh_product_electron_fields( + its: nx.Graph, + ) -> None: + """Refresh product-side electron fields from the scalar product graph.""" + product = SynReactor._prepared_electron_product_graph(its) + refreshed = refresh_electron_fields(product) + for node, attrs in refreshed.nodes(data=True): + current_charge = its.nodes[node].get("charge") + left_charge = ( + current_charge[0] + if isinstance(current_charge, tuple) and len(current_charge) == 2 + else current_charge + ) + product_charge = SynReactor._electron_product_charge(its, node, attrs) + if product_charge is not None: + its.nodes[node]["charge"] = (left_charge, product_charge) + + template_charge = its.nodes[node].get("template_charge") + if isinstance(template_charge, tuple) and len(template_charge) == 2: + attrs["charge_mismatch"] = template_charge[1] != attrs.get( + "recomputed_charge" + ) + + for key in ("bond_order_sum", "recomputed_charge", "charge_mismatch"): + if key in attrs: + current = its.nodes[node].get(key) + left_value = ( + current[0] + if isinstance(current, tuple) and len(current) == 2 + else current + ) + its.nodes[node][key] = (left_value, attrs[key]) + for u, v, attrs in refreshed.edges(data=True): + for key in ("kekule_order", "sigma_order", "pi_order"): + if key not in attrs: + continue + current = its.edges[u, v].get(key) + left_value = ( + current[0] + if isinstance(current, tuple) and len(current) == 2 + else current + ) + its.edges[u, v][key] = (left_value, attrs[key]) + + @staticmethod + def _prepared_electron_product_graph(its: nx.Graph) -> nx.Graph: + """Build the scalar product graph used for electron recomputation.""" + product = ITSReverter(its).to_product_graph() + preserved_hydrogens = _get_preserved_hydrogen_maps(its, "tuple") + product = implicit_hydrogen(product, set(preserved_hydrogens)) + return SynReactor._reperceive_product_kekule_phase(product, its) + + @staticmethod + def _electron_product_charge( + its: nx.Graph, + node: Any, + product_attrs: Mapping[str, Any], + ) -> Any: + """Choose the product charge used for electron-aware serialization. + + Non-aromatic tuple products are electron-authoritative and use the + recomputed formal charge. Aromatic tuple products are still an open + representation boundary: if the template explicitly carries a product + charge, preserve it instead of inventing cationic aromatic carbons from + an incomplete Kekule phase. + """ + if node in its: + template_charge = its.nodes[node].get("template_charge") + aromatic = product_attrs.get("aromatic", its.nodes[node].get("aromatic")) + if ( + aromatic is True + and isinstance(template_charge, tuple) + and len(template_charge) == 2 + ): + return template_charge[1] + return product_attrs.get("recomputed_charge") + + @staticmethod + def _reperceive_product_kekule_phase(product: nx.Graph, its: nx.Graph) -> nx.Graph: + """Refresh aromatic sigma/pi phase from full product presentation bonds.""" + if not any(data.get("order") == 1.5 for _, _, data in product.edges(data=True)): + return product + + probe = product.copy() + for node, attrs in probe.nodes(data=True): + template_charge = its.nodes[node].get("template_charge") + if isinstance(template_charge, tuple) and len(template_charge) == 2: + attrs["charge"] = template_charge[1] + + try: + mol = GraphToMol(edge_attributes={"order": "order"}).graph_to_mol( + probe, + sanitize=True, + use_h_count=True, + ) + reperceived = MolToGraph(attr_profile="minimal").transform( + mol, + use_index_as_atom_map=True, + ) + except Exception: + return product + + refreshed = product.copy() + for u, v in refreshed.edges(): + if not reperceived.has_edge(u, v): + continue + for key in ("kekule_order", "sigma_order", "pi_order"): + if key in reperceived[u][v]: + refreshed[u][v][key] = reperceived[u][v][key] + return refreshed + + @staticmethod + def _product_graph_for_diagnostics(its: nx.Graph) -> nx.Graph: + """Return the product graph matching the rewrite representation.""" + if its.graph.get("electron_aware_rewrite", False): + return ITSReverter(its).to_product_graph() + return its_decompose(its)[1] + # --------------------- explicit‑H handling ------------------------- @staticmethod def _explicit_h(rc: nx.Graph) -> nx.Graph: + if bool(rc.graph.get("electron_aware_rewrite", False)): + return SynReactor._explicit_h_tuple(rc) + next_id = max((n for n in rc.nodes if isinstance(n, int)), default=-1) + 1 orig_delta: Dict[int, int] = {} pair_to_nodes: Dict[int, List[int]] = defaultdict(list) @@ -625,14 +1269,161 @@ def _explicit_h(rc: nx.Graph) -> nx.Graph: ) return rc + @staticmethod + def _explicit_h_tuple(rc: nx.Graph) -> nx.Graph: + """Materialize only hydrogens that were explicit in the template.""" + next_id = max((n for n in rc.nodes if isinstance(n, int)), default=-1) + 1 + pair_left: Dict[int, int] = {} + pair_right: Dict[int, int] = {} + for n, data in rc.nodes(data=True): + for pair_id in data.get("h_pairs_left", []): + pair_left[pair_id] = n + for pair_id in data.get("h_pairs_right", []): + pair_right[pair_id] = n + + explicit_pairs = sorted(set(pair_left) & set(pair_right)) + used_maps = { + value + for _, data in rc.nodes(data=True) + for atom_map in [data.get("atom_map")] + for value in ( + atom_map + if isinstance(atom_map, tuple) + else (() if atom_map in (None, 0) else (atom_map,)) + ) + } + for pair_id in explicit_pairs: + src = pair_left[pair_id] + dst = pair_right[pair_id] + h = next_id + next_id += 1 + preferred_map = rc.nodes[src].get("h_pair_atom_maps", {}).get( + pair_id + ) or rc.nodes[dst].get("h_pair_atom_maps", {}).get(pair_id) + atom_map = preferred_map if preferred_map not in used_maps else h + while atom_map in used_maps: + atom_map += 1 + used_maps.add(atom_map) + rc.add_node( + h, + element=("H", "H"), + aromatic=(False, False), + charge=(0, 0), + atom_map=(atom_map, atom_map), + hcount=(0, 0), + radical=(0, 0), + lone_pairs=(0, 0), + valence_electrons=(1, 1), + neighbors=([], []), + present=(True, True), + typesGH=(("H", False, 0, 0, []), ("H", False, 0, 0, [])), + ) + if src == dst: + rc.add_edge( + src, + h, + order=(1.0, 1.0), + kekule_order=(1.0, 1.0), + sigma_order=(1.0, 1.0), + pi_order=(0.0, 0.0), + standard_order=0.0, + ) + continue + rc.add_edge( + src, + h, + order=(1.0, 0.0), + kekule_order=(1.0, 0.0), + sigma_order=(1.0, 0.0), + pi_order=(0.0, 0.0), + standard_order=1.0, + ) + rc.add_edge( + h, + dst, + order=(0.0, 1.0), + kekule_order=(0.0, 1.0), + sigma_order=(0.0, 1.0), + pi_order=(0.0, 0.0), + standard_order=-1.0, + ) + + for pair_id in explicit_pairs: + src = pair_left[pair_id] + dst = pair_right[pair_id] + if src == dst: + h0, h1 = rc.nodes[src]["hcount"] + rc.nodes[src]["hcount"] = (h0 - 1, h1 - 1) + continue + src_h0, src_h1 = rc.nodes[src]["hcount"] + dst_h0, dst_h1 = rc.nodes[dst]["hcount"] + rc.nodes[src]["hcount"] = (src_h0 - 1, src_h1) + rc.nodes[dst]["hcount"] = (dst_h0, dst_h1 - 1) + + for n in set(pair_left.values()) | set(pair_right.values()): + if "typesGH" in rc.nodes[n]: + t0, t1 = rc.nodes[n]["typesGH"] + rc.nodes[n]["typesGH"] = ( + t0[:2] + (rc.nodes[n]["hcount"][0],) + t0[3:], + t1[:2] + (rc.nodes[n]["hcount"][1],) + t1[3:], + ) + + SynReactor._ensure_tuple_atom_maps(rc) + return rc + + @staticmethod + def _ensure_tuple_atom_maps(graph: nx.Graph) -> None: + """Assign stable paired atom maps to tuple nodes lacking visible maps.""" + for node, attrs in graph.nodes(data=True): + atom_map = attrs.get("atom_map") + if atom_map in (None, 0) or atom_map == (0, 0): + attrs["atom_map"] = (node, node) + # --------------------- SMARTS serialisation ----------------------- @staticmethod def _to_smarts(its: nx.Graph) -> str: - left, right = its_decompose(its) + electron_aware = bool(its.graph.get("electron_aware_rewrite", False)) + if electron_aware: + reverter = ITSReverter(its) + left = reverter.to_reactant_graph() + right = reverter.to_product_graph() + preserved_hydrogens = _get_preserved_hydrogen_maps(its, "tuple") + else: + left, right = its_decompose(its) + preserved_hydrogens = [] left = remove_wildcard_nodes(left) right = remove_wildcard_nodes(right) - r_smi = graph_to_smi(left) - p_smi = graph_to_smi(right) + r_smi = graph_to_smi(left, preserve_atom_maps=preserved_hydrogens) + if electron_aware: + product_candidates = [ + right, + SynReactor._prepared_electron_product_graph(its), + ] + p_smi = None + for product in product_candidates: + product = refresh_electron_fields(product) + for node, attrs in product.nodes(data=True): + product_charge = SynReactor._electron_product_charge( + its, + node, + attrs, + ) + if product_charge is not None: + attrs["charge"] = product_charge + if any( + attrs.get("order") == 1.5 + for _, _, attrs in product.edges(data=True) + ): + p_smi = graph_to_smi(product) + if p_smi is not None: + break + try: + p_smi = Chem.MolToSmiles(graph_to_sanitized_kekule_mol(product)) + break + except Exception: + p_smi = None + else: + p_smi = graph_to_smi(right) if r_smi is None or p_smi is None: return None return f"{r_smi}>>{p_smi}" diff --git a/synkit/Vis/__init__.py b/synkit/Vis/__init__.py index aa5119a..d8c70a4 100644 --- a/synkit/Vis/__init__.py +++ b/synkit/Vis/__init__.py @@ -1,5 +1,47 @@ from .graph_visualizer import GraphVisualizer from .rule_vis import RuleVis from .rxn_vis import RXNVis +from .visual_model import ( + VisualEdge, + VisualGraph, + VisualKind, + VisualNode, + detect_visual_kind, + iter_changed_edges, + iter_changed_nodes, + summarize_visual_graph, + to_visual_graph, +) +from .visual_drawer import draw_graph +from .molecule_drawer import draw_molecule_graph +from .reaction_drawer import ( + ReactionHighlights, + draw_reaction_graph, + draw_reaction_graphs, + find_reaction_highlights, +) +from .its_drawer import draw_its_from_rsmi, draw_its_graph, draw_its_only -__all__ = ["GraphVisualizer", "RuleVis", "RXNVis"] +__all__ = [ + "GraphVisualizer", + "RuleVis", + "RXNVis", + "VisualEdge", + "VisualGraph", + "VisualKind", + "VisualNode", + "detect_visual_kind", + "iter_changed_edges", + "iter_changed_nodes", + "summarize_visual_graph", + "to_visual_graph", + "draw_graph", + "draw_molecule_graph", + "ReactionHighlights", + "draw_reaction_graph", + "draw_reaction_graphs", + "find_reaction_highlights", + "draw_its_from_rsmi", + "draw_its_graph", + "draw_its_only", +] diff --git a/synkit/Vis/graph_visualizer.py b/synkit/Vis/graph_visualizer.py index 4030548..6c6bed6 100644 --- a/synkit/Vis/graph_visualizer.py +++ b/synkit/Vis/graph_visualizer.py @@ -232,7 +232,7 @@ def plot_as_mol( for n, d in g.nodes(data=True): charge = d.get("charge", 0) cstr = "" if charge == 0 else f"{charge:+}" - lbl = f"{d.get(symbol_key,'')}{cstr}" + lbl = f"{d.get(symbol_key, '')}{cstr}" if show_atom_map: lbl += f" ({d.get(aam_key)})" labels[n] = lbl diff --git a/synkit/Vis/its_drawer.py b/synkit/Vis/its_drawer.py new file mode 100644 index 0000000..bc10b9c --- /dev/null +++ b/synkit/Vis/its_drawer.py @@ -0,0 +1,615 @@ +from __future__ import annotations + +"""ITS visualization. + +The default ITS view is a single molecule-like transition graph. Reactant / +product molecular projections remain available through ``projection=True`` for +debugging and comparison. +""" + +from typing import Any, Optional, Tuple + +import matplotlib.patheffects as pe +import matplotlib.pyplot as plt +import networkx as nx + +from synkit.Graph.ITS.its_decompose import its_decompose +from synkit.Graph.ITS.its_reverter import ITSReverter +from synkit.IO.chem_converter import rsmi_to_its +from synkit.Vis.molecule_drawer import ( + _draw_aromatic_circles, + _draw_bond_lines, + _edge_is_aromatic, + _element_colors, + _element_label, + _index_offset_vec, + _layout_positions, + _luminance, + _set_padded_limits, +) +from synkit.Vis.reaction_drawer import draw_reaction_graphs, find_reaction_highlights +from synkit.Vis.visual_drawer import draw_graph + + +def draw_its_graph( + its: nx.Graph, + *, + title: Optional[str] = None, + mode: str = "sigma_pi", + show_atom_map: bool = True, + label_mode: str = "hetero", + aromatic_style: str = "circle", + include_delta_panel: bool = True, + projection: bool = False, + show_edge_labels: bool = False, + edge_label_mode: str = "kekule", + show_electron_labels: bool = False, + electron_label_mode: str = "charge", +) -> tuple[plt.Figure, list[plt.Axes]]: + """Draw an ITS graph. + + By default this draws only the ITS as a molecule-like graph. Changed bonds + are colored and compactly labeled from ``kekule_order``. Optional node + electron labels can show one of charge, lone-pair, radical, or all changes. + Set ``projection=True`` to draw reactant/product molecular projections plus + a diagnostic ITS panel. + + :param its: ITS graph in tuple or legacy representation. + :type its: nx.Graph + :param title: Optional figure title. + :type title: Optional[str] + :param mode: Diagnostic label mode for the projection-mode delta panel. + :type mode: str + :param show_atom_map: Show atom-map labels. + :type show_atom_map: bool + :param label_mode: Atom label mode. + :type label_mode: str + :param aromatic_style: Aromatic style for molecular panels. + :type aromatic_style: str + :param include_delta_panel: In projection mode, include a diagnostic ITS + graph panel. + :type include_delta_panel: bool + :param projection: If ``True``, draw reactant/product molecular projection + panels plus an ITS delta panel. If ``False``, draw only the ITS graph. + :type projection: bool + :param show_edge_labels: If ``True``, show labels for unchanged edges too. + Changed edge labels are shown by default unless ``edge_label_mode`` is + ``"none"``. + :type show_edge_labels: bool + :param edge_label_mode: ``"kekule"``, ``"sigma_pi"``, or ``"none"``. + :type edge_label_mode: str + :param show_electron_labels: Show changed atom electron annotations. + :type show_electron_labels: bool + :param electron_label_mode: ``"charge"``, ``"lone_pair"``, ``"radical"``, + or ``"all"``. + :type electron_label_mode: str + :returns: ``(fig, axes)``. + :rtype: tuple[plt.Figure, list[plt.Axes]] + """ + + if not projection: + fig, ax = plt.subplots(figsize=(7.0, 5.0), facecolor="white") + draw_its_only( + its, + ax=ax, + title=title or "ITS", + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + show_edge_labels=show_edge_labels, + edge_label_mode=edge_label_mode, + show_electron_labels=show_electron_labels, + electron_label_mode=electron_label_mode, + ) + fig.tight_layout() + return fig, [ax] + + reactant, product = _its_to_side_graphs(its) + if not include_delta_panel: + return draw_reaction_graphs( + reactant, + product, + title=title or "ITS projections", + show_atom_map=show_atom_map, + highlight_reaction_center=True, + label_mode=label_mode, + aromatic_style=aromatic_style, + ) + + n_reaction_axes = ( + nx.number_connected_components(reactant) + + nx.number_connected_components(product) + + 1 + ) + fig = plt.figure( + figsize=(max(10.0, 3.1 * (n_reaction_axes + 1)), 3.7), + facecolor="white", + ) + grid = fig.add_gridspec( + 1, + n_reaction_axes + 1, + width_ratios=[1.0] * n_reaction_axes + [1.35], + ) + axes = [fig.add_subplot(grid[0, index]) for index in range(n_reaction_axes + 1)] + + # Draw panels directly here so the diagnostic ITS delta can share one + # figure with the molecular projections. + from synkit.Vis.reaction_drawer import _components, _draw_arrow, _draw_part + + highlights = find_reaction_highlights(reactant, product) + panel = 0 + for index, part in enumerate(_components(reactant)): + _draw_part( + part, + axes[panel], + title="Reactant" if index == 0 else "+", + highlights=highlights, + side="reactant", + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + ) + panel += 1 + _draw_arrow(axes[panel]) + panel += 1 + for index, part in enumerate(_components(product)): + _draw_part( + part, + axes[panel], + title="Product" if index == 0 else "+", + highlights=highlights, + side="product", + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + ) + panel += 1 + draw_graph( + its, + ax=axes[-1], + mode=mode, + title="ITS delta", + show_atom_map=show_atom_map, + layout="kamada_kawai", + ) + if title: + fig.suptitle(title, fontsize=12, fontweight="bold", y=0.98) + fig.tight_layout() + return fig, axes + + +def draw_its_from_rsmi( + rsmi: str, + *, + format: str = "tuple", + core: bool = False, + title: Optional[str] = None, + mode: str = "sigma_pi", + show_atom_map: bool = True, + label_mode: str = "hetero", + aromatic_style: str = "circle", + include_delta_panel: bool = True, + projection: bool = False, + show_edge_labels: bool = False, + edge_label_mode: str = "kekule", + show_electron_labels: bool = False, + electron_label_mode: str = "charge", +) -> tuple[plt.Figure, list[plt.Axes]]: + """Build an ITS from RSMI and draw it.""" + + its = rsmi_to_its(rsmi, core=core, format=format) + return draw_its_graph( + its, + title=title or "ITS from RSMI", + mode=mode, + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + include_delta_panel=include_delta_panel, + projection=projection, + show_edge_labels=show_edge_labels, + edge_label_mode=edge_label_mode, + show_electron_labels=show_electron_labels, + electron_label_mode=electron_label_mode, + ) + + +def draw_its_only( # noqa: C901 + its: nx.Graph, + *, + ax: Optional[plt.Axes] = None, + title: Optional[str] = None, + show_atom_map: bool = True, + label_mode: str = "hetero", + aromatic_style: str = "circle", + show_edge_labels: bool = False, + edge_label_mode: str = "kekule", + show_electron_labels: bool = False, + electron_label_mode: str = "charge", +) -> plt.Axes: + """Draw a molecule-like ITS transition graph on one axes.""" + + edge_label_mode = edge_label_mode.lower() + if edge_label_mode not in {"none", "kekule", "sigma_pi"}: + raise ValueError("edge_label_mode must be one of: none, kekule, sigma_pi") + electron_label_mode = electron_label_mode.lower() + if electron_label_mode not in {"charge", "lone_pair", "radical", "all"}: + raise ValueError( + "electron_label_mode must be one of: charge, lone_pair, radical, all" + ) + + display = _its_display_graph(its) + fig = None + if ax is None: + fig, ax = plt.subplots(figsize=(7.0, 5.0), facecolor="white") + else: + fig = ax.figure + ax.clear() + ax.set_facecolor("white") + ax.set_axis_off() + ax.set_aspect("equal") + + nodes = list(display.nodes()) + pos = _layout_positions(display, nodes, use_h_count=False) + avg_len = _avg_edge_length(pos, display) + bond_offset = avg_len * 0.09 + atom_map_offset = avg_len * 0.18 + n_nodes = max(1, len(nodes)) + node_size = max(210, min(560, 5200 // n_nodes)) + bond_width = max(1.5, min(2.8, 26 / n_nodes)) + element_font_size = max(7, min(12, 100 // n_nodes)) + atom_map_font_size = max(7, element_font_size) + + for u, v, attrs in display.edges(data=True): + p1, p2 = pos[u], pos[v] + state = attrs.get("its_state", "unchanged") + order = attrs.get("display_order", 1.0) + aromatic = bool(attrs.get("display_aromatic", False)) + color = _state_color(state) + if state in {"formed", "broken"}: + ax.plot( + [p1[0], p2[0]], + [p1[1], p2[1]], + color=color, + linewidth=bond_width * 3.6, + alpha=0.18, + solid_capstyle="round", + zorder=1, + ) + _draw_bond_lines( + ax, + p1, + p2, + order=max(1, int(round(order))), + aromatic=aromatic, + aromatic_style=aromatic_style, + offset=bond_offset, + lw=bond_width if state == "unchanged" else bond_width * 1.25, + color=color, + ) + if state in {"formed", "broken"}: + line_style = (0, (3, 3)) + ax.plot( + [p1[0], p2[0]], + [p1[1], p2[1]], + color=color, + linewidth=bond_width * 1.6, + linestyle=line_style, + alpha=0.95, + solid_capstyle="round", + zorder=3, + ) + edge_label = attrs.get(f"its_label_{edge_label_mode}", "") + if ( + edge_label_mode != "none" + and (show_edge_labels or state != "unchanged") + and edge_label + ): + ax.text( + (p1[0] + p2[0]) / 2, + (p1[1] + p2[1]) / 2, + edge_label, + fontsize=7, + ha="center", + va="center", + color="#111827", + bbox={ + "boxstyle": "round,pad=0.12", + "fc": "white", + "ec": "none", + "alpha": 0.9, + }, + zorder=9, + ) + + if aromatic_style == "circle": + _draw_aromatic_circles(ax, display, pos, scale=0.52) + + node_colors = [] + node_borders = [] + for node in nodes: + fill, border = _element_colors(str(display.nodes[node].get("element", "C"))) + if display.nodes[node].get("its_changed", False): + border = "#f97316" + node_colors.append(fill) + node_borders.append(border) + + node_artist = nx.draw_networkx_nodes( + display, + pos, + nodelist=nodes, + node_color=node_colors, + edgecolors=node_borders, + linewidths=[ + ( + max(2.2, node_size**0.5 * 0.1) + if display.nodes[node].get("its_changed", False) + else max(1.0, node_size**0.5 * 0.065) + ) + for node in nodes + ], + node_size=node_size, + ax=ax, + ) + node_artist.set_zorder(4) + + for node in nodes: + attrs = display.nodes[node] + text = _element_label(attrs, label_mode=label_mode) + if text: + x, y = pos[node] + fill, _ = _element_colors(str(attrs.get("element", "C"))) + ax.text( + x, + y, + text, + ha="center", + va="center", + fontsize=element_font_size, + fontweight="bold", + color="white" if _luminance(fill) < 0.5 else "#1f2937", + zorder=10, + ) + if show_atom_map: + atom_map = attrs.get("atom_map", node) + if atom_map in (None, 0): + atom_map = node + x, y = pos[node] + dx, dy = _index_offset_vec(node, display, pos, base=atom_map_offset) + ax.text( + x + dx, + y + dy, + str(atom_map), + ha="center", + va="center", + fontsize=atom_map_font_size, + fontweight="bold", + color="#111827", + path_effects=[pe.withStroke(linewidth=2.5, foreground="white")], + zorder=11, + ) + if show_electron_labels: + electron_label = attrs.get(f"its_electron_label_{electron_label_mode}", "") + if electron_label: + x, y = pos[node] + _, dy = _index_offset_vec( + node, display, pos, base=atom_map_offset * 2.35 + ) + ax.text( + x, + y - abs(dy), + electron_label, + ha="center", + va="center", + fontsize=max(7, element_font_size - 1), + color="#374151", + bbox={ + "boxstyle": "round,pad=0.16", + "fc": "white", + "ec": "#cbd5e1", + "alpha": 0.92, + }, + zorder=12, + ) + + if title: + ax.set_title(title, fontsize=12, fontweight="bold", pad=8) + _set_padded_limits(ax, pos, avg_len) + if fig is not None: + fig.tight_layout() + return ax + + +def _its_to_side_graphs(its: nx.Graph) -> Tuple[nx.Graph, nx.Graph]: + if _has_direct_tuple_attrs(its): + reverter = ITSReverter(its) + return ( + reverter.to_reactant_graph(recompute_neighbors=True), + reverter.to_product_graph(recompute_neighbors=True), + ) + return its_decompose(its) + + +def _its_display_graph(its: nx.Graph) -> nx.Graph: + reactant, product = _its_to_side_graphs(its) + display = nx.compose(reactant, product) + for node in display.nodes: + display.nodes[node]["its_changed"] = False + electron_labels = _electron_node_labels( + reactant.nodes[node] if node in reactant else {}, + product.nodes[node] if node in product else {}, + ) + for key, label in electron_labels.items(): + display.nodes[node][f"its_electron_label_{key}"] = label + for key in ("element", "charge", "hcount", "radical", "lone_pairs"): + r_value = reactant.nodes[node].get(key) if node in reactant else None + p_value = product.nodes[node].get(key) if node in product else None + if r_value != p_value: + display.nodes[node]["its_changed"] = True + break + + for u, v in display.edges(): + r_data = reactant.get_edge_data(u, v) + p_data = product.get_edge_data(u, v) + r_order = _edge_order_value(r_data) + p_order = _edge_order_value(p_data) + state = _edge_state(r_order, p_order) + display.edges[u, v]["its_state"] = state + display.edges[u, v]["display_order"] = max(r_order, p_order, 1.0) + display.edges[u, v]["display_aromatic"] = _is_display_aromatic(r_data, p_data) + display.edges[u, v]["order"] = display.edges[u, v]["display_order"] + display.edges[u, v][ + "its_label_kekule" + ] = f"{_fmt_order(r_order)}→{_fmt_order(p_order)}" + display.edges[u, v]["its_label_sigma_pi"] = _sigma_pi_label(r_data, p_data) + if state != "unchanged": + display.nodes[u]["its_changed"] = True + display.nodes[v]["its_changed"] = True + return display + + +def _edge_order_value(attrs: Optional[dict[str, Any]]) -> float: + if not attrs: + return 0.0 + value = attrs.get("kekule_order", attrs.get("order", 1.0)) + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _edge_state(before: float, after: float) -> str: + if abs(before - after) < 1e-9: + return "unchanged" + if before == 0 and after > 0: + return "formed" + if before > 0 and after == 0: + return "broken" + return "order_changed" + + +def _is_display_aromatic( + reactant_attrs: Optional[dict[str, Any]], + product_attrs: Optional[dict[str, Any]], +) -> bool: + return any( + attrs is not None and _edge_is_aromatic(attrs) + for attrs in (reactant_attrs, product_attrs) + ) + + +def _state_color(state: str) -> str: + return { + "formed": "#15803d", + "broken": "#b91c1c", + "order_changed": "#ca8a04", + "unchanged": "#374151", + }.get(state, "#374151") + + +def _fmt_order(order: float) -> str: + if order == 0: + return "∅" + if float(order).is_integer(): + return str(int(order)) + return f"{order:g}" + + +def _sigma_pi_label( + reactant_attrs: Optional[dict[str, Any]], + product_attrs: Optional[dict[str, Any]], +) -> str: + r_sigma = _specific_order_value(reactant_attrs, "sigma_order") + p_sigma = _specific_order_value(product_attrs, "sigma_order") + r_pi = _specific_order_value(reactant_attrs, "pi_order") + p_pi = _specific_order_value(product_attrs, "pi_order") + parts = [] + if abs(r_sigma - p_sigma) > 1e-9: + parts.append(f"σ{_fmt_order(r_sigma)}→{_fmt_order(p_sigma)}") + if abs(r_pi - p_pi) > 1e-9: + parts.append(f"π{_fmt_order(r_pi)}→{_fmt_order(p_pi)}") + return " ".join(parts) + + +def _specific_order_value(attrs: Optional[dict[str, Any]], key: str) -> float: + if not attrs: + return 0.0 + value = attrs.get(key, 0.0) + if value is None: + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _electron_node_labels( + reactant_attrs: dict[str, Any], + product_attrs: dict[str, Any], +) -> dict[str, str]: + labels: dict[str, str] = {} + all_parts = [] + for key, mode, label in ( + ("charge", "charge", "q"), + ("lone_pairs", "lone_pair", "λ"), + ("radical", "radical", "rad"), + ): + before = reactant_attrs.get(key, 0) + after = product_attrs.get(key, 0) + if before != after: + formatter = _fmt_signed if key == "charge" else _fmt_count + text = f"{label}{formatter(before)}→{formatter(after)}" + labels[mode] = text + all_parts.append(text) + labels["all"] = " ".join(all_parts) + return labels + + +def _fmt_signed(value: Any) -> str: + try: + number = int(value) + except (TypeError, ValueError): + return str(value) + if number > 0: + return f"+{number}" + if number < 0: + return str(number) + return "0" + + +def _fmt_count(value: Any) -> str: + try: + number = int(value) + except (TypeError, ValueError): + return str(value) + return str(number) + + +def _avg_edge_length(pos: dict[Any, tuple[float, float]], graph: nx.Graph) -> float: + if graph.number_of_edges() == 0: + return 1.0 + lengths = [ + ((pos[v][0] - pos[u][0]) ** 2 + (pos[v][1] - pos[u][1]) ** 2) ** 0.5 + for u, v in graph.edges() + ] + return sum(lengths) / len(lengths) + + +def _has_direct_tuple_attrs(its: nx.Graph) -> bool: + node_keys = ("element", "hcount", "charge", "radical", "lone_pairs", "present") + edge_keys = ("kekule_order", "sigma_order", "pi_order") + for _, attrs in its.nodes(data=True): + if any(_is_plain_pair(attrs.get(key)) for key in node_keys): + return True + for _, _, attrs in its.edges(data=True): + if any(_is_plain_pair(attrs.get(key)) for key in edge_keys): + return True + return False + + +def _is_plain_pair(value: object) -> bool: + return ( + isinstance(value, tuple) + and len(value) == 2 + and not any(isinstance(item, (tuple, list, set, dict)) for item in value) + ) diff --git a/synkit/Vis/molecule_drawer.py b/synkit/Vis/molecule_drawer.py new file mode 100644 index 0000000..5245f51 --- /dev/null +++ b/synkit/Vis/molecule_drawer.py @@ -0,0 +1,565 @@ +from __future__ import annotations + +"""Chemistry-oriented molecular graph drawing. + +This module draws scalar molecular ``nx.Graph`` objects as molecule-like +figures. It is adapted from the copied ``vis_synedu`` renderer, but uses +SynKit's own graph-to-mol conversion and avoids relying on broken copied +relative imports. +""" + +import math +from typing import Any, Dict, Mapping, Optional, Set, Tuple + +import matplotlib.patches as mpatches +import matplotlib.patheffects as pe +import matplotlib.pyplot as plt +import networkx as nx +from rdkit import Chem +from rdkit.Chem import AllChem, Draw + +from synkit.IO.graph_to_mol import GraphToMol + +ELEMENT_PALETTE: Dict[str, Tuple[str, str]] = { + "C": ("#5f6368", "#3d4145"), + "H": ("#f8fafc", "#94a3b8"), + "O": ("#e8524a", "#b83830"), + "N": ("#5b8dd9", "#3a65b0"), + "S": ("#e8a838", "#b87909"), + "P": ("#e878c8", "#b84898"), + "F": ("#5bc8af", "#2a9178"), + "Cl": ("#3dbe6c", "#1e8a46"), + "Br": ("#a0522d", "#6b3118"), + "I": ("#8c54c8", "#5e2fa0"), + "B": ("#d6a77a", "#9a6a44"), + "Si": ("#f0c8a0", "#b88860"), +} + +DEFAULT_FILL = "#a0a0a0" +DEFAULT_BORDER = "#606060" + + +def draw_molecule_graph( # noqa: C901 + graph: nx.Graph, + *, + ax: Optional[plt.Axes] = None, + title: Optional[str] = None, + label_mode: str = "hetero", + show_atom_map: bool = False, + show_bond_order: bool = False, + aromatic_style: str = "circle", + include_rdkit_panel: bool = False, + use_h_count: bool = False, + node_size: Optional[int] = None, + bond_width: Optional[float] = None, + figsize: Tuple[float, float] = (6.0, 5.0), + highlight_nodes: Optional[Set[Any]] = None, + highlight_edges: Optional[Set[Tuple[Any, Any]]] = None, + highlight_color: str = "#f97316", + custom_node_colors: Optional[Mapping[Any, str]] = None, +) -> plt.Axes | tuple[plt.Figure, tuple[plt.Axes, plt.Axes]]: + """Draw a scalar molecular graph using RDKit coordinates when possible. + + :param graph: Molecular NetworkX graph with scalar ``element`` and + ``order`` attributes. + :type graph: nx.Graph + :param ax: Optional Matplotlib axes. + :type ax: Optional[plt.Axes] + :param title: Optional title. + :type title: Optional[str] + :param label_mode: ``"all"``, ``"hetero"``, or ``"none"``. + :type label_mode: str + :param show_atom_map: Show atom-map numbers near atoms. + :type show_atom_map: bool + :param show_bond_order: Show numeric bond order labels. + :type show_bond_order: bool + :param aromatic_style: ``"circle"`` or ``"dashed"``. + :type aromatic_style: str + :param include_rdkit_panel: Also show RDKit's own rendering side-by-side. + :type include_rdkit_panel: bool + :param use_h_count: Pass graph ``hcount`` to ``GraphToMol`` for layout. + :type use_h_count: bool + :returns: Axes, or ``(fig, (rdkit_ax, graph_ax))`` when + ``include_rdkit_panel=True``. + :rtype: Union[plt.Axes, Tuple[plt.Figure, Tuple[plt.Axes, plt.Axes]]] + """ + + label_mode = label_mode.lower() + aromatic_style = aromatic_style.lower() + if label_mode not in {"all", "hetero", "none"}: + raise ValueError("label_mode must be one of: all, hetero, none") + if aromatic_style not in {"circle", "dashed"}: + raise ValueError("aromatic_style must be one of: circle, dashed") + + graph_view = graph.copy() + nodes = list(graph_view.nodes()) + n_nodes = max(1, len(nodes)) + + if include_rdkit_panel: + fig, (ax_rdkit, ax_graph) = plt.subplots( + 1, 2, figsize=(figsize[0] * 2, figsize[1]), facecolor="white" + ) + elif ax is None: + fig, ax_graph = plt.subplots(figsize=figsize, facecolor="white") + ax_rdkit = None + else: + fig = ax.figure + ax_graph = ax + ax_rdkit = None + + ax_graph.clear() + ax_graph.set_facecolor("white") + ax_graph.set_axis_off() + ax_graph.set_aspect("equal") + + pos = _layout_positions(graph_view, nodes, use_h_count=use_h_count) + avg_len = _avg_edge_length(pos, graph_view) + bond_offset = avg_len * 0.09 + atom_map_offset = avg_len * 0.18 + scaled_node_size = ( + node_size if node_size is not None else max(180, min(560, 4600 // n_nodes)) + ) + scaled_bond_width = ( + bond_width if bond_width is not None else max(1.3, min(2.6, 24 / n_nodes)) + ) + element_font_size = max(7, min(12, 100 // n_nodes)) + atom_map_font_size = max(7, element_font_size) + + normalized_highlight_edges = _normalize_edge_set(highlight_edges) + + _draw_highlights( + ax_graph, + graph_view, + pos, + highlight_nodes=highlight_nodes, + highlight_edges=normalized_highlight_edges, + node_size=scaled_node_size, + bond_width=scaled_bond_width, + color=highlight_color, + ) + + for u, v, attrs in graph_view.edges(data=True): + p1, p2 = pos[u], pos[v] + aromatic = _edge_is_aromatic(attrs) + order = _edge_order(attrs, aromatic=aromatic) + _draw_bond_lines( + ax_graph, + p1, + p2, + order=order, + aromatic=aromatic, + aromatic_style=aromatic_style, + offset=bond_offset, + lw=scaled_bond_width, + color="#262a2f", + ) + if show_bond_order and not aromatic: + _draw_bond_order_label(ax_graph, p1, p2, order) + + if aromatic_style == "circle": + _draw_aromatic_circles(ax_graph, graph_view, pos, scale=0.52) + + node_fills = [] + node_borders = [] + for node in nodes: + element = str(graph_view.nodes[node].get("element", "C")) + fill, border = _element_colors(element) + if custom_node_colors and node in custom_node_colors: + fill = custom_node_colors[node] + border = fill + node_fills.append(fill) + node_borders.append(border) + + node_artist = nx.draw_networkx_nodes( + graph_view, + pos, + nodelist=nodes, + node_color=node_fills, + edgecolors=node_borders, + linewidths=max(1.0, scaled_node_size**0.5 * 0.065), + node_size=scaled_node_size, + ax=ax_graph, + ) + node_artist.set_zorder(3) + + for node in nodes: + attrs = graph_view.nodes[node] + text = _element_label(attrs, label_mode=label_mode) + if not text: + continue + x, y = pos[node] + fill, _ = _element_colors(str(attrs.get("element", "C"))) + ax_graph.text( + x, + y, + text, + ha="center", + va="center", + fontsize=element_font_size, + fontweight="bold", + color="white" if _luminance(fill) < 0.5 else "#1f2937", + zorder=8, + ) + + if show_atom_map: + for node in nodes: + atom_map = graph_view.nodes[node].get("atom_map", node) + if atom_map in (None, 0): + atom_map = node + x, y = pos[node] + dx, dy = _index_offset_vec(node, graph_view, pos, base=atom_map_offset) + ax_graph.text( + x + dx, + y + dy, + str(atom_map), + ha="center", + va="center", + fontsize=atom_map_font_size, + fontweight="bold", + color="#111827", + path_effects=[pe.withStroke(linewidth=2.5, foreground="white")], + zorder=9, + ) + + if title: + ax_graph.set_title(title, fontsize=12, fontweight="bold", pad=8) + _set_padded_limits(ax_graph, pos, avg_len) + + if include_rdkit_panel and ax_rdkit is not None: + _draw_rdkit_panel(ax_rdkit, graph_view, nodes, use_h_count=use_h_count) + fig.tight_layout() + return fig, (ax_rdkit, ax_graph) + + fig.tight_layout() + return ax_graph + + +def _layout_positions( + graph: nx.Graph, + nodes: list[Any], + *, + use_h_count: bool, +) -> Dict[Any, Tuple[float, float]]: + try: + ordered = _ordered_graph(graph, nodes) + mol = _graph_to_mol(ordered, sanitize=True, use_h_count=use_h_count) + _ensure_2d(mol) + conf = mol.GetConformer(0) + return { + node: (conf.GetAtomPosition(idx).x, conf.GetAtomPosition(idx).y) + for idx, node in enumerate(nodes) + } + except Exception: + return { + node: (float(point[0]), float(point[1])) + for node, point in nx.kamada_kawai_layout(graph).items() + } + + +def _ordered_graph(graph: nx.Graph, nodes: list[Any]) -> nx.Graph: + ordered = nx.Graph() + for node in nodes: + ordered.add_node(node, **graph.nodes[node]) + for u, v, attrs in graph.edges(data=True): + ordered.add_edge(u, v, **attrs) + return ordered + + +def _graph_to_mol(graph: nx.Graph, *, sanitize: bool, use_h_count: bool) -> Chem.Mol: + converter = GraphToMol( + { + "element": "element", + "charge": "charge", + "atom_map": "atom_map", + "radical": "radical", + }, + {"order": "order"}, + ) + try: + return converter.graph_to_mol(graph, sanitize=sanitize, use_h_count=use_h_count) + except Exception: + return converter.graph_to_mol(graph, sanitize=False, use_h_count=use_h_count) + + +def _ensure_2d(mol: Chem.Mol) -> None: + if mol.GetNumConformers() == 0: + AllChem.Compute2DCoords(mol) + + +def _element_colors(element: str) -> Tuple[str, str]: + return ELEMENT_PALETTE.get(element, (DEFAULT_FILL, DEFAULT_BORDER)) + + +def _element_label(attrs: Mapping[str, Any], *, label_mode: str) -> str: + element = str(attrs.get("element", "C")) + if label_mode == "none": + return "" + if label_mode == "hetero" and element == "C": + charge = int(attrs.get("charge", 0) or 0) + radical = int(attrs.get("radical", 0) or 0) + return "C" if charge or radical else "" + charge_suffix = _charge_suffix(attrs.get("charge", 0)) + radical_suffix = "." * int(attrs.get("radical", 0) or 0) + return f"{element}{charge_suffix}{radical_suffix}" + + +def _charge_suffix(charge: Any) -> str: + try: + value = int(charge) + except (TypeError, ValueError): + return "" + if value == 0: + return "" + sign = "+" if value > 0 else "-" + mag = abs(value) + return sign if mag == 1 else f"{sign}{mag}" + + +def _edge_order(attrs: Mapping[str, Any], *, aromatic: bool) -> int: + if aromatic: + return 1 + try: + order = abs(float(attrs.get("kekule_order", attrs.get("order", 1.0)))) + except (TypeError, ValueError): + order = 1.0 + return max(1, min(3, int(round(order)))) + + +def _edge_is_aromatic(attrs: Mapping[str, Any]) -> bool: + if bool(attrs.get("aromatic", False)): + return True + try: + return float(attrs.get("order", 0.0)) == 1.5 + except (TypeError, ValueError): + return False + + +def _draw_bond_lines( + ax: plt.Axes, + p1: Tuple[float, float], + p2: Tuple[float, float], + *, + order: int, + aromatic: bool, + aromatic_style: str, + offset: float, + lw: float, + color: str, +) -> None: + kwargs = { + "color": color, + "linewidth": lw, + "solid_capstyle": "round", + "solid_joinstyle": "round", + "zorder": 2, + } + if aromatic and aromatic_style == "dashed": + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], linestyle="--", **kwargs) + return + if aromatic or order <= 1: + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **kwargs) + return + dx, dy = _perp_offset(p1, p2, offset) + if order == 2: + ax.plot([p1[0] + dx, p2[0] + dx], [p1[1] + dy, p2[1] + dy], **kwargs) + ax.plot([p1[0] - dx, p2[0] - dx], [p1[1] - dy, p2[1] - dy], **kwargs) + return + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **{**kwargs, "linewidth": lw * 0.9}) + ax.plot( + [p1[0] + dx, p2[0] + dx], + [p1[1] + dy, p2[1] + dy], + **{**kwargs, "linewidth": lw * 0.9}, + ) + ax.plot( + [p1[0] - dx, p2[0] - dx], + [p1[1] - dy, p2[1] - dy], + **{**kwargs, "linewidth": lw * 0.9}, + ) + + +def _draw_aromatic_circles( + ax: plt.Axes, + graph: nx.Graph, + pos: Mapping[Any, Tuple[float, float]], + *, + scale: float, +) -> None: + for cycle in nx.cycle_basis(graph): + if len(cycle) < 5: + continue + if not all(bool(graph.nodes[node].get("aromatic", False)) for node in cycle): + continue + xs = [pos[node][0] for node in cycle] + ys = [pos[node][1] for node in cycle] + cx, cy = sum(xs) / len(xs), sum(ys) / len(ys) + radius = sum(math.hypot(x - cx, y - cy) for x, y in zip(xs, ys)) / len(xs) + ax.add_patch( + mpatches.Circle( + (cx, cy), + radius * scale, + fill=False, + linewidth=1.15, + color="#333333", + zorder=1, + ) + ) + + +def _draw_highlights( + ax: plt.Axes, + graph: nx.Graph, + pos: Mapping[Any, Tuple[float, float]], + *, + highlight_nodes: Optional[Set[Any]], + highlight_edges: Set[Tuple[Any, Any]], + node_size: int, + bond_width: float, + color: str, +) -> None: + if highlight_edges: + for u, v in graph.edges(): + if _edge_key(u, v) not in highlight_edges: + continue + p1, p2 = pos[u], pos[v] + ax.plot( + [p1[0], p2[0]], + [p1[1], p2[1]], + color=color, + linewidth=bond_width * 5.0, + alpha=0.25, + solid_capstyle="round", + zorder=1, + ) + if highlight_nodes: + nodes = [node for node in highlight_nodes if node in graph] + if nodes: + artist = nx.draw_networkx_nodes( + graph, + pos, + nodelist=nodes, + node_size=int(node_size * 1.75), + node_color=color, + edgecolors="none", + alpha=0.22, + ax=ax, + ) + artist.set_zorder(1) + + +def _draw_bond_order_label( + ax: plt.Axes, + p1: Tuple[float, float], + p2: Tuple[float, float], + order: int, +) -> None: + ax.text( + (p1[0] + p2[0]) / 2, + (p1[1] + p2[1]) / 2, + str(order), + fontsize=7, + ha="center", + va="center", + color="#111827", + bbox={"boxstyle": "round,pad=0.12", "fc": "white", "ec": "none", "alpha": 0.9}, + zorder=8, + ) + + +def _draw_rdkit_panel( + ax: plt.Axes, + graph: nx.Graph, + nodes: list[Any], + *, + use_h_count: bool, +) -> None: + ax.clear() + ax.set_axis_off() + try: + mol = _graph_to_mol( + _ordered_graph(graph, nodes), sanitize=True, use_h_count=use_h_count + ) + _ensure_2d(mol) + options = Draw.MolDrawOptions() + options.addAtomIndices = True + image = Draw.MolToImage(mol, size=(500, 500), kekulize=False, options=options) + ax.imshow(image) + ax.set_title("RDKit", fontsize=12, fontweight="bold", pad=8) + except Exception as exc: + ax.text(0.5, 0.5, f"RDKit render failed\n{exc}", ha="center", va="center") + + +def _perp_offset( + p1: Tuple[float, float], + p2: Tuple[float, float], + offset: float, +) -> Tuple[float, float]: + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + length = math.hypot(dx, dy) + if length == 0: + return 0.0, 0.0 + return -dy / length * offset, dx / length * offset + + +def _index_offset_vec( + node: Any, + graph: nx.Graph, + pos: Mapping[Any, Tuple[float, float]], + *, + base: float, +) -> Tuple[float, float]: + x, y = pos[node] + neighbors = list(graph.neighbors(node)) + if not neighbors: + return 0.0, base + cx = sum(pos[nbr][0] for nbr in neighbors) / len(neighbors) + cy = sum(pos[nbr][1] for nbr in neighbors) / len(neighbors) + dx, dy = x - cx, y - cy + length = math.hypot(dx, dy) + if length == 0: + return 0.0, base + return dx / length * base, dy / length * base + + +def _avg_edge_length( + pos: Mapping[Any, Tuple[float, float]], + graph: nx.Graph, +) -> float: + if graph.number_of_edges() == 0: + return 1.0 + lengths = [ + math.hypot(pos[v][0] - pos[u][0], pos[v][1] - pos[u][1]) + for u, v in graph.edges() + ] + return sum(lengths) / len(lengths) + + +def _set_padded_limits( + ax: plt.Axes, + pos: Mapping[Any, Tuple[float, float]], + avg_len: float, +) -> None: + if not pos: + return + xs = [point[0] for point in pos.values()] + ys = [point[1] for point in pos.values()] + x_span = max(xs) - min(xs) + y_span = max(ys) - min(ys) + pad = max(avg_len * 0.45, x_span * 0.08, y_span * 0.08, 0.2) + ax.set_xlim(min(xs) - pad, max(xs) + pad) + ax.set_ylim(min(ys) - pad, max(ys) + pad) + + +def _normalize_edge_set(edges: Optional[Set[Tuple[Any, Any]]]) -> Set[Tuple[Any, Any]]: + if not edges: + return set() + return {_edge_key(u, v) for u, v in edges} + + +def _edge_key(u: Any, v: Any) -> Tuple[Any, Any]: + return (u, v) if str(u) <= str(v) else (v, u) + + +def _luminance(hex_color: str) -> float: + color = hex_color.lstrip("#") + red, green, blue = (int(color[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # noqa + return 0.2126 * red + 0.7152 * green + 0.0722 * blue diff --git a/synkit/Vis/reaction_drawer.py b/synkit/Vis/reaction_drawer.py new file mode 100644 index 0000000..7b61324 --- /dev/null +++ b/synkit/Vis/reaction_drawer.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +"""Reaction visualization built from molecular graph panels.""" + +from dataclasses import dataclass +from typing import Any, Dict, FrozenSet, Iterable, Optional, Set, Tuple + +import matplotlib.pyplot as plt +import networkx as nx + +from synkit.IO.chem_converter import rsmi_to_graph +from synkit.Vis.molecule_drawer import draw_molecule_graph + + +@dataclass(frozen=True) +class ReactionHighlights: + """Atom-map based reaction-center highlights.""" + + changed_atoms: frozenset[int] + formed_bonds: frozenset[frozenset[int]] + broken_bonds: frozenset[frozenset[int]] + order_changed_bonds: frozenset[frozenset[int]] + + +def draw_reaction_graph( + rsmi: str, + *, + title: Optional[str] = None, + show_atom_map: bool = True, + highlight_reaction_center: bool = True, + label_mode: str = "hetero", + aromatic_style: str = "circle", + figsize_per_mol: Tuple[float, float] = (3.2, 2.8), + sanitize: bool = True, +) -> tuple[plt.Figure, list[plt.Axes]]: + """Draw an RSMI as molecular graph panels. + + :param rsmi: Reaction SMILES, preferably atom-mapped when reaction-center + highlighting is desired. + :type rsmi: str + :param title: Optional figure title. + :type title: Optional[str] + :param show_atom_map: Show atom-map/index labels on molecule panels. + :type show_atom_map: bool + :param highlight_reaction_center: Highlight changed mapped atoms/bonds. + :type highlight_reaction_center: bool + :param label_mode: Molecule label mode passed to ``draw_molecule_graph``. + :type label_mode: str + :param aromatic_style: Molecule aromatic style. + :type aromatic_style: str + :param figsize_per_mol: Approximate panel size for each molecular graph. + :type figsize_per_mol: tuple[float, float] + :param sanitize: Whether to sanitize molecules during RSMI conversion. + :type sanitize: bool + :returns: ``(fig, axes)``. + :rtype: tuple[plt.Figure, list[plt.Axes]] + """ + + reactant, product = rsmi_to_graph( + rsmi, + drop_non_aam=False, + sanitize=sanitize, + use_index_as_atom_map=True, + ) + if reactant is None or product is None: + raise ValueError(f"Could not convert RSMI to graphs: {rsmi!r}") + return draw_reaction_graphs( + reactant, + product, + title=title or rsmi, + show_atom_map=show_atom_map, + highlight_reaction_center=highlight_reaction_center, + label_mode=label_mode, + aromatic_style=aromatic_style, + figsize_per_mol=figsize_per_mol, + ) + + +def draw_reaction_graphs( + reactant: nx.Graph, + product: nx.Graph, + *, + title: Optional[str] = None, + show_atom_map: bool = True, + highlight_reaction_center: bool = True, + label_mode: str = "hetero", + aromatic_style: str = "circle", + figsize_per_mol: Tuple[float, float] = (3.2, 2.8), +) -> tuple[plt.Figure, list[plt.Axes]]: + """Draw reactant and product graphs as molecule panels.""" + + highlights = ( + find_reaction_highlights(reactant, product) + if highlight_reaction_center + else ReactionHighlights(frozenset(), frozenset(), frozenset(), frozenset()) + ) + reactant_parts = _components(reactant) + product_parts = _components(product) + n_panels = len(reactant_parts) + len(product_parts) + 1 + fig_width = max(6.0, figsize_per_mol[0] * n_panels) + fig_height = figsize_per_mol[1] + (0.45 if title else 0.0) + fig, axes_arr = plt.subplots( + 1, + n_panels, + figsize=(fig_width, fig_height), + facecolor="white", + gridspec_kw={"width_ratios": _width_ratios(reactant_parts, product_parts)}, + ) + axes = list(axes_arr if isinstance(axes_arr, Iterable) else [axes_arr]) + + if title: + fig.suptitle(title, fontsize=12, fontweight="bold", y=0.98) + + panel_index = 0 + for index, part in enumerate(reactant_parts): + _draw_part( + part, + axes[panel_index], + title="Reactant" if index == 0 else "+", + highlights=highlights, + side="reactant", + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + ) + panel_index += 1 + + _draw_arrow(axes[panel_index]) + panel_index += 1 + + for index, part in enumerate(product_parts): + _draw_part( + part, + axes[panel_index], + title="Product" if index == 0 else "+", + highlights=highlights, + side="product", + show_atom_map=show_atom_map, + label_mode=label_mode, + aromatic_style=aromatic_style, + ) + panel_index += 1 + + fig.tight_layout() + return fig, axes + + +def find_reaction_highlights( + reactant: nx.Graph, + product: nx.Graph, +) -> ReactionHighlights: + """Find atom-map based changed atoms and bonds between two side graphs.""" + + reactant_bonds = _mapped_bond_orders(reactant) + product_bonds = _mapped_bond_orders(product) + formed: set[FrozenSet[int]] = set() + broken: set[FrozenSet[int]] = set() + order_changed: set[FrozenSet[int]] = set() + changed_atoms: set[int] = set() + + for pair in set(reactant_bonds) | set(product_bonds): + r_order = reactant_bonds.get(pair) + p_order = product_bonds.get(pair) + if r_order is None: + formed.add(pair) + changed_atoms.update(pair) + elif p_order is None: + broken.add(pair) + changed_atoms.update(pair) + elif abs(r_order - p_order) > 1e-6: + order_changed.add(pair) + changed_atoms.update(pair) + + return ReactionHighlights( + changed_atoms=frozenset(changed_atoms), + formed_bonds=frozenset(formed), + broken_bonds=frozenset(broken), + order_changed_bonds=frozenset(order_changed), + ) + + +def _components(graph: nx.Graph) -> list[nx.Graph]: + return [ + graph.subgraph(nodes).copy() for nodes in nx.connected_components(graph) + ] or [graph.copy()] + + +def _width_ratios(reactants: list[nx.Graph], products: list[nx.Graph]) -> list[float]: + ratios = [max(1.0, part.number_of_nodes() / 5.0) for part in reactants] + ratios.append(0.45) + ratios.extend(max(1.0, part.number_of_nodes() / 5.0) for part in products) + return ratios + + +def _draw_part( + graph: nx.Graph, + ax: plt.Axes, + *, + title: str, + highlights: ReactionHighlights, + side: str, + show_atom_map: bool, + label_mode: str, + aromatic_style: str, +) -> None: + edge_maps = ( + highlights.broken_bonds | highlights.order_changed_bonds + if side == "reactant" + else highlights.formed_bonds | highlights.order_changed_bonds + ) + highlight_nodes = _nodes_for_atom_maps(graph, highlights.changed_atoms) + highlight_edges = _edges_for_atom_map_pairs(graph, edge_maps) + draw_molecule_graph( + graph, + ax=ax, + title=title, + label_mode=label_mode, + show_atom_map=show_atom_map, + aromatic_style=aromatic_style, + highlight_nodes=highlight_nodes, + highlight_edges=highlight_edges, + highlight_color="#f97316", + ) + + +def _draw_arrow(ax: plt.Axes) -> None: + ax.clear() + ax.set_axis_off() + ax.annotate( + "", + xy=(0.92, 0.5), + xytext=(0.08, 0.5), + xycoords="axes fraction", + arrowprops={"arrowstyle": "->", "lw": 2.2, "color": "#374151"}, + ) + + +def _mapped_bond_orders(graph: nx.Graph) -> Dict[FrozenSet[int], float]: + bonds: Dict[FrozenSet[int], float] = {} + for u, v, attrs in graph.edges(data=True): + a = _atom_map(graph.nodes[u], fallback=u) + b = _atom_map(graph.nodes[v], fallback=v) + if not a or not b: + continue + bonds[frozenset({a, b})] = float( + attrs.get("kekule_order", attrs.get("order", 1.0)) + ) + return bonds + + +def _nodes_for_atom_maps( + graph: nx.Graph, atom_maps: Set[int] | frozenset[int] +) -> Set[Any]: + return { + node + for node, attrs in graph.nodes(data=True) + if _atom_map(attrs, fallback=node) in atom_maps + } + + +def _edges_for_atom_map_pairs( + graph: nx.Graph, + pairs: Set[FrozenSet[int]] | frozenset[FrozenSet[int]], +) -> Set[Tuple[Any, Any]]: + out: set[Tuple[Any, Any]] = set() + for u, v in graph.edges(): + pair = frozenset( + { + _atom_map(graph.nodes[u], fallback=u), + _atom_map(graph.nodes[v], fallback=v), + } + ) + if pair in pairs: + out.add((u, v)) + return out + + +def _atom_map(attrs: Dict[str, Any], *, fallback: Any) -> int: + value = attrs.get("atom_map", 0) + if value in (None, 0): + value = fallback + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/synkit/Vis/vis_synedu/Vis/__init__.py b/synkit/Vis/vis_synedu/Vis/__init__.py new file mode 100644 index 0000000..9271b5c --- /dev/null +++ b/synkit/Vis/vis_synedu/Vis/__init__.py @@ -0,0 +1,7 @@ +from .dpo import DPODecomp, visualize_dpo_rule, dpo_decompose_atom_conserving + +__all__ = [ + "DPODecomp", + "visualize_dpo_rule", + "dpo_decompose_atom_conserving", +] diff --git a/synkit/Vis/vis_synedu/Vis/dpo.py b/synkit/Vis/vis_synedu/Vis/dpo.py new file mode 100644 index 0000000..7002607 --- /dev/null +++ b/synkit/Vis/vis_synedu/Vis/dpo.py @@ -0,0 +1,862 @@ +from __future__ import annotations + +import networkx as nx +import matplotlib.pyplot as plt +import matplotlib.patheffects as pe +from matplotlib.lines import Line2D +from matplotlib.patches import Patch +from dataclasses import dataclass +from typing import Dict, Tuple, Optional, Any + +from rdkit import Chem +from rdkit.Chem import AllChem + +from ..conversion import graph_to_mol +from ..its_vis import visualize_its as _visualize_its + +# ── CPK element palette (fill, border) — matches its_vis / vis ──────────── +_ELEMENT_PALETTE: Dict[str, Tuple[str, str]] = { + "C": ("#636363", "#3d3d3d"), + "O": ("#E8524A", "#b83830"), + "N": ("#5B8DD9", "#3a65b0"), + "S": ("#E8A838", "#c07a10"), + "Cl": ("#3DBE6C", "#1e8a46"), + "F": ("#5BC8AF", "#2a9178"), + "Br": ("#A0522D", "#6b3118"), + "I": ("#8C54C8", "#5e2fa0"), + "P": ("#E878C8", "#b84898"), + "H": ("#C8C8C8", "#909090"), + "Na": ("#AB5CF2", "#7b34c8"), + "Mg": ("#8AFF00", "#58b000"), + "Si": ("#F0C8A0", "#b88860"), +} +_DEFAULT_FILL = "#A0A0A0" +_DEFAULT_BORDER = "#606060" + + +def _fill(el: str) -> str: + return _ELEMENT_PALETTE.get(el, (_DEFAULT_FILL, _DEFAULT_BORDER))[0] + + +def _border(el: str) -> str: + return _ELEMENT_PALETTE.get(el, (_DEFAULT_FILL, _DEFAULT_BORDER))[1] + + +def _luminance(hex_color: str) -> float: + h = hex_color.lstrip("#") + r, g, b = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # noqa + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _pos_from_mol( + mol: Chem.Mol, G: nx.Graph +) -> Optional[Dict[Any, Tuple[float, float]]]: + """Return {node_id: (x, y)} from RDKit 2D conformer matched via atom_map attribute.""" + if mol.GetNumConformers() == 0: + AllChem.Compute2DCoords(mol) + conf = mol.GetConformer(0) + mol_pos: Dict[int, Tuple[float, float]] = { + a.GetAtomMapNum(): ( + conf.GetAtomPosition(a.GetIdx()).x, + conf.GetAtomPosition(a.GetIdx()).y, + ) + for a in mol.GetAtoms() + if a.GetAtomMapNum() > 0 + } + out: Dict[Any, Tuple[float, float]] = {} + for n in G.nodes(): + am = G.nodes[n].get("atom_map", n) + if am in mol_pos: + out[n] = mol_pos[am] + elif n in mol_pos: + out[n] = mol_pos[n] + return out if len(out) == G.number_of_nodes() else None + + +# ============================================================ +# ITS visualizer +# ============================================================ +def visualize_its( # noqa: C901 + its: nx.Graph, + *, + mol: Optional[Chem.Mol] = None, + ax=None, + title: str | None = None, + pos: dict | None = None, + layout: str = "kamada_kawai", # "spring" | "kamada_kawai" | "circular" + node_size: int = 900, + font_size: int = 10, + edge_width: float = 2.8, + show_edge_labels: bool = True, + show_unchanged_edge_labels: bool = False, + show_node_labels: bool = True, + show_atom_map: bool = False, + show_legends: bool = False, + rc_ring_color: str = "#FFD700", +): + """ + Visualize an ITS graph with CPK node colors and colored edge types. + + Pass *mol* (an RDKit molecule with 2D coords and atom-map numbers) to use + chemically correct 2D layout instead of the graph-theoretic fallback. + + Edges (its[u][v]['order'] == (br, bp)): + - br > bp : broken (red) + - br < bp : formed (green) + - br = bp : unchanged (grey dashed) + """ + created_fig = False + if ax is None: + fig, ax = plt.subplots(figsize=(6, 4), facecolor="white") + created_fig = True + + ax.set_facecolor("white") + ax.set_axis_off() + if title: + ax.set_title( + title, fontsize=font_size + 1, fontweight="bold", pad=6, color="#1a1a1a" + ) + + # ── layout ─────────────────────────────────────────────────────────── + if pos is None: + if mol is not None: + pos = _pos_from_mol(mol, its) + if pos is None: + if layout == "spring": + pos = nx.spring_layout(its, seed=0, k=0.9) + elif layout == "circular": + pos = nx.circular_layout(its) + else: + pos = nx.kamada_kawai_layout(its) + + broken, formed, unchanged = [], [], [] + lbl_b, lbl_f, lbl_u = {}, {}, {} + rc_nodes = set() + + for u, v, d in its.edges(data=True): + br, bp = d.get("order", (0.0, 0.0)) + if br > bp: + broken.append((u, v)) + lbl_b[(u, v)] = f"({br:g},{bp:g})" + rc_nodes.update([u, v]) + elif br < bp: + formed.append((u, v)) + lbl_f[(u, v)] = f"({br:g},{bp:g})" + rc_nodes.update([u, v]) + else: + unchanged.append((u, v)) + lbl_u[(u, v)] = f"({br:g},{bp:g})" + + nodelist = list(its.nodes()) + node_colors, node_borders, label_colors = [], [], [] + elems_present = [] + + for n in nodelist: + elem = its.nodes[n].get("element", "?") + if elem not in elems_present: + elems_present.append(elem) + fc = _fill(elem) + bc = _border(elem) + node_colors.append(fc) + node_borders.append(bc) + label_colors.append("white" if _luminance(fc) < 0.50 else "#1a1a1a") + + # ── RC glow ────────────────────────────────────────────────────────── + rc_list = [n for n in nodelist if n in rc_nodes] + if rc_list: + nc = nx.draw_networkx_nodes( + its, + pos, + ax=ax, + nodelist=rc_list, + node_size=int(node_size * 1.9), + node_color=rc_ring_color, + edgecolors="none", + linewidths=0, + alpha=0.20, + ) + nc.set_zorder(1) + + # ── edges ──────────────────────────────────────────────────────────── + if unchanged: + nx.draw_networkx_edges( + its, + pos, + ax=ax, + edgelist=unchanged, + width=max(1.1, edge_width * 0.55), + edge_color="#888888", + alpha=0.45, + style="--", + arrows=False, + ) + if broken: + nx.draw_networkx_edges( + its, + pos, + ax=ax, + edgelist=broken, + width=edge_width * 1.25, + edge_color="#D62728", + alpha=0.95, + arrows=False, + ) + if formed: + nx.draw_networkx_edges( + its, + pos, + ax=ax, + edgelist=formed, + width=edge_width * 1.25, + edge_color="#2CA02C", + alpha=0.95, + arrows=False, + ) + + # ── nodes ──────────────────────────────────────────────────────────── + nc = nx.draw_networkx_nodes( + its, + pos, + nodelist=nodelist, + ax=ax, + node_size=node_size, + node_color=node_colors, + edgecolors=node_borders, + linewidths=max(1.2, node_size**0.5 * 0.055), + ) + nc.set_zorder(3) + + if rc_list: + nc = nx.draw_networkx_nodes( + its, + pos, + nodelist=rc_list, + ax=ax, + node_size=int(node_size * 1.32), + node_color="none", + edgecolors=rc_ring_color, + linewidths=max(2.0, node_size**0.5 * 0.10), + alpha=0.9, + ) + nc.set_zorder(4) + + # ── node labels ────────────────────────────────────────────────────── + if show_node_labels: + for i, n in enumerate(nodelist): + el = its.nodes[n].get("element", "?") + am = its.nodes[n].get("atom_map", 0) + lbl = f"{el}:{am}" if (show_atom_map and am) else el + x, y = pos[n] + ax.text( + x, + y, + lbl, + ha="center", + va="center", + fontsize=font_size, + fontweight="bold", + color=label_colors[i], + zorder=9, + ) + + # ── edge labels ────────────────────────────────────────────────────── + if show_edge_labels: + for lbl_dict, color in ( + (lbl_b, "#D62728"), + (lbl_f, "#2CA02C"), + ): + if lbl_dict: + nx.draw_networkx_edge_labels( + its, + pos, + ax=ax, + edge_labels=lbl_dict, + font_size=font_size - 1, + font_color=color, + bbox=dict( + boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.85 + ), + ) + if show_unchanged_edge_labels and lbl_u: + nx.draw_networkx_edge_labels( + its, + pos, + ax=ax, + edge_labels=lbl_u, + font_size=font_size - 3, + font_color="#888888", + ) + + if show_legends: + edge_legend = [ + Line2D([0], [0], color="#D62728", lw=3, label="broken (br>bp)"), + Line2D([0], [0], color="#2CA02C", lw=3, label="formed (br Tuple[int, int]: + return (u, v) if u < v else (v, u) + + +def _edge_orders(G: nx.Graph) -> Dict[Tuple[int, int], float]: + out: Dict[Tuple[int, int], float] = {} + for u, v, d in G.edges(data=True): + out[_ekey(u, v)] = float(d.get("order", 1.0)) + return out + + +def dpo_decompose_atom_conserving( + L: nx.Graph, + R: nx.Graph, + *, + order_tol: float = 1e-9, +) -> DPODecomp: + """ + Atom-conserving DPO-like decomposition: + - K_nodes: nodes present on both sides (by node id) + - K_edges: edges present on both sides with same 'order' + - L_only_edges: edges deleted or order-changed (treated as delete) + - R_only_edges: edges created or order-changed (treated as add) + """ + K_nodes = set(L.nodes()) & set(R.nodes()) + L_orders = _edge_orders(L) + R_orders = _edge_orders(R) + + common = set(L_orders) & set(R_orders) + K_edges = {e for e in common if abs(L_orders[e] - R_orders[e]) <= order_tol} + + L_only = set(L_orders) - K_edges + R_only = set(R_orders) - K_edges + + return DPODecomp( + K_nodes=K_nodes, + K_edges=K_edges, + L_only_edges=L_only, + R_only_edges=R_only, + L_orders=L_orders, + R_orders=R_orders, + ) + + +def build_its_from_LR( + L: nx.Graph, R: nx.Graph, *, dec: Optional[DPODecomp] = None +) -> nx.Graph: + """Build ITS with edge attribute order=(br,bp) from L and R.""" + if dec is None: + dec = dpo_decompose_atom_conserving(L, R) + + its = nx.Graph() + + for n in set(L.nodes()) | set(R.nodes()): + if n in L.nodes: + its.add_node(n, **L.nodes[n]) + else: + its.add_node(n, **R.nodes[n]) + + all_edges = set(dec.L_orders) | set(dec.R_orders) + for u, v in all_edges: + br = dec.L_orders.get((u, v), 0.0) + bp = dec.R_orders.get((u, v), 0.0) + its.add_edge(u, v, order=(br, bp)) + + return its + + +def _layout_pos(G: nx.Graph, layout: str, seed: int = 0) -> Dict[Any, Any]: + if layout == "spring": + return nx.spring_layout(G, seed=seed, k=0.9) + if layout == "circular": + return nx.circular_layout(G) + if layout == "kamada_kawai": + return nx.kamada_kawai_layout(G) + raise ValueError("layout must be one of: 'spring', 'kamada_kawai', 'circular'") + + +def _graph_to_layout_pos(G: nx.Graph) -> Optional[Dict[Any, Tuple[float, float]]]: + """Use graph_to_mol + RDKit 2D coordinates, mapped back to graph node ids.""" + nodelist = list(G.nodes()) + ordered = nx.Graph() + for node in nodelist: + ordered.add_node(node, **G.nodes[node]) + for u, v, data in G.edges(data=True): + ordered.add_edge(u, v, **data) + + try: + mol = graph_to_mol(ordered, sanitize=True) + except Exception: + try: + mol = graph_to_mol(ordered, sanitize=False) + except Exception: + return None + + if mol.GetNumConformers() == 0: + AllChem.Compute2DCoords(mol) + conf = mol.GetConformer(0) + return { + node: ( + conf.GetAtomPosition(atom_idx).x, + conf.GetAtomPosition(atom_idx).y, + ) + for atom_idx, node in enumerate(nodelist) + } + + +def _set_shared_limits(axes, pos: Dict[Any, Tuple[float, float]]) -> None: + if not pos: + return + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + x_span = max(xs) - min(xs) + y_span = max(ys) - min(ys) + pad = max(x_span * 0.12, y_span * 0.12, 0.45) + for ax in axes: + ax.set_xlim(min(xs) - pad, max(xs) + pad) + ax.set_ylim(min(ys) - pad, max(ys) + pad) + ax.set_aspect("equal") + + +def _edge_order_label(order: float) -> str: + return str(int(order)) if float(order).is_integer() else f"{order:g}" + + +# ============================================================ +# DPO visualizer +# ============================================================ +def visualize_dpo_rule( # noqa: C901 + L: nx.Graph, + R: nx.Graph, + *, + mol: Optional[Chem.Mol] = None, + use_its: bool = False, + ax=None, + title: str | None = None, + layout: str = "kamada_kawai", + pos: dict | None = None, + seed: int = 0, + node_size: int = 900, + font_size: int = 10, + edge_width: float = 2.8, + show_edge_labels: bool = True, + show_node_labels: bool = True, + show_atom_map: bool = False, + show_legends: bool = True, + show_unchanged_edge_labels: bool = False, + order_tol: float = 1e-9, +): + """ + Visualize a DPO rule as L | K (or ITS) | R with a shared layout. + + The default layout is chemistry-aware: the union graph is converted with + ``graph_to_mol`` and laid out by RDKit. The ``mol`` argument is kept for + older notebooks but is no longer required. + + - use_its=False: middle panel is K (context); changed edges are dashed. + - use_its=True: middle panel shows the full ITS with (br,bp) edge labels. + """ + created_fig = False + if ax is None: + fig, axes = plt.subplots(1, 3, figsize=(13, 4), facecolor="white") + fig.subplots_adjust(wspace=0.05) + created_fig = True + else: + axes = ax + + dec = dpo_decompose_atom_conserving(L, R, order_tol=order_tol) + + # build K (context) graph: preserved nodes+edges only + K = nx.Graph() + for n in dec.K_nodes: + K.add_node(n, **(L.nodes[n] if n in L.nodes else R.nodes[n])) + for u, v in dec.K_edges: + if L.has_edge(u, v): + K.add_edge(u, v, **L.get_edge_data(u, v)) + else: + K.add_edge(u, v, **R.get_edge_data(u, v)) + + its = build_its_from_LR(L, R, dec=dec) if use_its else None + + # ── shared layout ───────────────────────────────────────────────────── + if pos is None: + union = nx.compose(L, R) + if mol is not None: + pos = _pos_from_mol(mol, union) + if pos is None: + pos = _graph_to_layout_pos(union) + if pos is None: + pos = _layout_pos(union, layout=layout, seed=seed) + + rc_nodes = set() + for e in dec.L_only_edges | dec.R_only_edges: + rc_nodes.update(e) + + def _draw_panel( + G: nx.Graph, + axp, + *, + panel_title: str, + panel_subtitle: str = "", + dashed_edges: Optional[set] = None, + dashed_color: str = "#D62728", + dashed_label: str = "", + ): + axp.set_facecolor("white") + axp.set_axis_off() + axp.set_title( + panel_title, + fontsize=font_size + 1, + fontweight="bold", + pad=6, + color="#1a1a1a", + ) + if panel_subtitle: + axp.text( + 0.5, + 0.98, + panel_subtitle, + transform=axp.transAxes, + ha="center", + va="top", + fontsize=max(7, font_size - 2), + color="#555555", + ) + + nodes = list(G.nodes()) + node_colors = [_fill(G.nodes[n].get("element", "?")) for n in nodes] + node_borders = [_border(G.nodes[n].get("element", "?")) for n in nodes] + label_colors = [ + ( + "white" + if _luminance(_fill(G.nodes[n].get("element", "?"))) < 0.50 + else "#1a1a1a" + ) + for n in nodes + ] + + _dashed = dashed_edges or set() + solid, dashed = [], [] + for u, v in G.edges(): + if _ekey(u, v) in _dashed: + dashed.append((u, v)) + else: + solid.append((u, v)) + + if solid: + nx.draw_networkx_edges( + G, + pos, + ax=axp, + edgelist=solid, + width=max(1.1, edge_width * 0.65), + edge_color="#2a2a2a", + alpha=0.65, + arrows=False, + ) + if dashed: + nx.draw_networkx_edges( + G, + pos, + ax=axp, + edgelist=dashed, + width=edge_width * 3.2, + edge_color=dashed_color, + alpha=0.18, + arrows=False, + ) + nx.draw_networkx_edges( + G, + pos, + ax=axp, + edgelist=dashed, + width=edge_width * 1.35, + edge_color=dashed_color, + style="dashed", + alpha=0.95, + arrows=False, + ) + + if rc_nodes: + rc_in_panel = [n for n in nodes if n in rc_nodes] + if rc_in_panel: + nc = nx.draw_networkx_nodes( + G, + pos, + ax=axp, + nodelist=rc_in_panel, + node_size=int(node_size * 1.9), + node_color="#FFD700", + edgecolors="none", + linewidths=0, + alpha=0.16, + ) + nc.set_zorder(1) + + lw = [ + ( + max(2.0, node_size**0.5 * 0.10) + if n in rc_nodes + else max(1.2, node_size**0.5 * 0.055) + ) + for n in nodes + ] + border_colors = [ + "#FFD700" if n in rc_nodes else node_borders[i] for i, n in enumerate(nodes) + ] + + nc = nx.draw_networkx_nodes( + G, + pos, + ax=axp, + nodelist=nodes, + node_size=node_size, + node_color=node_colors, + edgecolors=border_colors, + linewidths=lw, + ) + nc.set_zorder(3) + + if show_node_labels: + for i, n in enumerate(nodes): + el = G.nodes[n].get("element", "?") + am = G.nodes[n].get("atom_map", n) + lbl = f"{el}:{am}" if show_atom_map else el + x, y = pos[n] + axp.text( + x, + y, + lbl, + ha="center", + va="center", + fontsize=font_size, + fontweight="bold", + color=label_colors[i], + zorder=9, + path_effects=[pe.withStroke(linewidth=1.4, foreground="none")], + ) + + if show_edge_labels: + elabs = {} + for u, v, d in G.edges(data=True): + o = float(d.get("order", 1.0)) + elabs[(u, v)] = _edge_order_label(o) + if elabs: + nx.draw_networkx_edge_labels( + G, + pos, + ax=axp, + edge_labels=elabs, + font_size=font_size - 1, + bbox=dict( + boxstyle="round,pad=0.1", fc="white", ec="none", alpha=0.85 + ), + ) + + if dashed_label and dashed: + axp.text( + 0.5, + 0.04, + dashed_label, + transform=axp.transAxes, + ha="center", + va="bottom", + fontsize=max(7, font_size - 2), + color=dashed_color, + fontweight="bold", + bbox=dict( + boxstyle="round,pad=0.25", + fc="white", + ec=dashed_color, + alpha=0.90, + linewidth=1.0, + ), + ) + + if title and created_fig: + fig.suptitle(title, fontsize=font_size + 2, fontweight="bold", color="#1a1a1a") + + _draw_panel( + L, + axes[0], + panel_title="L reactant pattern", + panel_subtitle=f"{L.number_of_nodes()} atoms · {L.number_of_edges()} bonds", + dashed_edges=dec.L_only_edges, + dashed_color="#D62728", + dashed_label=( + f"delete {len(dec.L_only_edges)} bond(s)" if dec.L_only_edges else "" + ), + ) + + if use_its: + _visualize_its( + its, + ax=axes[1], + title="ITS bond-change view", + pos=pos, + layout=layout, + node_size=node_size, + font_size=font_size, + edge_width=edge_width, + show_edge_labels=show_edge_labels, + show_unchanged_edge_labels=show_unchanged_edge_labels, + show_node_labels=show_node_labels, + show_atom_map=show_atom_map, + show_legend=False, + ) + else: + _draw_panel( + K, + axes[1], + panel_title="K preserved context", + panel_subtitle=f"{K.number_of_nodes()} atoms · {K.number_of_edges()} preserved bonds", + ) + + _draw_panel( + R, + axes[2], + panel_title="R product pattern", + panel_subtitle=f"{R.number_of_nodes()} atoms · {R.number_of_edges()} bonds", + dashed_edges=dec.R_only_edges, + dashed_color="#2CA02C", + dashed_label=f"add {len(dec.R_only_edges)} bond(s)" if dec.R_only_edges else "", + ) + + if created_fig: + axes[0].annotate( + "", + xy=(1.03, 0.50), + xytext=(0.97, 0.50), + xycoords="axes fraction", + arrowprops=dict(arrowstyle="-|>", color="#6b7280", lw=1.4), + annotation_clip=False, + ) + axes[1].annotate( + "", + xy=(1.03, 0.50), + xytext=(0.97, 0.50), + xycoords="axes fraction", + arrowprops=dict(arrowstyle="-|>", color="#6b7280", lw=1.4), + annotation_clip=False, + ) + + _set_shared_limits(axes, pos) + + # legends (optional) + if show_legends: + if use_its: + edge_handles = [ + Line2D([0], [0], color="#D62728", lw=3, label="broken (br>bp)"), + Line2D([0], [0], color="#2CA02C", lw=3, label="formed (br Chem.Mol: + """Ensure the molecule has 2D coords (in-place).""" + if m is None: + return m + if m.GetNumConformers() == 0: + # RDKit tends to do better with this than Compute2DCoords alone for some cases + AllChem.Compute2DCoords(m) + return m + + +def _bond_signature(m: Chem.Mol) -> Dict[Tuple[int, int], Tuple[int, int]]: + """ + Return mapping: (map_i, map_j) -> (bondTypeInt, isAromaticInt) + Only for bonds where both atoms have atom-map numbers. + """ + out: Dict[Tuple[int, int], Tuple[int, int]] = {} + for b in m.GetBonds(): + a1, a2 = b.GetBeginAtom(), b.GetEndAtom() + m1, m2 = a1.GetAtomMapNum(), a2.GetAtomMapNum() + if m1 <= 0 or m2 <= 0: + continue + key = (m1, m2) if m1 < m2 else (m2, m1) + # bond type as an int-ish bucket + aromatic flag + bt = int(b.GetBondTypeAsDouble() * 10) # e.g., single=10, double=20 + ar = 1 if b.GetIsAromatic() else 0 + out[key] = (bt, ar) + return out + + +def _mapnum_to_atomidx(m: Chem.Mol) -> Dict[int, int]: + out: Dict[int, int] = {} + for a in m.GetAtoms(): + mn = a.GetAtomMapNum() + if mn > 0: + out[mn] = a.GetIdx() + return out + + +def _find_changed_bonds_by_atommap( # noqa: C901 + rxn: rdChemReactions.ChemicalReaction, +) -> Optional[RxnHighlights]: + """ + Detect changed bonds (formed/broken/changed order) using atom-map numbers. + If the reaction has no atom maps, return None. + """ + reactants = [m for m in rxn.GetReactants()] + products = [m for m in rxn.GetProducts()] + + # If we have essentially no mapping info, bail + total_mapped = 0 + for m in reactants + products: + total_mapped += sum(1 for a in m.GetAtoms() if a.GetAtomMapNum() > 0) + if total_mapped == 0: + return None + + # Build global bond signatures for each side + r_sig: Dict[Tuple[int, int], Tuple[int, int]] = {} + p_sig: Dict[Tuple[int, int], Tuple[int, int]] = {} + for m in reactants: + r_sig.update(_bond_signature(m)) + for m in products: + p_sig.update(_bond_signature(m)) + + changed_pairs = set(r_sig.keys()) | set(p_sig.keys()) + changed_pairs = {k for k in changed_pairs if r_sig.get(k) != p_sig.get(k)} + + # Precompute mapnum->atomidx per molecule, and mapnum->(mol_i, atom_i) + r_mn_loc: Dict[int, Tuple[int, int]] = {} + p_mn_loc: Dict[int, Tuple[int, int]] = {} + r_mn2aidx: List[Dict[int, int]] = [] + p_mn2aidx: List[Dict[int, int]] = [] + + for i, m in enumerate(reactants): + d = _mapnum_to_atomidx(m) + r_mn2aidx.append(d) + for mn, aidx in d.items(): + r_mn_loc[mn] = (i, aidx) + + for i, m in enumerate(products): + d = _mapnum_to_atomidx(m) + p_mn2aidx.append(d) + for mn, aidx in d.items(): + p_mn_loc[mn] = (i, aidx) + + # Collect atom + bond highlights per molecule index + r_atoms: Dict[int, List[int]] = {i: [] for i in range(len(reactants))} + r_bonds: Dict[int, List[int]] = {i: [] for i in range(len(reactants))} + p_atoms: Dict[int, List[int]] = {i: [] for i in range(len(products))} + p_bonds: Dict[int, List[int]] = {i: [] for i in range(len(products))} + + def _add_atom(side_atoms: Dict[int, List[int]], mol_i: int, atom_i: int) -> None: + if atom_i not in side_atoms[mol_i]: + side_atoms[mol_i].append(atom_i) + + def _add_bond( + side_bonds: Dict[int, List[int]], m: Chem.Mol, mol_i: int, a: int, b: int + ) -> None: + bond = m.GetBondBetweenAtoms(a, b) + if bond is None: + return + bi = bond.GetIdx() + if bi not in side_bonds[mol_i]: + side_bonds[mol_i].append(bi) + + # For each changed mapped pair, mark the corresponding bond (if present) + endpoint atoms + for mn1, mn2 in changed_pairs: + # reactants + if mn1 in r_mn_loc and mn2 in r_mn_loc: + mi1, ai1 = r_mn_loc[mn1] + mi2, ai2 = r_mn_loc[mn2] + if mi1 == mi2: + m = reactants[mi1] + _add_atom(r_atoms, mi1, ai1) + _add_atom(r_atoms, mi1, ai2) + _add_bond(r_bonds, m, mi1, ai1, ai2) + + # products + if mn1 in p_mn_loc and mn2 in p_mn_loc: + mi1, ai1 = p_mn_loc[mn1] + mi2, ai2 = p_mn_loc[mn2] + if mi1 == mi2: + m = products[mi1] + _add_atom(p_atoms, mi1, ai1) + _add_atom(p_atoms, mi1, ai2) + _add_bond(p_bonds, m, mi1, ai1, ai2) + + return RxnHighlights( + r_atoms=r_atoms, r_bonds=r_bonds, p_atoms=p_atoms, p_bonds=p_bonds + ) + + +def _reaction_molecules(rxn: rdChemReactions.ChemicalReaction) -> List[Chem.Mol]: + return list(rxn.GetReactants()) + list(rxn.GetAgents()) + list(rxn.GetProducts()) + + +def _auto_canvas_size( + rxn: rdChemReactions.ChemicalReaction, + size: Optional[Tuple[int, int]], + legend: Optional[str], +) -> Tuple[int, int]: + """Choose a compact canvas so small reactions do not become tiny.""" + if size is not None: + return size + + mols = _reaction_molecules(rxn) + n_components = max(1, len(mols)) + n_atoms = sum(m.GetNumAtoms() for m in mols if m is not None) + width = int(max(520, min(1500, 180 + 150 * n_components + 34 * n_atoms))) + height = 290 if legend else 250 + return width, height + + +def _fallback_sub_img_size( + canvas_size: Tuple[int, int], rxn: rdChemReactions.ChemicalReaction +) -> Tuple[int, int]: + n_panels = max(1, len(_reaction_molecules(rxn)) + 1) + sub_width = int(max(220, min(340, canvas_size[0] / n_panels))) + return sub_width, canvas_size[1] + + +def _add_svg_title(svg_text: str, title: str, canvas_size: Tuple[int, int]) -> str: + """Inject a centered SVG title after RDKit drawing finishes.""" + safe_title = html.escape(title) + title_svg = ( + f'{safe_title}' + ) + return svg_text.replace("", f"{title_svg}") + + +def _add_pil_title(image: Any, title: str, canvas_size: Tuple[int, int]) -> Any: + """Overlay a centered title on a PIL reaction image.""" + from PIL import ImageDraw, ImageFont + + image = image.convert("RGBA") + draw = ImageDraw.Draw(image) + try: + font = ImageFont.truetype( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 20 + ) + except Exception: + font = ImageFont.load_default() + bbox = draw.textbbox((0, 0), title, font=font) + text_width = bbox[2] - bbox[0] + x = max(8, (canvas_size[0] - text_width) / 2) + draw.text((x, 8), title, fill="#1a1a1a", font=font) + return image + + +def visualize_reaction( # noqa: C901 + rsmi: str, + *, + size: Optional[Tuple[int, int]] = None, + sub_img_size: Tuple[int, int] = ( + 450, + 300, + ), # kept for compatibility; used in fallback + svg: bool = True, + kekulize: bool = False, + show_atom_maps: bool = False, + highlight_changes: bool = True, + legend: Optional[str] = None, + fixed_bond_length: Optional[float] = None, + padding: float = 0.06, +) -> Union[str, Any]: # Any covers PIL.Image.Image when Cairo is available + """ + More visual RDKit reaction rendering. + + Improvements vs Draw.ReactionToImage: + - Uses rdMolDraw2D for cleaner SVG/Cairo output and better control. + - Optional highlighting of changed bonds using atom-map numbers. + - Optional atom-map labels overlay (useful for debugging / talktorials). + - Title/legend support. + + Notes + ----- + - `highlight_changes=True` works best when rsmi contains atom-maps like [C:1]. + - For PNG/PIL output, your RDKit must be built with Cairo support. + + Parameters + ---------- + rsmi : str + Reaction SMILES / SMARTS (e.g. '[CH3:1][Br:2]>>[CH3:1][OH:2]'). + size : (w, h), optional + Canvas size in pixels. If omitted, a compact size is inferred from + the number of reaction components and atoms. + svg : bool + If True return SVG string; else return PIL image (Cairo). + kekulize : bool + If True kekulize molecules before drawing (sometimes nicer for aromatic). + show_atom_maps : bool + If True, draw atom-map numbers as labels. + highlight_changes : bool + If True, detect and highlight changed bonds (requires atom maps). + legend : str | None + Optional title at the top. + fixed_bond_length : float, optional + Affects perceived scale / whitespace. If omitted, a readable default + is chosen for the inferred canvas. + padding : float + Relative padding around the drawing. + + Returns + ------- + str (SVG) or PIL.Image.Image + """ + rxn = rdChemReactions.ReactionFromSmarts(rsmi, useSmiles=True) + if rxn is None: + raise ValueError("Invalid reaction SMILES/SMARTS") + + rxn.Initialize() + canvas_size = _auto_canvas_size(rxn, size, legend) + bond_length = fixed_bond_length if fixed_bond_length is not None else 34.0 + + # Ensure 2D coords + for m in list(rxn.GetReactants()) + list(rxn.GetProducts()) + list(rxn.GetAgents()): + if m is not None: + _ensure_2d(m) + + # Optional kekulization (copy to be safe) + if kekulize: + + def _kek(m: Chem.Mol) -> Chem.Mol: + m2 = Chem.Mol(m) + try: + Chem.Kekulize(m2, clearAromaticFlags=True) + except Exception: + pass + return m2 + + rxn2 = rdChemReactions.ChemicalReaction() + for m in rxn.GetReactants(): + rxn2.AddReactantTemplate(_kek(m)) + for m in rxn.GetAgents(): + rxn2.AddAgentTemplate(_kek(m)) + for m in rxn.GetProducts(): + rxn2.AddProductTemplate(_kek(m)) + rxn2.Initialize() + rxn = rxn2 + + # Highlights + hl = _find_changed_bonds_by_atommap(rxn) if highlight_changes else None + # rdMolDraw2D expects a single highlight dict; for reactions, DrawReaction accepts + # per-mol highlights in newer RDKit builds. We'll attempt that; otherwise fallback. + # (Fallback still gives improved aesthetics via Draw.ReactionToImage.) + try: + if svg: + drawer = rdMolDraw2D.MolDraw2DSVG(canvas_size[0], canvas_size[1]) + else: + drawer = rdMolDraw2D.MolDraw2DCairo(canvas_size[0], canvas_size[1]) + + opts = drawer.drawOptions() + opts.fixedBondLength = bond_length + opts.padding = padding + opts.continuousHighlight = True + opts.highlightBondWidthMultiplier = 18 + opts.useBWAtomPalette() # crisp, publication-ish defaults + + if show_atom_maps: + # draw atom-map numbers + opts.atomLabels = {} # type: ignore[attr-defined] + for m in ( + list(rxn.GetReactants()) + + list(rxn.GetAgents()) + + list(rxn.GetProducts()) + ): + for a in m.GetAtoms(): + mn = a.GetAtomMapNum() + if mn > 0: + opts.atomLabels[(m, a.GetIdx())] = str( + mn + ) # may be ignored in some builds + + # Per-molecule highlights (reactants/products only; agents usually ignored) + if hl is not None: + # Build per-template highlight specs + # Newer RDKit supports passing these directly to DrawReaction. + drawer.DrawReaction( + rxn, + highlightByReactant=False, + highlightReactantAtoms=hl.r_atoms, + highlightReactantBonds=hl.r_bonds, + highlightProductAtoms=hl.p_atoms, + highlightProductBonds=hl.p_bonds, + ) + else: + drawer.DrawReaction(rxn, highlightByReactant=False) + + drawer.FinishDrawing() + if svg: + svg_text = drawer.GetDrawingText() + return _add_svg_title(svg_text, legend, canvas_size) if legend else svg_text + else: + # Cairo returns PNG bytes + from PIL import Image + import io + + png = drawer.GetDrawingText() + image = Image.open(io.BytesIO(png)) + return _add_pil_title(image, legend, canvas_size) if legend else image + + except Exception: + # Safe fallback (still decent) if DrawReaction signature differs in your RDKit build + from rdkit.Chem import Draw as _Draw + + fallback = _Draw.ReactionToImage( + rxn, + subImgSize=( + _fallback_sub_img_size(canvas_size, rxn) + if size is None + else sub_img_size + ), + useSVG=svg, + ) + if legend and svg: + return _add_svg_title(fallback, legend, canvas_size) + if legend: + return _add_pil_title(fallback, legend, canvas_size) + return fallback diff --git a/synkit/Vis/vis_synedu/vis.py b/synkit/Vis/vis_synedu/vis.py new file mode 100644 index 0000000..07dfdde --- /dev/null +++ b/synkit/Vis/vis_synedu/vis.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple +import math + +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +import matplotlib.patheffects as pe +import networkx as nx +from rdkit import Chem +from rdkit.Chem import AllChem, Draw + +from .conversion import graph_to_mol + +# ── CPK-inspired palette (fill, border) ─────────────────────────────────── +_ELEMENT_PALETTE: Dict[str, Tuple[str, str]] = { + "C": ("#636363", "#3d3d3d"), + "O": ("#E8524A", "#b83830"), + "N": ("#5B8DD9", "#3a65b0"), + "S": ("#E8A838", "#c07a10"), + "Cl": ("#3DBE6C", "#1e8a46"), + "F": ("#5BC8AF", "#2a9178"), + "Br": ("#A0522D", "#6b3118"), + "I": ("#8C54C8", "#5e2fa0"), + "P": ("#E878C8", "#b84898"), + "H": ("#C8C8C8", "#909090"), + "Na": ("#AB5CF2", "#7b34c8"), + "Mg": ("#8AFF00", "#58b000"), + "Si": ("#F0C8A0", "#b88860"), +} +_DEFAULT_FILL = "#A0A0A0" +_DEFAULT_BORDER = "#606060" + + +def _fill(el: str) -> str: + return _ELEMENT_PALETTE.get(el, (_DEFAULT_FILL, _DEFAULT_BORDER))[0] + + +def _border(el: str) -> str: + return _ELEMENT_PALETTE.get(el, (_DEFAULT_FILL, _DEFAULT_BORDER))[1] + + +def _luminance(hex_color: str) -> float: + h = hex_color.lstrip("#") + r, g, b = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) # noqa + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _ensure_2d(mol: Chem.Mol) -> None: + if mol.GetNumConformers() == 0: + AllChem.Compute2DCoords(mol) + + +def _avg_edge_length(pos: Dict, G: nx.Graph) -> float: + if G.number_of_edges() == 0: + return 1.0 + lengths = [ + math.hypot(pos[int(v)][0] - pos[int(u)][0], pos[int(v)][1] - pos[int(u)][1]) + for u, v in G.edges() + ] + return sum(lengths) / len(lengths) + + +def _perp_offset(p1, p2, offset): + dx, dy = p2[0] - p1[0], p2[1] - p1[1] + L = math.hypot(dx, dy) + if L == 0: + return 0.0, 0.0 + return -dy / L * offset, dx / L * offset + + +def _index_offset_vec(n, G, pos, *, base): + x, y = pos[n] + nbrs = [int(m) for m in G.neighbors(n)] + if not nbrs: + return 0.0, base + cx = sum(pos[m][0] for m in nbrs) / len(nbrs) + cy = sum(pos[m][1] for m in nbrs) / len(nbrs) + dx, dy = x - cx, y - cy + L = math.hypot(dx, dy) + if L == 0: + return 0.0, base + return dx / L * base, dy / L * base + + +def _draw_bond_lines( + ax, p1, p2, *, order, aromatic, aromatic_style, offset, lw, color="k" +): + kw = dict( + color=color, linewidth=lw, solid_capstyle="round", solid_joinstyle="round" + ) + if aromatic and aromatic_style == "dashed": + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], linestyle="--", **kw) + return + if aromatic: + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **kw) + return + if order <= 1: + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **kw) + return + dx, dy = _perp_offset(p1, p2, offset) + if order == 2: + ax.plot([p1[0] + dx, p2[0] + dx], [p1[1] + dy, p2[1] + dy], **kw) + ax.plot([p1[0] - dx, p2[0] - dx], [p1[1] - dy, p2[1] - dy], **kw) + elif order == 3: + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **{**kw, "linewidth": lw * 0.9}) + ax.plot( + [p1[0] + dx, p2[0] + dx], + [p1[1] + dy, p2[1] + dy], + **{**kw, "linewidth": lw * 0.9}, + ) + ax.plot( + [p1[0] - dx, p2[0] - dx], + [p1[1] - dy, p2[1] - dy], + **{**kw, "linewidth": lw * 0.9}, + ) + else: + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], **kw) + + +def _draw_aromatic_circles(ax, G, pos, scale): + for cyc in nx.cycle_basis(G): + if len(cyc) < 5: + continue + if not all(bool(G.nodes[int(n)].get("aromatic", False)) for n in cyc): + continue + ok = all( + bool( + G.edges[int(cyc[i]), int(cyc[(i + 1) % len(cyc)])].get( + "aromatic", False + ) + ) + for i in range(len(cyc)) + ) + if not ok: + continue + xs = [pos[int(n)][0] for n in cyc] + ys = [pos[int(n)][1] for n in cyc] + cx, cy = sum(xs) / len(xs), sum(ys) / len(ys) + rs = [math.hypot(x - cx, y - cy) for x, y in zip(xs, ys)] + r = (sum(rs) / len(rs)) * scale + ax.add_patch( + mpatches.Circle( + (cx, cy), r, fill=False, linewidth=1.2, color="#333333", zorder=1 + ) + ) + + +def _set_padded_limits(ax, pos: Dict[int, Tuple[float, float]], avg_len: float) -> None: + """Pad plot limits so node markers and index labels are not clipped.""" + if not pos: + return + + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + x_span = max(xs) - min(xs) + y_span = max(ys) - min(ys) + pad = max(avg_len * 0.45, x_span * 0.08, y_span * 0.08, 0.20) + + ax.set_xlim(min(xs) - pad, max(xs) + pad) + ax.set_ylim(min(ys) - pad, max(ys) + pad) + + +def _ordered_graph_for_layout( + G: nx.Graph, nodelist: List[int] +) -> Tuple[nx.Graph, Dict[int, int]]: + """Create an insertion-ordered graph and mapping node id -> RDKit atom idx.""" + ordered = nx.Graph() + for node in nodelist: + ordered.add_node(node, **G.nodes[node]) + for u, v, data in G.edges(data=True): + ordered.add_edge(int(u), int(v), **data) + return ordered, {node: idx for idx, node in enumerate(nodelist)} + + +def _graph_to_layout_mol(G: nx.Graph) -> Chem.Mol: + try: + return graph_to_mol(G, sanitize=True) + except Exception: + return graph_to_mol(G, sanitize=False) + + +def _layout_from_graph_mol( + G: nx.Graph, + nodelist: List[int], +) -> Dict[int, Tuple[float, float]]: + """ + Compute RDKit 2D coordinates for graph nodes. + + The molecule is reconstructed from the graph itself so callers do not need + to pass a parallel RDKit Mol object. Coordinates are mapped back to the + original graph node ids. + """ + try: + ordered, node_to_atom = _ordered_graph_for_layout(G, nodelist) + layout_mol = _graph_to_layout_mol(ordered) + _ensure_2d(layout_mol) + conf = layout_mol.GetConformer(0) + pos = {} + for node in nodelist: + p = conf.GetAtomPosition(node_to_atom[node]) + pos[node] = (p.x, p.y) + return pos + except Exception: + return { + int(k): (float(v[0]), float(v[1])) + for k, v in nx.kamada_kawai_layout(G).items() + } + + +def draw_molecular_graph( # noqa: C901 + G: nx.Graph, + *, + ax: Optional[plt.Axes] = None, + title: Optional[str] = None, + include_mol: bool = False, + label_mode: str = "hetero", # "all" | "hetero" | "none" + show_indices: bool = False, + indices_for_carbons: bool = True, + show_bond_labels: bool = False, + aromatic_style: str = "circle", # "circle" | "dashed" + # --- sizing (auto-scaled to graph; override if needed) --- + node_size: Optional[int] = None, + bond_lw: Optional[float] = None, + figsize: Tuple[float, float] = (6, 5), + # --- highlighting --- + highlight_nodes: Optional[Set[int]] = None, + highlight_edges: Optional[Set[Tuple[int, int]]] = None, + highlight_color: str = "#FF7F0E", + highlight_alpha: float = 0.85, + # --- custom node colors (overrides element palette) --- + custom_node_colors: Optional[Dict[int, str]] = None, + # --- typography --- + element_fontsize: Optional[int] = None, + index_fontsize: Optional[int] = None, + title_fontsize: int = 11, +) -> plt.Axes: + """ + Visualize a labeled molecular NetworkX graph with CPK-style node coloring, + element borders, proper bond styles, and optional MCS/WL highlighting. + """ + aromatic_style = aromatic_style.lower() + label_mode = label_mode.lower() + + hl_edges_norm: Set[Tuple[int, int]] = set() + if highlight_edges: + for u, v in highlight_edges: + hl_edges_norm.add((min(int(u), int(v)), max(int(u), int(v)))) + + # ── figure / axis setup ────────────────────────────────────────────── + created_fig = False + if include_mol: + fig, (ax_mol, ax_g) = plt.subplots( + 1, 2, figsize=(figsize[0] * 2, figsize[1]), facecolor="white" + ) + created_fig = True + elif ax is None: + fig, ax_g = plt.subplots(figsize=figsize, facecolor="white") + created_fig = True + else: + ax_g = ax + fig = ax_g.figure + + ax_g.set_facecolor("white") + + # ── stable node order ──────────────────────────────────────────────── + nodelist = sorted(int(n) for n in G.nodes()) + n_nodes = len(nodelist) + + # ── auto-scale sizes ───────────────────────────────────────────────── + _ns = ( + node_size + if node_size is not None + else max(180, min(500, 4000 // max(n_nodes, 1))) + ) + _lw = bond_lw if bond_lw is not None else max(1.2, min(2.2, 18 / max(n_nodes, 1))) + _efs = ( + element_fontsize + if element_fontsize is not None + else max(7, min(11, 90 // max(n_nodes, 1))) + ) + _ifs = index_fontsize if index_fontsize is not None else _efs + 1 + + # ── positions: graph -> RDKit 2D layout; fallback to NetworkX layout ── + pos = _layout_from_graph_mol(G, nodelist) + + avg_len = _avg_edge_length(pos, G) + bond_offset = avg_len * 0.09 + idx_offset = avg_len * 0.16 + + # ── node styling ───────────────────────────────────────────────────── + node_colors: List[str] = [] + node_borders: List[str] = [] + element_labels: Dict[int, str] = {} + label_colors: Dict[int, str] = {} + + for n in nodelist: + data = G.nodes[n] + el = str(data.get("element", "C")) + + if custom_node_colors and n in custom_node_colors: + fill = custom_node_colors[n] + bord = fill # same color, will look fine + else: + fill = _fill(el) + bord = _border(el) + + node_colors.append(fill) + node_borders.append(bord) + + if label_mode == "none": + txt = "" + elif label_mode == "hetero" and el == "C": + txt = "" + else: + txt = el + element_labels[n] = txt + label_colors[n] = "white" if _luminance(fill) < 0.50 else "#1a1a1a" + + # ── draw highlight glow (under nodes) ──────────────────────────────── + if highlight_nodes: + hl = [int(n) for n in highlight_nodes if int(n) in G] + if hl: + nc = nx.draw_networkx_nodes( + G, + pos, + nodelist=hl, + node_size=int(_ns * 2.2), + node_color=highlight_color, + edgecolors="none", + linewidths=0, + ax=ax_g, + alpha=0.25, + ) + nc.set_zorder(1) + + # ── draw edges ─────────────────────────────────────────────────────── + for u, v, data in G.edges(data=True): + u, v = int(u), int(v) + p1, p2 = pos[u], pos[v] + aromatic = bool(data.get("aromatic", False)) + try: + order = 1 if aromatic else int(round(abs(float(data.get("order", 1.0))))) + except Exception: + order = 1 + + # highlight glow under edge + if hl_edges_norm and (min(u, v), max(u, v)) in hl_edges_norm: + ax_g.plot( + [p1[0], p2[0]], + [p1[1], p2[1]], + color=highlight_color, + linewidth=_lw * 4.5, + alpha=0.3, + solid_capstyle="round", + zorder=1, + ) + ax_g.plot( + [p1[0], p2[0]], + [p1[1], p2[1]], + color=highlight_color, + linewidth=_lw * 2.2, + alpha=highlight_alpha, + solid_capstyle="round", + zorder=2, + ) + + _draw_bond_lines( + ax_g, + p1, + p2, + order=order, + aromatic=aromatic, + aromatic_style=aromatic_style, + offset=bond_offset, + lw=_lw, + color="#2a2a2a", + ) + + if show_bond_labels and not aromatic: + mx, my = (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2 + ax_g.text( + mx, + my, + str(order), + fontsize=7, + ha="center", + va="center", + color="#333333", + bbox=dict(boxstyle="round,pad=0.12", fc="white", ec="none", alpha=0.9), + zorder=8, + ) + + if aromatic_style == "circle": + _draw_aromatic_circles(ax_g, G, pos, scale=0.52) + + # ── draw nodes ─────────────────────────────────────────────────────── + nc = nx.draw_networkx_nodes( + G, + pos, + nodelist=nodelist, + node_size=_ns, + node_color=node_colors, + edgecolors=node_borders, + linewidths=max(1.0, _ns**0.5 * 0.065), + ax=ax_g, + ) + nc.set_zorder(3) + + # highlight ring (on top of node) + if highlight_nodes: + hl = [int(n) for n in highlight_nodes if int(n) in G] + if hl: + nc = nx.draw_networkx_nodes( + G, + pos, + nodelist=hl, + node_size=int(_ns * 1.35), + node_color="none", + edgecolors=highlight_color, + linewidths=max(2.0, _ns**0.5 * 0.12), + ax=ax_g, + alpha=highlight_alpha, + ) + nc.set_zorder(4) + + # ── element labels ─────────────────────────────────────────────────── + for n in nodelist: + txt = element_labels.get(n, "") + if not txt: + continue + x, y = pos[n] + ax_g.text( + x, + y, + txt, + ha="center", + va="center", + fontsize=_efs, + fontweight="bold", + color=label_colors[n], + zorder=9, + ) + + # ── index labels ───────────────────────────────────────────────────── + if show_indices: + for n in nodelist: + el = str(G.nodes[n].get("element", "C")) + if label_mode == "hetero" and el == "C" and not indices_for_carbons: + continue + x, y = pos[n] + dx, dy = _index_offset_vec(n, G, pos, base=idx_offset) + ax_g.text( + x + dx, + y + dy, + str(n), + fontsize=_ifs, + ha="center", + va="center", + color="#222222", + fontweight="bold", + path_effects=[pe.withStroke(linewidth=2.5, foreground="white")], + zorder=10, + ) + + # ── title ──────────────────────────────────────────────────────────── + if title: + ax_g.set_title( + title, fontsize=title_fontsize, fontweight="bold", pad=6, color="#1a1a1a" + ) + + _set_padded_limits(ax_g, pos, avg_len) + ax_g.set_axis_off() + ax_g.set_aspect("equal") + + # ── optional RDKit panel ────────────────────────────────────────────── + if include_mol: + ax_mol.set_axis_off() + try: + ordered, _ = _ordered_graph_for_layout(G, nodelist) + display_mol = _graph_to_layout_mol(ordered) + except Exception: + display_mol = None + if display_mol is not None: + _ensure_2d(display_mol) + try: + dopt = Draw.MolDrawOptions() + dopt.addAtomIndices = bool(show_indices) + img = Draw.MolToImage( + display_mol, size=(500, 500), kekulize=False, options=dopt + ) + except Exception: + img = Draw.MolToImage(display_mol, size=(500, 500), kekulize=False) + ax_mol.imshow(img) + if created_fig: + fig.tight_layout() + return fig, (ax_mol, ax_g) + + if created_fig: + fig.tight_layout() + return ax_g diff --git a/synkit/Vis/visual_drawer.py b/synkit/Vis/visual_drawer.py new file mode 100644 index 0000000..dcd8ad9 --- /dev/null +++ b/synkit/Vis/visual_drawer.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +"""Matplotlib drawing helpers for representation-aware SynKit visuals.""" + +from typing import Any, Mapping + +import matplotlib.pyplot as plt +import networkx as nx + +from synkit.Vis.visual_model import VisualGraph, to_visual_graph + +ELEMENT_COLORS = { + "H": "#ffffff", + "C": "#f8fafc", + "N": "#bfdbfe", + "O": "#fecaca", + "F": "#bbf7d0", + "Cl": "#bbf7d0", + "Br": "#fed7aa", + "I": "#ddd6fe", + "S": "#fde68a", + "P": "#fecdd3", + "B": "#e7e5e4", + "Si": "#e9d5ff", +} + + +def draw_graph( + graph: nx.Graph | VisualGraph, + *, + ax: plt.Axes | None = None, + mode: str = "compact", + title: str | None = None, + show_atom_map: bool = True, + layout: str = "spring", + pos: Mapping[Any, tuple[float, float]] | None = None, + seed: int = 7, + node_size: int = 980, + font_size: int = 9, + edge_label_font_size: int = 8, + show_edge_labels: bool = True, + show_node_badges: bool = True, +) -> tuple[plt.Figure, plt.Axes]: + """Draw a molecule, ITS, or MTG graph using the visual adapter. + + :param graph: Raw NetworkX graph or already adapted ``VisualGraph``. + :type graph: Union[nx.Graph, VisualGraph] + :param ax: Optional Matplotlib axes. + :type ax: Optional[plt.Axes] + :param mode: Adapter label mode, e.g. ``compact``, ``electron``, + ``sigma_pi``, or ``timeline``. + :type mode: str + :param title: Optional title. Defaults to the detected visual kind. + :type title: Optional[str] + :param show_atom_map: Include atom maps in labels when adapting raw graphs. + :type show_atom_map: bool + :param layout: Layout name: ``spring``, ``kamada_kawai``, ``circular``, or + ``shell``. + :type layout: str + :param pos: Optional fixed positions. + :type pos: Optional[Mapping[Any, Tuple[float, float]]] + :returns: ``(figure, axes)``. + :rtype: Tuple[plt.Figure, plt.Axes] + """ + + visual = ( + graph + if isinstance(graph, VisualGraph) + else to_visual_graph( + graph, + mode=mode, # type: ignore[arg-type] + show_atom_map=show_atom_map, + title=title or "", + ) + ) + nx_graph = _to_nx_graph(visual) + + if ax is None: + fig, ax = plt.subplots(figsize=_figure_size(nx_graph)) + else: + fig = ax.figure + + if pos is None: + pos = _layout(nx_graph, layout=layout, seed=seed) + + ax.clear() + ax.set_axis_off() + ax.set_aspect("equal") + ax.set_title(title or visual.title or visual.kind, fontsize=12, fontweight="bold") + + edges = list(nx_graph.edges(data=True)) + nodes = list(nx_graph.nodes(data=True)) + + if edges: + nx.draw_networkx_edges( + nx_graph, + pos, + ax=ax, + edge_color=[data["visual_color"] for _, _, data in edges], + width=[data["visual_width"] for _, _, data in edges], + alpha=0.88, + ) + + node_collection = nx.draw_networkx_nodes( + nx_graph, + pos, + ax=ax, + node_color=[data["fill"] for _, data in nodes], + edgecolors=[data["border"] for _, data in nodes], + linewidths=[2.4 if data["changed"] else 1.2 for _, data in nodes], + node_size=node_size, + ) + node_collection.set_zorder(3) + + labels = { + node: _node_label(data, show_node_badges=show_node_badges) + for node, data in nx_graph.nodes(data=True) + } + nx.draw_networkx_labels( + nx_graph, + pos, + labels=labels, + ax=ax, + font_size=font_size, + font_color="#111827", + ) + + if show_edge_labels: + edge_labels = { + (u, v): data["label"] + for u, v, data in nx_graph.edges(data=True) + if data.get("label") + } + if edge_labels: + nx.draw_networkx_edge_labels( + nx_graph, + pos, + edge_labels=edge_labels, + ax=ax, + font_size=edge_label_font_size, + font_color="#111827", + bbox={ + "boxstyle": "round,pad=0.18", + "fc": "white", + "ec": "#d1d5db", + "alpha": 0.92, + }, + ) + + _pad_limits(ax, pos) + return fig, ax + + +def _to_nx_graph(visual: VisualGraph) -> nx.Graph: + graph = nx.Graph() + for node in visual.nodes: + graph.add_node( + node.node_id, + label=node.label, + badges=node.badges, + changed=node.changed, + fill=ELEMENT_COLORS.get(node.element or "", "#f3f4f6"), + border="#dc2626" if node.changed else "#374151", + ) + for edge in visual.edges: + graph.add_edge( + edge.source, + edge.target, + label=edge.label, + state=edge.state, + visual_color=edge.color, + visual_width=edge.width, + ) + return graph + + +def _node_label(data: Mapping[str, Any], *, show_node_badges: bool) -> str: + label = str(data.get("label", "")) + badges = data.get("badges") or () + if show_node_badges and badges: + return f"{label}\n{' '.join(badges)}" + return label + + +def _layout(graph: nx.Graph, *, layout: str, seed: int) -> dict[Any, Any]: + if graph.number_of_nodes() == 0: + return {} + if layout == "spring": + return nx.spring_layout(graph, seed=seed, k=1.1) + if layout == "kamada_kawai": + return nx.kamada_kawai_layout(graph) + if layout == "circular": + return nx.circular_layout(graph) + if layout == "shell": + return nx.shell_layout(graph) + raise ValueError("layout must be one of: spring, kamada_kawai, circular, shell") + + +def _figure_size(graph: nx.Graph) -> tuple[float, float]: + n_nodes = max(1, graph.number_of_nodes()) + width = min(12.0, max(4.8, 1.25 * n_nodes)) + height = min(8.0, max(3.6, 0.85 * n_nodes)) + return width, height + + +def _pad_limits(ax: plt.Axes, pos: Mapping[Any, Any]) -> None: + if not pos: + return + xs = [point[0] for point in pos.values()] + ys = [point[1] for point in pos.values()] + x_span = max(xs) - min(xs) + y_span = max(ys) - min(ys) + pad = max(x_span, y_span, 1.0) * 0.18 + ax.set_xlim(min(xs) - pad, max(xs) + pad) + ax.set_ylim(min(ys) - pad, max(ys) + pad) diff --git a/synkit/Vis/visual_model.py b/synkit/Vis/visual_model.py new file mode 100644 index 0000000..f0d1ae4 --- /dev/null +++ b/synkit/Vis/visual_model.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +"""Representation-aware visual adapters for SynKit graphs. + +This module is intentionally lightweight: it detects the graph representation +and converts raw NetworkX attributes into stable labels/colors that drawing +backends can consume. The adapters never mutate the input graph. +""" + +from dataclasses import dataclass, field +from typing import Any, Dict, Hashable, Iterable, Literal, Mapping + +import networkx as nx + +VisualKind = Literal[ + "molecule", + "legacy_its", + "tuple_its", + "compact_mtg", + "mechanism_dag", + "unknown", +] + + +@dataclass(frozen=True) +class VisualNode: + """Drawing-ready node information.""" + + node_id: Hashable + label: str + element: str | None = None + atom_map: int | None = None + badges: tuple[str, ...] = () + changed: bool = False + raw: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class VisualEdge: + """Drawing-ready edge information.""" + + source: Hashable + target: Hashable + label: str = "" + state: str = "unchanged" + color: str = "#2f3437" + width: float = 2.0 + raw: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class VisualGraph: + """A compact, immutable view of graph content for renderers.""" + + kind: VisualKind + nodes: tuple[VisualNode, ...] + edges: tuple[VisualEdge, ...] + title: str = "" + metadata: Mapping[str, Any] = field(default_factory=dict) + + +BOND_SYMBOLS = { + None: "∅", + 0: "∅", + 0.0: "∅", + 1: "—", + 1.0: "—", + 1.5: ":", + 2: "=", + 2.0: "=", + 3: "≡", + 3.0: "≡", +} + +EDGE_COLORS = { + "unchanged": "#6b7280", + "formed": "#15803d", + "broken": "#b91c1c", + "order_changed": "#ca8a04", + "transient": "#7c3aed", + "unknown": "#2f3437", +} + +NODE_TIMELINE_ATTRS = ( + "aromatic", + "hcount", + "charge", + "radical", + "lone_pairs", + "present", +) +EDGE_TIMELINE_ATTRS = ("order", "kekule_order", "sigma_order", "pi_order") +ELECTRON_NODE_ATTRS = ("charge", "hcount", "lone_pairs", "radical") + + +def detect_visual_kind(graph: nx.Graph) -> VisualKind: + """Return the visualization representation kind for ``graph``. + + :param graph: NetworkX graph to inspect. + :type graph: nx.Graph + :returns: Detected graph kind. + :rtype: VisualKind + """ + + if not isinstance(graph, nx.Graph): + return "unknown" + + if _looks_like_mechanism_dag(graph): + return "mechanism_dag" + if _looks_like_compact_mtg(graph): + return "compact_mtg" + if _looks_like_tuple_its(graph): + return "tuple_its" + if _looks_like_legacy_its(graph): + return "legacy_its" + if _looks_like_molecule(graph): + return "molecule" + return "unknown" + + +def to_visual_graph( + graph: nx.Graph, + *, + kind: VisualKind | None = None, + mode: Literal["compact", "electron", "sigma_pi", "timeline"] = "compact", + show_atom_map: bool = True, + title: str = "", +) -> VisualGraph: + """Adapt a SynKit graph to drawing-ready labels. + + :param graph: NetworkX graph to adapt. + :type graph: nx.Graph + :param kind: Optional explicit representation kind. + :type kind: Optional[VisualKind] + :param mode: Label density. ``sigma_pi`` and ``timeline`` are mostly useful + for tuple ITS and compact MTG. + :type mode: str + :param show_atom_map: Include atom-map numbers in node labels when present. + :type show_atom_map: bool + :param title: Optional title carried to renderer metadata. + :type title: str + :returns: Immutable visual graph model. + :rtype: VisualGraph + """ + + detected = kind or detect_visual_kind(graph) + nodes = tuple( + _adapt_node(node_id, attrs, detected, mode=mode, show_atom_map=show_atom_map) + for node_id, attrs in graph.nodes(data=True) + ) + edges = tuple( + _adapt_edge(u, v, attrs, detected, mode=mode) + for u, v, attrs in graph.edges(data=True) + ) + return VisualGraph( + kind=detected, + nodes=nodes, + edges=edges, + title=title, + metadata={ + "mode": mode, + "node_count": graph.number_of_nodes(), + "edge_count": graph.number_of_edges(), + }, + ) + + +def summarize_visual_graph(visual: VisualGraph) -> Dict[str, Any]: + """Return a notebook-friendly summary of a visual graph.""" + + return { + "kind": visual.kind, + "title": visual.title, + "metadata": dict(visual.metadata), + "nodes": [ + { + "id": node.node_id, + "label": node.label, + "badges": list(node.badges), + "changed": node.changed, + } + for node in visual.nodes + ], + "edges": [ + { + "source": edge.source, + "target": edge.target, + "label": edge.label, + "state": edge.state, + "color": edge.color, + } + for edge in visual.edges + ], + } + + +def _looks_like_molecule(graph: nx.Graph) -> bool: + return bool(graph.nodes) and all( + "element" in attrs and not _is_pair(attrs.get("element")) + for _, attrs in graph.nodes(data=True) + ) + + +def _looks_like_legacy_its(graph: nx.Graph) -> bool: + if not graph.edges: + return False + has_pair_order = any( + _is_pair(attrs.get("order")) for _, _, attrs in graph.edges(data=True) + ) + has_tuple_electron_edges = any( + key in attrs + for _, _, attrs in graph.edges(data=True) + for key in ("sigma_order", "pi_order", "kekule_order") + ) + return has_pair_order and not has_tuple_electron_edges + + +def _looks_like_tuple_its(graph: nx.Graph) -> bool: + if not graph.nodes and not graph.edges: + return False + node_pair = any( + _is_pair(attrs.get(key)) + for _, attrs in graph.nodes(data=True) + for key in ("element", "hcount", "charge", "lone_pairs", "radical", "present") + ) + edge_pair = any( + _is_pair(attrs.get(key)) + for _, _, attrs in graph.edges(data=True) + for key in ("sigma_order", "pi_order", "kekule_order") + ) + return node_pair or edge_pair + + +def _looks_like_compact_mtg(graph: nx.Graph) -> bool: + graph_steps = graph.graph.get("steps") + if isinstance(graph_steps, int) and graph_steps >= 2: + return True + has_steps_attr = any( + "steps" in attrs for _, attrs in graph.nodes(data=True) + ) or any("steps" in attrs for _, _, attrs in graph.edges(data=True)) + if not has_steps_attr: + return False + has_timeline = any( + _is_timeline(attrs.get(key)) + for _, attrs in graph.nodes(data=True) + for key in NODE_TIMELINE_ATTRS + ) or any( + _is_timeline(attrs.get(key)) + for _, _, attrs in graph.edges(data=True) + for key in EDGE_TIMELINE_ATTRS + ) + return has_timeline + + +def _looks_like_mechanism_dag(graph: nx.Graph) -> bool: + if not graph.is_directed(): + return False + graph_kind = str(graph.graph.get("kind", "")).lower() + if graph_kind in {"mechanism_dag", "mechanism", "reaction_dag"}: + return True + return any( + attrs.get("kind") in {"reaction", "step", "species"} + for _, attrs in graph.nodes(data=True) + ) + + +def _adapt_node( + node_id: Hashable, + attrs: Mapping[str, Any], + kind: VisualKind, + *, + mode: str, + show_atom_map: bool, +) -> VisualNode: + element = _first_present(attrs.get("element")) + atom_map = _first_present(attrs.get("atom_map")) + label = str(element or node_id) + if show_atom_map and atom_map not in (None, 0): + label = f"{label}:{atom_map}" + elif show_atom_map and atom_map == 0: + label = f"{label}:{node_id}" + + badges = _node_badges(attrs, kind, mode) + changed = bool(badges) or any( + _is_changed_pair(attrs.get(key)) for key in ELECTRON_NODE_ATTRS + ) + return VisualNode( + node_id=node_id, + label=label, + element=str(element) if element is not None else None, + atom_map=int(atom_map) if isinstance(atom_map, int) else None, + badges=tuple(badges), + changed=changed, + raw=dict(attrs), + ) + + +def _adapt_edge( + u: Hashable, + v: Hashable, + attrs: Mapping[str, Any], + kind: VisualKind, + *, + mode: str, +) -> VisualEdge: + if kind == "compact_mtg": + label = _mtg_edge_label(attrs, mode) + state = _timeline_edge_state(attrs) + elif kind == "tuple_its": + label = _tuple_its_edge_label(attrs, mode) + state = _pair_edge_state(_preferred_edge_pair(attrs)) + elif kind == "legacy_its": + pair = attrs.get("order", (None, None)) + label = _pair_label(pair) + state = _pair_edge_state(pair) + else: + order = attrs.get("order") + label = _bond_symbol(order) + state = "unchanged" + + return VisualEdge( + source=u, + target=v, + label=label, + state=state, + color=EDGE_COLORS.get(state, EDGE_COLORS["unknown"]), + width=( + 3.0 if state in {"formed", "broken", "order_changed", "transient"} else 2.0 + ), + raw=dict(attrs), + ) + + +def _node_badges(attrs: Mapping[str, Any], kind: VisualKind, mode: str) -> list[str]: + badges: list[str] = [] + if kind in {"tuple_its", "compact_mtg"}: + for key in ELECTRON_NODE_ATTRS: + value = attrs.get(key) + if kind == "compact_mtg" and _is_timeline(value): + if _timeline_changes(value) or mode in {"electron", "timeline"}: + badges.append(f"{_short_node_key(key)}:{_format_timeline(value)}") + elif _is_pair(value): + if value[0] != value[1] or mode in {"electron", "timeline"}: + badges.append(f"{_short_node_key(key)}:{_format_pair(value)}") + elif mode in {"electron", "timeline"} and value not in (None, 0, False): + badges.append(f"{_short_node_key(key)}:{value}") + elif kind == "molecule": + for key in ("charge", "radical", "lone_pairs"): + value = attrs.get(key) + if value not in (None, 0, False): + badges.append(f"{_short_node_key(key)}:{value}") + return badges + + +def _tuple_its_edge_label(attrs: Mapping[str, Any], mode: str) -> str: + if mode == "sigma_pi": + sigma = attrs.get("sigma_order") + pi = attrs.get("pi_order") + return f"σ{_format_pair(sigma)} π{_format_pair(pi)}" + pair = attrs.get("kekule_order", attrs.get("order")) + return _pair_label(pair) + + +def _mtg_edge_label(attrs: Mapping[str, Any], mode: str) -> str: + if mode == "sigma_pi": + parts = [] + for key in ("sigma_order", "pi_order"): + value = attrs.get(key) + if _is_timeline(value) and (_timeline_changes(value) or mode == "timeline"): + parts.append(f"{_short_edge_key(key)}:{_format_timeline(value)}") + return " ".join(parts) or _format_timeline( + attrs.get("kekule_order", attrs.get("order")) + ) + if mode == "timeline": + parts = [] + for key in EDGE_TIMELINE_ATTRS: + value = attrs.get(key) + if _is_timeline(value): + parts.append(f"{_short_edge_key(key)}:{_format_timeline(value)}") + return " ".join(parts) + value = attrs.get("kekule_order", attrs.get("order")) + return _format_timeline(value) if _is_timeline(value) else _bond_symbol(value) + + +def _preferred_edge_pair(attrs: Mapping[str, Any]) -> Any: + return attrs.get("kekule_order", attrs.get("order")) + + +def _pair_edge_state(pair: Any) -> str: + if not _is_pair(pair): + return "unknown" + before, after = pair + before = _none_order(before) + after = _none_order(after) + if before == after: + return "unchanged" + if before == 0 and after > 0: + return "formed" + if before > 0 and after == 0: + return "broken" + return "order_changed" + + +def _timeline_edge_state(attrs: Mapping[str, Any]) -> str: + timeline = attrs.get("kekule_order", attrs.get("order")) + if not _is_timeline(timeline): + return "unknown" + numeric = [_none_order(v) for v in timeline if _is_order_value(v)] + if not numeric or len(set(numeric)) == 1: + return "unchanged" + if numeric[0] == numeric[-1] and len(set(numeric)) > 1: + return "transient" + if numeric[0] == 0 and numeric[-1] > 0: + return "formed" + if numeric[0] > 0 and numeric[-1] == 0: + return "broken" + return "order_changed" + + +def _pair_label(pair: Any) -> str: + if not _is_pair(pair): + return _bond_symbol(pair) + return f"{_bond_symbol(pair[0])}>{_bond_symbol(pair[1])}" + + +def _format_pair(pair: Any) -> str: + if not _is_pair(pair): + return str(pair) + return f"{_format_value(pair[0])}>{_format_value(pair[1])}" + + +def _format_timeline(value: Any) -> str: + if not _is_timeline(value): + return str(value) + return "-".join(_format_value(item) for item in value) + + +def _format_value(value: Any) -> str: + if value is None: + return "∅" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _bond_symbol(order: Any) -> str: + return BOND_SYMBOLS.get(order, _format_value(order)) + + +def _short_node_key(key: str) -> str: + return { + "charge": "q", + "hcount": "H", + "lone_pairs": "lp", + "radical": "rad", + }.get(key, key) + + +def _short_edge_key(key: str) -> str: + return { + "order": "ord", + "kekule_order": "kek", + "sigma_order": "σ", + "pi_order": "π", + }.get(key, key) + + +def _is_pair(value: Any) -> bool: + return ( + isinstance(value, tuple) + and len(value) == 2 + and not any(isinstance(item, (set, list, tuple, dict)) for item in value) + ) + + +def _is_timeline(value: Any) -> bool: + return ( + isinstance(value, tuple) + and len(value) >= 3 + and not any(isinstance(item, (set, list, tuple, dict)) for item in value) + ) + + +def _is_changed_pair(value: Any) -> bool: + return _is_pair(value) and value[0] != value[1] + + +def _timeline_changes(value: Any) -> bool: + return _is_timeline(value) and len(set(value)) > 1 + + +def _is_order_value(value: Any) -> bool: + return value is None or isinstance(value, (int, float)) + + +def _none_order(value: Any) -> float: + return 0.0 if value is None else float(value) + + +def _first_present(value: Any) -> Any: + if _is_pair(value): + return value[0] if value[0] is not None else value[1] + if _is_timeline(value): + for item in value: + if item is not None: + return item + return None + return value + + +def iter_changed_edges(visual: VisualGraph) -> Iterable[VisualEdge]: + """Yield edges whose visual state is not unchanged.""" + + return (edge for edge in visual.edges if edge.state != "unchanged") + + +def iter_changed_nodes(visual: VisualGraph) -> Iterable[VisualNode]: + """Yield nodes with at least one visual badge/change marker.""" + + return (node for node in visual.nodes if node.changed) From 52328fca6d6343aeebb6fc37799019050a4e54a0 Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Fri, 22 May 2026 15:29:20 +0200 Subject: [PATCH 3/7] release beta --- .github/workflows/test-and-lint.yml | 2 +- plan.html | 553 ---------------------------- pyproject.toml | 2 +- 3 files changed, 2 insertions(+), 555 deletions(-) delete mode 100644 plan.html diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index d18ff2a..1529047 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -2,7 +2,7 @@ name: Test & Lint on: push: - branches: [ "main", "staging", "fix_query" ] + branches: [ "main", "staging", "mech" ] pull_request: branches: [ "main" ] diff --git a/plan.html b/plan.html deleted file mode 100644 index 3079cdd..0000000 --- a/plan.html +++ /dev/null @@ -1,553 +0,0 @@ - - - - - Electron-Aware Reactor Design Plan - - - -

Electron-Aware Reactor Design Plan

- -
-

What We Are Trying To Do

-

- The current SynKit reactor can apply structural reaction rules, but its - main matching logic is still mostly blind to electron state. Molecular - graphs already know about lone pairs and radicals, but that information is - not yet consistently exposed or used by the reactor. -

- -

- The goal is to make the reactor gradually become electron-aware: -

- -
    -
  • lone pairs should become visible and usable,
  • -
  • radicals should become visible and usable,
  • -
  • charge should eventually be checked from Lewis-style bookkeeping,
  • -
  • aromatic matching and electron accounting should stop being forced into the same bond attribute.
  • -
- -

- We do not want to break old templates immediately. The correct shape is a - staged migration: expose the right data first, add observability next, - enforce later. -

-
- -
-

Current State In The Repository

- -

What already exists

-
    -
  • - mol_to_graph.py already computes: - lone_pairs, - available_lp, - radical, - and richer bookkeeping fields. -
  • -
  • - The newer ITS path already supports named paired attributes such as: - lone_pairs=(2,1). -
  • -
  • - kekule_order already exists on graph edges and can represent - a Kekule form separately from aromatic bond order. -
  • -
  • - Some older mechanism code already recomputes charge from local electron - inventory, which strongly suggests the intended chemistry model. -
  • -
- -

What is missing

-
    -
  • - Default graph conversion currently does not expose the small electron-state - surface by default. -
  • -
  • - SynReactor currently matches mostly on - element and charge. -
  • -
  • - Lone pairs are not part of reactor matching semantics. -
  • -
  • - Radicals are handled mostly by wildcard decoration after the fact, not as - true state in the reactor core. -
  • -
  • - GraphToMol does not yet write radical state back into RDKit atoms, - so radical round-tripping is incomplete. -
  • -
  • - Reaction-center extraction knows about lone-pair changes, but not yet - radical changes. -
  • -
-
- -
-

Step 1: Expose The Small Electron-State Surface

- -
- Decision: - Add these to the default public node attributes: - lone_pairs, - available_lp, - radical. -
- -

- This is the minimum useful set for electron-aware reaction behavior: -

- - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldMeaningWhy reactor needs it
lone_pairsNumber of lone pairs on the atomNeeded to know whether an atom can serve as an electron-pair donor
available_lpWhether at least one lone pair is locally available for donationUseful as a compact chemistry-facing flag
radicalNumber of unpaired electronsNeeded to distinguish radical from closed-shell matching
- -
- Why not expose everything? - We should not push all detailed bookkeeping into every default graph yet. - Fields like valence_electrons, oxidation state, and LP diagnostics - are useful internally, but they are a larger public surface than v1 needs. -
-
- -
-

Step 2: Expose Two Bond Views Instead Of One

- -
- Decision: - Default edge attributes should include both - order and kekule_order. -
- -

- These two fields answer different questions: -

- - - - - - - - - - - - - - - - - - - - - -
AttributeUseExample
orderGraph identity / aromatic matchingAromatic edge can stay represented as 1.5
kekule_orderElectron bookkeeping / mechanistic bond changesSame aromatic system represented as alternating single/double bonds
- -

- This separation prevents a recurring confusion: -

- -
    -
  • For matching, aromatic systems are often better treated aromatically.
  • -
  • For electron movement, sigma and pi changes are easier to reason about in Kekule form.
  • -
- -
- Related decision: - Do not store separate sigma and pi fields in v1. - Derive them from kekule_order when needed. -
- -
0 -> 1  means new sigma bond
-1 -> 2  means added pi bond
-2 -> 1  means lost pi bond
-
- -
-

Step 3: Use Named Paired ITS Attributes For Electron State

- -
- Decision: - The electron-aware reactor should target the newer named paired ITS - representation, not extend the old positional typesGH tuple. -
- -

- Good electron-aware ITS state should look like this: -

- -
element      = ("O", "O")
-lone_pairs   = (2, 1)
-radical      = (0, 0)
-charge       = (0, 1)
-hcount       = (1, 0)
- -

- This is preferable to adding more positions into a legacy tuple like: -

- -
(element, aromatic, hcount, charge, neighbors, ...)
- -

- because named attributes: -

- -
    -
  • are easier to read,
  • -
  • are harder to misuse,
  • -
  • allow lone pairs and radicals to evolve independently,
  • -
  • make validation code much clearer.
  • -
- -
- Important: - This does not mean all old typesGH code must disappear immediately. - It means the new electron-aware path should be built on the named paired form, - and any legacy adapters should be explicit. -
-
- -
-

Step 4: Make Radical Changes First-Class Reaction-Center Events

- -
- Decision: - Reaction-center extraction should treat radical change the same way it - already treats lone-pair change. -
- -

- Today a node can enter the reaction center because: -

- -
    -
  • its element changes,
  • -
  • its hydrogen count changes,
  • -
  • its charge changes,
  • -
  • its lone-pair count changes,
  • -
  • its valence-electron count changes.
  • -
- -

- Add: -

- -
    -
  • radical changes.
  • -
- -

- Example: -

- -
radical = (1, 0)
- -

- should be a reason that the atom belongs to the reaction center. -

-
- -
-

Step 5: Define Matching Semantics Clearly

- -
- Decision: - Lone pairs and radicals do not use the same matching rule. -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldMatching RuleReason
hcounthost >= templateAlready existing behavior
lone_pairshost >= templateIf a template needs one LP donor, an atom with two LPs still satisfies that requirement
radicalhost == templateOne radical electron is chemically different from zero or two
- -

- This means: -

- -
template lone_pairs = 1
-host lone_pairs     = 2
-=> allowed
-
-template radical = 1
-host radical     = 0
-=> rejected in strict mode
-
- -
-

Step 6: Add A Compatibility Mode To SynReactor

- -
- Decision: - Add an explicit electron_mode parameter. -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
ModeBehaviorPurpose
legacyCurrent matching behavior onlyPreserve old workflows exactly
warnRun current behavior but report electron-aware mismatchesAudit templates and datasets safely
strictReject mappings that violate electron constraintsFuture chemically stricter mode
- -
- Initial default: - electron_mode="warn" -
- -

- This gives us visibility before enforcement. Old templates may accidentally - depend on broad matching behavior; we should learn where before changing - results. -

-
- -
-

Step 7: Change The Meaning Of Charge

- -
- Decision: - Charge should move toward “derived plus validated,” not remain the primary - electron-state source of truth. -
- -

- For a Lewis-style graph, charge can be recomputed locally from: -

- -
charge =
-    valence_electrons
-    - (2 * lone_pairs + hcount + bond_order_sum)
- -

- In the first implementation: -

- -
    -
  • do not auto-correct charge yet,
  • -
  • recompute it after rewrite,
  • -
  • compare recomputed charge with represented charge,
  • -
  • warn in warn mode,
  • -
  • reject in strict mode.
  • -
- -
- Why not auto-overwrite immediately? - Because auto-correction can hide bad transformations and make debugging - much harder. First we want validation signal; later we can decide whether - canonical recomputation should become authoritative. -
-
- -
-

Step 8: Preserve Radical State During Output

- -
- Decision: - Radical state must survive graph -> RDKit -> SMILES conversion. -
- -

- Today the read direction exists: -

- -
RDKit atom -> graph node["radical"]
- -

- But the write direction is missing: -

- -
graph node["radical"] -> RDKit atom
- -

- Without that second half, the reactor can carry correct radical state - internally and still lose it when serializing products. -

- -

- So GraphToMol must set RDKit radical electrons from the graph - node attribute during reconstruction. -

-
- -
-

Step 9: Keep Existing Wildcard Radical Tools As Transitional Support

- -
\ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2826742..f9223db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "synkit" -version = "1.3.2" +version = "1.3.2b1" description = "Utility for reaction modeling using graph grammar" readme = "README.md" long-description = { file = "CHANGELOG.md" } From ab3ff0ec7f89efc7b92cce4f48479799ce26fbe9 Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Fri, 22 May 2026 15:31:02 +0200 Subject: [PATCH 4/7] release beta --- .github/workflows/publish-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 70adda3..c240e88 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -3,7 +3,7 @@ name: PyPI publish on: push: branches: - - beta + - mech release: types: - published From 3e0cf7414e4c67e184e83bbf1b41a446cf81b5d3 Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Fri, 22 May 2026 16:45:45 +0200 Subject: [PATCH 5/7] fix test --- .github/workflows/publish-package.yml | 2 +- Test/Vis/test_mtg_drawer.py | 106 ++++++++++++++++++ doc/api/vis.rst | 4 + doc/vis.rst | 33 +++++- requirements.txt | 1 + synkit/Vis/__init__.py | 3 + synkit/Vis/mtg_drawer.py | 151 ++++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 Test/Vis/test_mtg_drawer.py create mode 100644 synkit/Vis/mtg_drawer.py diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index c240e88..70adda3 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -3,7 +3,7 @@ name: PyPI publish on: push: branches: - - mech + - beta release: types: - published diff --git a/Test/Vis/test_mtg_drawer.py b/Test/Vis/test_mtg_drawer.py new file mode 100644 index 0000000..ef2dccc --- /dev/null +++ b/Test/Vis/test_mtg_drawer.py @@ -0,0 +1,106 @@ +import unittest + +import matplotlib +import networkx as nx + +matplotlib.use("Agg") + +from synkit.Graph.ITS.its_construction import ITSConstruction # noqa: E402 +from synkit.Graph.MTG.mtg import MTG # noqa: E402 +from synkit.Vis.mtg_drawer import draw_mtg_graph, draw_mtg_steps # noqa: E402 + + +class TestMTGDrawer(unittest.TestCase): + @staticmethod + def _atom(element, *, hcount=0, charge=0, lone_pairs=0, radical=0): + return { + "element": element, + "aromatic": False, + "hcount": hcount, + "charge": charge, + "lone_pairs": lone_pairs, + "radical": radical, + "valence_electrons": {"H": 1, "C": 4, "N": 5, "O": 6, "Cl": 7}[element], + } + + @staticmethod + def _bond(graph, u, v, sigma=1.0, pi=0.0): + graph.add_edge( + u, + v, + order=sigma + pi, + kekule_order=sigma + pi, + sigma_order=sigma, + pi_order=pi, + ) + + def _graph(self, nodes, edges): + graph = nx.Graph() + for node, attrs in nodes.items(): + graph.add_node(node, **attrs) + for edge in edges: + self._bond(graph, *edge) + return graph + + def _mtg(self): + g0 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + g1 = self._graph( + { + 1: self._atom("C", hcount=3, radical=1), + 2: self._atom("Cl", radical=1, lone_pairs=3), + }, + [], + ) + g2 = self._graph( + { + 1: self._atom("C", hcount=3), + 2: self._atom("Cl", lone_pairs=3), + }, + [(1, 2, 1.0, 0.0)], + ) + return MTG( + [ITSConstruction.construct(g0, g1), ITSConstruction.construct(g1, g2)], + mappings=[{1: 1, 2: 2}], + ) + + def test_draw_mtg_graph_accepts_mtg_object(self): + mtg = self._mtg() + + fig, ax = draw_mtg_graph(mtg, title="radical rebound") + + self.assertIs(fig, ax.figure) + self.assertEqual(ax.get_title(), "radical rebound") + + def test_draw_mtg_graph_accepts_raw_graph_without_mutation(self): + graph = self._mtg().get_mtg() + before_nodes = dict(graph.nodes(data=True)) + before_edges = list(graph.edges(data=True)) + + fig, ax = draw_mtg_graph(graph) + + self.assertIs(fig, ax.figure) + self.assertEqual(dict(graph.nodes(data=True)), before_nodes) + self.assertEqual(list(graph.edges(data=True)), before_edges) + + def test_draw_mtg_steps_draws_ordered_its_panels_and_composed_panel(self): + mtg = self._mtg() + + fig, axes = draw_mtg_steps(mtg, include_composed=True, show_edge_labels=True) + + self.assertIs(fig, axes[0].figure) + self.assertEqual(len(axes), 3) + self.assertEqual([ax.get_title() for ax in axes], ["Step 1", "Step 2", "Composed"]) + + def test_draw_mtg_steps_validates_indices(self): + with self.assertRaises(IndexError): + draw_mtg_steps(self._mtg(), steps=[2]) + + +if __name__ == "__main__": + unittest.main() diff --git a/doc/api/vis.rst b/doc/api/vis.rst index 13b4386..d285a51 100644 --- a/doc/api/vis.rst +++ b/doc/api/vis.rst @@ -19,6 +19,10 @@ Modern molecule/reaction/ITS renderers :members: :show-inheritance: +.. automodule:: synkit.Vis.mtg_drawer + :members: + :show-inheritance: + Diagnostic adapter layer ------------------------ diff --git a/doc/vis.rst b/doc/vis.rst index e3b859f..5b4d5e8 100644 --- a/doc/vis.rst +++ b/doc/vis.rst @@ -168,6 +168,38 @@ Use ``projection=True`` when you need to inspect how an ITS decomposes back into left and right molecular graphs. Use the default ITS-only view for reports and notebooks. +MTG Timelines +------------- + +Compact MTG visualization has two complementary views: + +* ``draw_mtg_graph`` shows the fused MTG as a timeline graph; +* ``draw_mtg_steps`` reconstructs ordered ITS steps and draws each step with + the ITS renderer. + +.. code-block:: python + + from synkit.Graph.MTG.mtg import MTG + from synkit.Vis import draw_mtg_graph, draw_mtg_steps + + mtg = MTG([step_1_its, step_2_its]) + + fig, ax = draw_mtg_graph( + mtg, + title="MTG timeline", + mode="timeline", + ) + + fig, axes = draw_mtg_steps( + mtg, + include_composed=True, + show_edge_labels=True, + ) + +Use the timeline graph to see transient bonds and electron-state paths across +the mechanism. Use the step panels when you need to check each reconstructed +ITS independently. + Diagnostic Graph View --------------------- @@ -198,4 +230,3 @@ The older visualization classes are still exported for compatibility: New code should use ``draw_molecule_graph``, ``draw_reaction_graph``, and ``draw_its_from_rsmi`` unless a legacy workflow specifically depends on the class-based API. - diff --git a/requirements.txt b/requirements.txt index 8d0f010..b050ee8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ scikit-learn>=1.4.0 seaborn>=0.13.2 rdkit>=2025.3.1 pandas>=2.2.0 +networkx>=3.3 requests>=2.32.3 numpy>=2.2.0 regex>=2024.11.6 diff --git a/synkit/Vis/__init__.py b/synkit/Vis/__init__.py index d8c70a4..9bae5ca 100644 --- a/synkit/Vis/__init__.py +++ b/synkit/Vis/__init__.py @@ -21,6 +21,7 @@ find_reaction_highlights, ) from .its_drawer import draw_its_from_rsmi, draw_its_graph, draw_its_only +from .mtg_drawer import draw_mtg_graph, draw_mtg_steps __all__ = [ "GraphVisualizer", @@ -44,4 +45,6 @@ "draw_its_from_rsmi", "draw_its_graph", "draw_its_only", + "draw_mtg_graph", + "draw_mtg_steps", ] diff --git a/synkit/Vis/mtg_drawer.py b/synkit/Vis/mtg_drawer.py new file mode 100644 index 0000000..3070865 --- /dev/null +++ b/synkit/Vis/mtg_drawer.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +"""MTG visualization helpers. + +The compact MTG view is a timeline diagnostic. Step panels reuse the molecule- +like ITS renderer so each reconstructed ITS step is inspected with the same +visual language as normal tuple ITS drawings. +""" + +from typing import Any, Iterable, Optional + +import matplotlib.pyplot as plt +import networkx as nx + +from synkit.Vis.its_drawer import draw_its_only +from synkit.Vis.visual_drawer import draw_graph + + +def draw_mtg_graph( + mtg: Any, + *, + ax: Optional[plt.Axes] = None, + title: Optional[str] = None, + mode: str = "timeline", + layout: str = "kamada_kawai", + show_atom_map: bool = True, + show_edge_labels: bool = True, + show_node_badges: bool = True, +) -> tuple[plt.Figure, plt.Axes]: + """Draw a compact MTG timeline graph. + + ``mtg`` may be a :class:`synkit.Graph.MTG.mtg.MTG` instance or a raw + compact MTG ``networkx.Graph`` from ``MTG.get_mtg()``. + + :param mtg: MTG object or compact MTG graph. + :type mtg: Any + :param ax: Optional Matplotlib axes. + :type ax: Optional[plt.Axes] + :param title: Optional title. + :type title: Optional[str] + :param mode: Visual adapter mode. ``"timeline"`` is the recommended MTG + view; ``"sigma_pi"`` gives a shorter electron-bond diagnostic. + :type mode: str + :param layout: NetworkX layout name passed to ``draw_graph``. + :type layout: str + :returns: ``(figure, axes)``. + :rtype: tuple[plt.Figure, plt.Axes] + """ + + graph = _as_mtg_graph(mtg) + return draw_graph( + graph, + ax=ax, + mode=mode, + title=title or "MTG timeline", + show_atom_map=show_atom_map, + layout=layout, + show_edge_labels=show_edge_labels, + show_node_badges=show_node_badges, + ) + + +def draw_mtg_steps( + mtg: Any, + *, + steps: Optional[Iterable[int]] = None, + include_composed: bool = False, + title: Optional[str] = None, + max_columns: int = 3, + show_atom_map: bool = True, + label_mode: str = "hetero", + edge_label_mode: str = "kekule", + show_edge_labels: bool = False, + show_electron_labels: bool = False, + electron_label_mode: str = "charge", +) -> tuple[plt.Figure, list[plt.Axes]]: + """Draw reconstructed MTG ITS steps as ordered panels. + + :param mtg: MTG object exposing ``get_its_steps``. + :type mtg: Any + :param steps: Optional zero-based step indices to draw. + :type steps: Optional[Iterable[int]] + :param include_composed: Append the composed outer-state ITS panel. + :type include_composed: bool + :param title: Optional figure title. + :type title: Optional[str] + :param max_columns: Maximum subplot columns. + :type max_columns: int + :returns: ``(figure, axes)``. + :rtype: tuple[plt.Figure, list[plt.Axes]] + """ + + if not hasattr(mtg, "get_its_steps"): + raise TypeError("draw_mtg_steps expects an MTG object with get_its_steps().") + + all_steps = list(mtg.get_its_steps()) + selected = list(range(len(all_steps))) if steps is None else list(steps) + for step in selected: + if step < 0 or step >= len(all_steps): + raise IndexError(f"MTG step index out of range: {step}") + + panels = [(f"Step {step + 1}", all_steps[step]) for step in selected] + if include_composed: + if not hasattr(mtg, "get_compose_its"): + raise TypeError("include_composed requires an MTG object with get_compose_its().") + panels.append(("Composed", mtg.get_compose_its())) + + if not panels: + raise ValueError("No MTG steps selected for drawing.") + + ncols = min(max(1, max_columns), len(panels)) + nrows = (len(panels) + ncols - 1) // ncols + fig, axes_grid = plt.subplots( + nrows, + ncols, + figsize=(4.8 * ncols, 4.2 * nrows), + squeeze=False, + facecolor="white", + ) + axes = [ax for row in axes_grid for ax in row] + if title: + fig.suptitle(title, fontsize=13, fontweight="bold") + + for ax, (panel_title, its) in zip(axes, panels): + draw_its_only( + its, + ax=ax, + title=panel_title, + show_atom_map=show_atom_map, + label_mode=label_mode, + edge_label_mode=edge_label_mode, + show_edge_labels=show_edge_labels, + show_electron_labels=show_electron_labels, + electron_label_mode=electron_label_mode, + ) + + for ax in axes[len(panels):]: + ax.set_axis_off() + + fig.tight_layout() + return fig, axes[: len(panels)] + + +def _as_mtg_graph(mtg: Any) -> nx.Graph: + if isinstance(mtg, nx.Graph): + return mtg + if hasattr(mtg, "get_mtg"): + graph = mtg.get_mtg() + if isinstance(graph, nx.Graph): + return graph + raise TypeError("Expected an MTG object or a NetworkX compact MTG graph.") From 3d898ee9f1c9aee374b3a4685fb98f771a535e2d Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Tue, 26 May 2026 10:28:57 +0200 Subject: [PATCH 6/7] prepare release --- .github/dependabot.yml | 81 ++- .github/workflows/conda-forge-publish.yml | 10 +- .github/workflows/verify-pypi-install.yml | 105 ++-- Test/Graph/MTG/test_mtg_tuple.py | 26 + Test/Vis/test_mtg_drawer.py | 63 ++- doc/_static/custom.css | 89 ++- doc/api/graph.rst | 38 ++ doc/changelog.rst | 88 +++ doc/chem.rst | 21 + doc/figures/mtg_lsg_changed_core.png | Bin 0 -> 80210 bytes doc/figures/vis_lsg_sn2.png | Bin 0 -> 54761 bytes doc/figures/vis_molecule_aspirin.png | Bin 0 -> 80674 bytes doc/figures/vis_mtg_steps.png | Bin 0 -> 40879 bytes doc/figures/vis_mtg_timeline.png | Bin 0 -> 80210 bytes doc/figures/vis_reaction_sn2.png | Bin 0 -> 48131 bytes doc/graph.rst | 242 ++++++-- doc/index.rst | 39 ++ doc/synthesis.rst | 85 +++ doc/vis.rst | 111 +++- pyproject.toml | 2 +- recipe/meta.yaml | 2 +- synkit/Graph/MTG/mtg.py | 13 +- synkit/Vis/mtg_drawer.py | 644 +++++++++++++++++++++- 23 files changed, 1546 insertions(+), 113 deletions(-) create mode 100644 doc/figures/mtg_lsg_changed_core.png create mode 100644 doc/figures/vis_lsg_sn2.png create mode 100644 doc/figures/vis_molecule_aspirin.png create mode 100644 doc/figures/vis_mtg_steps.png create mode 100644 doc/figures/vis_mtg_timeline.png create mode 100644 doc/figures/vis_reaction_sn2.png diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c0c0eb1..e4a59ab 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,10 +1,79 @@ -# .github/dependabot.yml version: 2 + updates: - package-ecosystem: "pip" - directory: "/" # location of requirements.txt or pyproject.toml - target-branch: "staging" # open PRs against staging instead of main + directory: "/" + target-branch: "staging" schedule: - interval: "weekly" # check for updates once a week - open-pull-requests-limit: 5 # max concurrent Dependabot PRs - rebase-strategy: "auto" # auto-rebase PRs when they fall out of date + interval: "weekly" + day: "monday" + time: "04:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + rebase-strategy: "auto" + labels: + - "dependencies" + - "python" + commit-message: + prefix: "deps" + include: "scope" + groups: + python-runtime: + patterns: + - "networkx" + - "pandas" + - "rdkit" + - "regex" + - "requests" + - "scikit-learn" + - "seaborn" + python-optional: + patterns: + - "numpy" + - "sympy" + - "torch" + docs: + patterns: + - "sphinx*" + - "pydata-sphinx-theme" + - "sphinx-rtd-theme" + - "graphviz" + - "myst-parser" + + - package-ecosystem: "github-actions" + directory: "/" + target-branch: "staging" + schedule: + interval: "weekly" + day: "monday" + time: "04:30" + timezone: "Etc/UTC" + open-pull-requests-limit: 5 + rebase-strategy: "auto" + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" + include: "scope" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "docker" + directory: "/" + target-branch: "staging" + schedule: + interval: "weekly" + day: "monday" + time: "05:00" + timezone: "Etc/UTC" + open-pull-requests-limit: 2 + rebase-strategy: "auto" + labels: + - "dependencies" + - "docker" + commit-message: + prefix: "deps" + include: "scope" diff --git a/.github/workflows/conda-forge-publish.yml b/.github/workflows/conda-forge-publish.yml index d6e1bf8..e0cb9fd 100644 --- a/.github/workflows/conda-forge-publish.yml +++ b/.github/workflows/conda-forge-publish.yml @@ -21,13 +21,14 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Miniconda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 with: + miniconda-version: "latest" channels: conda-forge auto-update-conda: true auto-activate-base: true @@ -53,13 +54,14 @@ jobs: pkg_paths: ${{ steps.build.outputs.paths }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Miniconda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 with: + miniconda-version: "latest" channels: conda-forge auto-update-conda: true auto-activate-base: true diff --git a/.github/workflows/verify-pypi-install.yml b/.github/workflows/verify-pypi-install.yml index 4306e76..201bc7e 100644 --- a/.github/workflows/verify-pypi-install.yml +++ b/.github/workflows/verify-pypi-install.yml @@ -1,65 +1,96 @@ -# .github/workflows/verify-synkit-pypi-install.yml -name: Verify SynKit PyPI install +name: Verify PyPI install on: workflow_dispatch: inputs: - branches: + package-version: + description: "Optional exact SynKit version to install, for example 1.4.0" + required: false type: string - required: true - default: refractor - - # Scheduled test every Monday at 03:00 UTC schedule: - - cron: '0 3 * * 1' + - cron: "0 3 * * 1" + +permissions: + contents: read + +concurrency: + group: verify-pypi-install-${{ github.event_name }}-${{ github.event.inputs['package-version'] || 'latest' }} + cancel-in-progress: false jobs: verify: - name: Verify PyPI install on ${{ matrix.os }} + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11", "3.12"] steps: - - name: Setup Python - uses: actions/setup-python@v4 + - name: Set up Python + uses: actions/setup-python@v6 with: - python-version: '3.x' + python-version: ${{ matrix.python-version }} + cache: "pip" - - name: Create & activate virtualenv, upgrade pip, install SynKit + - name: Install SynKit from PyPI + shell: bash run: | - python -m venv venv - source venv/bin/activate python -m pip install --upgrade pip - pip install synkit[all] - - name: Show installed SynKit version + version="${{ github.event.inputs['package-version'] }}" + if [ -n "$version" ]; then + python -m pip install "synkit==$version" + else + python -m pip install synkit + fi + python -m pip install packaging + + - name: Show installed package metadata + shell: bash run: | - source venv/bin/activate - python -c "import importlib.metadata as m; print('SynKit version:', m.version('synkit'))" + python - <<'PY' + import importlib.metadata as metadata + import sys + + print("Python:", sys.version) + print("SynKit:", metadata.version("synkit")) + PY - - name: Write smoke-test script + - name: Run import and smoke tests + shell: bash run: | - cat << 'EOF' > test_synkit.py - from synkit.IO import rsmi_to_rsmarts + python - <<'PY' + import importlib.metadata as metadata - template = ( - '[C:2]=[O:3].[C:4]([H:7])[H:8]' - '>>' - '[C:2]=[C:4].[O:3]([H:7])[H:8]' - ) + from packaging.version import Version - smart = rsmi_to_rsmarts(template) - print("Reaction SMARTS:", smart) - EOF + from synkit.IO import rsmi_to_its, rsmi_to_rsmarts - - name: Run smoke-test - run: | - source venv/bin/activate - python test_synkit.py + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + smarts = rsmi_to_rsmarts(rsmi) + assert ">>" in smarts + + version = Version(metadata.version("synkit")) + if version >= Version("1.4.0"): + import networkx as nx + + from synkit.Graph.MTG.mtg import MTG + + its = rsmi_to_its(rsmi, core=False, format="tuple") + assert isinstance(its, nx.Graph) + assert not any("typesGH" in attrs for _, attrs in its.nodes(data=True)) + + mtg_steps = [ + "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]", + "[CH3:1][NH3+:3].[Cl-:2]>>[CH3:1][NH2:3].[Cl:2][H]", + ] + mtg = MTG(mtg_steps, mcs_mol=True) + graph = mtg.get_mtg() + assert mtg._tuple_its + assert graph.number_of_nodes() > 0 - - name: Success message - run: echo "✅ synkit[all] installed and smoke-test passed" + print("PyPI SynKit smoke tests passed.") + PY diff --git a/Test/Graph/MTG/test_mtg_tuple.py b/Test/Graph/MTG/test_mtg_tuple.py index 2fd2c29..a60eaab 100644 --- a/Test/Graph/MTG/test_mtg_tuple.py +++ b/Test/Graph/MTG/test_mtg_tuple.py @@ -299,6 +299,32 @@ def test_mech_fixture_round_trips_ordered_tuple_rsmi_steps(self): self.assertEqual(len(exported), len(steps)) self.assertTrue(all(">>" in step for step in exported)) + def test_string_sequences_default_to_lewis_state_graph(self): + data = load_database("./Data/Testcase/mech.json.gz") + mech = data[0]["mechanisms"][1] + steps = [step["smart_string"] for step in mech["steps"]] + + mtg = MTG(steps, mcs_mol=True) + graph = mtg.get_mtg() + + self.assertTrue(mtg._tuple_its) + self.assertFalse(any("typesGH" in attrs for _, attrs in graph.nodes(data=True))) + self.assertTrue( + all("sigma_order" in attrs for _, _, attrs in graph.edges(data=True)) + ) + + def test_string_sequences_can_request_legacy_typesgh(self): + data = load_database("./Data/Testcase/mech.json.gz") + mech = data[0]["mechanisms"][1] + steps = [step["smart_string"] for step in mech["steps"][:2]] + + mtg = MTG(steps, mcs_mol=True, its_format="typesGH") + + self.assertFalse(mtg._tuple_its) + self.assertTrue( + any("typesGH" in attrs for _, attrs in mtg.get_mtg().nodes(data=True)) + ) + def test_mech_fixture_tuple_mtg_automatic_mapping_matches_identity_mapping(self): data = load_database("./Data/Testcase/mech.json.gz") mech = data[0]["mechanisms"][1] diff --git a/Test/Vis/test_mtg_drawer.py b/Test/Vis/test_mtg_drawer.py index ef2dccc..6e6fb40 100644 --- a/Test/Vis/test_mtg_drawer.py +++ b/Test/Vis/test_mtg_drawer.py @@ -7,7 +7,12 @@ from synkit.Graph.ITS.its_construction import ITSConstruction # noqa: E402 from synkit.Graph.MTG.mtg import MTG # noqa: E402 -from synkit.Vis.mtg_drawer import draw_mtg_graph, draw_mtg_steps # noqa: E402 +from synkit.IO import load_database # noqa: E402 +from synkit.Vis.mtg_drawer import ( # noqa: E402 + _mtg_display_graph, + draw_mtg_graph, + draw_mtg_steps, +) class TestMTGDrawer(unittest.TestCase): @@ -88,6 +93,39 @@ def test_draw_mtg_graph_accepts_raw_graph_without_mutation(self): self.assertEqual(dict(graph.nodes(data=True)), before_nodes) self.assertEqual(list(graph.edges(data=True)), before_edges) + def test_draw_mtg_graph_supports_3d_layout(self): + mtg = self._mtg() + + fig, ax = draw_mtg_graph(mtg, dimension="3d", layout="spring") + + self.assertIs(fig, ax.figure) + self.assertEqual(getattr(ax, "name", None), "3d") + + def test_mtg_edge_labels_compress_by_default(self): + graph = self._mtg().get_mtg() + + compact = _mtg_display_graph( + graph, + mode="timeline", + show_atom_map=True, + show_node_badges=False, + hydrogen_mode="changed", + changed_only=True, + compress=True, + ) + full = _mtg_display_graph( + graph, + mode="timeline", + show_atom_map=True, + show_node_badges=False, + hydrogen_mode="changed", + changed_only=True, + compress=False, + ) + + self.assertEqual(compact.edges[1, 2]["label"], "1→1") + self.assertEqual(full.edges[1, 2]["label"], "1→0→1") + def test_draw_mtg_steps_draws_ordered_its_panels_and_composed_panel(self): mtg = self._mtg() @@ -95,12 +133,33 @@ def test_draw_mtg_steps_draws_ordered_its_panels_and_composed_panel(self): self.assertIs(fig, axes[0].figure) self.assertEqual(len(axes), 3) - self.assertEqual([ax.get_title() for ax in axes], ["Step 1", "Step 2", "Composed"]) + self.assertEqual( + [ax.get_title() for ax in axes], ["Step 1", "Step 2", "Composed"] + ) def test_draw_mtg_steps_validates_indices(self): with self.assertRaises(IndexError): draw_mtg_steps(self._mtg(), steps=[2]) + def test_draw_mtg_graph_handles_real_neutral_mechanism(self): + data = load_database("Data/Testcase/mech.json.gz")[0] + neutral = data["mechanisms"][1] + steps = [step["smart_string"] for step in neutral["steps"]] + mtg = MTG(steps, mcs_mol=True) + graph = mtg.get_mtg() + + fig, ax = draw_mtg_graph( + mtg, + title=neutral["mech_name"], + hydrogen_mode="changed", + show_edge_labels=True, + ) + + self.assertIs(fig, ax.figure) + self.assertEqual(ax.get_title(), "Aldol reaction (neutral cat)") + self.assertTrue(mtg._tuple_its) + self.assertFalse(any("typesGH" in attrs for _, attrs in graph.nodes(data=True))) + if __name__ == "__main__": unittest.main() diff --git a/doc/_static/custom.css b/doc/_static/custom.css index e79ec76..b9ce14a 100644 --- a/doc/_static/custom.css +++ b/doc/_static/custom.css @@ -21,6 +21,8 @@ --sk-header-height: 64px; --sk-accent: #1f77b4; + --sk-accent-2: #0f766e; + --sk-accent-3: #7c3aed; --sk-radius: 10px; } @@ -45,6 +47,23 @@ padding-bottom: 0.35rem; } +/* Keep the PyData header tools slightly farther to the right. */ +@media (min-width: 992px) { + .navbar-header-items { + justify-content: flex-end; + } + + .navbar-header-items__end { + margin-left: auto; + padding-right: 0.45rem; + column-gap: 0.35rem; + } + + .navbar-header-items__end .navbar-persistent--container { + margin-right: 0.65rem; + } +} + /* ========================================================================= Sidebar widths ========================================================================= */ @@ -113,6 +132,74 @@ box-shadow: 0 1px 6px rgba(15, 23, 42, 0.04); } +.sk-feature-card { + border: 1px solid rgba(31, 119, 180, 0.16); + border-radius: 14px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.88)); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); +} + +.sk-feature-card .sd-card-title { + color: var(--sk-accent); +} + +html[data-theme="dark"] .sk-feature-card { + background: + linear-gradient(180deg, rgba(30, 41, 59, 0.78), rgba(15, 23, 42, 0.78)); + border-color: rgba(125, 211, 252, 0.18); +} + +.sk-badge-row { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin: 0.75rem 0 1.1rem; +} + +.sk-badge { + display: inline-flex; + align-items: center; + min-height: 1.75rem; + padding: 0.18rem 0.62rem; + border-radius: 999px; + background: rgba(31, 119, 180, 0.1); + color: var(--sk-accent); + font-weight: 650; + font-size: 0.82rem; + letter-spacing: 0.01em; +} + +.sk-badge.green { + background: rgba(15, 118, 110, 0.1); + color: var(--sk-accent-2); +} + +.sk-badge.purple { + background: rgba(124, 58, 237, 0.1); + color: var(--sk-accent-3); +} + +.bd-content .list-table table { + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 10px; + overflow: hidden; + display: table; +} + +.bd-content .list-table th { + background: rgba(31, 119, 180, 0.07); + font-size: 0.84rem; +} + +html[data-theme="dark"] .bd-content .list-table table { + border-color: rgba(255, 255, 255, 0.09); +} + +html[data-theme="dark"] .bd-content .list-table th { + background: rgba(125, 211, 252, 0.09); +} + div.highlight pre, pre.literal-block, .codehilite pre, @@ -314,4 +401,4 @@ html[data-theme="dark"] .sk-globaltoc.card { html[data-theme="dark"] .sk-bibliography .bibtex-bibliography { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.08); -} \ No newline at end of file +} diff --git a/doc/api/graph.rst b/doc/api/graph.rst index 8024bee..c2e739b 100644 --- a/doc/api/graph.rst +++ b/doc/api/graph.rst @@ -76,6 +76,33 @@ Features :members: :show-inheritance: +Functional groups +----------------- + +.. automodule:: synkit.Graph.FG.api + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.FG.audit + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.FG.catalog + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.FG.detector + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.FG.model + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.FG.ring_system + :members: + :show-inheritance: + Hydrogen utilities ------------------ @@ -126,6 +153,17 @@ ITS :members: :show-inheritance: +Mechanistic and Lewis-state utilities +------------------------------------- + +.. automodule:: synkit.Graph.Mech.conversion + :members: + :show-inheritance: + +.. automodule:: synkit.Graph.Mech.electron_accounting + :members: + :show-inheritance: + Matcher ------- diff --git a/doc/changelog.rst b/doc/changelog.rst index 742fd7f..d40d263 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,94 @@ Changelog ========= +Version 1.4.0 +------------- + +**Highlights** + +- Added the Lewis State Graph (LSG) framework for ``SynReactor``. LSG + templates carry ``lone_pairs``, ``radical``, ``valence_electrons``, + ``sigma_order``, ``pi_order``, and ``kekule_order`` so the NetworkX reactor + can rewrite from explicit valence-state information while keeping the legacy + ``typesGH`` path available. +- Added graph-native functional-group detection under ``synkit.Graph.FG``. + The detector works directly on SynKit molecular ``networkx`` graphs and + provides a SMILES convenience API returning both the graph and detected + ``(name, atom_indices)`` labels. +- Added compact MTG and visualization helpers for LSG/ITS and MTG timeline + inspection. The modern Vis API now covers molecule graphs, reaction panels, + ITS-only drawings, Lewis-state labels, and MTG step/timeline panels. + +**Lewis State Graph reactor** + +- LSG matching now uses explicit valence-state fields for new-mode templates: + element, charge, lone-pair count, radical count, and bond changes represented + by ``sigma_order`` / ``pi_order`` / ``kekule_order``. +- Product charge recomputation is driven from Lewis-state accounting in + new-mode rewrites, with ``kekule_order = sigma_order + pi_order`` used + instead of aromatic ``order`` values. +- Hydrogen handling was tightened for explicit-H reaction centers, implicit-H + templates, and simple ``H-H`` transfer cases. +- Atom-map preservation for LSG-reactor SMARTS output was fixed by using graph + node identity where the template does not carry original AAM. +- Real-case regression tooling was added around the first smart-database + fixture, batch round trips, and previously failing LSG rewrite examples. + +**Functional groups** + +- Added ``FunctionalGroupDetector``, ``FunctionalGroupRegistry``, + ``FunctionalGroupAudit``, and + ``smiles_to_graph_and_functional_groups``. +- Added hierarchical family handling so more specific labels such as + ``carboxylic_acid`` suppress generic nested labels such as ``carbonyl`` when + appropriate. +- Added aromatic ring-system detection, selected fused heteroaromatic public + names, and transform-relevant families across carbonyl/acyl, oxygen, + nitrogen/C=N, sulfur, boron, silicon, and phosphorus chemistry. +- Replaced the previous ``fgutils`` usage in tautomerization support with the + SynKit-native functional-group API. + +**MTG** + +- MTG construction from RSMI strings now defaults to Lewis State Graph ITS, + producing compact atom and bond timelines without ``typesGH``. Use + ``its_format="typesGH"`` to request legacy string conversion. +- Reworked the MTG plan around LSG/ITS representation: invariant atom fields + are stored once, while temporal fields store compact histories across + mechanism snapshots. +- Added round-trip coverage for converting reaction sequences to MTG and back + to ordered ITS steps / composed ITS views. +- Marked aromatic relabeling and partial-order mechanism DAGs as active design + areas rather than solved MTG semantics. + +**Visualization** + +- Added ``draw_molecule_graph``, ``draw_reaction_graph``, + ``draw_its_from_rsmi``, ``draw_its_only``, ``draw_mtg_graph``, and + ``draw_mtg_steps`` as the preferred modern rendering helpers. +- Added compact LSG/ITS labels for ``kekule_order`` transitions and optional + ``sigma/pi`` labels that suppress unchanged components. +- Added selectable Lewis-state labels for charge, lone-pair, and radical + changes. +- Added Matplotlib ``Agg`` smoke tests for molecule, reaction, ITS, visual + adapter, and MTG drawing paths. + +**Compatibility and known limits** + +- Legacy ITS / ``typesGH`` behavior remains available for existing workflows. +- MØD-backed workflows remain separate from the new SynKit LSG reactor path. +- Aromatic LSG matching is still conservative. Some aromatic false-positive + or false-negative cases require a future aromatic-system relabeling policy + rather than a local matcher tweak. +- Functional-group fused positional isomers such as quinoline vs isoquinoline + are not fully distinguished yet. + +**Infrastructure** + +- Added ``networkx>=3.3`` to ``requirements.txt`` so non-Linux CI jobs do not + rely on the Linux-only ``mod`` install to pull in NetworkX indirectly. + + Version 1.1.1 ------------- diff --git a/doc/chem.rst b/doc/chem.rst index 0a63db4..88a7593 100644 --- a/doc/chem.rst +++ b/doc/chem.rst @@ -120,6 +120,27 @@ and downstream CRN construction. 'CC=O.CC=O>>CC=CC=O.O' +Tautomerization and functional-group support +-------------------------------------------- + +``Tautomerize`` now uses SynKit's native functional-group detector instead of +an external FG utility. The detector works on the same molecular graph +representation used elsewhere in SynKit, so tautomer targets and graph-indexed +functional-group labels stay aligned. + +.. code-block:: python + :caption: Detecting tautomer-relevant functional groups + :linenos: + + from synkit.Graph.FG import smiles_to_graph_and_functional_groups + + graph, groups = smiles_to_graph_and_functional_groups("C=C(O)C") + print(groups) + +The tautomerization helper still keeps a small local compatibility rule for +geminal diols. Those are treated as hydrated-carbonyl repair targets, not as a +general public functional-group label. + See Also -------- diff --git a/doc/figures/mtg_lsg_changed_core.png b/doc/figures/mtg_lsg_changed_core.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d9bae0e0e1d8bfa234382ad1c1cc89f3a836a5 GIT binary patch literal 80210 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�a%jV;O2dI^lEaFD*Ke>utL+{dX>;VM#)jMm`I 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 literal 0 HcmV?d00001 diff --git a/doc/figures/vis_lsg_sn2.png b/doc/figures/vis_lsg_sn2.png new file mode 100644 index 0000000000000000000000000000000000000000..ae91a5c141876874399c5c6f2030801b44700b55 GIT binary patch literal 54761 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

o{RDeykW8XDb98 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 Ms^7@Fe)q}$2MO}LEdT%j literal 0 HcmV?d00001 diff --git a/doc/figures/vis_molecule_aspirin.png b/doc/figures/vis_molecule_aspirin.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff8066910f63ffa04dc56533b56d20426b803bd GIT binary patch literal 80674 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 literal 0 HcmV?d00001 diff --git a/doc/figures/vis_mtg_timeline.png b/doc/figures/vis_mtg_timeline.png new file mode 100644 index 0000000000000000000000000000000000000000..e1d9bae0e0e1d8bfa234382ad1c1cc89f3a836a5 GIT binary patch literal 80210 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�a%jV;O2dI^lEaFD*Ke>utL+{dX>;VM#)jMm`I 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 literal 0 HcmV?d00001 diff --git a/doc/figures/vis_reaction_sn2.png b/doc/figures/vis_reaction_sn2.png new file mode 100644 index 0000000000000000000000000000000000000000..899aef85f6b9029d08b5968eecb5f179a3e77d36 GIT binary patch literal 48131 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 QcW|o)S4>e<9sB?JHwK@)!vFvP literal 0 HcmV?d00001 diff --git a/doc/graph.rst b/doc/graph.rst index fb5c645..c13cbb5 100644 --- a/doc/graph.rst +++ b/doc/graph.rst @@ -12,6 +12,7 @@ Key submodules include: - **Matcher** — graph isomorphism and subgraph search engines - **ITS** — Internal Transition State (ITS) graph construction and decomposition - **MTG** — Mechanistic Transition Graph generation and exploration +- **FG** — graph-native functional-group detection and audit tooling - **Context** — reaction-center expansion for context-aware matching and analysis .. raw:: html @@ -49,6 +50,12 @@ Key submodules include: Build **Mechanistic Transition Graphs** from reaction-center ITS graphs to represent stepwise mechanisms and compare pathways. + .. grid-item-card:: :octicon:`filter` FG + :class-card: sd-shadow-sm + + Detect functional groups directly on SynKit molecular graphs, with + hierarchical labels and aromatic ring-system reporting. + Graph Canonicalization ---------------------- @@ -178,10 +185,58 @@ ITS The ``synkit.Graph.ITS`` package supports the construction and decomposition of **Internal Transition State (ITS)** graphs: -- :py:class:`~synkit.Graph.ITS.its_construction.ITSConstructor` — build ITS graphs from reactant/product graphs +- :py:class:`~synkit.Graph.ITS.its_construction.ITSConstruction` — build ITS graphs from reactant/product graphs - :py:func:`~synkit.Graph.ITS.its_decompose.get_rc` — extract the minimal reaction-center subgraph - :py:func:`~synkit.Graph.ITS.its_decompose.its_decompose` — split an ITS graph into reactant/product graphs +Lewis State Graph fields +~~~~~~~~~~~~~~~~~~~~~~~~ + +SynKit 1.4 introduces the Lewis State Graph (LSG) framework for the +pure-Python reactor and new mechanistic work. Legacy ITS remains available, +but LSG is the preferred representation when valence-state information must be +explicit. In the current API this representation is requested with +``format="tuple"``. + +Important LSG fields: + +.. list-table:: + :header-rows: 1 + + * - Field + - Meaning + * - ``sigma_order`` / ``pi_order`` + - Authoritative bond components for Lewis-state rewriting. + * - ``kekule_order`` + - Integer-like bond order used for product reconstruction; normally + ``sigma_order + pi_order``. + * - ``lone_pairs`` / ``radical`` + - Valence-state fields used by LSG matching and product accounting. + * - ``valence_electrons`` + - Element valence-shell reference used when recomputing charge. + * - ``order`` + - Legacy or presentation order. Aromatic ``1.5`` values are useful for + matching and visualization, but not the LSG-authoritative rewrite + source. + +.. code-block:: python + :caption: Building an LSG/ITS graph with Lewis-state fields + :linenos: + + from synkit.IO import rsmi_to_its + + rsmi = "[CH3:1][Cl:2].[NH3:3]>>[CH3:1][NH3+:3].[Cl-:2]" + its = rsmi_to_its(rsmi, format="tuple", core=False) + + print(its.nodes[2]["lone_pairs"]) + print(its.edges[1, 2]["sigma_order"]) + +.. note:: + + Aromatic LSG matching is intentionally conservative. Aromaticity is still + useful for presentation and pruning, but full aromatic-system relabeling is + tracked as ongoing work. + Example: Construct and Visualize an ITS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -232,61 +287,172 @@ The ``synkit.Graph.MTG`` package provides tools for constructing and analyzing - :py:class:`~synkit.Graph.MTG.mcs_matcher.MCSMatcher` — maximum common substructure mappings - :py:class:`~synkit.Graph.MTG.mtg.MTG` — MTG construction from ITS graphs and MCS mapping -Example: Generate an MTG (with Composite Reaction Visualization) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This example builds reaction-center ITS graphs for two mechanistic sequences, constructs -MTGs, and visualizes both MTG-style centers and minimal centers (without MTG annotations). +The current MTG direction is aligned with LSG/ITS. Invariant atom data such +as ``element`` and ``atom_map`` should be stored once, while temporal fields +such as ``charge``, ``hcount``, ``lone_pairs``, ``radical``, +``sigma_order``, and ``pi_order`` store compact histories across snapshots. +This avoids redundant ``*_step_history`` attributes and makes MTG-to-ITS +round trips easier to inspect. .. code-block:: python - :caption: Building and visualizing MTGs for aldol mechanisms + :caption: MTG to ordered ITS steps :linenos: from synkit.Graph.MTG.mtg import MTG - from synkit.Graph.ITS.its_decompose import get_rc - from synkit.examples import load_example - import matplotlib.pyplot as plt - from synkit.Vis.graph_visualizer import GraphVisualizer - data = load_example("aldol") + mtg = MTG([step_1_its, step_2_its]) + step_its = mtg.get_its_steps() + composed = mtg.get_compose_its() - mech_neutral = data[0]['mechanisms'][1]['steps'] - smart_neutral = [i['smart_string'] for i in mech_neutral] +When an MTG is built from RSMI strings, SynKit 1.4.0 converts those strings +to Lewis State Graph ITS by default: - mech_acid = data[0]['mechanisms'][2]['steps'] - smart_acid = [i['smart_string'] for i in mech_acid] +.. code-block:: python + :caption: RSMI sequence to LSG MTG + :linenos: - # neutral - mtg = MTG(smart_neutral, mcs_mol=True) - its_neutral = mtg.get_compose_its() - mtg_rc_neutral = get_rc(its_neutral, keep_mtg=True) - rc_neutral = get_rc(its_neutral, keep_mtg=False) + mtg = MTG(step_rsmis, mcs_mol=True) - # acid - mtg = MTG(smart_acid, mcs_mol=True) - its_acid = mtg.get_compose_its() - mtg_rc_acid = get_rc(its_acid, keep_mtg=True) - rc_acid = get_rc(its_acid, keep_mtg=False) +Legacy string conversion is still available for compatibility: - fig, ax = plt.subplots(2, 2, figsize=(16, 8)) - vis = GraphVisualizer() +.. code-block:: python + :caption: Legacy MTG from RSMI strings + :linenos: - vis.plot_its(mtg_rc_neutral, ax=ax[0, 0], use_edge_color=True, og=True, title='A. MTG (neutral)') - vis.plot_its(rc_neutral, ax=ax[0, 1], use_edge_color=True, og=True, title='B. Reaction center (neutral)') - vis.plot_its(mtg_rc_acid, ax=ax[1, 0], use_edge_color=True, og=True, title='C. MTG (acid)') - vis.plot_its(rc_acid, ax=ax[1, 1], use_edge_color=True, og=True, title='D. Reaction center (acid)') + mtg = MTG(step_rsmis, mcs_mol=True, its_format="typesGH") + +Compact MTG data model +~~~~~~~~~~~~~~~~~~~~~~ + +An LSG-backed MTG is a normal ``networkx.Graph``. Node attributes split into +two categories: + +.. list-table:: + :header-rows: 1 + + * - Attribute type + - Examples + - Meaning + * - Invariant atom fields + - ``element``, ``atom_map``, ``valence_electrons`` + - Stored once because the atom identity does not change across the + mechanism. + * - State timelines + - ``hcount``, ``charge``, ``radical``, ``lone_pairs``, ``present`` + - Tuples with one value per mechanism state. For ``n`` elementary + steps, these timelines have length ``n + 1``. + * - Bond timelines + - ``kekule_order``, ``sigma_order``, ``pi_order`` + - Tuples with one bond state per mechanism state. ``None`` means the + bond or one endpoint is outside that state; ``0`` means both atoms are + present but no bond exists. + +This compact form intentionally avoids legacy ``typesGH`` and redundant +``*_step_history`` attributes in the new Lewis State Graph path. + +Example: LSG MTG changed core +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example reads a stepwise aldol mechanism, constructs an LSG-backed MTG +directly from the RSMI strings, and visualizes the changed core. The default +MTG string conversion uses ``format="tuple"`` internally, so the result stores +Lewis-state timelines rather than legacy ``typesGH`` fields. - plt.tight_layout() - plt.show() +.. code-block:: python + :caption: Building and visualizing a compact LSG MTG + :linenos: + + from synkit.IO import load_database + from synkit.Graph.MTG.mtg import MTG + from synkit.Vis import draw_mtg_graph + + data = load_database("Data/Testcase/mech.json.gz")[0] + neutral = data["mechanisms"][1] + steps = [step["smart_string"] for step in neutral["steps"]] + + mtg = MTG(steps, mcs_mol=True) + graph = mtg.get_mtg() + + assert mtg._tuple_its + assert not any("typesGH" in attrs for _, attrs in graph.nodes(data=True)) + + fig, ax = draw_mtg_graph( + mtg, + title=f"{neutral['mech_name']} - changed core", + changed_only=True, + show_edge_labels=True, + compress=True, + ) + +``compress=True`` labels only the first and final state of each changed edge. +Use ``compress=False`` when debugging the full mechanism-state sequence. .. container:: figure - .. image:: ./figures/mtg_mechanism.png - :alt: Composite ITS and MTG visualization + .. image:: ./figures/mtg_lsg_changed_core.png + :alt: Compact LSG MTG changed-core visualization :align: center - :width: 1000px + :width: 760px + + *Figure:* LSG MTG changed-core view for the neutral aldol mechanism. + Green edges are net formed, red edges are net broken, and pink dashed edges + are transient timelines that change internally but have the same compressed + first/final state. + +Round-trip helpers +~~~~~~~~~~~~~~~~~~ + +MTGs can be projected back to their ordered ITS steps or to a composed +outer-state ITS: + +.. code-block:: python + :caption: MTG projections + :linenos: + + step_its = mtg.get_its_steps() + step_rsmi = mtg.get_rsmi_steps() + composed = mtg.get_compose_its() + +Use ``get_its_steps()`` when validating temporal history. Use +``get_compose_its()`` when you need the net start/end reaction encoded as a +single ITS graph. + +Functional Groups +----------------- + +The ``synkit.Graph.FG`` package detects functional groups directly on SynKit +molecular ``networkx`` graphs. It avoids an external FG representation and +returns labels in graph/node-index space. + +Core APIs: + +- :py:class:`~synkit.Graph.FG.detector.FunctionalGroupDetector` +- :py:func:`~synkit.Graph.FG.api.smiles_to_graph_and_functional_groups` +- :py:class:`~synkit.Graph.FG.audit.FunctionalGroupAudit` + +.. code-block:: python + :caption: Functional groups from SMILES + :linenos: + + from synkit.Graph.FG import smiles_to_graph_and_functional_groups + + graph, groups = smiles_to_graph_and_functional_groups( + "CC(=O)OC1=CC=CC=C1C(=O)O" + ) + + print(groups) + +.. admonition:: Example output + :class: note synkit-example-output + + .. code-block:: text + + [('ester', (2, 3, 4)), ('carboxylic_acid', (11, 12, 13))] - *Figure:* Composite MTG visualization for aldol addition under neutral and acidic conditions. +Detection is hierarchical: specific labels such as ``carboxylic_acid`` can +suppress generic nested labels such as ``carbonyl`` when the broader label +would be less useful. Public labels cover common carbonyl/acyl, oxygen, +nitrogen/C=N, sulfur, boron, silicon, phosphorus, and heteroaromatic families. Context graph ------------- diff --git a/doc/index.rst b/doc/index.rst index 6347f68..eb9f17c 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -34,6 +34,45 @@ production workflows. :align: center :width: 100% +Version 1.4.0 highlights +------------------------ + +.. raw:: html + +

+ +.. grid:: 1 1 2 4 + :gutter: 2 + + .. grid-item-card:: :octicon:`zap` Lewis State Graph + :class-card: sk-feature-card + + LSG stores sigma/pi orders, lone pairs, radicals, valence electrons, and + charge recomputation metadata for the pure-Python reactor. + + .. grid-item-card:: :octicon:`filter` Functional Groups + :class-card: sk-feature-card + + Native ``networkx`` functional-group detection now replaces the previous + external FG helper and returns graph-indexed labels. + + .. grid-item-card:: :octicon:`git-branch` MTG + :class-card: sk-feature-card + + Mechanistic Transition Graphs keep temporal bond histories and can be + projected back to ordered ITS steps or composed transformations. + + .. grid-item-card:: :octicon:`eye` Visualization + :class-card: sk-feature-card + + Modern drawers cover molecule graphs, reaction panels, ITS-only views, + Lewis-state labels, and compact MTG timelines. + .. Core features .. ------------- diff --git a/doc/synthesis.rst b/doc/synthesis.rst index d96b8ad..2edbfca 100644 --- a/doc/synthesis.rst +++ b/doc/synthesis.rst @@ -70,6 +70,22 @@ Reactor parameters - ``'comp'``: component-aware matching (fastest; recommended for multi-component SMILES) - ``'all'``: exhaustive arbitrary subgraph search (most expensive) - ``'bt'``: fallback strategy (tries ``comp`` first, then ``all`` if no match is found) + * - ``template_format`` + - str + - ``'typesGH'`` + - ITS representation used when the template is a reaction string. + Use ``'tuple'`` for the Lewis State Graph representation. + * - ``electron_diagnostics`` + - bool + - ``False`` + - When ``True``, keep Lewis-state accounting diagnostics on generated ITS + objects. This is useful when inspecting charge, lone-pair, or radical + recomputation. The option name remains ``electron_diagnostics`` for API + compatibility. + * - ``automorphism`` + - bool + - ``True`` + - Deduplicate symmetry-equivalent matches before rewriting. Example: Forward Prediction (NetworkX) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -174,6 +190,75 @@ while keeping ``explicit_h=False``. '[CH3:1][CH3:2].[CH:3]([CH:4]=[O:5])=[O:6]>>[CH3:1][CH:2]=[CH:3][CH:4]=[O:5].[OH2:6]' ] +Lewis State Graph Templates +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The NetworkX reactor can consume Lewis State Graph (LSG) templates. This is +the SynKit-native path for transformations where valence-state information +matters: lone pairs, radicals, valence electrons, and sigma/pi bond components +are stored in the template and used during matching/rewrite. In the current API +LSG construction is requested with ``format="tuple"``. + +There are two common entry points: + +.. code-block:: python + :caption: Build the LSG template explicitly + :linenos: + + from synkit.IO import rsmi_to_its + from synkit.Synthesis.Reactor.syn_reactor import SynReactor + + smart = "[NH3:1].[CH3:2][Cl:3]>>[NH3+:1][CH3:2].[Cl-:3]" + substrate = "CCl.N" + template = rsmi_to_its(smart, core=False, format="tuple") + + reactor = SynReactor( + substrate=substrate, + template=template, + implicit_temp=True, + explicit_h=False, + electron_diagnostics=True, + ) + + print(reactor.smarts) + +.. code-block:: python + :caption: Let SynReactor build an LSG template from a reaction string + :linenos: + + reactor = SynReactor( + substrate="CCl.N", + template="[NH3:1].[CH3:2][Cl:3]>>[NH3+:1][CH3:2].[Cl-:3]", + template_format="tuple", + implicit_temp=True, + explicit_h=False, + electron_diagnostics=True, + ) + +LSG rewrite policy: + +.. list-table:: + :header-rows: 1 + + * - Concept + - Policy + * - Bond truth + - ``sigma_order`` and ``pi_order`` are authoritative in new mode. + * - Product reconstruction + - ``kekule_order`` is computed from ``sigma_order + pi_order`` before + conversion through RDKit. + * - Charge + - Charge is recomputed from valence electrons, lone pairs, hydrogen count, + radical count, and Kekule bond-order sum. + * - Aromaticity + - Aromatic flags are still useful for matching and display, but aromatic + ``order=1.5`` is not used as the LSG-authoritative rewrite value. + +.. note:: + + LSG rewriting is currently a SynKit ``SynReactor`` path. MØD-backed + reactors remain on the legacy rule representation. + Example: Forward Prediction (MØD) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/vis.rst b/doc/vis.rst index 5b4d5e8..2f17018 100644 --- a/doc/vis.rst +++ b/doc/vis.rst @@ -13,6 +13,15 @@ For normal chemistry work, prefer the molecule, reaction, and ITS helpers from ``synkit.Vis``. The generic graph drawer is useful when debugging attributes or new graph representations, but it is intentionally less polished. +.. raw:: html + +
+ molecule graph + reaction panel + ITS-only + MTG timeline +
+ Molecular Graphs From SMILES ---------------------------- @@ -40,6 +49,16 @@ with atom-map-aware labels. ) ax.figure +.. container:: figure + + .. image:: ./figures/vis_molecule_aspirin.png + :alt: Molecular graph visualization of aspirin + :align: center + :width: 760px + + *Figure:* A molecular graph rendered from aspirin SMILES with aromatic rings + drawn compactly and atom indices visible. + Useful molecule options: .. list-table:: @@ -72,6 +91,15 @@ and bonds that change. show_atom_map=True, ) +.. container:: figure + + .. image:: ./figures/vis_reaction_sn2.png + :alt: Reaction panel visualization for an SN2 reaction + :align: center + :width: 820px + + *Figure:* Reactant/product panels with the reaction center highlighted. + ITS Graphs ---------- @@ -111,10 +139,10 @@ ITS edge-label modes: * - ``edge_label_mode="none"`` - Hide edge labels and use only edge color/style. -Electron Labels ---------------- +Lewis-State Labels +------------------ -For tuple/electron-aware ITS graphs, node labels can show charge, lone-pair, or +For Lewis State Graph / ITS graphs, node labels can show charge, lone-pair, or radical changes. Use one signal at a time for readable figures. .. code-block:: python @@ -132,7 +160,17 @@ radical changes. Use one signal at a time for readable figures. electron_label_mode="lone_pair", ) -Electron-label modes: +.. container:: figure + + .. image:: ./figures/vis_lsg_sn2.png + :alt: Lewis State Graph visualization for SN2 lone-pair changes + :align: center + :width: 820px + + *Figure:* LSG/ITS view of the SN2 example. Bond colors show broken/formed + edges and the node badges show a lone-pair transfer. + +Lewis-state label modes: .. list-table:: :header-rows: 1 @@ -146,7 +184,8 @@ Electron-label modes: * - ``electron_label_mode="radical"`` - Show radical changes. * - ``electron_label_mode="all"`` - - Show every changed electron attribute. This is useful for debugging but can be busy. + - Show every changed Lewis-state attribute. This is useful for debugging + but can be busy. Reactant/Product Projections ---------------------------- @@ -182,12 +221,23 @@ Compact MTG visualization has two complementary views: from synkit.Graph.MTG.mtg import MTG from synkit.Vis import draw_mtg_graph, draw_mtg_steps - mtg = MTG([step_1_its, step_2_its]) + # Step RSMI strings are converted to Lewis State Graph ITS by default. + mtg = MTG(step_rsmis, mcs_mol=True) fig, ax = draw_mtg_graph( mtg, - title="MTG timeline", + title="MTG changed core", mode="timeline", + changed_only=True, + show_edge_labels=True, + compress=True, + ) + + fig, ax = draw_mtg_graph( + mtg, + title="MTG timeline 3D", + dimension="3d", + layout="spring", ) fig, axes = draw_mtg_steps( @@ -196,10 +246,55 @@ Compact MTG visualization has two complementary views: show_edge_labels=True, ) -Use the timeline graph to see transient bonds and electron-state paths across +.. container:: figure + + .. image:: ./figures/vis_mtg_timeline.png + :alt: Compact MTG timeline visualization + :align: center + :width: 760px + + *Figure:* Compact MTG changed-core view for the neutral aldol mechanism. + Green edges are net formed, red edges are net broken, and pink dashed edges + are transient timelines. + +.. container:: figure + + .. image:: ./figures/vis_mtg_steps.png + :alt: MTG step projection visualization + :align: center + :width: 900px + + *Figure:* Ordered ITS step panels reconstructed from the MTG, plus the + composed outer-state view. + +Use the timeline graph to see transient bonds and Lewis-state paths across the mechanism. Use the step panels when you need to check each reconstructed ITS independently. +The 2D view is the default and gives a flattened changed-core drawing. The 3D +view is optional and is useful when a dense MTG has too many overlapping +timeline edges in a single plane. + +MTG display conventions: + +.. list-table:: + :header-rows: 1 + + * - Signal + - Display + * - Edge timeline + - Compressed first/final state by default, such as ``1-1``. Set + ``compress=False`` to show a full state path such as ``1-2-1-2-1``. + ``∅`` means the edge or atom is outside that state. + * - Formed / broken edge + - Green for net formation, red for net loss. + * - Transient edge + - Pink dashed edge for any changing timeline that is not simple net + formation or net loss. + * - Step panels + - Reuse the ITS-only renderer so a step can be checked with the same + visual language as a normal LSG/ITS graph. + Diagnostic Graph View --------------------- diff --git a/pyproject.toml b/pyproject.toml index f9223db..eb4edbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "synkit" -version = "1.3.2b1" +version = "1.4.0" description = "Utility for reaction modeling using graph grammar" readme = "README.md" long-description = { file = "CHANGELOG.md" } diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 99851a8..ba84e89 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -1,6 +1,6 @@ package: name: synkit - version: "1.3.2" + version: "1.4.0" source: path: .. diff --git a/synkit/Graph/MTG/mtg.py b/synkit/Graph/MTG/mtg.py index f033d00..c2212cb 100644 --- a/synkit/Graph/MTG/mtg.py +++ b/synkit/Graph/MTG/mtg.py @@ -34,7 +34,7 @@ compute_standard_order, ) from synkit.Graph.canon_graph import GraphCanonicaliser -from synkit.IO import its_to_rsmi, rsmi_to_its +from synkit.IO import ITSFormat, its_to_rsmi, rsmi_to_its NodeID = int MissingOrder = Tuple[Set[float], Set[float]] @@ -63,6 +63,9 @@ class MTG: :param mappings: Optional list of precomputed mappings; computed via MCS if None. :param node_label_names: Keys for node-label matching. :param canonicaliser: Optional GraphCanonicaliser for snapshot canonicalisation. + :param its_format: ITS format used when ``sequences`` contains RSMI strings. + Defaults to ``"tuple"`` for Lewis State Graph MTGs. Pass + ``"typesGH"`` to build legacy MTGs from strings. :raises ValueError: On invalid sequence or mapping lengths. :raises RuntimeError: On mapping failures. """ @@ -76,6 +79,7 @@ def __init__( canonicaliser: GraphCanonicaliser | None = None, mcs_mol: bool = False, mcs: bool = False, + its_format: ITSFormat = "tuple", ) -> None: if len(sequences) < 2: raise ValueError("Need at least two snapshots.") @@ -84,6 +88,7 @@ def __init__( self._canonicaliser = canonicaliser self.mcs_mol = mcs_mol self.mcs = mcs + self.its_format = its_format self._graphs = self._prepare_graph_sequence(sequences) self._k = len(self._graphs) @@ -443,7 +448,11 @@ def _prepare_graph_sequence( ) -> List[nx.Graph]: out: List[nx.Graph] = [] for item in seq: - g = rsmi_to_its(item, core=False) if isinstance(item, str) else item + g = ( + rsmi_to_its(item, core=False, format=self.its_format) + if isinstance(item, str) + else item + ) if self._canonicaliser: g = self._canonicaliser.canonicalise_graph(g).canonical_graph if self._is_tuple_its(g): diff --git a/synkit/Vis/mtg_drawer.py b/synkit/Vis/mtg_drawer.py index 3070865..4c3dd89 100644 --- a/synkit/Vis/mtg_drawer.py +++ b/synkit/Vis/mtg_drawer.py @@ -4,16 +4,39 @@ The compact MTG view is a timeline diagnostic. Step panels reuse the molecule- like ITS renderer so each reconstructed ITS step is inspected with the same -visual language as normal tuple ITS drawings. +visual language as normal Lewis State Graph / ITS drawings. """ -from typing import Any, Iterable, Optional +from typing import Any, Iterable, Mapping, Optional +import matplotlib.lines as mlines import matplotlib.pyplot as plt import networkx as nx +from mpl_toolkits.mplot3d import Axes3D # noqa: F401 from synkit.Vis.its_drawer import draw_its_only -from synkit.Vis.visual_drawer import draw_graph + +ELEMENT_COLORS = { + "H": "#ffffff", + "C": "#f8fafc", + "N": "#bfdbfe", + "O": "#fecaca", + "F": "#bbf7d0", + "Cl": "#bbf7d0", + "Br": "#fed7aa", + "I": "#ddd6fe", + "S": "#fde68a", + "P": "#fecdd3", + "B": "#e7e5e4", + "Si": "#e9d5ff", +} + +EDGE_STYLES = { + "unchanged": ("#94a3b8", "solid", 1.7), + "formed": ("#15803d", "solid", 3.1), + "broken": ("#b91c1c", "solid", 3.1), + "transient": ("#ec4899", "dashed", 3.0), +} def draw_mtg_graph( @@ -25,7 +48,13 @@ def draw_mtg_graph( layout: str = "kamada_kawai", show_atom_map: bool = True, show_edge_labels: bool = True, - show_node_badges: bool = True, + show_node_badges: bool = False, + hydrogen_mode: str = "changed", + changed_only: bool = False, + compress: bool = True, + show_step_axis: bool = False, + dimension: str = "2d", + seed: int = 7, ) -> tuple[plt.Figure, plt.Axes]: """Draw a compact MTG timeline graph. @@ -38,25 +67,52 @@ def draw_mtg_graph( :type ax: Optional[plt.Axes] :param title: Optional title. :type title: Optional[str] - :param mode: Visual adapter mode. ``"timeline"`` is the recommended MTG - view; ``"sigma_pi"`` gives a shorter electron-bond diagnostic. + :param mode: Label mode. ``"timeline"`` is the recommended MTG view; + ``"sigma_pi"`` gives a shorter Lewis-state bond diagnostic when + sigma/pi timelines are present. :type mode: str - :param layout: NetworkX layout name passed to ``draw_graph``. + :param layout: NetworkX layout name: ``"kamada_kawai"``, ``"spring"``, + ``"circular"``, or ``"shell"``. :type layout: str + :param hydrogen_mode: Hydrogen display policy. ``"changed"`` keeps only + hydrogens participating in changing edges, ``"all"`` keeps all, and + ``"none"`` hides all hydrogens. + :type hydrogen_mode: str + :param changed_only: If True, hide unchanged edges and isolated nodes. + :type changed_only: bool + :param compress: If True, edge labels show only first and final state. + If False, edge labels show the full mechanism-state timeline. + :type compress: bool + :param show_step_axis: Draw a compact state axis under the graph. + :type show_step_axis: bool + :param dimension: Draw as ``"2d"`` or ``"3d"``. The 3D mode uses a + spring layout with ``dim=3`` and is helpful for dense changed cores. + :type dimension: str :returns: ``(figure, axes)``. :rtype: tuple[plt.Figure, plt.Axes] """ + if dimension not in {"2d", "3d"}: + raise ValueError("dimension must be '2d' or '3d'") graph = _as_mtg_graph(mtg) - return draw_graph( + display = _mtg_display_graph( graph, - ax=ax, mode=mode, - title=title or "MTG timeline", show_atom_map=show_atom_map, + show_node_badges=show_node_badges, + hydrogen_mode=hydrogen_mode, + changed_only=changed_only, + compress=compress, + ) + return _draw_mtg_display( + display, + ax=ax, + title=title or "MTG timeline", layout=layout, show_edge_labels=show_edge_labels, - show_node_badges=show_node_badges, + show_step_axis=show_step_axis, + dimension=dimension, + seed=seed, ) @@ -102,7 +158,9 @@ def draw_mtg_steps( panels = [(f"Step {step + 1}", all_steps[step]) for step in selected] if include_composed: if not hasattr(mtg, "get_compose_its"): - raise TypeError("include_composed requires an MTG object with get_compose_its().") + raise TypeError( + "include_composed requires an MTG object with get_compose_its()." + ) panels.append(("Composed", mtg.get_compose_its())) if not panels: @@ -134,7 +192,7 @@ def draw_mtg_steps( electron_label_mode=electron_label_mode, ) - for ax in axes[len(panels):]: + for ax in axes[len(panels) :]: # noqa ax.set_axis_off() fig.tight_layout() @@ -149,3 +207,563 @@ def _as_mtg_graph(mtg: Any) -> nx.Graph: if isinstance(graph, nx.Graph): return graph raise TypeError("Expected an MTG object or a NetworkX compact MTG graph.") + + +def _mtg_display_graph( + graph: nx.Graph, + *, + mode: str, + show_atom_map: bool, + show_node_badges: bool, + hydrogen_mode: str, + changed_only: bool, + compress: bool, +) -> nx.Graph: + if hydrogen_mode not in {"changed", "all", "none"}: + raise ValueError("hydrogen_mode must be one of: changed, all, none") + + edge_info = { + _edge_key(u, v): _edge_visual(attrs, mode=mode, compress=compress) + for u, v, attrs in graph.edges(data=True) + } + changed_incident = { + node + for (u, v), info in edge_info.items() + if info["state"] != "unchanged" + for node in (u, v) + } + + display = nx.Graph() + for node, attrs in graph.nodes(data=True): + element = str(_first_present(attrs.get("element")) or "") + atom_map = _first_present(attrs.get("atom_map")) + if element == "H": + if hydrogen_mode == "none": + continue + if hydrogen_mode == "changed" and atom_map in (None, 0): + continue + if hydrogen_mode == "changed" and node not in changed_incident: + continue + if changed_only and node not in changed_incident: + continue + + label = _node_label(node, attrs, show_atom_map=show_atom_map) + badges = _node_badges(attrs) if show_node_badges else [] + display.add_node( + node, + label=label, + badges=tuple(badges), + element=element, + changed=bool(badges) or node in changed_incident, + fill=ELEMENT_COLORS.get(element, "#f3f4f6"), + ) + + for u, v, attrs in graph.edges(data=True): + key = _edge_key(u, v) + info = edge_info[key] + if changed_only and info["state"] == "unchanged": + continue + if u not in display or v not in display: + continue + display.add_edge(u, v, **info, raw=dict(attrs)) + + display.graph["steps"] = _infer_state_count(graph) + return display + + +def _draw_mtg_display( + graph: nx.Graph, + *, + ax: Optional[plt.Axes], + title: str, + layout: str, + show_edge_labels: bool, + show_step_axis: bool, + dimension: str, + seed: int, +) -> tuple[plt.Figure, plt.Axes]: + if ax is None: + fig = plt.figure(figsize=_figure_size(graph), facecolor="white") + ax = ( + fig.add_subplot(111, projection="3d") + if dimension == "3d" + else fig.add_subplot(111) + ) + else: + fig = ax.figure + + pos = _layout(graph, layout=layout, dimension=dimension, seed=seed) + ax.clear() + ax.set_axis_off() + if dimension == "2d": + ax.set_aspect("equal") + ax.set_title(title, fontsize=13, fontweight="bold", pad=12) + + if dimension == "3d": + _draw_mtg_display_3d( + graph, + pos, + ax=ax, + show_edge_labels=show_edge_labels, + ) + _draw_legend(ax) + fig.tight_layout() + return fig, ax + + for state in ("unchanged", "formed", "broken", "transient"): + edges = [ + (u, v) + for u, v, attrs in graph.edges(data=True) + if attrs.get("state") == state + ] + if not edges: + continue + color, style, width = EDGE_STYLES[state] + nx.draw_networkx_edges( + graph, + pos, + ax=ax, + edgelist=edges, + edge_color=color, + style=style, + width=width, + alpha=0.88 if state != "unchanged" else 0.38, + ) + + nodes = list(graph.nodes(data=True)) + if nodes: + nx.draw_networkx_nodes( + graph, + pos, + ax=ax, + node_color=[attrs["fill"] for _, attrs in nodes], + edgecolors=[ + "#f97316" if attrs.get("changed") else "#475569" for _, attrs in nodes + ], + linewidths=[2.6 if attrs.get("changed") else 1.2 for _, attrs in nodes], + node_size=[ + 760 if attrs.get("element") != "H" else 500 for _, attrs in nodes + ], + ) + nx.draw_networkx_labels( + graph, + pos, + labels={ + node: _stack_node_label(attrs) for node, attrs in graph.nodes(data=True) + }, + ax=ax, + font_size=8, + font_weight="bold", + font_color="#111827", + ) + + if show_edge_labels: + edge_labels = { + (u, v): attrs["label"] + for u, v, attrs in graph.edges(data=True) + if attrs.get("label") + } + if edge_labels: + nx.draw_networkx_edge_labels( + graph, + pos, + edge_labels=edge_labels, + ax=ax, + font_size=7, + rotate=False, + font_color="#111827", + bbox={ + "boxstyle": "round,pad=0.18", + "fc": "white", + "ec": "#cbd5e1", + "alpha": 0.94, + }, + ) + + _draw_legend(ax) + if show_step_axis: + _draw_step_axis(ax, graph.graph.get("steps", 0)) + _pad_limits(ax, pos) + fig.tight_layout() + return fig, ax + + +def _draw_mtg_display_3d( + graph: nx.Graph, + pos: Mapping[Any, Any], + *, + ax: plt.Axes, + show_edge_labels: bool, +) -> None: + for state in ("unchanged", "formed", "broken", "transient"): + color, style, width = EDGE_STYLES[state] + alpha = 0.88 if state != "unchanged" else 0.28 + for u, v, attrs in graph.edges(data=True): + if attrs.get("state") != state: + continue + p0 = pos[u] + p1 = pos[v] + ax.plot( + [p0[0], p1[0]], + [p0[1], p1[1]], + [p0[2], p1[2]], + color=color, + linestyle=style, + linewidth=width, + alpha=alpha, + ) + if show_edge_labels and attrs.get("label"): + mid = ((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2, (p0[2] + p1[2]) / 2) + ax.text( + *mid, + attrs["label"], + fontsize=7, + color="#111827", + ha="center", + va="center", + ) + + for node, attrs in graph.nodes(data=True): + x, y, z = pos[node] + edge_color = "#f97316" if attrs.get("changed") else "#475569" + size = 430 if attrs.get("element") != "H" else 320 + ax.scatter( + [x], + [y], + [z], + s=size, + c=[attrs["fill"]], + edgecolors=[edge_color], + linewidths=1.5, + depthshade=True, + ) + ax.text( + x, + y, + z + 0.12, + _stack_node_label(attrs), + fontsize=8.5, + fontweight="bold", + color="#111827", + ha="center", + va="center", + bbox={ + "boxstyle": "round,pad=0.08", + "fc": "white", + "ec": "none", + "alpha": 0.78, + }, + ) + + +def _edge_visual( + attrs: Mapping[str, Any], + *, + mode: str, + compress: bool, +) -> dict[str, Any]: + preferred = _preferred_timeline(attrs, mode=mode) + state = _timeline_state(preferred) + label = _timeline_label( + attrs, + preferred, + mode=mode, + state=state, + compress=compress, + ) + color, style, width = EDGE_STYLES[state] + return { + "history": tuple(preferred), + "state": state, + "label": label, + "color": color, + "style": style, + "width": width, + } + + +def _preferred_timeline(attrs: Mapping[str, Any], *, mode: str) -> tuple[Any, ...]: + if mode == "sigma_pi": + sigma = _coerce_timeline(attrs.get("sigma_order")) + pi = _coerce_timeline(attrs.get("pi_order")) + if _changes(sigma) or _changes(pi): + return tuple( + None if s is None and p is None else _none_order(s) + _none_order(p) + for s, p in zip(_pad(sigma, pi), _pad(pi, sigma)) + ) + for key in ("kekule_order", "order", "sigma_order", "pi_order"): + timeline = _coerce_timeline(attrs.get(key)) + if timeline: + return timeline + return () + + +def _timeline_label( + attrs: Mapping[str, Any], + preferred: tuple[Any, ...], + *, + mode: str, + state: str, + compress: bool, +) -> str: + if state == "unchanged": + return "" + timeline = _compressed_timeline(preferred) if compress else preferred + if mode == "sigma_pi": + parts = [] + for key, prefix in (("sigma_order", "σ"), ("pi_order", "π")): + part_timeline = _coerce_timeline(attrs.get(key)) + if part_timeline and _changes(_known_timeline(part_timeline)): + part_timeline = ( + _compressed_timeline(part_timeline) if compress else part_timeline + ) + parts.append(f"{prefix}:{_format_timeline(part_timeline)}") + if parts: + return " ".join(parts) + return _format_timeline(timeline) + + +def _coerce_timeline(value: Any) -> tuple[Any, ...]: + if not isinstance(value, tuple): + return () + if value and all(_is_step_pair(item) for item in value): + history = [] + for idx, pair in enumerate(value): + left, right = pair + if idx == 0: + history.append(_clean_order(left)) + history.append(_clean_order(right)) + return tuple(history) + if value and not any(isinstance(item, (tuple, list, dict, set)) for item in value): + return value + return () + + +def _is_step_pair(value: Any) -> bool: + return isinstance(value, tuple) and len(value) == 2 + + +def _clean_order(value: Any) -> Any: + if isinstance(value, set): + return None + return value + + +def _timeline_state(timeline: tuple[Any, ...]) -> str: + known = _known_timeline(timeline) + numeric = [_none_order(value) for value in known] + if not numeric or len(set(numeric)) == 1: + return "unchanged" + if numeric[0] == numeric[-1]: + return "transient" + if numeric[0] == 0 and numeric[-1] > 0: + return "formed" + if numeric[0] > 0 and numeric[-1] == 0: + return "broken" + return "transient" + + +def _node_label( + node: Any, + attrs: Mapping[str, Any], + *, + show_atom_map: bool, +) -> str: + element = _first_present(attrs.get("element")) or str(node) + atom_map = _first_present(attrs.get("atom_map")) + if show_atom_map and atom_map not in (None, 0): + return f"{element}:{atom_map}" + if show_atom_map: + return f"{element}:{node}" + return str(element) + + +def _node_badges(attrs: Mapping[str, Any]) -> list[str]: + badges = [] + for key, label in ( + ("charge", "q"), + ("hcount", "H"), + ("lone_pairs", "lp"), + ("radical", "rad"), + ): + timeline = _coerce_node_timeline(attrs.get(key)) + if timeline and _changes(timeline): + badges.append(f"{label}:{_format_timeline(timeline)}") + return badges[:2] + + +def _coerce_node_timeline(value: Any) -> tuple[Any, ...]: + if ( + isinstance(value, tuple) + and value + and all(_is_step_pair(item) for item in value) + ): + return _coerce_timeline(value) + if isinstance(value, tuple) and len(value) >= 3: + return value + if isinstance(value, tuple) and len(value) == 2: + return value + return () + + +def _stack_node_label(attrs: Mapping[str, Any]) -> str: + label = str(attrs.get("label", "")) + badges = attrs.get("badges") or () + return f"{label}\n{' '.join(badges)}" if badges else label + + +def _format_timeline(timeline: tuple[Any, ...]) -> str: + return "→".join(_format_order(value) for value in timeline) + + +def _compressed_timeline(timeline: tuple[Any, ...]) -> tuple[Any, ...]: + if len(timeline) <= 2: + return timeline + return (timeline[0], timeline[-1]) + + +def _trim_timeline(timeline: tuple[Any, ...]) -> tuple[Any, ...]: + if len(timeline) <= 2: + return timeline + start = 0 + end = len(timeline) + while start + 1 < end and timeline[start] == timeline[start + 1]: + start += 1 + while end - 2 >= start and timeline[end - 1] == timeline[end - 2]: + end -= 1 + return timeline[start:end] + + +def _format_order(value: Any) -> str: + if value is None: + return "∅" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _none_order(value: Any) -> float: + return 0.0 if value is None else float(value) + + +def _changes(timeline: tuple[Any, ...]) -> bool: + return bool(timeline) and len(set(timeline)) > 1 + + +def _known_timeline(timeline: tuple[Any, ...]) -> tuple[Any, ...]: + start = 0 + end = len(timeline) + while start < end and timeline[start] is None: + start += 1 + while end > start and timeline[end - 1] is None: + end -= 1 + return timeline[start:end] + + +def _pad(first: tuple[Any, ...], second: tuple[Any, ...]) -> tuple[Any, ...]: + if len(first) >= len(second): + return first + return first + (None,) * (len(second) - len(first)) + + +def _first_present(value: Any) -> Any: + if isinstance(value, tuple): + for item in value: + if isinstance(item, tuple): + for side in item: + if side not in (None, set()): + return side + elif item is not None: + return item + return None + return value + + +def _edge_key(u: Any, v: Any) -> tuple[Any, Any]: + return (u, v) if str(u) <= str(v) else (v, u) + + +def _infer_state_count(graph: nx.Graph) -> int: + max_len = 0 + for _, _, attrs in graph.edges(data=True): + for key in ("kekule_order", "order", "sigma_order", "pi_order"): + max_len = max(max_len, len(_coerce_timeline(attrs.get(key)))) + return max_len + + +def _layout( + graph: nx.Graph, + *, + layout: str, + dimension: str, + seed: int, +) -> dict[Any, Any]: + if graph.number_of_nodes() == 0: + return {} + if dimension == "3d": + if layout not in {"spring", "kamada_kawai"}: + raise ValueError("3D MTG layout supports: spring, kamada_kawai") + return nx.spring_layout(graph, seed=seed, k=1.15, iterations=160, dim=3) + if layout == "spring": + return nx.spring_layout(graph, seed=seed, k=1.15, iterations=120) + if layout == "kamada_kawai": + return nx.kamada_kawai_layout(graph) + if layout == "circular": + return nx.circular_layout(graph) + if layout == "shell": + return nx.shell_layout(graph) + raise ValueError("layout must be one of: spring, kamada_kawai, circular, shell") + + +def _figure_size(graph: nx.Graph) -> tuple[float, float]: + n_nodes = max(1, graph.number_of_nodes()) + return min(14.0, max(7.0, n_nodes * 0.78)), min(10.0, max(5.2, n_nodes * 0.55)) + + +def _draw_legend(ax: plt.Axes) -> None: + handles = [ + mlines.Line2D( + [], [], color=color, linestyle=style, linewidth=width, label=label + ) + for label, (color, style, width) in ( + ("formed", EDGE_STYLES["formed"]), + ("broken", EDGE_STYLES["broken"]), + ("transient", EDGE_STYLES["transient"]), + ) + ] + ax.legend( + handles=handles, + loc="upper right", + bbox_to_anchor=(1.0, 1.0), + frameon=False, + fontsize=8, + ncol=1, + ) + + +def _draw_step_axis(ax: plt.Axes, states: int) -> None: + if states <= 1: + return + text = "states " + " → ".join(f"S{i}" for i in range(states)) + ax.text( + 0.5, + -0.045, + text, + transform=ax.transAxes, + ha="center", + va="top", + fontsize=8, + color="#475569", + ) + + +def _pad_limits(ax: plt.Axes, pos: Mapping[Any, Any]) -> None: + if not pos: + return + xs = [point[0] for point in pos.values()] + ys = [point[1] for point in pos.values()] + x_span = max(xs) - min(xs) + y_span = max(ys) - min(ys) + pad = max(x_span, y_span, 1.0) * 0.25 + ax.set_xlim(min(xs) - pad, max(xs) + pad) + ax.set_ylim(min(ys) - pad, max(ys) + pad) From 0a41b89403c6349f6dc5dba3a28f88a488b27cb0 Mon Sep 17 00:00:00 2001 From: TieuLongPhan Date: Tue, 26 May 2026 10:46:32 +0200 Subject: [PATCH 7/7] fix version --- .github/workflows/docker-publish.yml | 6 +++--- .github/workflows/test-and-lint.yml | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 363e899..18b04c8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -20,16 +20,16 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU (optional, for multi‑arch) uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 1529047..52934be 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -20,11 +20,11 @@ jobs: steps: # 0) Check out the code - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 # 1) Install Miniconda (downloaded — the “bundled” version was removed) - name: Set up Miniconda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 with: miniconda-version: "latest" # <<–‑‑ mandatory or the action fails python-version: "3.11"