diff --git a/.gitignore b/.gitignore index 9ede6f6c..4264994a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules/ static/css/output.css *.code-workspace +/pnpm-workspace.yaml diff --git a/epdb/models.py b/epdb/models.py index 7fefa928..67a34829 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -1241,7 +1241,12 @@ class SimpleAmbitRule(SimpleRule): return "simple-rule" def apply(self, smiles): - return FormatConverter.apply(smiles, self.smirks) + return FormatConverter.apply( + smiles, + self.smirks, + reactant_filter_smarts=self.reactant_filter_smarts, + product_filter_smarts=self.product_filter_smarts, + ) @property def reactants_smarts(self): diff --git a/tests/test_simpleambitrule.py b/tests/test_simpleambitrule.py index 7647da27..851c0757 100644 --- a/tests/test_simpleambitrule.py +++ b/tests/test_simpleambitrule.py @@ -5,6 +5,7 @@ from django.test import TestCase from epdb.logic import PackageManager from epdb.models import SimpleAmbitRule, User +from utilities.chem import FormatConverter class SimpleAmbitRuleTest(TestCase): @@ -189,7 +190,9 @@ class SimpleAmbitRuleTest(TestCase): test_smiles = "CCO" result = rule.apply(test_smiles) - mock_apply.assert_called_once_with(test_smiles, rule.smirks) + mock_apply.assert_called_once_with( + test_smiles, rule.smirks, reactant_filter_smarts=None, product_filter_smarts=None + ) self.assertEqual(result, ["product1", "product2"]) def test_reactants_smarts_property(self): @@ -338,3 +341,41 @@ class SimpleAmbitRuleTest(TestCase): self.assertEqual( rule._meta.get_field("product_filter_smarts").verbose_name, "Product Filter SMARTS" ) + + def test_reactant_filter_smarts(self): + rule = SimpleAmbitRule.create( + package=self.package, + smirks="[#6,#8,#16:6]-[#6:4](=[O:5])[#6:2]=[#6;!$(CO)!$(C=CN[H])!$(C(=C)N[H]):1]>>[#8]([H])-[#6:1]-[#6:2]-[#6:4](-[#6,#8,#16:6])=[O:5]", + reactant_filter_smarts="[$(OC(=O)C=CC=CC(O)=O),$(OC1C=CC=CC1O)]", + product_filter_smarts=None, + ) + + SMILES = "OC(=O)C=CC=CC(O)=O" + res = FormatConverter.apply(SMILES, rule.smirks) + + # Assert Rule triggers, if we use FormatConverter without reactant_filter_smarts passed along + self.assertTrue(len(res) > 0) + + res = rule.apply(SMILES) + + # Check if reactant_filter_smarts prevents rule from application + self.assertTrue(len(res) == 0) + + def test_product_filter_smarts(self): + rule = SimpleAmbitRule.create( + package=self.package, + smirks="[#6,#8,#16:6]-[#6:4](=[O:5])[#6:2]=[#6;!$(CO)!$(C=CN[H])!$(C(=C)N[H]):1]>>[#8]([H])-[#6:1]-[#6:2]-[#6:4](-[#6,#8,#16:6])=[O:5]", + reactant_filter_smarts=None, + product_filter_smarts="[$(O=C(O)C=CC=CC(O)CC(=O)O)]", + ) + + SMILES = "OC(=O)C=CC=CC=CC(O)=O" + res = FormatConverter.apply(SMILES, rule.smirks) + + # Assert Rule triggers, if we use FormatConverter without product_filter_smarts passed along + self.assertTrue(len(res) > 0) + + res = rule.apply(SMILES) + + # Check if reactant_filter_smarts prevents rule from application + self.assertTrue(len(res) == 0) diff --git a/utilities/chem.py b/utilities/chem.py index cec5e7d8..2d0fd99c 100644 --- a/utilities/chem.py +++ b/utilities/chem.py @@ -279,6 +279,24 @@ class FormatConverter(object): except Exception: return False + @staticmethod + def smarts_matches(mol: str | Chem.Mol, smarts: str) -> bool: + """ + Returns True if the SMARTS pattern matches the given SMILES / Molecule. + """ + _mol = mol + if isinstance(mol, str): + _mol = Chem.MolFromSmiles(mol) + + if _mol is None: + raise ValueError(f"Invalid Molecule: {mol}") + + pattern = Chem.MolFromSmarts(smarts) + if pattern is None: + raise ValueError(f"Invalid SMARTS: {smarts}") + + return _mol.HasSubstructMatch(pattern) + @staticmethod def apply( smiles: str, @@ -288,6 +306,8 @@ class FormatConverter(object): standardize: bool = True, kekulize: bool = True, remove_stereo: bool = True, + reactant_filter_smarts: str | None = None, + product_filter_smarts: str | None = None, ) -> List["ProductSet"]: logger.debug(f"Applying {smirks} on {smiles}") @@ -306,6 +326,15 @@ class FormatConverter(object): Chem.SanitizeMol(mol) mol = Chem.AddHs(mol) + # Check if reactant_filter_smarts matches and we shouldn't apply the rule + if reactant_filter_smarts and FormatConverter.smarts_matches( + mol, reactant_filter_smarts + ): + logger.debug( + f"Reactant {FormatConverter.to_smiles(mol)} matches {reactant_filter_smarts}, skipping" + ) + return list(pss) + # apply! sites = rxn.RunReactants((mol,)) logger.debug(f"{len(sites)} products sets generated") @@ -321,6 +350,17 @@ class FormatConverter(object): p = FormatConverter.standardize( Chem.MolToSmiles(p), remove_stereo=remove_stereo ) + + if product_filter_smarts and FormatConverter.smarts_matches( + p, product_filter_smarts + ): + logger.debug( + f"Product {FormatConverter.to_smiles(mol)} matches {product_filter_smarts}, skipping" + ) + # clear products we might have already collected + prods.clear() + break + prods.append(p) # if kekulize: @@ -357,7 +397,7 @@ class FormatConverter(object): except Exception as e: logger.error(f"Applying {smirks} on {smiles} failed:\n{e}") - return pss + return list(pss) @staticmethod def MACCS(smiles):