12 Commits

Author SHA1 Message Date
67aa3731cb PES Adjustments, Check is User is in enviPath Entra Group
Some checks failed
CI / test (pull_request) Failing after 28s
API CI / api-tests (pull_request) Failing after 42s
2026-08-20 08:59:24 +02:00
0033513d99 adjusted migration
Some checks failed
CI / test (pull_request) Failing after 27s
API CI / api-tests (pull_request) Failing after 40s
Initial bayer app

Show Pack Classification

Adjusted docker compose to bayer specifics

Adjusted Dockerfile for Bayer

Adding secret flags to group, add secret pools to packages

Adjusted View for Package creation

Prep configs, added Package Create Modal

wip

More on PES

wip

wip

Wip

minor

PW interactions

API PES

wip

Make Select Widget reflect required

make required generallay available

Update UI if pathway mode is set to build

Added ais

circle adjustments

Initial Zoom, fix AD Creation

wip

auth log, bb4g fix

missing import

Added viz hint if PES is part of reaction

Add Edge check for pes

flip boolean

...

pes

Added extra

...

In / Out Edges Viz, Submitting Button Text

...

Make PES Link clickable

Return proper http response instead of error

Fixed error return, removed unused options

Fix PES Link HTML for other entities

Fixed molfile assignment, adjusted Export

Package Export/Import cycle

highlight Description links

implemented non persistent

Harmonised proposed field in Json output

Added pesLink field to PW Api output

PES Fields in API Output

removed debug

Fix Classification import, Fix PES Deserialization

underline pes link in templates

Fix alter name/desc for node, make /node /edge funcitonal

provide setting link and copy button

Implemented Compound Names / Reaction Names View Option

Unconnected Nodes

Make links thicker, reduce timeout trigger time

Show proposed info in popover

Pathway Build no stereo removal

Include probs in reaction name option viz

Detect clicks outside nodes/edges

Provide proper Error Pages

View Package Perm

wip

sync

Auth log

auth log leftovers

...

secret packs viz

auth log for api

model stats

Fix Package Adjustment

Adjust Group Auth Log

Fix Secret image size in Navbar

leftover

...

minor
2026-08-19 21:05:40 +02:00
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
032ebc30a2 [Fix] Return a File instead of raw text for new export endpoint (#442)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#442
2026-07-31 08:24:14 +12:00
703f377b7f [Fix] Export IUCLID Properties, Show Model Params and Statistics, Adjust Batch Prediction Settings (#434)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#434
2026-07-30 08:30:18 +12:00
7639b23e4e [Feature] Package Pathway Export to CSV (#439)
Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#439
2026-07-30 08:03:01 +12:00
30 changed files with 902 additions and 155 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

@ -60,6 +60,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 ./
@ -81,6 +85,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \ libxext6 \
libfontconfig1 \ libfontconfig1 \
nano \ nano \
openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash django RUN useradd -ms /bin/bash django
@ -102,4 +107,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"]

View File

@ -5,11 +5,11 @@
class="modal" class="modal"
x-data="{ x-data="{
isSubmitting: false, isSubmitting: false,
packageClassification: null, packageClassification: '',
reset() { reset() {
this.isSubmitting = false; this.isSubmitting = false;
this.packageClassification = null; this.packageClassification = '';
}, },
setFormData(data) { setFormData(data) {
@ -114,7 +114,7 @@
x-model="packageClassification" x-model="packageClassification"
required required
> >
<option value="null" disabled selected>Select Classification</option> <option value="" disabled>Select Classification</option>
<option value="0">Internal</option> <option value="0">Internal</option>
<option value="10">Restricted</option> <option value="10">Restricted</option>
<option value="20">Secret</option> <option value="20">Secret</option>

View File

@ -8,7 +8,7 @@ from django.shortcuts import redirect
from bayer.models import PESCompound from bayer.models import PESCompound
from epdb.logic import PackageManager from epdb.logic import PackageManager
from epdb.models import Pathway, Node from epdb.models import Pathway, Node, Group
from epdb.views import _anonymous_or_real, error from epdb.views import _anonymous_or_real, error
from utilities.decorators import package_permission_required from utilities.decorators import package_permission_required
@ -18,6 +18,23 @@ Package = s.GET_PACKAGE_MODEL()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def has_secret_group(user):
"""
Determines if the specified user belongs to any secret group.
This function checks whether the given user is a member of any group
that is marked as secret.
Args:
user: The user for whom the check is performed.
Returns:
bool: True if the user belongs to at least one secret group,
False otherwise.
"""
return Group.objects.filter(secret=True, user_member=user).exists()
@package_permission_required() @package_permission_required()
def create_pes(request, package_uuid): def create_pes(request, package_uuid):
current_user = _anonymous_or_real(request) current_user = _anonymous_or_real(request)
@ -38,7 +55,7 @@ def create_pes(request, package_uuid):
if pes_link: if pes_link:
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link, current_user)
except ValueError as e: except ValueError as e:
return error( return error(
request, request,
@ -98,7 +115,7 @@ def create_pes_node(request, package_uuid, pathway_uuid):
if pes_link: if pes_link:
try: try:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link, current_user)
except ValueError as e: except ValueError as e:
return error( return error(
request, request,
@ -157,16 +174,33 @@ def create_pes_node(request, package_uuid, pathway_uuid):
return HttpResponseNotAllowed(["POST"]) return HttpResponseNotAllowed(["POST"])
def fetch_pes(request, pes_url) -> dict: def get_application_token(prod: bool) -> str:
from epauth.views import get_access_token_from_request scope = f"{s.PROD_PES_SCOPE if prod else s.NON_PROD_PES_SCOPE}/.default"
token = get_access_token_from_request(request)
if token is None: url = f"https://login.microsoftonline.com/{s.MS_TENANT_ID}/oauth2/v2.0/token"
token = pes_url.split('/')[-1] == 'dummy' data = {
"grant_type": "client_credentials",
"client_id": s.MS_ENTRA_CLIENT_ID,
"client_secret": s.MS_ENTRA_CLIENT_SECRET,
"scope": scope,
}
try:
response = requests.post(url, data=data)
response.raise_for_status()
return response.json()["access_token"]
except requests.exceptions.HTTPError as e:
logger.error(f"Could not fetch application token: {e}")
raise ValueError(f"Could not fetch application token!")
def fetch_pes(request, pes_url, user) -> dict:
if token:
for k, v in s.PES_API_MAPPING.items(): for k, v in s.PES_API_MAPPING.items():
if pes_url.startswith(k): if pes_url.startswith(k):
prod = "cropkey-np" not in pes_url
pes_id = pes_url.split('/')[-1] pes_id = pes_url.split('/')[-1]
if pes_id == 'dummy': if pes_id == 'dummy':
@ -175,7 +209,16 @@ def fetch_pes(request, pes_url) -> dict:
res_data["pes_url"] = pes_url res_data["pes_url"] = pes_url
return res_data return res_data
else: else:
headers = {"Authorization": f"Bearer {token['access_token']}"} headers = {
"accept": "*/*",
"authorization": "Bearer " + get_application_token(prod),
}
# Restrict request if user is not part of any secret group
if not has_secret_group(user):
headers["app-classification-level-restriction"] = "restrict-pes-secret-structure-access"
params = {"pes_reg_entity_corporate_id": pes_id} params = {"pes_reg_entity_corporate_id": pes_id}
res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None) res = requests.get(v, headers=headers, params=params, proxies=s.PROXIES or None)
@ -184,6 +227,11 @@ def fetch_pes(request, pes_url) -> dict:
res.raise_for_status() res.raise_for_status()
pes_data = res.json() pes_data = res.json()
# Handle missing response
if "detail" in pes_data and "The following PES Reg Entities Corporate Ids could not be found" in pes_data["detail"]:
raise ValueError(f"PES with id {pes_id} not found")
# Ensure we have a entity
if len(pes_data) == 0: if len(pes_data) == 0:
raise ValueError(f"PES with id {pes_id} not found") raise ValueError(f"PES with id {pes_id} not found")
@ -193,17 +241,15 @@ def fetch_pes(request, pes_url) -> dict:
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
raise ValueError(f"Error fetching PES with id {pes_id}: {e}") raise ValueError(f"Error fetching PES with id {pes_id}: {e}")
else:
raise ValueError(f"Unknown URL {pes_url}") raise ValueError(f"Unknown URL {pes_url}")
else:
raise ValueError("Could not fetch access token from request.")
def visualize_pes(request): def visualize_pes(request):
pes_link = request.GET.get('pesLink') pes_link = request.GET.get('pesLink')
if pes_link: if pes_link:
pes_data = fetch_pes(request, pes_link) pes_data = fetch_pes(request, pes_link, request.user)
representations = pes_data.get('representations') representations = pes_data.get('representations')

View File

@ -1,4 +1,5 @@
import enum import enum
from typing import Any, Dict
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from envipy_additional_information import EnviPyModel from envipy_additional_information import EnviPyModel
@ -69,6 +70,12 @@ class Plugin(ABC):
class Property(Plugin): class Property(Plugin):
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the PropertyPlugin.
"""
return {}
@classmethod @classmethod
@abstractmethod @abstractmethod
def requires_rule_packages(cls) -> bool: def requires_rule_packages(cls) -> bool:
@ -300,6 +307,12 @@ class Classifier(Plugin):
""" """
pass pass
def parameters(self) -> Dict[str, Any]:
"""
Returns the parameters of the ClassifierPlugin.
"""
return {}
@abstractmethod @abstractmethod
def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None: def build(self, eP: EnviPyDTO, *args, **kwargs) -> BuildResult | None:
""" """

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

@ -56,7 +56,7 @@ def get_pathway_for_iuclid_export(user, pathway_uuid: UUID) -> PathwayExportDTO:
ai_for_node = [] ai_for_node = []
scenario_entries: list[PathwayScenarioDTO] = [] scenario_entries: list[PathwayScenarioDTO] = []
for scenario in sorted(node.scenarios.all(), key=lambda item: item.pk): for scenario in sorted(node.get_scenarios(), key=lambda item: item.pk):
ai_for_scenario = list(scenario.get_additional_information(direct_only=True)) ai_for_scenario = list(scenario.get_additional_information(direct_only=True))
ai_for_node.extend(ai_for_scenario) ai_for_node.extend(ai_for_scenario)
scenario_entries.append( scenario_entries.append(

View File

@ -9,7 +9,7 @@ from django.shortcuts import redirect
from epdb.logic import UserManager, GroupManager from epdb.logic import UserManager, GroupManager
from epdb.models import Group from epdb.models import Group
from epdb.views import get_remote_address from epdb.views import get_remote_address, error
auth_log = logging.getLogger("auth") auth_log = logging.getLogger("auth")
@ -72,6 +72,15 @@ def entra_callback(request):
claims = result["id_token_claims"] claims = result["id_token_claims"]
if claims.get("roles") is None or claims.get("roles") == [] or "envipath_registered_user" not in claims.get("roles"):
auth_log.error(f"Login attempt by {get_remote_address(request)} failed due to missing role")
return error(
request,
"Login Failed",
"The user is not authenticated. A reason for this might be a missing assignment to the respective enviPath group.",
403,
)
user_name = claims.get("name") user_name = claims.get("name")
# preferred_username is a fallback for 2nd CWID # preferred_username is a fallback for 2nd CWID
user_email = claims.get("emailaddress", claims.get("email", claims.get("preferred_username"))) user_email = claims.get("emailaddress", claims.get("email", claims.get("preferred_username")))
@ -141,9 +150,9 @@ def entra_callback(request):
if group.user_member.contains(u): if group.user_member.contains(u):
# User is eligible for secrete # User is eligible for secrete
pack = u.default_package pack = u.default_package
u.data_pool = group pack.data_pool = group
u.classification_level = u.Classification.SECRET pack.classification_level = pack.Classification.SECRET
u.save() pack.save()
# EDIT END # EDIT END

View File

@ -72,6 +72,10 @@ def get_cached_jwks(tenant_id: str, force=False) -> Dict:
return jwks return jwks
def get_package_for_read(user, package_uuid):
return PackageManager.get_package_by_id(user, package_uuid)
def get_package_for_write(user, package_uuid): def get_package_for_write(user, package_uuid):
p = PackageManager.get_package_by_id(user, package_uuid) p = PackageManager.get_package_by_id(user, package_uuid)
if not PackageManager.writable(user, p): if not PackageManager.writable(user, p):
@ -885,7 +889,7 @@ def create_package_compound(
from bayer.models import PESCompound from bayer.models import PESCompound
try: try:
pes_data = fetch_pes(request, c.pesLink) pes_data = fetch_pes(request, c.pesLink, request.user)
except ValueError as e: except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {c.pesLink}"} return 400, {"message": f"Could not fetch PES data for {c.pesLink}"}
@ -2010,7 +2014,7 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
from bayer.models import PESCompound from bayer.models import PESCompound
try: try:
pes_data = fetch_pes(request, n.pesLink) pes_data = fetch_pes(request, n.pesLink, request.user)
except ValueError as e: except ValueError as e:
return 400, {"message": f"Could not fetch PES data for {n.pesLink}"} return 400, {"message": f"Could not fetch PES data for {n.pesLink}"}
@ -2430,3 +2434,42 @@ def predict(request, np: Form[NonPersistent]):
return 403, { return 403, {
"message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!" "message": f"Getting Setting with id {np.setting_url} failed due to insufficient rights!"
} }
##########
# Export #
##########
class PackageExportInSchema(Schema):
package_uuid: str
additional_information_types: List[str] | None = None
@router.get("/export", response={200: Any, 403: Error})
def export(request, q: Query[PackageExportInSchema]):
try:
p = get_package_for_read(request.user, q.package_uuid)
from envipy_additional_information import registry
from utilities.misc import PathwayExporter
ai_types = []
if q.additional_information_types is not None:
for ai_type in q.additional_information_types:
if registry.get_model(ai_type) is None:
return 400, {
"message": f"Exporting Package with id {q.package_uuid} failed as {ai_type} is not a valid additional information type!"
}
ai_types.append(ai_type)
exporter = PathwayExporter(p, add_infs_to_export=ai_types)
res = exporter.do_export()
filename = f"{p.get_name().replace(' ', '_')}_{p.uuid}.tsv"
response = HttpResponse(res, content_type="text/csv")
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except ValueError:
return 403, {
"message": f"Exporting Package with id {q.package_uuid} failed due to insufficient rights!"
}

View File

@ -321,7 +321,7 @@ class GroupManager(object):
else: else:
group.group_member.remove(member) group.group_member.remove(member)
auth_log.info( auth_log.info(
f"{caller.username} ({caller.url}) removes {member.name} ({member.url}) to {group.name} ({group.url})") f"{caller.username} ({caller.url}) removes {member.name} ({member.url}) from {group.name} ({group.url})")
else: else:
if add_or_remove == "add": if add_or_remove == "add":
group.user_member.add(member) group.user_member.add(member)
@ -329,7 +329,7 @@ class GroupManager(object):
else: else:
group.user_member.remove(member) group.user_member.remove(member)
auth_log.info( auth_log.info(
f"{caller.username} ({caller.url}) adds {member.username} ({member.url}) to {group.name} ({group.url})") f"{caller.username} ({caller.url}) removes {member.username} ({member.url}) from {group.name} ({group.url})")
group.save() group.save()

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

@ -859,9 +859,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):
@ -1735,6 +1741,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
): ):
@ -1760,6 +1774,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)
@ -2173,7 +2193,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
row += [cs.smiles, cs.get_name(), n.depth] row += [cs.smiles, cs.get_name(), n.depth]
edges = self.edges.filter(end_nodes__in=[n]) edges = self.edges.filter(end_nodes=n)
if len(edges): if len(edges):
for e in edges: for e in edges:
_row = row.copy() _row = row.copy()
@ -2585,7 +2605,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"] = (
@ -2815,21 +2835,6 @@ class PackageBasedModel(EPModel):
) )
multigen_eval = models.BooleanField(null=False, blank=False, default=False) multigen_eval = models.BooleanField(null=False, blank=False, default=False)
def statistics(self):
return {
'accuracy': [
self.eval_results["average_accuracy"],
self.eval_results.get("multigen_average_accuracy")],
'precision': [
self.eval_results["average_precision_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_precision_per_threshold", {}).get(f"{self.threshold:.2f}")
],
'recall': [
self.eval_results["average_recall_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_recall_per_threshold", {}).get(f"{self.threshold:.2f}")
],
}
@property @property
def pr_curve(self): def pr_curve(self):
if self.model_status != self.FINISHED: if self.model_status != self.FINISHED:
@ -2873,6 +2878,58 @@ class PackageBasedModel(EPModel):
return res return res
def parameters(self):
params = {
"Model Evaluation Threshold": f"{self.threshold:.2f}",
"Multi Gen Evaluation": "Yes" if self.multigen_eval else "No",
}
if self.app_domain:
params["Applicability Domain Num Neighbors"] = f"{self.app_domain.num_neighbours:.2f}"
params["Applicability Domain Reliability Threshold"] = (
f"{self.app_domain.reliability_threshold:.2f}"
)
params["Applicability Domain Local Compatibility Threshold"] = (
f"{self.app_domain.local_compatibilty_threshold:.2f}"
)
return params
def statistics(self):
from sklearn.metrics import auc
recall = list(self.eval_results["average_recall_per_threshold"].values())
precision = list(self.eval_results["average_precision_per_threshold"].values())
mg_recall = list(
self.eval_results.get("multigen_average_recall_per_threshold", {}).values()
)
mg_precision = list(
self.eval_results.get("multigen_average_precision_per_threshold", {}).values()
)
return {
"accuracy": [
self.eval_results["average_accuracy"],
self.eval_results.get("multigen_average_accuracy"),
],
"precision": [
self.eval_results["average_precision_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_precision_per_threshold", {}).get(
f"{self.threshold:.2f}"
),
],
"recall": [
self.eval_results["average_recall_per_threshold"][f"{self.threshold:.2f}"],
self.eval_results.get("multigen_average_recall_per_threshold", {}).get(
f"{self.threshold:.2f}"
),
],
"Area under PR Curve": [
auc(recall, precision),
auc(mg_recall, mg_precision) if self.multigen_eval else None,
],
}
@cached_property @cached_property
def applicable_rules(self) -> List["Rule"]: def applicable_rules(self) -> List["Rule"]:
""" """
@ -3053,8 +3110,6 @@ class PackageBasedModel(EPModel):
thresholds.append(np.float64(threshold)) thresholds.append(np.float64(threshold))
thresholds.sort() thresholds.sort()
logger.info(f"Thresholds: {thresholds}")
precision = {f"{t:.2f}": [] for t in thresholds} precision = {f"{t:.2f}": [] for t in thresholds}
recall = {f"{t:.2f}": [] for t in thresholds} recall = {f"{t:.2f}": [] for t in thresholds}
@ -3103,14 +3158,17 @@ class PackageBasedModel(EPModel):
for t in thresholds: for t in thresholds:
for true, pred in zip(pathways, pred_pathways): for true, pred in zip(pathways, pred_pathways):
acc, pre, rec = multigen_eval(true, pred, t) acc, pre, rec = multigen_eval(true, pred, t)
if abs(t - threshold) < 0.01:
mg_acc = acc if f"{t:.2f}" == f"{threshold:.2f}":
mg_acc += acc
precision[f"{t:.2f}"].append(pre) precision[f"{t:.2f}"].append(pre)
recall[f"{t:.2f}"].append(rec) recall[f"{t:.2f}"].append(rec)
avg_mg_acc = mg_acc / len(root_compounds)
precision = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in precision.items()} precision = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in precision.items()}
recall = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in recall.items()} recall = {k: sum(v) / len(v) if len(v) > 0 else 0 for k, v in recall.items()}
return mg_acc, precision, recall return avg_mg_acc, precision, recall
# If there are eval packages perform single generation evaluation on them instead of random splits # If there are eval packages perform single generation evaluation on them instead of random splits
if self.eval_packages.count() > 0: if self.eval_packages.count() > 0:
@ -4242,6 +4300,9 @@ class ClassifierPluginModel(PackageBasedModel):
instance = impl(conf) instance = impl(conf)
return instance return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self): def build_dataset(self):
""" """
Required by general model contract but actual implementation resides in plugin. Required by general model contract but actual implementation resides in plugin.
@ -4462,6 +4523,9 @@ class PropertyPluginModel(PackageBasedModel):
instance = impl() instance = impl()
return instance return instance
def parameters(self):
return self.instance().parameters()
def build_dataset(self): def build_dataset(self):
""" """
Required by general model contract but actual implementation resides in plugin. Required by general model contract but actual implementation resides in plugin.

View File

@ -477,8 +477,7 @@ def batch_predict(
limit=None, limit=None,
setting_overrides={ setting_overrides={
"max_nodes": num_tps, "max_nodes": num_tps,
"max_depth": num_tps, "model_threshold": 0.0,
"model_threshold": 0.001,
}, },
) )

View File

@ -1145,13 +1145,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

@ -351,7 +351,8 @@ class PathwayMapper:
props = SoilPropertiesData() props = SoilPropertiesData()
for ai in ai_list: for ai_obj in ai_list:
ai = ai_obj.get()
if isinstance(ai, SoilTexture1) and props.soil_type is None: if isinstance(ai, SoilTexture1) and props.soil_type is None:
props.soil_type = ai.type.value props.soil_type = ai.type.value
elif isinstance(ai, SoilTexture2): elif isinstance(ai, SoilTexture2):

View File

@ -108,8 +108,7 @@
{% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Restricted" %} {% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Restricted" %}
<img src="{% static 'images/restricted_mid.png' %}" width="200"> <img src="{% static 'images/restricted_mid.png' %}" width="200">
{% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Secret" %} {% elif not meta.url_contains_package and meta.user.default_package.get_classification_level_display == "Secret" %}
<img src="{% static 'images/secret_mid.png' %}" width="200"> <img src="{% static 'images/secret_mid.png' %}" width="120">
{% endif %} {% endif %}
{% if not public_mode %} {% if not public_mode %}
<a id="search-trigger" role="button" class="cursor-pointer"> <a id="search-trigger" role="button" class="cursor-pointer">

View File

@ -117,6 +117,39 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if model.parameters %}
<!-- Model Parameters Panel -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Parameters</div>
<div class="collapse-content">
<div class="flex justify-center">
<div
id="model-stats"
class="overflow-x-auto rounded-box shadow-md bg-base-100"
>
<table class="table table-fixed w-full">
<thead class="text-base">
<tr>
<th class="w-3/5">Parameter</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for param, value in model.parameters.items %}
<tr>
<td>{{ param }}</td>
<td>{{ value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block usemodel %} {% block usemodel %}

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

@ -139,7 +139,7 @@
</tr> </tr>
<tr> <tr>
<td>Model</td> <td>Model</td>
<td>{{ half_lifes.0.model }}</td> <td>{{ half_lifes.0.model.value }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@ -537,7 +537,7 @@
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-medium">Setting</div> <div class="collapse-title text-xl font-medium">Setting</div>
<div class="collapse-content"> <div class="collapse-content">
{% with setting_to_render=pathway.setting can_be_default=False %} {% with setting_to_render=pathway.setting_with_overrides can_be_default=False %}
{% include "objects/setting_template.html" %} {% include "objects/setting_template.html" %}
{% endwith %} {% endwith %}
</div> </div>

View File

@ -95,6 +95,7 @@
</div> </div>
<!-- Other Prediction Settings --> <!-- Other Prediction Settings -->
{% if meta.available_settings|length > 1 %}
<div class="collapse-arrow bg-base-200 collapse"> <div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
@ -110,6 +111,7 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
<script> <script>

View File

@ -58,7 +58,7 @@ class MultiGenTest(TestCase):
self.assertEqual( self.assertEqual(
pw.setting_with_overrides.max_depth, pw.setting_with_overrides.max_depth,
f"{num_tps} (this is an override for this particular pathway)", 5,
) )
self.assertEqual( self.assertEqual(
pw.setting_with_overrides.max_nodes, pw.setting_with_overrides.max_nodes,

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

@ -4,11 +4,13 @@ import hmac
import json import json
import logging import logging
import uuid import uuid
from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, TYPE_CHECKING from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type
from django.conf import settings as s from django.conf import settings as s
from envipy_additional_information import EnviPyModel, UIConfig
from ninja import Schema from ninja import Schema
from pydantic import HttpUrl, ValidationError from pydantic import HttpUrl, ValidationError
@ -156,7 +158,7 @@ class EnzymeExportSchema(RefEnzymeExportSchema):
class EnzymeRuleExportSchema(RefRuleExportSchema): class EnzymeRuleExportSchema(RefRuleExportSchema):
enzymes: List[EnzymeExportSchema] | None = None enzymes: List[EnzymeExportSchema] = []
@staticmethod @staticmethod
def resolve_enzymes(obj): def resolve_enzymes(obj):
@ -319,15 +321,20 @@ class PackageExportSchema(Schema):
return AdditionalInformation.objects.filter(package=obj) return AdditionalInformation.objects.filter(package=obj)
class PackageExporter: class Exporter(ABC):
def __init__(self, package: Package): def __init__(self, package: Package):
self._raw_package = package self._raw_package = package
def do_export(self): def do_export(self):
return PackageExporter._export_package_as_json(self._raw_package) return self._export()
@staticmethod @abstractmethod
def _export_package_as_json(package: Package) -> Dict[str, Any]: def _export(self):
pass
class PackageExporter(Exporter):
def _export(self) -> Dict[str, Any]:
""" """
Dumps a Package and all its related objects as JSON. Dumps a Package and all its related objects as JSON.
@ -338,11 +345,126 @@ class PackageExporter:
Dict containing the complete package data as JSON-serializable structure Dict containing the complete package data as JSON-serializable structure
""" """
data = PackageExportSchema.from_orm(package) data = PackageExportSchema.from_orm(self._raw_package)
return data.model_dump(mode="json") return data.model_dump(mode="json")
class PathwayExporter(Exporter):
def __init__(self, package: Package, add_infs_to_export: List[str] = []):
super().__init__(package)
self._add_infs_to_export = add_infs_to_export
def _flatten_additional_information(self, ai: AdditionalInformation) -> dict[str, Any]:
model_cls: Type[EnviPyModel] = type(ai.get())
def _flatten(d: dict, parent_key: str = "") -> dict[str, Any]:
items: dict[str, Any] = {}
for key, value in d.items():
new_key = f"{parent_key}__{key.lower()}" if parent_key else key.lower()
if isinstance(value, dict):
items.update(_flatten(value, new_key))
else:
items[new_key] = value
return items
flat = _flatten(ai.data, ai.type)
ui_class = getattr(model_cls, "UI", None)
if ui_class is None:
return flat
for f in model_cls.model_fields:
ui_info = getattr(ui_class, f, None)
if not isinstance(ui_info, UIConfig) or ui_info.unit is None:
continue
flat[f"{model_cls.__name__}__{f}__unit"] = ui_info.unit
return flat
def _export(self):
from io import StringIO
from csv import DictWriter
rows = []
for pw in self._raw_package.pathways.all():
for n in pw.nodes:
for scen in pw.scenarios.all():
row = {
"pathway_name": pw.name,
"pathway_id": str(pw.url),
"node_depth": n.depth,
"compound_id": str(n.default_node_label.compound.url),
"pubchem_ID": n.default_node_label.pubchem_compound_id,
"compound_name": n.default_node_label.compound.name,
"compound_smiles": n.default_node_label.smiles,
"scenario_id": str(scen.url),
"scenario_name": scen.name,
"scenario_type": scen.scenario_type,
"scenario_description": scen.description,
}
if self._add_infs_to_export:
ai_qs = AdditionalInformation.objects.filter(
scenario=scen, type__in=self._add_infs_to_export
)
else:
ai_qs = AdditionalInformation.objects.filter(scenario=scen)
for ai in ai_qs:
if ai.type == "ProposedIntermediate" and ai.content_object == n:
row.update({"proposed_intermediate": True})
elif ai.type == "SpikeCompound":
spike = {"SpikeCompound__url": ai.get().url}
try:
struc = CompoundStructure.objects.get(
compound__package=self._raw_package, url=ai.get().url
)
spike["SpikeCompound__smiles"] = struc.smiles
except Exception:
spike["SpikeCompound__smiles"] = None
row.update(**spike)
else:
row.update(self._flatten_additional_information(ai))
rows.append(row)
# Get all header fields
all_header_fields = set()
for row in rows:
all_header_fields.update(row.keys())
# Per request the CSV should start with these fields
header = [
"pathway_name",
"pathway_id",
"node_depth",
"compound_id",
"pubchem_ID",
"compound_name",
"compound_smiles",
"scenario_id",
"scenario_name",
"scenario_type",
"scenario_description",
]
# User remaining fields and place them after the predefined values in a sorted manner
remainder = sorted(list(all_header_fields.difference(set(header))))
header.extend(remainder)
buffer = StringIO()
writer = DictWriter(buffer, fieldnames=header, delimiter="\t")
writer.writeheader()
writer.writerows(rows)
buffer.seek(0)
return buffer.getvalue()
class PackageImporter: class PackageImporter:
def __init__(self, package: Dict[str, Any], preserve_uuids: bool = False): def __init__(self, package: Dict[str, Any], preserve_uuids: bool = False):
self.preserve_uuids = preserve_uuids self.preserve_uuids = preserve_uuids

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