From e554243383f91306d9bd9ff96a0b999d4cd0b81c Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Thu, 19 Oct 2023 11:31:12 -0400 Subject: [PATCH 01/11] Add O2 and CO fixes and and improve comments for remedies --- rdmc/fix.py | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/rdmc/fix.py b/rdmc/fix.py index 7086ce30..47063dfa 100644 --- a/rdmc/fix.py +++ b/rdmc/fix.py @@ -13,24 +13,42 @@ DEFAULT_REMEDIES = [ + # Remedy 1 - Carbon monoxide: [C]=O to [C-]#[O+] + rdChemReactions.ReactionFromSmarts( + "[O+0-0v2X1:1]=[C+0-0v2X1:2]>>[O+1v3X1:1]#[C-1v3X1:2]" + ), + # Remedy 2 - Oxygen Molecule: O=O to [O]-[O] + rdChemReactions.ReactionFromSmarts( + "[O+0-0v2X1:1]=[O+0-0v2X1:2]>>[O+0-0v1X1:1]-[O+0-0v1X1:2]" + ), + # Remedy 3 - isocyanide: R[N]#[C] to R[N+]#[C-] rdChemReactions.ReactionFromSmarts( "[N+0-0v4X2:1]#[C+0-0v3X1:2]>>[N+v4X2:1]#[C-v3X1:2]" - ), # R1N#[C.] to R1[N+]#[C-] + ), + # Remedy 4 - amine radical: RC(R)-N(R)(R)R to R[C-](R)-[N+](R)(R)R rdChemReactions.ReactionFromSmarts( "[N+0-0v4X3:1]=[C+0-0v4X3:2]>>[N+0-0v3X3:1]-[C+0-0v3X3:2]" - ), # R1N(R2)=C(R3)R4 to R1N(R2)-[C.](R3)R4 + ), + # Remedy 5 - amine radical: RN(R)=C to RN(R)-[C] rdChemReactions.ReactionFromSmarts( - "[C+0-0v5X3:1]=[O+0-0v2X1:2]>>[C+0-0v4X3:1]-[O+0-0v1X1:2]" - ), # R1=C(R2)=O to R1=C(R2)-[O.] + "[N+0-0v4X3:1]=[C+0-0v4X3:2]>>[N+0-0v3X3:1]-[C+0-0v3X3:2]" + ), + # Remedy 5 - quintuple C bond, usually due to RC(=O)=O: R=C(R)=O to R=[C+](R)-[O-] rdChemReactions.ReactionFromSmarts( - "[C+0-0v3X3:1]-[N+0-0v4X4:2]-[C+0-0v4X3:3]=[O+0-0v2X1:4]>>[C-1v3X3:1]-[N+1v4X4:2]-[C+0-0v4X3:3]=[O+0-0v2X1:4]" - ), # R1C(R2)N(R3)(R4)C(R5)=O to R1[C-](R2)[N+](R3)(R4)C(R5)=O + "[C+0-0v5X3:1]=[O+0-0v2X1:2]>>[C+0-0v4X3:1]-[O+0-0v1X1:2]" + ), + # Remedy 6 - amine oxide: RN(R)(R)-O to R[N+](R)(R)-[O-] rdChemReactions.ReactionFromSmarts( "[N+0-0v4X4:1]-[O+0-0v1X1:2]>>[N+1v4X4:1]-[O-1v1X1:2]" - ), # R1N(R2)(R3)[O.] to R1[N+](R2)(R3)[O-] + ), + # Remedy 7 - criegee like molecule: RN(R)(R)-C(R)(R)=O to R[N+](R)(R)-[C](R)(R)-[O-] rdChemReactions.ReactionFromSmarts( "[N+0-0v4X4:1]-[C+0-0v4X3:2]=[O+0-0v2X1:3]>>[N+1v4X4:1]-[C+0-0v3X3:2]-[O-1v1X1:3]" - ), # R1N(R2)(R3)C(R4)=O to R1[N+](R2)(R3)[C.](R4)[O-] + ), + # Remedy 8 - criegee like molecule: RN(R)(R)-C(R)(R)=O to R[N+](R)(R)-[C](R)(R)-[O-] + rdChemReactions.ReactionFromSmarts( + "[N+0-0v4X4:1]-[C+0-0v3X3:2]-[O+0-0v1X1:3]>>[N+1v4X4:1]-[C+0-0v3X3:2]-[O-1v1X1:3]" + ), ] @@ -163,7 +181,7 @@ def fix_mol_by_remedies( def fix_mol_spin_multiplicity( - mol: 'RDKitMol', + mol: "RDKitMol", mult: int, ): """ From 83dab058d9eeee14ff58ebb51aa302802c81ccd8 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:04:02 -0400 Subject: [PATCH 02/11] Correct fix mol for amine radical --- rdmc/fix.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rdmc/fix.py b/rdmc/fix.py index 47063dfa..08adbef2 100644 --- a/rdmc/fix.py +++ b/rdmc/fix.py @@ -27,7 +27,7 @@ ), # Remedy 4 - amine radical: RC(R)-N(R)(R)R to R[C-](R)-[N+](R)(R)R rdChemReactions.ReactionFromSmarts( - "[N+0-0v4X3:1]=[C+0-0v4X3:2]>>[N+0-0v3X3:1]-[C+0-0v3X3:2]" + "[N+0-0v4X4:1]-[C+0-0v3X3:2]>>[N+1v4X4:1]-[C-1v3X3:2]" ), # Remedy 5 - amine radical: RN(R)=C to RN(R)-[C] rdChemReactions.ReactionFromSmarts( @@ -201,7 +201,7 @@ def fix_mol( remedies: List["ChemicalReaction"] = DEFAULT_REMEDIES, max_attempts: int = 10, sanitize: bool = True, - fix_spin_multiplicity: bool = True, + fix_spin_multiplicity: bool = False, mult: int = 1, ) -> "RDKitMol": """ @@ -216,7 +216,7 @@ def fix_mol( Defaults to ``10``. sanitize (bool, optional): Whether to sanitize the molecule after the fix. Defaults to ``True``. fix_spin_multiplicity (bool, optional): Whether to fix the spin multiplicity of the molecule. - Defaults to ``True``. + Defaults to ``False``. mult (int, optional): The desired spin multiplicity. Defaults to ``1``. Only used when ``fix_spin_multiplicity`` is ``True``. From 912184322915b366720a92487b1ada2145f99db0 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:05:17 -0400 Subject: [PATCH 03/11] Add unit tests for fixing O2 and CO --- test/test_fix.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/test_fix.py b/test/test_fix.py index d02aa291..ef9c8ff3 100644 --- a/test/test_fix.py +++ b/test/test_fix.py @@ -102,7 +102,7 @@ "C=CC(=O)OCC.[C-]#[N+][C](C)C", ) ]) -def test_fix_mol_1(xyz, smiles): +def test_fix_mol_from_xyz(xyz, smiles): mol = RDKitMol.FromXYZ(xyz, backend="openbabel", sanitize=False) @@ -111,3 +111,15 @@ def test_fix_mol_1(xyz, smiles): assert set(fix_mol(mol).ToSmiles().split(".")) \ == set(smiles.split(".")) + + +def test_fix_o2(): + + mol = RDKitMol.FromSmiles("O=O") + assert fix_mol(mol).ToSmiles() == "[O][O]" + + +def test_fix_co(): + + mol = RDKitMol.FromSmiles("[C]=O") + assert fix_mol(mol).ToSmiles() == "[C-]#[O+]" From 9b3c108c5bbb4dd00a87be9737ea430b8099a924 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:21:15 -0400 Subject: [PATCH 04/11] Add get element counts to mol --- rdmc/mol.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/rdmc/mol.py b/rdmc/mol.py index d0a81c52..2fdaee17 100644 --- a/rdmc/mol.py +++ b/rdmc/mol.py @@ -5,11 +5,12 @@ This module provides class and methods for dealing with RDKit RWMol, Mol. """ +from collections import Counter import copy from itertools import combinations from itertools import product as cartesian_product import traceback -from typing import Iterable, List, Optional, Sequence, Union +from typing import Dict, Iterable, List, Optional, Sequence, Union import pathlib import numpy as np @@ -825,6 +826,15 @@ def GetElementSymbols(self) -> List[str]: """ return get_element_symbols(self.GetAtomicNumbers()) + def GetElementCounts(self) -> Dict[str, int]: + """ + Get the element counts of the molecules. + + Returns: + dict: A dictionary of element counts. + """ + return dict(Counter(self.GetElementSymbols())) + def GetAtomMasses(self) -> List[float]: """ Get the mass of each atom. The order is consistent with the atom indexes. From abd00b2628d18c557c6c14c54fe0ff53a0c3f069 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:22:35 -0400 Subject: [PATCH 05/11] Force get radical resonance to return original mol if generation fails --- rdmc/mol.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rdmc/mol.py b/rdmc/mol.py index 2fdaee17..20722958 100644 --- a/rdmc/mol.py +++ b/rdmc/mol.py @@ -2047,6 +2047,10 @@ def generate_radical_resonance_structures(mol: RDKitMol, # https://github.com/rdkit/rdkit/blob/9249ca5cc840fc72ea3bb73c2ff1d71a1fbd3f47/rdkit/Chem/Draw/IPythonConsole.py#L152 # highlight info is stored in __sssAtoms mol._mol.__setattr__('__sssAtoms', []) + + if not cleaned_mols: + return [mol] # At least return the original molecule if no resonance structure is found + return cleaned_mols @@ -2103,7 +2107,7 @@ def get_unique_mols(mols: List[RDKitMol], consider_atommap (bool, optional): If treat chemically equivalent molecules with different atommap numbers as different molecules. Defaults to ``False``. - same_formula (bool, opional): If the mols has the same formula you may set it to True + same_formula (bool, opional): If the mols has the same formula you may set it to ``True`` to save computational time. Defaults to ``False``. Returns: From add6548d4c4efed7d0327f1c19458eadce213573 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:23:58 -0400 Subject: [PATCH 06/11] Add get resonance structure match --- rdmc/mol.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/rdmc/mol.py b/rdmc/mol.py index 20722958..05466102 100644 --- a/rdmc/mol.py +++ b/rdmc/mol.py @@ -2133,3 +2133,25 @@ def get_unique_mols(mols: List[RDKitMol], unique_formula_mol[form] = [mol] return sum(unique_formula_mol.values(), []) + + +def get_resonance_structure_match(mol1_res: List['RDKitMol'], + mol2_res: List['RDKitMol'], + ) -> tuple: + """ + Get the match between two lists of resonance structures. + + Args: + mol1_res (List['RDKitMol']): The first list of resonance structures. + mol2_res (List['RDKitMol']): The second list of resonance structures. + + Returns: + tuple: The match between the two lists of resonance structures. Empty tuple if no match is found. + """ + for m1 in mol1_res: + for m2 in mol2_res: + match = m1.GetSubstructMatch(m2) + if match: + return match + return tuple() + From 4da5368a5ae4aba7dd489c72e786992135ce952c Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 10:32:13 -0400 Subject: [PATCH 07/11] Add is same complex This function helps check if molecule complex are the same with capability of considering resonance structures --- rdmc/mol.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/rdmc/mol.py b/rdmc/mol.py index 05466102..1a44a5ef 100644 --- a/rdmc/mol.py +++ b/rdmc/mol.py @@ -10,7 +10,7 @@ from itertools import combinations from itertools import product as cartesian_product import traceback -from typing import Dict, Iterable, List, Optional, Sequence, Union +from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Union import pathlib import numpy as np @@ -2155,3 +2155,57 @@ def get_resonance_structure_match(mol1_res: List['RDKitMol'], return match return tuple() + +def is_same_complex(complex1: Union['RDKitMol', Union[List['RDKitMol'], Tuple['RDKitMol']]], + complex2: Union['RDKitMol', Union[List['RDKitMol'], Tuple['RDKitMol']]], + resonance: bool = False, + ) -> bool: + """ + Check if two complexes are the same regardless of the sequence of the molecules + and the atom mapping. + + Args: + complex1 (Union['RDKitMol', list['RDKitMol']]): The first complex. + complex2 (Union['RDKitMol', list['RDKitMol']]): The second complex. + resonance (bool, optional): Whether to consider resonance structures. Defaults to ``False``. + + Returns: + bool: Whether the two complexes are the same. + """ + if not isinstance(complex1, (list, tuple)): + complex1 = list(complex1.GetMolFrags(asMols=True)) + if not isinstance(complex2, (list, tuple)): + complex2 = list(complex2.GetMolFrags(asMols=True)) + + if len(complex1) != len(complex2): + return False + + mol1s = sorted([(m, m.GetNumAtoms()) for m in complex1], + key=lambda x: x[1]) + mol2s = sorted([(m, m.GetNumAtoms()) for m in complex2], + key=lambda x: x[1]) + + matched = [] + mol2_res_dict = {} + + for mol1 in mol1s: + mol1_res = generate_radical_resonance_structures(mol1[0], kekulize=True) if resonance else [mol1[0]] + for i, mol2 in enumerate(mol2s): + if mol1[1] > mol2[1] or i in matched: + continue + if mol1[1] < mol2[1]: + return False + + mol2_res = mol2_res_dict.get(i) + if mol2_res is None: + mol2_res = generate_radical_resonance_structures(mol2[0], kekulize=True) if resonance else [mol2[0]] + mol2_res_dict[i] = mol2_res + + match = get_resonance_structure_match(mol1_res, mol2_res) + + if match: + matched.append(i) + break + else: + return False + return True From dedb0f13b1668c94ba6e61471807219bea17af51 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 14:11:40 -0400 Subject: [PATCH 08/11] Decouple mol comparison and resonance from mol --- rdmc/mol.py | 259 ------------------------------------ rdmc/mol_compare.py | 169 +++++++++++++++++++++++ rdmc/reaction.py | 2 +- rdmc/resonance/__init__.py | 4 + rdmc/resonance/resonance.py | 138 +++++++++++++++++++ test/test_mol.py | 9 +- 6 files changed, 316 insertions(+), 265 deletions(-) create mode 100644 rdmc/mol_compare.py create mode 100644 rdmc/resonance/__init__.py create mode 100644 rdmc/resonance/resonance.py diff --git a/rdmc/mol.py b/rdmc/mol.py index 1a44a5ef..032cc9d8 100644 --- a/rdmc/mol.py +++ b/rdmc/mol.py @@ -1950,262 +1950,3 @@ def generate_vdw_mat(rd_mol, (vdw_radii[atom1.GetAtomicNum()] + vdw_radii[atom2.GetAtomicNum()]) return vdw_mat - - -def generate_radical_resonance_structures(mol: RDKitMol, - unique: bool = True, - consider_atommap: bool = False, - kekulize: bool = False): - """ - Generate resonance structures for a radical molecule. RDKit by design doesn't work - for radical resonance. The approach is a temporary workaround by replacing radical electrons by positive - charges and generating resonance structures by RDKit ResonanceMolSupplier. - Currently, this function only works for neutral radicals. - - Known issues: - - - Phenyl radical only generate one resonance structure when ``kekulize=True``, expecting 2. - - Args: - mol (RDKitMol): A radical molecule. - unique (bool, optional): Filter out duplicate resonance structures from the list. Defaults to ``True``. - consider_atommap (bool, atommap): If consider atom map numbers in filtration duplicates. - Only effective when ``unique=True``. Defaults to ``False``. - kekulize (bool, optional): Whether to kekulize the molecule. Defaults to ``False``. As an example, - benzene have one resonance structure if not kekulized (``False``) and - two resonance structures if kekulized (``True``). - - Returns: - list: a list of molecules with resonance structures. - """ - assert mol.GetFormalCharge() == 0, "The current function only works for radical species." - mol_copy = mol.Copy(quickCopy=True) # Make a copy of the original molecule - - # Modify the original molecule to make it a positively charged species - recipe = {} # Used to record changes. Temporarily not used now. - for atom in mol_copy.GetAtoms(): - radical_electrons = atom.GetNumRadicalElectrons() - if radical_electrons > 0: # Find a radical site - recipe[atom.GetIdx()] = radical_electrons - atom.SetFormalCharge(+radical_electrons) - atom.SetNumRadicalElectrons(0) - # Make sure conjugation is assigned - # Only assign the conjugation after changing radical sites to positively charged sites - Chem.rdmolops.SetConjugation(mol_copy._mol) - - # Avoid generating certain resonance bonds - for atom in mol_copy.GetAtoms(): - if (atom.GetAtomicNum() == 8 and len(atom.GetNeighbors()) > 1) or \ - (atom.GetAtomicNum() == 7 and len(atom.GetNeighbors()) > 2): - # Avoid X-O-Y be part of the resonance and forms X-O.=Y - # Avoid X-N(-Y)-Z be part of the resonance and forms X=N.(-Y)(-Z) - [bond.SetIsConjugated(False) for bond in atom.GetBonds()] - mol_copy.UpdatePropertyCache() # Make sure the assignment is boardcast to atoms / bonds - - # Generate Resonance Structures - flags = Chem.ALLOW_INCOMPLETE_OCTETS | Chem.UNCONSTRAINED_CATIONS - if kekulize: - flags |= Chem.KEKULE_ALL - suppl = Chem.ResonanceMolSupplier(mol_copy._mol, flags=flags) - res_mols = [RDKitMol(RWMol(mol)) for mol in suppl] - - # Post-processing resonance structures - cleaned_mols = [] - for res_mol in res_mols: - for atom in res_mol.GetAtoms(): - # Convert positively charged species back to radical species - charge = atom.GetFormalCharge() - if charge > 0: # Find a radical site - recipe[atom.GetIdx()] = radical_electrons - atom.SetFormalCharge(0) - atom.SetNumRadicalElectrons(charge) - elif charge < 0: # Shouldn't appear, just for bug detection - raise RuntimeError('Encounter charge separation during resonance structure generation.') - - # If a structure cannot be sanitized, removed it - try: - # Sanitization strategy is inspired by - # https://github.com/rdkit/rdkit/discussions/6358 - flags = Chem.SanitizeFlags.SANITIZE_ALL - if kekulize: - flags ^= (Chem.SanitizeFlags.SANITIZE_KEKULIZE | Chem.SanitizeFlags.SANITIZE_SETAROMATICITY) - res_mol.Sanitize(sanitizeOps=flags) - except BaseException as e: - print(e) - # todo: make error type more specific and add a warning message - continue - if kekulize: - _unset_aromatic_flags(res_mol) - cleaned_mols.append(res_mol) - - # To remove duplicate resonance structures - if unique: - cleaned_mols = get_unique_mols(cleaned_mols, - consider_atommap=consider_atommap) - for mol in cleaned_mols: - # According to - # https://github.com/rdkit/rdkit/blob/9249ca5cc840fc72ea3bb73c2ff1d71a1fbd3f47/rdkit/Chem/Draw/IPythonConsole.py#L152 - # highlight info is stored in __sssAtoms - mol._mol.__setattr__('__sssAtoms', []) - - if not cleaned_mols: - return [mol] # At least return the original molecule if no resonance structure is found - - return cleaned_mols - - -def _unset_aromatic_flags(mol): - """ - A helper function to unset aromatic flags in a molecule. - This is useful when cleaning up the molecules from resonance structure generation. - In such case, a molecule may have single-double bonds but are marked as aromatic bonds. - """ - for bond in mol.GetBonds(): - if bond.GetBondType() != Chem.BondType.AROMATIC and bond.GetIsAromatic(): - bond.SetIsAromatic(False) - bond.GetBeginAtom().SetIsAromatic(False) - bond.GetEndAtom().SetIsAromatic(False) - return mol - - -def has_matched_mol(mol: RDKitMol, - mols: List[RDKitMol], - consider_atommap: bool = False, - ): - """ - Check if a molecule has a structure match in a list of molecules. - - Args: - mol (RDKitMol): The target molecule. - mols (List[RDKitMol]): The list of molecules to be processed. - consider_atommap (bool, optional): If treat chemically equivalent molecules with - different atommap numbers as different molecules. - Defaults to ``False``. - - Returns: - bool: if a matched molecules if found. - """ - for mol_in_list in mols: - mapping = mol_in_list.GetSubstructMatch(mol) # A tuple of atom indexes if matched - if mapping and not consider_atommap: - return True - elif mapping and mapping == tuple(range(len(mapping))): - # if identical, the mapping is always as 1,2,...,N - return True - return False - - -def get_unique_mols(mols: List[RDKitMol], - consider_atommap: bool = False, - same_formula: bool = False, - ): - """ - Find the unique molecules from a list of molecules. - - Args: - mols (list): The molecules to be processed. - consider_atommap (bool, optional): If treat chemically equivalent molecules with - different atommap numbers as different molecules. - Defaults to ``False``. - same_formula (bool, opional): If the mols has the same formula you may set it to ``True`` - to save computational time. Defaults to ``False``. - - Returns: - list: A list of unique molecules. - """ - # Dictionary: - # Keys: chemical formula; - # Values: list of mols with same formula - # Use chemical formula to reduce the call of the more expensive graph substructure check - unique_formula_mol = {} - - for mol in mols: - - # Get the molecules with the same formula as the query molecule - form = 'same' if same_formula else Chem.rdMolDescriptors.CalcMolFormula(mol._mol) - unique_mol_list = unique_formula_mol.get(form) - - if unique_mol_list and has_matched_mol(mol, unique_mol_list, consider_atommap=consider_atommap): - continue - elif unique_mol_list: - unique_formula_mol[form].append(mol) - else: - unique_formula_mol[form] = [mol] - - return sum(unique_formula_mol.values(), []) - - -def get_resonance_structure_match(mol1_res: List['RDKitMol'], - mol2_res: List['RDKitMol'], - ) -> tuple: - """ - Get the match between two lists of resonance structures. - - Args: - mol1_res (List['RDKitMol']): The first list of resonance structures. - mol2_res (List['RDKitMol']): The second list of resonance structures. - - Returns: - tuple: The match between the two lists of resonance structures. Empty tuple if no match is found. - """ - for m1 in mol1_res: - for m2 in mol2_res: - match = m1.GetSubstructMatch(m2) - if match: - return match - return tuple() - - -def is_same_complex(complex1: Union['RDKitMol', Union[List['RDKitMol'], Tuple['RDKitMol']]], - complex2: Union['RDKitMol', Union[List['RDKitMol'], Tuple['RDKitMol']]], - resonance: bool = False, - ) -> bool: - """ - Check if two complexes are the same regardless of the sequence of the molecules - and the atom mapping. - - Args: - complex1 (Union['RDKitMol', list['RDKitMol']]): The first complex. - complex2 (Union['RDKitMol', list['RDKitMol']]): The second complex. - resonance (bool, optional): Whether to consider resonance structures. Defaults to ``False``. - - Returns: - bool: Whether the two complexes are the same. - """ - if not isinstance(complex1, (list, tuple)): - complex1 = list(complex1.GetMolFrags(asMols=True)) - if not isinstance(complex2, (list, tuple)): - complex2 = list(complex2.GetMolFrags(asMols=True)) - - if len(complex1) != len(complex2): - return False - - mol1s = sorted([(m, m.GetNumAtoms()) for m in complex1], - key=lambda x: x[1]) - mol2s = sorted([(m, m.GetNumAtoms()) for m in complex2], - key=lambda x: x[1]) - - matched = [] - mol2_res_dict = {} - - for mol1 in mol1s: - mol1_res = generate_radical_resonance_structures(mol1[0], kekulize=True) if resonance else [mol1[0]] - for i, mol2 in enumerate(mol2s): - if mol1[1] > mol2[1] or i in matched: - continue - if mol1[1] < mol2[1]: - return False - - mol2_res = mol2_res_dict.get(i) - if mol2_res is None: - mol2_res = generate_radical_resonance_structures(mol2[0], kekulize=True) if resonance else [mol2[0]] - mol2_res_dict[i] = mol2_res - - match = get_resonance_structure_match(mol1_res, mol2_res) - - if match: - matched.append(i) - break - else: - return False - return True diff --git a/rdmc/mol_compare.py b/rdmc/mol_compare.py new file mode 100644 index 00000000..eea940b4 --- /dev/null +++ b/rdmc/mol_compare.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +This module provides methods for comparing molecules. +""" + +from typing import List, Tuple, Union + +from rdkit.Chem.rdMolDescriptors import CalcMolFormula + + +def get_resonance_structure_match( + mol1_res: List["RDKitMol"], + mol2_res: List["RDKitMol"], +) -> tuple: + """ + Get the match between two lists of resonance structures. + + Args: + mol1_res (List['RDKitMol']): The first list of resonance structures. + mol2_res (List['RDKitMol']): The second list of resonance structures. + + Returns: + tuple: The match between the two lists of resonance structures. Empty tuple if no match is found. + """ + for m1 in mol1_res: + for m2 in mol2_res: + match = m1.GetSubstructMatch(m2) + if match: + return match + return tuple() + + +def get_unique_mols( + mols: List["RDKitMol"], + consider_atommap: bool = False, + same_formula: bool = False, +): + """ + Find the unique molecules from a list of molecules. + + Args: + mols (list): The molecules to be processed. + consider_atommap (bool, optional): If treat chemically equivalent molecules with + different atommap numbers as different molecules. + Defaults to ``False``. + same_formula (bool, opional): If the mols has the same formula you may set it to ``True`` + to save computational time. Defaults to ``False``. + + Returns: + list: A list of unique molecules. + """ + # Dictionary: + # Keys: chemical formula; + # Values: list of mols with same formula + # Use chemical formula to reduce the call of the more expensive graph substructure check + unique_formula_mol = {} + + for mol in mols: + # Get the molecules with the same formula as the query molecule + form = "same" if same_formula else CalcMolFormula(mol._mol) + unique_mol_list = unique_formula_mol.get(form) + + if unique_mol_list and has_matched_mol( + mol, unique_mol_list, consider_atommap=consider_atommap + ): + continue + elif unique_mol_list: + unique_formula_mol[form].append(mol) + else: + unique_formula_mol[form] = [mol] + + return sum(unique_formula_mol.values(), []) + + +def has_matched_mol( + mol: "RDKitMol", + mols: List["RDKitMol"], + consider_atommap: bool = False, +) -> bool: + """ + Check if a molecule has a structure match in a list of molecules. + + Args: + mol (RDKitMol): The target molecule. + mols (List[RDKitMol]): The list of molecules to be processed. + consider_atommap (bool, optional): If treat chemically equivalent molecules with + different atommap numbers as different molecules. + Defaults to ``False``. + + Returns: + bool: if a matched molecules if found. + """ + for mol_in_list in mols: + mapping = mol_in_list.GetSubstructMatch( + mol + ) # A tuple of atom indexes if matched + if mapping and not consider_atommap: + return True + elif mapping and mapping == tuple(range(len(mapping))): + # if identical, the mapping is always as 1,2,...,N + return True + return False + + +def is_same_complex( + complex1: Union["RDKitMol", Union[List["RDKitMol"], Tuple["RDKitMol"]]], + complex2: Union["RDKitMol", Union[List["RDKitMol"], Tuple["RDKitMol"]]], + resonance: bool = False, +) -> bool: + """ + Check if two complexes are the same regardless of the sequence of the molecules + and the atom mapping. + + Args: + complex1 (Union['RDKitMol', list['RDKitMol']]): The first complex. + complex2 (Union['RDKitMol', list['RDKitMol']]): The second complex. + resonance (bool, optional): Whether to consider resonance structures. Defaults to ``False``. + + Returns: + bool: Whether the two complexes are the same. + """ + if resonance: + from rdmc.resonance import generate_radical_resonance_structures + + if not isinstance(complex1, (list, tuple)): + complex1 = list(complex1.GetMolFrags(asMols=True)) + if not isinstance(complex2, (list, tuple)): + complex2 = list(complex2.GetMolFrags(asMols=True)) + + if len(complex1) != len(complex2): + return False + + mol1s = sorted([(m, m.GetNumAtoms()) for m in complex1], key=lambda x: x[1]) + mol2s = sorted([(m, m.GetNumAtoms()) for m in complex2], key=lambda x: x[1]) + + matched = [] + mol2_res_dict = {} + + for mol1 in mol1s: + mol1_res = ( + generate_radical_resonance_structures(mol1[0], kekulize=True) + if resonance + else [mol1[0]] + ) + for i, mol2 in enumerate(mol2s): + if mol1[1] > mol2[1] or i in matched: + continue + if mol1[1] < mol2[1]: + return False + + mol2_res = mol2_res_dict.get(i) + if mol2_res is None: + mol2_res = ( + generate_radical_resonance_structures(mol2[0], kekulize=True) + if resonance + else [mol2[0]] + ) + mol2_res_dict[i] = mol2_res + + match = get_resonance_structure_match(mol1_res, mol2_res) + + if match: + matched.append(i) + break + else: + return False + return True diff --git a/rdmc/reaction.py b/rdmc/reaction.py index c1dd75f5..39f3e361 100644 --- a/rdmc/reaction.py +++ b/rdmc/reaction.py @@ -14,7 +14,7 @@ from rdkit.Chem.Draw import rdMolDraw2D from rdmc import RDKitMol -from rdmc.mol import generate_radical_resonance_structures +from rdmc.resonance import generate_radical_resonance_structures from rdmc.ts import get_all_changing_bonds diff --git a/rdmc/resonance/__init__.py b/rdmc/resonance/__init__.py new file mode 100644 index 00000000..6c74bc35 --- /dev/null +++ b/rdmc/resonance/__init__.py @@ -0,0 +1,4 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from rdmc.resonance.resonance import generate_radical_resonance_structures diff --git a/rdmc/resonance/resonance.py b/rdmc/resonance/resonance.py new file mode 100644 index 00000000..e8c0b2b7 --- /dev/null +++ b/rdmc/resonance/resonance.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +This module contains the function generating resonance structures. +""" + +from rdmc.mol import RDKitMol +from rdmc.mol_compare import get_unique_mols +from rdkit import Chem +from rdkit.Chem import RWMol + + +def generate_radical_resonance_structures( + mol: RDKitMol, + unique: bool = True, + consider_atommap: bool = False, + kekulize: bool = False, +): + """ + Generate resonance structures for a radical molecule. RDKit by design doesn't work + for radical resonance. The approach is a temporary workaround by replacing radical electrons by positive + charges and generating resonance structures by RDKit ResonanceMolSupplier. + Currently, this function only works for neutral radicals. + + Known issues: + + - Phenyl radical only generate one resonance structure when ``kekulize=True``, expecting 2. + + Args: + mol (RDKitMol): A radical molecule. + unique (bool, optional): Filter out duplicate resonance structures from the list. Defaults to ``True``. + consider_atommap (bool, atommap): If consider atom map numbers in filtration duplicates. + Only effective when ``unique=True``. Defaults to ``False``. + kekulize (bool, optional): Whether to kekulize the molecule. Defaults to ``False``. As an example, + benzene have one resonance structure if not kekulized (``False``) and + two resonance structures if kekulized (``True``). + + Returns: + list: a list of molecules with resonance structures. + """ + assert ( + mol.GetFormalCharge() == 0 + ), "The current function only works for radical species." + mol_copy = mol.Copy(quickCopy=True) # Make a copy of the original molecule + + # Modify the original molecule to make it a positively charged species + recipe = {} # Used to record changes. Temporarily not used now. + for atom in mol_copy.GetAtoms(): + radical_electrons = atom.GetNumRadicalElectrons() + if radical_electrons > 0: # Find a radical site + recipe[atom.GetIdx()] = radical_electrons + atom.SetFormalCharge(+radical_electrons) + atom.SetNumRadicalElectrons(0) + # Make sure conjugation is assigned + # Only assign the conjugation after changing radical sites to positively charged sites + Chem.rdmolops.SetConjugation(mol_copy._mol) + + # Avoid generating certain resonance bonds + for atom in mol_copy.GetAtoms(): + if (atom.GetAtomicNum() == 8 and len(atom.GetNeighbors()) > 1) or ( + atom.GetAtomicNum() == 7 and len(atom.GetNeighbors()) > 2 + ): + # Avoid X-O-Y be part of the resonance and forms X-O.=Y + # Avoid X-N(-Y)-Z be part of the resonance and forms X=N.(-Y)(-Z) + [bond.SetIsConjugated(False) for bond in atom.GetBonds()] + mol_copy.UpdatePropertyCache() # Make sure the assignment is boardcast to atoms / bonds + + # Generate Resonance Structures + flags = Chem.ALLOW_INCOMPLETE_OCTETS | Chem.UNCONSTRAINED_CATIONS + if kekulize: + flags |= Chem.KEKULE_ALL + suppl = Chem.ResonanceMolSupplier(mol_copy._mol, flags=flags) + res_mols = [RDKitMol(RWMol(mol)) for mol in suppl] + + # Post-processing resonance structures + cleaned_mols = [] + for res_mol in res_mols: + for atom in res_mol.GetAtoms(): + # Convert positively charged species back to radical species + charge = atom.GetFormalCharge() + if charge > 0: # Find a radical site + recipe[atom.GetIdx()] = radical_electrons + atom.SetFormalCharge(0) + atom.SetNumRadicalElectrons(charge) + elif charge < 0: # Shouldn't appear, just for bug detection + raise RuntimeError( + "Encounter charge separation during resonance structure generation." + ) + + # If a structure cannot be sanitized, removed it + try: + # Sanitization strategy is inspired by + # https://github.com/rdkit/rdkit/discussions/6358 + flags = Chem.SanitizeFlags.SANITIZE_ALL + if kekulize: + flags ^= ( + Chem.SanitizeFlags.SANITIZE_KEKULIZE + | Chem.SanitizeFlags.SANITIZE_SETAROMATICITY + ) + res_mol.Sanitize(sanitizeOps=flags) + except BaseException as e: + print(e) + # todo: make error type more specific and add a warning message + continue + if kekulize: + _unset_aromatic_flags(res_mol) + cleaned_mols.append(res_mol) + + # To remove duplicate resonance structures + if unique: + cleaned_mols = get_unique_mols(cleaned_mols, consider_atommap=consider_atommap) + for mol in cleaned_mols: + # According to + # https://github.com/rdkit/rdkit/blob/9249ca5cc840fc72ea3bb73c2ff1d71a1fbd3f47/rdkit/Chem/Draw/IPythonConsole.py#L152 + # highlight info is stored in __sssAtoms + mol._mol.__setattr__("__sssAtoms", []) + + if not cleaned_mols: + return [ + mol + ] # At least return the original molecule if no resonance structure is found + + return cleaned_mols + + +def _unset_aromatic_flags(mol): + """ + A helper function to unset aromatic flags in a molecule. + This is useful when cleaning up the molecules from resonance structure generation. + In such case, a molecule may have single-double bonds but are marked as aromatic bonds. + """ + for bond in mol.GetBonds(): + if bond.GetBondType() != Chem.BondType.AROMATIC and bond.GetIsAromatic(): + bond.SetIsAromatic(False) + bond.GetBeginAtom().SetIsAromatic(False) + bond.GetEndAtom().SetIsAromatic(False) + return mol diff --git a/test/test_mol.py b/test/test_mol.py index 54beebe8..88d836f1 100644 --- a/test/test_mol.py +++ b/test/test_mol.py @@ -15,11 +15,10 @@ except ImportError: import pybel -from rdmc import (generate_radical_resonance_structures, - get_unique_mols, - has_matched_mol, - parse_xyz_or_smiles_list, - RDKitMol) +from rdmc.mol import RDKitMol, parse_xyz_or_smiles_list +from rdmc.mol_compare import get_unique_mols, has_matched_mol +from rdmc.resonance import generate_radical_resonance_structures + import pytest logging.basicConfig(level=logging.DEBUG) From 2c10287e24389fcaaef945341b51f83d4ed7a174 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 14:26:13 -0400 Subject: [PATCH 09/11] Update the code format of reaction module --- rdmc/reaction.py | 165 ++++++++++++++++++++++++++--------------------- 1 file changed, 91 insertions(+), 74 deletions(-) diff --git a/rdmc/reaction.py b/rdmc/reaction.py index 39f3e361..fa8b42fa 100644 --- a/rdmc/reaction.py +++ b/rdmc/reaction.py @@ -14,6 +14,7 @@ from rdkit.Chem.Draw import rdMolDraw2D from rdmc import RDKitMol +from rdmc.mol_compare import is_same_complex from rdmc.resonance import generate_radical_resonance_structures from rdmc.ts import get_all_changing_bonds @@ -24,11 +25,12 @@ class Reaction: The Reaction class that stores the reactant, product, and transition state information. """ - def __init__(self, - reactant: Union[List[RDKitMol], RDKitMol], - product: Union[List[RDKitMol], RDKitMol], - ts: Optional['RDKitMol'] = None, - ): + def __init__( + self, + reactant: Union[List[RDKitMol], RDKitMol], + product: Union[List[RDKitMol], RDKitMol], + ts: Optional["RDKitMol"] = None, + ): """ Initialize the Reaction class. @@ -56,9 +58,9 @@ def _repr_svg_(self): return self.draw_2d() @classmethod - def from_reactant_and_product_smiles(cls, - rsmi: Union[List[str], str], - psmi: Union[List[str], str]): + def from_reactant_and_product_smiles( + cls, rsmi: Union[List[str], str], psmi: Union[List[str], str] + ): """ Initialize the Reaction class from reactant and product smile(s). @@ -66,31 +68,26 @@ def from_reactant_and_product_smiles(cls, """ if isinstance(rsmi, list): - rsmi = '.'.join(rsmi) + rsmi = ".".join(rsmi) if isinstance(psmi, list): - psmi = '.'.join(psmi) + psmi = ".".join(psmi) try: - reactant = RDKitMol.FromSmiles(rsmi, - removeHs=False, - addHs=True, - sanitize=True, - keepAtomMap=True) + reactant = RDKitMol.FromSmiles( + rsmi, removeHs=False, addHs=True, sanitize=True, keepAtomMap=True + ) except Exception as exc: - raise ValueError(f'Got invalid reactant smiles ({rsmi})') from exc + raise ValueError(f"Got invalid reactant smiles ({rsmi})") from exc try: - product = RDKitMol.FromSmiles(psmi, - removeHs=False, - addHs=True, - sanitize=True, - keepAtomMap=True) + product = RDKitMol.FromSmiles( + psmi, removeHs=False, addHs=True, sanitize=True, keepAtomMap=True + ) except Exception as exc: - raise ValueError(f'Got invalid product smiles ({psmi})') from exc + raise ValueError(f"Got invalid product smiles ({psmi})") from exc return cls(reactant=reactant, product=product) @classmethod - def from_reaction_smiles(cls, - smiles: str): + def from_reaction_smiles(cls, smiles: str): """ Initialize the Reaction class from reaction SMILES. @@ -101,16 +98,17 @@ def from_reaction_smiles(cls, Reaction: The Reaction class. """ try: - rsmi, psmi = smiles.split('>>') + rsmi, psmi = smiles.split(">>") except ValueError as exc: raise ValueError('Not a valid reaction smiles, missing ">>".') from exc return cls.from_reactant_and_product_smiles(rsmi=rsmi, psmi=psmi) - def init_reactant_product(self, - reactant: Union[List[RDKitMol], RDKitMol], - product: Union[List[RDKitMol], RDKitMol]): - """ - """ + def init_reactant_product( + self, + reactant: Union[List[RDKitMol], RDKitMol], + product: Union[List[RDKitMol], RDKitMol], + ): + """ """ if isinstance(reactant, list): self.reactant = reactant self.reactant_complex = self._combine_multiple_mols(reactant) @@ -147,8 +145,9 @@ def is_element_balanced(self) -> bool: Whether the elements in the reactant(s) and product(s) are balanced. """ if self.is_num_atoms_balanced: - return Counter(self.reactant_complex.GetElementSymbols()) == \ - Counter(self.product_complex.GetElementSymbols()) + return Counter(self.reactant_complex.GetElementSymbols()) == Counter( + self.product_complex.GetElementSymbols() + ) return False @property @@ -156,23 +155,29 @@ def is_charge_balanced(self) -> bool: """ Whether the charge in the reactant(s) and product(s) are balanced. """ - return self.reactant_complex.GetFormalCharge() == \ - self.product_complex.GetFormalCharge() + return ( + self.reactant_complex.GetFormalCharge() + == self.product_complex.GetFormalCharge() + ) @property def is_mult_equal(self) -> bool: """ Whether the spin multiplicity in the reactant(s) and product(s) are equal. """ - return self.reactant_complex.GetSpinMultiplicity() == \ - self.product_complex.GetSpinMultiplicity() + return ( + self.reactant_complex.GetSpinMultiplicity() + == self.product_complex.GetSpinMultiplicity() + ) @property def num_atoms(self) -> bool: """ The number of atoms involved in the reactant(s) and product(s). """ - assert self.is_num_atoms_balanced, "The number of atoms in the reactant(s) and product(s) are not balanced." + assert ( + self.is_num_atoms_balanced + ), "The number of atoms in the reactant(s) and product(s) are not balanced." return self.reactant_complex.GetNumAtoms() @property @@ -205,18 +210,27 @@ def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except AttributeError: - self._formed_bonds, self._broken_bonds, self._changed_bonds = get_all_changing_bonds( + ( + self._formed_bonds, + self._broken_bonds, + self._changed_bonds, + ) = get_all_changing_bonds( r_mol=self.reactant_complex, p_mol=self.product_complex, ) return func(self, *args, **kwargs) + return wrapper def bond_analysis(self): """ Perform bond analysis on the reaction. """ - self._formed_bonds, self._broken_bonds, self._changed_bonds = get_all_changing_bonds( + ( + self._formed_bonds, + self._broken_bonds, + self._changed_bonds, + ) = get_all_changing_bonds( r_mol=self.reactant_complex, p_mol=self.product_complex, ) @@ -301,19 +315,24 @@ def involved_atoms(self) -> List[int]: """ return list(set(chain(*self.involved_bonds))) - def apply_resonance_correction(self, - inplace: bool = True, - kekulize: bool = True, - ) -> 'Reaction': + def apply_resonance_correction( + self, + inplace: bool = True, + kekulize: bool = True, + ) -> "Reaction": """ Apply resonance correction to the reactant and product complexes. """ try: - rcps = generate_radical_resonance_structures(self.reactant_complex, kekulize=kekulize) + rcps = generate_radical_resonance_structures( + self.reactant_complex, kekulize=kekulize + ) except BaseException: rcps = [self.reactant_complex] try: - pcps = generate_radical_resonance_structures(self.product_complex, kekulize=kekulize) + pcps = generate_radical_resonance_structures( + self.product_complex, kekulize=kekulize + ) except BaseException: pcps = [self.product_complex] @@ -343,25 +362,24 @@ def get_reverse_reaction(self): """ Get the reverse reaction. """ - return Reaction(self.product_complex, - self.reactant_complex, - ts=self.ts) + return Reaction(self.product_complex, self.reactant_complex, ts=self.ts) - def to_smiles(self, - remove_hs: bool = False, - remove_atom_map: bool = False, - **kwargs, - ) -> str: + def to_smiles( + self, + remove_hs: bool = False, + remove_atom_map: bool = False, + **kwargs, + ) -> str: """ Convert the reaction to reaction SMILES. """ - rsmi = self.reactant_complex.ToSmiles(removeAtomMap=remove_atom_map, - removeHs=remove_hs, - **kwargs) - psmi = self.product_complex.ToSmiles(removeAtomMap=remove_atom_map, - removeHs=remove_hs, - **kwargs) - return f'{rsmi}>>{psmi}' + rsmi = self.reactant_complex.ToSmiles( + removeAtomMap=remove_atom_map, removeHs=remove_hs, **kwargs + ) + psmi = self.product_complex.ToSmiles( + removeAtomMap=remove_atom_map, removeHs=remove_hs, **kwargs + ) + return f"{rsmi}>>{psmi}" def make_ts(self): """ @@ -377,11 +395,11 @@ def _update_ts(self): Update the transition state of the reaction. Assign reaction, reactant, and product attributes to the transition state based on the reaction. """ - if not hasattr(self._ts, 'reaction'): + if not hasattr(self._ts, "reaction"): self._ts.reaction = self - if not hasattr(self._ts, 'reactant'): + if not hasattr(self._ts, "reactant"): self._ts.reactant = self.reactant_complex - if not hasattr(self._ts, 'product'): + if not hasattr(self._ts, "product"): self._ts.product = self.product_complex @property @@ -389,14 +407,13 @@ def ts(self): """ The transition state of the reaction. """ - if not hasattr(self, '_ts'): + if not hasattr(self, "_ts"): self.make_ts() self._update_ts() return self._ts @ts.setter - def ts(self, - mol: 'RDKitMol'): + def ts(self, mol: "RDKitMol"): """ Set the transition state of the reaction. """ @@ -407,13 +424,13 @@ def to_rdkit_reaction(self) -> rdChemReactions.ChemicalReaction: """ Convert the reaction to RDKit ChemicalReaction. """ - return rdChemReactions.ReactionFromSmarts(self.to_smiles(), - useSmiles=True) + return rdChemReactions.ReactionFromSmarts(self.to_smiles(), useSmiles=True) - def draw_2d(self, - font_scale: float = 1.0, - highlight_by_reactant: bool = True, - ) -> str: + def draw_2d( + self, + font_scale: float = 1.0, + highlight_by_reactant: bool = True, + ) -> str: """ This is a modified version of the drawReaction2D function in RDKit. @@ -424,6 +441,7 @@ def draw_2d(self, Returns: str: The SVG string. To display the SVG, use IPython.display.SVG(svg_string). """ + def move_atommaps_to_notes(mol): for atom in mol.GetAtoms(): if atom.GetAtomMapNum(): @@ -439,8 +457,7 @@ def move_atommaps_to_notes(mol): d2d = rdMolDraw2D.MolDraw2DSVG(800, 300) d2d.drawOptions().annotationFontScale = font_scale - d2d.DrawReaction(rxn, - highlightByReactant=highlight_by_reactant) + d2d.DrawReaction(rxn, highlightByReactant=highlight_by_reactant) d2d.FinishDrawing() From 35f5e4bb1b0a8c836e555bc444c1b3d5db1a3185 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 14:27:47 -0400 Subject: [PATCH 10/11] add reactant and product comparison --- rdmc/reaction.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/rdmc/reaction.py b/rdmc/reaction.py index fa8b42fa..8c832152 100644 --- a/rdmc/reaction.py +++ b/rdmc/reaction.py @@ -462,3 +462,75 @@ def move_atommaps_to_notes(mol): d2d.FinishDrawing() return d2d.GetDrawingText() + + def has_same_reactants( + self, + other: "Reaction", + resonance: bool = False, + ) -> bool: + """ + Check if the reaction has the same reactants as the other reaction. + + Args: + other (Reaction): The other reaction to compare. + + Returns: + bool: Whether the reaction has the same reactants as the other reaction. + """ + return self.is_same_reactants(other.reactant_complex, + resonance=resonance) + + def is_same_reactants( + self, + reactants: Union[List[RDKitMol], RDKitMol], + resonance: bool = False, + ) -> bool: + """ + Check if the reaction has the same reactants as the given reactants or reactant complex. + + Args: + reactant (Union[List[RDKitMol], RDKitMol]): The reactants or reactant complex to compare. + resonance (bool, optional): Whether to consider resonance structures. Defaults to ``False``. + + Returns: + bool: Whether the reaction has the same reactants as the given reactants or reactant complex. + """ + return is_same_complex(self.reactant_complex, + reactants, + resonance=resonance) + + def has_same_products( + self, + other: "Reaction", + resonance: bool = False, + ) -> bool: + """ + Check if the reaction has the same products as the other reaction. + + Args: + other (Reaction): The other reaction to compare. + + Returns: + bool: Whether the reaction has the same products as the other reaction. + """ + return self.is_same_products(other.product_complex, + resonance=resonance) + + def is_same_products( + self, + products: Union[List[RDKitMol], RDKitMol], + resonance: bool = False, + ): + """ + Check if the reaction has the same products as the given products or product complex. + + Args: + product (Union[List[RDKitMol], RDKitMol]): The products or product complex to compare. + resonance (bool, optional): Whether to consider resonance structures. Defaults to ``False``. + + Returns: + bool: Whether the reaction has the same products as the given products or product complex. + """ + return is_same_complex(self.product_complex, + products, + resonance=resonance) From cc64388f0dc0e56d8a3649e4dc1ca0cf5b759f04 Mon Sep 17 00:00:00 2001 From: Xiaorui Dong Date: Fri, 20 Oct 2023 14:38:51 -0400 Subject: [PATCH 11/11] add reaction element count --- rdmc/reaction.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/rdmc/reaction.py b/rdmc/reaction.py index 8c832152..4df357a0 100644 --- a/rdmc/reaction.py +++ b/rdmc/reaction.py @@ -139,6 +139,20 @@ def is_num_atoms_balanced(self) -> bool: """ return self.reactant_complex.GetNumAtoms() == self.product_complex.GetNumAtoms() + @property + def reactant_element_count(self) -> dict: + """ + The element count in the reactant(s) and product(s). + """ + return dict(Counter(self.reactant_complex.GetElementSymbols())) + + @property + def product_element_count(self) -> dict: + """ + The element count in the reactant(s) and product(s). + """ + return dict(Counter(self.product_complex.GetElementSymbols())) + @property def is_element_balanced(self) -> bool: """ @@ -477,8 +491,7 @@ def has_same_reactants( Returns: bool: Whether the reaction has the same reactants as the other reaction. """ - return self.is_same_reactants(other.reactant_complex, - resonance=resonance) + return self.is_same_reactants(other.reactant_complex, resonance=resonance) def is_same_reactants( self, @@ -495,9 +508,7 @@ def is_same_reactants( Returns: bool: Whether the reaction has the same reactants as the given reactants or reactant complex. """ - return is_same_complex(self.reactant_complex, - reactants, - resonance=resonance) + return is_same_complex(self.reactant_complex, reactants, resonance=resonance) def has_same_products( self, @@ -513,8 +524,7 @@ def has_same_products( Returns: bool: Whether the reaction has the same products as the other reaction. """ - return self.is_same_products(other.product_complex, - resonance=resonance) + return self.is_same_products(other.product_complex, resonance=resonance) def is_same_products( self, @@ -531,6 +541,4 @@ def is_same_products( Returns: bool: Whether the reaction has the same products as the given products or product complex. """ - return is_same_complex(self.product_complex, - products, - resonance=resonance) + return is_same_complex(self.product_complex, products, resonance=resonance)