7 Commits

Author SHA1 Message Date
fedd1b5280 [Feature] Management Command to Compute and Store Reaction <-> Rule Associations (#453)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#453
2026-08-14 20:45:12 +12:00
2d3dca6a75 [Fix] Adjust Rules according to manual QA, Add missing Rule (#452)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#452
2026-08-13 17:37:38 +12:00
ada270aa3c [Fix] Canonicalize SMILES when checking overlaps in smiles_covered_by (#443)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#443
2026-08-12 21:01:41 +12:00
093daa5ecf [Fix] 'Object of type ConfidenceLevel is not JSON serializable' in Pathway.d3_json (#450)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#450
2026-08-07 19:58:36 +12:00
f4f284925a [Fix] Entrypoint Location (#446)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#446
2026-08-05 09:15:09 +12:00
ca6e926b30 [Fix] Fix Image Tags (#445)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#445
2026-08-05 08:55:35 +12:00
2504d7045b [Chore] Build Docker Images on pushes to develop, main (#444)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#444
2026-08-05 08:36:24 +12:00
14 changed files with 489 additions and 54 deletions

View File

@ -0,0 +1,62 @@
name: Build Docker Image
# Trigger when a PR to main/develop is completed.
on:
pull_request:
branches:
- main
- develop
types:
- closed
jobs:
build-and-push:
if: ${{ github.event.pull_request.merged == true }}
runs-on: ubuntu-latest
steps:
# Fetch the repository content for the Docker build context.
- name: Checkout repository
uses: actions/checkout@v4
# Enable Buildx for BuildKit features (incl. SSH mount support).
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Authenticate against the container registry before pushing images.
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: git.envipath.com
username: ${{ secrets.CI_REGISTRY_USER }}
password: ${{ secrets.CI_REGISTRY_PASSWORD }}
# Generate image tags/labels:
# - PRs targeting main get "latest" and "main-sha"
# - PRs targeting develop get "dev" and "dev-sha"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: git.envipath.com/envipath/envipy
tags: |
type=raw,value=latest,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=sha,prefix=main-,enable=${{ github.event.pull_request.base.ref == 'main' }}
type=raw,value=dev,enable=${{ github.event.pull_request.base.ref == 'develop' }}
type=sha,prefix=dev-,enable=${{ github.event.pull_request.base.ref == 'develop' }}
# Load SSH key so Docker can pull private git+ssh dependencies during build.
- name: Setup SSH for private git dependencies
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }}
# Build and push the production image; forward SSH agent without registry cache reuse.
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile
push: true
ssh: default
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@ -58,6 +58,10 @@ COPY tests tests
COPY utilities utilities COPY utilities utilities
COPY manage.py . COPY manage.py .
# Used to run migrations etc
COPY entrypoint.sh entrypoint.sh
RUN chmod +x entrypoint.sh
# Install frontend deps # Install frontend deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
@ -100,4 +104,5 @@ USER django
EXPOSE 8000 EXPOSE 8000
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "envipath.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "8"]

9
entrypoint.sh Normal file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -e
if [ "${SKIP_DJANGO_SETUP:-false}" != "true" ]; then
python manage.py migrate --no-input
python manage.py collectstatic --no-input
fi
exec "$@"

View File

@ -44,15 +44,19 @@ class Command(BaseCommand):
"EPModel", "EPModel",
"ApplicabilityDomain", "ApplicabilityDomain",
"EnzymeLink", "EnzymeLink",
"AdditionalInformation",
] ]
for model in MODELS: for model in MODELS:
obj_cls = apps.get_model("epdb", model) obj_cls = apps.get_model("epdb", model)
obj_cls.objects.update(
url=Replace(F("url"), Value(options["old"]), Value(options["new"])) update_fields = {"url": Replace(F("url"), Value(options["old"]), Value(options["new"]))}
if hasattr(obj_cls, "description"):
update_fields["description"] = Replace(
F("description"), Value(options["old"]), Value(options["new"])
) )
if issubclass(obj_cls, EnviPathModel): if issubclass(obj_cls, EnviPathModel):
obj_cls.objects.update( update_fields["kv"] = Cast(
kv=Cast(
Replace( Replace(
Cast(F("kv"), output_field=TextField()), Cast(F("kv"), output_field=TextField()),
Value(options["old"]), Value(options["old"]),
@ -60,4 +64,5 @@ class Command(BaseCommand):
), ),
output_field=JSONField(), output_field=JSONField(),
) )
)
obj_cls.objects.update(**update_fields)

View File

@ -0,0 +1,97 @@
import logging
from django.core.management.base import BaseCommand
from django.db import transaction
from uuid import uuid4
from epdb.models import Package, ReactionExplanation
from utilities.chem import FormatConverter
from django.utils import timezone
logger = logging.getLogger(__name__)
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--rule-package",
action="append",
default=["32de3cf4-e3e6-4168-956e-32fa5ddb0ce1"],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--reaction-package",
action="append",
default=[
"32de3cf4-e3e6-4168-956e-32fa5ddb0ce1", # BBD
"f05e38d8-e9b4-4c3e-b0d8-9ab29966eccf", # Sediment
"521c547a-fd2a-491c-ad5b-7eaa1577fb65", # Sludge
"5882df9c-dae1-4d80-a40e-db4724271456", # Soil
"87a49584-d937-482c-9c33-25928dcb02a8", # PFAS
],
type=str,
help="UUID to process. Can be specified multiple times.",
)
parser.add_argument(
"--dry-run",
default=False,
action="store_true",
help="Perform dry run",
)
@transaction.atomic
def handle(self, *args, **options):
RUN_UUID = uuid4()
RUN_START = timezone.now()
rule_packages = Package.objects.filter(uuid__in=options["rule_package"])
reaction_packages = Package.objects.filter(uuid__in=options["reaction_package"])
rules = []
for rule_package in rule_packages:
rules.extend(rule_package.get_applicable_rules())
reactions = []
for reaction_package in reaction_packages:
reactions.extend(reaction_package.reactions)
logger.debug(f"Collected {len(rules)} rules and {len(reactions)} reactions.")
for i, reaction in enumerate(reactions):
logger.debug(f"Reaction {i} / {len(reactions)}")
for j, rule in enumerate(rules):
reactants, products = reaction.smirks().split(">>")
if len(reactants.split(".")) > 1:
logger.debug(f"Skipping reaction {reaction.uuid} as it has multiple reactants.")
break
products = products.split(".")
# Run reaction with rule
rule_products = rule.apply(reactants)
# Check if products match (in both directions if extras are not allowed)
for product_set in rule_products:
covered, exact = FormatConverter.smiles_covered_by(
products,
product_set.product_set,
standardize=True,
canonicalize_tautomers=True,
return_exact_match=True,
)
if covered and not options["dry-run"]:
logger.debug(f"Reaction {reaction.uuid} explained by rule {rule.uuid}")
re = ReactionExplanation()
re.run_uuid = RUN_UUID
re.run_start = RUN_START
re.reaction = reaction
re.rule = rule
re.exact = exact
re.save()
# Its explained, if there are more sets skip them
break

View File

@ -0,0 +1,113 @@
# Generated by Django 6.0.3 on 2026-08-12 09:02
from django.conf import settings as s
from django.db import migrations
from envipy_additional_information import Likelihood, RuleLikelihood
NEW_RULE = {
"parent": "bt0005",
"name": "bt0005-3667",
"description": "vic-unsubstituted Aromatic > vic-Dihydroxyaromatic",
"smirks": "[#8:7]([H])-[#6:1]([H])-1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]([H])-1-[#8:8]([H])>>[#8:7]([H])-[#6:1]=1-[#6:2]=[#6:3]-[#6:4]=[#6:5]-[#6:6]=1-[#8:8]([H])",
"scenario_name": "bt0005-3667 aerobic likelihood",
"scenario_aerobic_likelihood": RuleLikelihood(likelihood=Likelihood.LIKELY),
}
RULE_FIXES = {
"bt0005-4282": "[c:1]([H])1:[c:2]([H]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1>>[c:1]([#8])1:[c:2]([#8]):[#6,#7;a:3]:[c:4]:[c:5]:[c:6]1",
"bt0014-4215": "[c:1]([H])1[c:8][#6,#7;a:7][c:6][c:5][c:4]1[#8;!$([OH]c:[#6,#7;a:7]([OH])):9]([H])>>[#8:9]([H])[c:4]1:[c:5]:[c:6]:[#6,#7;a:7]:[c:8]:[c:1]1[#8]([H])",
# "bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([H:3])(=[#1,#6:7]).[#6;A:2]=O",
# CN1C=NC2=C1C(=O)N(C)C(=O)N2 not working anymore with bt0063-3938 if change above is applied
"bt0063-3938": "[#1,#6:6][#7;X3;!$(NC1CC1)!$([N][C]=O)!$([!#8]CNC=O):1]([#1,#6:7])[#6;A;X4:2][H:3]>>[#1,#6:6][#7;X3:1]([#1,#6:7])[H:3].[#6;A:2]=O",
"bt0068-3564": "[#7:4]!@-[#6:2](!@-[#7:1])=[O:5]>>[#7:4]-[#6:2](-[O+0H1])=[O:5].[#7H1:1]",
"bt0180-2844": "[H][C:2]([#6:5]([H])([H])([H]))([#1,#6:4])!@-[#6:1]([H])([H])-[#6:3](-[#8-:8])=[O:6]>>[#6:5]([H])([H])([H])\\[#6:2](-[#1,#6:4])=[#6H:1]\\[#6:3](-[#8-:8])=[O:6]",
"bt0181-1278": "[#8-:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-[#6:3](-[H])=[#6:5](-Cl)-[#6:6](-[#8-:10])=[O:9]>>[O+0H1:10]-[#6:6](=[O:9])-[#6:5]=[#6:3]-1-[O+0:1]-[#6:2](=[O:11])-[#6:7]=[#6:8]-1",
"bt0298-3335": "[#6:1][N+:2]#[C:3]>>[#6:1]-[#7H2:2]-[#6:3]=O",
"bt0322-3393": "[H:10]\\[#6:6](=[#6:9](/[#6:1]([H])([H])([H]))-[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:5](-[#16:7])=[O:8]>>[H:10]\\[#6:6](-[#6:5](-[#16:7])=[O:8])=[#6:9](\\[#6:11]-[#6:12]-[#6:13]=[#6:14])-[#6:1]-[#6](-[#8-])=O",
"bt0343-2675": "[#8-]-[#6](=O)-[c:1]1[c:6][cH:7][c:8](-[#7H2,#8H1:9])[cH:10][c:11]1>>[#8H][c:1]1[c:6][c:7][c:8]([*:9])[c:10][c:11]1",
"bt0350-3319": "[#6:6][#7:3][#6;!R:2]=[#7;!R:1][#6:5]>>[#6:5][#7:1][#6:2]=O.[#6:6][#7:3]", # Trig before 5 -> all of them shouldn't
"bt0374-4081": "[cH:4]1[c:16][c:15][c:14][c:13][c:3]1[#7,#8:2][c:1]1[c:8][c:9][c:10][c:11][c:12]1>>[#7,#8:2]-[c:1]1[c:12][c:11][c:10][c:9][c:8]1[c:13]1[c:14][c:15][c:16][c:4](-[#8])[c:3]1-[#8]",
"bt0378-3188": "[#8-:7][c:1]1[c:6]([#7+]([#8-])=O)[c:5][c:4]([#7+:9]([#8-])=O)[c:3][c:2]1([#7+:8]([#8-])=O)>>[#8+0:7]=[#6:1]1-[#6:6]-[#6:5]-[#6:4]([#7+:9]([#8-])=O)-[#6:3]-[#6:2]1([#7+:8]([#8-])=O)",
"bt0379-3190": "[#9,#17,#35,#53]-[#6:1](-[H])-1-[#6:5]-,=[#6:6]-[#6:7]-,=[#6:8]-[#6:2](-[H])-1-[#9,#17,#35,#53]>>[#6:6]~1-[#6:7]~[#6:8]-[#6:2]=[#6:1]-[#6:5]~1",
"bt0393-3367": "[#6:5]-[#6:1](-[#7:2](-[H])(-[H]))=[S+:3]-[#8-:6]>>[#6:5]-[#6:1](=[#7H1:2])-[S+0:3](=[O])-[#8+0H1:6]",
}
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
pkg_class = s.EPDB_PACKAGE_MODEL
if len(pkg_class.split(".")) != 2:
raise ValueError(
f"EPDB_PACKAGE_MODEL must be of the form 'app_label.model_name', got {pkg_class}"
)
app_label, model_name = pkg_class.split(".")
Package = apps.get_model(app_label, model_name)
SimpleAmbitRule = apps.get_model("epdb", "SimpleAmbitRule")
ParallelRule = apps.get_model("epdb", "ParallelRule")
Scenario = apps.get_model("epdb", "Scenario")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
simple_ambit_rule_ct = ContentType.objects.get_for_model(SimpleAmbitRule)
if Package.objects.filter(name="EAWAG-BBD").exists():
p = Package.objects.get(name="EAWAG-BBD")
if not SimpleAmbitRule.objects.filter(package=p, name=NEW_RULE["name"]).exists():
# Create Missing Rule
new_sr = SimpleAmbitRule()
new_sr.polymorphic_ctype = simple_ambit_rule_ct
new_sr.package = p
new_sr.name = NEW_RULE["name"]
new_sr.description = NEW_RULE["description"]
new_sr.smirks = NEW_RULE["smirks"]
new_sr.save()
new_sr.url = "{}/simple-ambit-rule/{}".format(new_sr.package.url, new_sr.uuid)
new_sr.save()
# Add likelihood
new_scen = Scenario()
new_scen.package = p
new_scen.name = NEW_RULE["scenario_name"]
new_scen.save()
new_scen.url = "{}/scenario/{}".format(new_scen.package.url, new_scen.uuid)
new_scen.save()
ai = NEW_RULE["scenario_aerobic_likelihood"]
new_add_inf = AdditionalInformation()
new_add_inf.package = p
new_add_inf.type = ai.__class__.__name__
new_add_inf.data = ai.model_dump(mode="json")
new_add_inf.scenario = new_scen
new_add_inf.save()
new_add_inf.url = "{}/additional-information/{}".format(
new_add_inf.scenario.url, new_add_inf.uuid
)
new_add_inf.save()
# Link Scenario
new_sr.scenarios.add(new_scen)
# Link to bt0005
pr = ParallelRule.objects.get(package=p, name="bt0005")
pr.simple_rules.add(new_sr)
# Update others
for rule_name, smirks in RULE_FIXES.items():
sr = SimpleAmbitRule.objects.get(package=p, name=rule_name)
sr.smirks = smirks
sr.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0027_alter_compound_aliases_and_more"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -0,0 +1,63 @@
# Generated by Django 6.0.3 on 2026-08-13 09:58
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("epdb", "0028_auto_20260812_0902"),
]
operations = [
migrations.CreateModel(
name="ReactionExplanation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"created",
model_utils.fields.AutoCreatedField(
default=django.utils.timezone.now, editable=False, verbose_name="created"
),
),
(
"modified",
model_utils.fields.AutoLastModifiedField(
default=django.utils.timezone.now, editable=False, verbose_name="modified"
),
),
("run_uuid", models.UUIDField()),
("run_start", models.DateTimeField()),
("exact", models.BooleanField(default=False)),
(
"reaction",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="epdb.reaction"
),
),
(
"rule",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="epdb.rule"),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="reaction",
name="explained_by",
field=models.ManyToManyField(
related_name="explained_reactions",
through="epdb.ReactionExplanation",
to="epdb.rule",
),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 6.0.3 on 2026-08-14 07:41
from django.db import migrations
def forward_func(apps, schema_editor):
ContentType = apps.get_model("contenttypes", "ContentType")
AdditionalInformation = apps.get_model("epdb", "AdditionalInformation")
models = {}
for c in ContentType.objects.all():
try:
models[(c.app_label, c.model)] = apps.get_model(c.app_label, c.model)
except Exception:
pass
for ai in AdditionalInformation.objects.all():
if ai.url is None:
if ai.content_type is None:
ai.url = "{}/additional-information/{}".format(ai.scenario.url, ai.uuid)
else:
model = models[(ai.content_type.app_label, ai.content_type.model)]
obj = model.objects.get(pk=ai.object_id)
ai.url = "{}/additional-information/{}".format(obj.url, ai.uuid)
ai.save()
class Migration(migrations.Migration):
dependencies = [
("epdb", "0029_reactionexplanation_reaction_explained_by"),
]
operations = [
migrations.RunPython(forward_func, reverse_code=migrations.RunPython.noop),
]

View File

@ -854,9 +854,15 @@ class Compound(
@property @property
def related_reactions(self): def related_reactions(self):
return ( return (
(
Reaction.objects.filter(package=self.package, educts__in=[self.default_structure]) Reaction.objects.filter(package=self.package, educts__in=[self.default_structure])
| Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) | Reaction.objects.filter(
).order_by("name") package=self.package, products__in=[self.default_structure]
)
)
.distinct()
.order_by("name")
)
@property @property
def related_nodes(self): def related_nodes(self):
@ -1724,6 +1730,14 @@ class SequentialRuleOrdering(models.Model):
order_index = models.IntegerField(null=False, blank=False) order_index = models.IntegerField(null=False, blank=False)
class ReactionExplanation(TimeStampedModel):
run_uuid = models.UUIDField(null=False, blank=False)
run_start = models.DateTimeField(null=False, blank=False)
reaction = models.ForeignKey("epdb.Reaction", on_delete=models.CASCADE)
rule = models.ForeignKey("epdb.Rule", on_delete=models.CASCADE)
exact = models.BooleanField(default=False)
class Reaction( class Reaction(
EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin, AdditionalInformationMixin EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin, AdditionalInformationMixin
): ):
@ -1749,6 +1763,12 @@ class Reaction(
external_identifiers = GenericRelation("ExternalIdentifier") external_identifiers = GenericRelation("ExternalIdentifier")
explained_by = models.ManyToManyField(
"epdb.Rule",
through="ReactionExplanation",
related_name="explained_reactions",
)
def _url(self): def _url(self):
return "{}/reaction/{}".format(self.package.url, self.uuid) return "{}/reaction/{}".format(self.package.url, self.uuid)
@ -2574,7 +2594,7 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
collected[str(ai.scenario.uuid)]["proposed"] = True collected[str(ai.scenario.uuid)]["proposed"] = True
if ai.type == "Confidence": if ai.type == "Confidence":
collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level collected[str(ai.scenario.uuid)]["Confidence"] = ai.get().level.value
if ai.type == "TransformationProductImportance": if ai.type == "TransformationProductImportance":
collected[str(ai.scenario.uuid)]["Transformation product importance"] = ( collected[str(ai.scenario.uuid)]["Transformation product importance"] = (

View File

@ -1080,13 +1080,17 @@ def package_model(request, package_uuid, model_uuid):
for pr in pred_res: for pr in pred_res:
if len(pr) > 0: if len(pr) > 0:
products = [] products = []
for prod_set in pr.product_sets: for prod_set in pr.product_sets:
logger.debug(f"Checking {prod_set}") logger.debug(f"Checking {prod_set}")
products.append(tuple([x for x in prod_set])) products.append(tuple([x for x in prod_set]))
products = list(set(products))
for prod in products:
res["pred"].append( res["pred"].append(
{ {
"products": list(set(products)), "products": list(prod),
"probability": pr.probability, "probability": pr.probability,
"btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]} "btrule": {k: getattr(pr.rule, k) for k in ["url", "name"]}
if pr.rule is not None if pr.rule is not None

View File

@ -109,12 +109,12 @@
res += "<th scope='row'>" + cnt + "</th>"; res += "<th scope='row'>" + cnt + "</th>";
res += res +=
"<th scope='row'>" + "<th scope='row'>" +
data[transformation]["products"][0].join(", ") + data[transformation]["products"].join(", ") +
"</th>"; "</th>";
res += res +=
"<th scope='row'>" + "<th scope='row'>" +
"<img width='400' src='{% url 'depict' %}?smiles=" + "<img width='400' src='{% url 'depict' %}?smiles=" +
encodeURIComponent(data[transformation]["products"][0].join(".")) + encodeURIComponent(data[transformation]["products"].join(".")) +
"'></th>"; "'></th>";
res += res +=
"<th scope='row'>" + "<th scope='row'>" +

View File

@ -47,7 +47,7 @@ class ModelViewTest(TestCase):
expected = [ expected = [
{ {
"products": [["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"]], "products": ["CCN(CC)C(=O)C1=CC(C=O)=CC=C1"],
"probability": 0.75, "probability": 0.75,
"btrule": { "btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022", "url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/2f2e0c39-e109-4836-959f-2bda2524f022",
@ -55,7 +55,7 @@ class ModelViewTest(TestCase):
}, },
}, },
{ {
"products": [["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"]], "products": ["O=C(O)C1=CC(CO)=CC=C1", "CCNCC"],
"probability": 0.25, "probability": 0.25,
"btrule": { "btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/0e6e9290-b658-4450-b291-3ec19fa19206", "url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/0e6e9290-b658-4450-b291-3ec19fa19206",
@ -63,7 +63,7 @@ class ModelViewTest(TestCase):
}, },
}, },
{ {
"products": [["CCNC(=O)C1=CC(CO)=CC=C1", "CC=O"]], "products": ["CCNC(=O)C1=CC(CO)=CC=C1", "CC=O"],
"probability": 0.0, "probability": 0.0,
"btrule": { "btrule": {
"url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/27a3a353-0b66-4228-bd16-e407949e90df", "url": "http://localhost:8000/package/1869d3f0-60bb-41fd-b6f8-afa75ffb09d3/simple-ambit-rule/27a3a353-0b66-4228-bd16-e407949e90df",

View File

@ -68,6 +68,8 @@ class PredictionResult(object):
class FormatConverter(object): class FormatConverter(object):
tautomer_enumerator = rdMolStandardize.TautomerEnumerator()
@staticmethod @staticmethod
def mass(smiles): def mass(smiles):
return Descriptors.MolWt(FormatConverter.from_smiles(smiles)) return Descriptors.MolWt(FormatConverter.from_smiles(smiles))
@ -240,8 +242,9 @@ class FormatConverter(object):
Chem.RemoveStereochemistry(res_mol) Chem.RemoveStereochemistry(res_mol)
if canonicalize_tautomers: if canonicalize_tautomers:
te = rdMolStandardize.TautomerEnumerator() # idem tautomers = FormatConverter.tautomer_enumerator.Enumerate(res_mol)
res_mol = te.Canonicalize(res_mol) if len(tautomers) >= 1:
res_mol = FormatConverter.tautomer_enumerator.PickCanonical(tautomers)
return Chem.MolToSmiles(res_mol, kekuleSmiles=True) return Chem.MolToSmiles(res_mol, kekuleSmiles=True)
@ -389,7 +392,7 @@ class FormatConverter(object):
prods.append(p) prods.append(p)
except ValueError as e: except ValueError as e:
logger.error(f"Sanitizing and converting failed:\n{e}") logger.debug(f"Sanitizing and converting failed:\n{e}")
continue continue
if len(prods): if len(prods):
@ -397,7 +400,8 @@ class FormatConverter(object):
pss.add(ps) pss.add(ps)
except Exception as e: except Exception as e:
logger.error(f"Applying {smirks} on {smiles} failed:\n{e}") logger.debug(f"Applying {smirks} on {smiles} failed:\n{e}")
pass
return list(pss) return list(pss)
@ -444,6 +448,7 @@ class FormatConverter(object):
r_smiles: List[str], r_smiles: List[str],
standardize: bool = True, standardize: bool = True,
canonicalize_tautomers: bool = True, canonicalize_tautomers: bool = True,
return_exact_match: bool = False,
) -> bool: ) -> bool:
""" """
Check if all SMILES in the left list are covered by (contained in) the right list. Check if all SMILES in the left list are covered by (contained in) the right list.
@ -482,9 +487,11 @@ class FormatConverter(object):
if standardize: if standardize:
for smi in l_smiles: for smi in l_smiles:
try: try:
smi = FormatConverter.standardize( smi = FormatConverter.canonicalize(
FormatConverter.standardize(
smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers
) )
)
except Exception: except Exception:
# :shrug: # :shrug:
# logger.debug(f'Standardizing SMILES failed for {smi}') # logger.debug(f'Standardizing SMILES failed for {smi}')
@ -497,8 +504,12 @@ class FormatConverter(object):
if standardize: if standardize:
for smi in r_smiles: for smi in r_smiles:
try: try:
smi = FormatConverter.standardize( smi = FormatConverter.canonicalize(
smi, remove_stereo=True, canonicalize_tautomers=canonicalize_tautomers FormatConverter.standardize(
smi,
remove_stereo=True,
canonicalize_tautomers=canonicalize_tautomers,
)
) )
except Exception: except Exception:
# :shrug: # :shrug:
@ -507,8 +518,11 @@ class FormatConverter(object):
standardized_r_smiles.append(smi) standardized_r_smiles.append(smi)
else: else:
standardized_r_smiles = r_smiles standardized_r_smiles = r_smiles
if not return_exact_match:
return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0 return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0
return len(set(standardized_l_smiles).difference(set(standardized_r_smiles))) == 0, set(
standardized_l_smiles
) == set(standardized_r_smiles)
class Standardizer(ABC): class Standardizer(ABC):

View File

@ -238,9 +238,11 @@ class RuleBasedDataset(Dataset):
): ):
if feat_funcs is None: if feat_funcs is None:
feat_funcs = [FormatConverter.maccs] feat_funcs = [FormatConverter.maccs]
_structures = set() # Get all the structures
_structures = set()
for r in reactions: for r in reactions:
_structures.update(r.educts.all()) _structures.update(r.educts.all())
if not educts_only: if not educts_only:
_structures.update(r.products.all()) _structures.update(r.products.all())
@ -282,17 +284,14 @@ class RuleBasedDataset(Dataset):
if key not in triggered: if key not in triggered:
continue continue
# standardize products from reactions for comparison if FormatConverter.smiles_covered_by(
standardized_products = [] [prod.smiles for prod in r.products.all()],
for cs in r.products.all(): list(triggered[key]),
smi = cs.smiles standardize=True,
try: canonicalize_tautomers=True,
smi = FormatConverter.standardize(smi, remove_stereo=True) ):
except Exception:
logger.debug(f"Standardizing SMILES failed for {smi}")
standardized_products.append(smi)
if len(set(standardized_products).difference(triggered[key])) == 0:
observed.add(key) observed.add(key)
feat_columns = [] feat_columns = []
for feat_func in feat_funcs: for feat_func in feat_funcs:
if isinstance(feat_func, Descriptor): if isinstance(feat_func, Descriptor):
@ -301,6 +300,7 @@ class RuleBasedDataset(Dataset):
feats = feat_func(compounds[0].smiles) feats = feat_func(compounds[0].smiles)
start_i = len(feat_columns) start_i = len(feat_columns)
feat_columns.extend([f"feature_{start_i + i}" for i, _ in enumerate(feats)]) feat_columns.extend([f"feature_{start_i + i}" for i, _ in enumerate(feats)])
ds_columns = ( ds_columns = (
["structure_id"] ["structure_id"]
+ feat_columns + feat_columns
@ -334,7 +334,9 @@ class RuleBasedDataset(Dataset):
obs.append(None) obs.append(None)
else: else:
obs.append(0) obs.append(0)
rows.append([str(comp.uuid)] + feats + trig + obs) rows.append([str(comp.uuid)] + feats + trig + obs)
ds = RuleBasedDataset(len(applicable_rules), ds_columns, data=rows) ds = RuleBasedDataset(len(applicable_rules), ds_columns, data=rows)
return ds return ds
@ -680,9 +682,13 @@ class RelativeReasoning:
def predict(self, X): def predict(self, X):
res = np.zeros((len(X), (self.end_index + 1 - self.start_index))) res = np.zeros((len(X), (self.end_index + 1 - self.start_index)))
immutable_res = np.zeros((len(X), (self.end_index + 1 - self.start_index)))
# Loop through all instances # Loop through all instances
for inst_idx, inst in enumerate(X): for inst_idx, inst in enumerate(X):
for i, t in enumerate(inst[self.start_index : self.end_index + 1]):
immutable_res[inst_idx][i] = t
# Loop through all "triggered" features # Loop through all "triggered" features
for i, t in enumerate(inst[self.start_index : self.end_index + 1]): for i, t in enumerate(inst[self.start_index : self.end_index + 1]):
# Set label # Set label
@ -696,7 +702,7 @@ class RelativeReasoning:
if i2 in self.winmap.get(i, []): if i2 in self.winmap.get(i, []):
# if thatat rule also triggered, it dominated the current # if thatat rule also triggered, it dominated the current
# set label to 0 # set label to 0
if X[inst_idx][i2]: if immutable_res[inst_idx][i2]:
res[inst_idx][i] = 0 res[inst_idx][i] = 0
return res return res