[Feature] Implemented SMARTS filtering for Rules (#246)

Reactant Filter SMARTS as well as Product Filter SMARTS are now reflected when applying rules.

Fixes #245

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#246
This commit is contained in:
2025-11-28 23:28:41 +13:00
parent fd2e2c2534
commit e8ae494c16
4 changed files with 90 additions and 3 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ node_modules/
static/css/output.css static/css/output.css
*.code-workspace *.code-workspace
/pnpm-workspace.yaml

View File

@ -1241,7 +1241,12 @@ class SimpleAmbitRule(SimpleRule):
return "simple-rule" return "simple-rule"
def apply(self, smiles): 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 @property
def reactants_smarts(self): def reactants_smarts(self):

View File

@ -5,6 +5,7 @@ from django.test import TestCase
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import SimpleAmbitRule, User from epdb.models import SimpleAmbitRule, User
from utilities.chem import FormatConverter
class SimpleAmbitRuleTest(TestCase): class SimpleAmbitRuleTest(TestCase):
@ -189,7 +190,9 @@ class SimpleAmbitRuleTest(TestCase):
test_smiles = "CCO" test_smiles = "CCO"
result = rule.apply(test_smiles) 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"]) self.assertEqual(result, ["product1", "product2"])
def test_reactants_smarts_property(self): def test_reactants_smarts_property(self):
@ -338,3 +341,41 @@ class SimpleAmbitRuleTest(TestCase):
self.assertEqual( self.assertEqual(
rule._meta.get_field("product_filter_smarts").verbose_name, "Product Filter SMARTS" 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)

View File

@ -279,6 +279,24 @@ class FormatConverter(object):
except Exception: except Exception:
return False 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 @staticmethod
def apply( def apply(
smiles: str, smiles: str,
@ -288,6 +306,8 @@ class FormatConverter(object):
standardize: bool = True, standardize: bool = True,
kekulize: bool = True, kekulize: bool = True,
remove_stereo: bool = True, remove_stereo: bool = True,
reactant_filter_smarts: str | None = None,
product_filter_smarts: str | None = None,
) -> List["ProductSet"]: ) -> List["ProductSet"]:
logger.debug(f"Applying {smirks} on {smiles}") logger.debug(f"Applying {smirks} on {smiles}")
@ -306,6 +326,15 @@ class FormatConverter(object):
Chem.SanitizeMol(mol) Chem.SanitizeMol(mol)
mol = Chem.AddHs(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! # apply!
sites = rxn.RunReactants((mol,)) sites = rxn.RunReactants((mol,))
logger.debug(f"{len(sites)} products sets generated") logger.debug(f"{len(sites)} products sets generated")
@ -321,6 +350,17 @@ class FormatConverter(object):
p = FormatConverter.standardize( p = FormatConverter.standardize(
Chem.MolToSmiles(p), remove_stereo=remove_stereo 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) prods.append(p)
# if kekulize: # if kekulize:
@ -357,7 +397,7 @@ class FormatConverter(object):
except Exception as e: except Exception as e:
logger.error(f"Applying {smirks} on {smiles} failed:\n{e}") logger.error(f"Applying {smirks} on {smiles} failed:\n{e}")
return pss return list(pss)
@staticmethod @staticmethod
def MACCS(smiles): def MACCS(smiles):