From 7c60a28801d83e035cda9448a3cb6773c91cad23 Mon Sep 17 00:00:00 2001 From: jebus Date: Sat, 20 Dec 2025 02:11:47 +1300 Subject: [PATCH] [Feature] Threshold Warning + Cosmetics (#277) Co-authored-by: Tim Lorsbach Reviewed-on: https://git.envipath.com/enviPath/enviPy/pulls/277 --- .gitea/workflows/api-ci.yaml | 10 +- .gitea/workflows/ci.yaml | 3 - epdb/logic.py | 24 ++- epdb/models.py | 59 ++++-- epdb/views.py | 1 + static/js/alpine/pathway.js | 24 ++- templates/modals/search_modal.html | 12 +- templates/objects/compound.html | 49 +++++ templates/objects/pathway.html | 279 ++++++++++++++++------------- tests/test_sobjects.py | 8 +- 10 files changed, 304 insertions(+), 165 deletions(-) diff --git a/.gitea/workflows/api-ci.yaml b/.gitea/workflows/api-ci.yaml index 1550cba0..59027ac6 100644 --- a/.gitea/workflows/api-ci.yaml +++ b/.gitea/workflows/api-ci.yaml @@ -15,6 +15,8 @@ jobs: api-tests: if: ${{ !contains(gitea.event.pull_request.title, 'WIP') }} runs-on: ubuntu-latest + container: + image: git.envipath.com/envipath/envipy-ci:latest services: postgres: @@ -67,19 +69,17 @@ jobs: uses: ./.gitea/actions/setup-envipy with: skip-frontend: 'true' - skip-playwright: 'true' + skip-playwright: 'false' ssh-private-key: ${{ secrets.ENVIPY_CI_PRIVATE_KEY }} run-migrations: 'true' - name: Run API tests run: | - source .venv/bin/activate - python manage.py test epapi -v 2 + .venv/bin/python manage.py test epapi -v 2 - name: Test API endpoints availability run: | - source .venv/bin/activate - python manage.py runserver 0.0.0.0:8000 & + .venv/bin/python manage.py runserver 0.0.0.0:8000 & SERVER_PID=$! sleep 5 curl -f http://localhost:8000/api/v1/docs || echo "API docs not available" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9dbb3d9e..7cdd2345 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -12,9 +12,6 @@ jobs: runs-on: ubuntu-latest container: image: git.envipath.com/envipath/envipy-ci:latest - credentials: - username: ${{ secrets.CI_REGISTRY_USER }} - password: ${{ secrets.CI_REGISTRY_PASSWORD }} services: postgres: diff --git a/epdb/logic.py b/epdb/logic.py index 08f2adca..19d872e7 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -1489,6 +1489,7 @@ class SPathway(object): self.smiles_to_node: Dict[str, SNode] = dict(**{n.smiles: n for n in self.root_nodes}) self.edges: Set["SEdge"] = set() self.done = False + self.empty_due_to_threshold = False @staticmethod def from_pathway(pw: "Pathway", persist: bool = True): @@ -1601,9 +1602,24 @@ class SPathway(object): sub.app_domain_assessment = app_domain_assessment - candidates = self.prediction_setting.expand(self, sub) + expansion_result = self.prediction_setting.expand(self, sub) + + # We don't have any substrate, but technically we have at least one rule that triggered. + # If our substrate is a root node a.k.a. depth == 0 store that info in SPathway + if ( + len(expansion_result["transformations"]) == 0 + and expansion_result["rule_triggered"] + and sub.depth == 0 + ): + self.empty_due_to_threshold = True + + # Emit directly + if self.persist is not None: + self.persist.kv["empty_due_to_threshold"] = True + self.persist.save() + # candidates is a List of PredictionResult. The length of the List is equal to the number of rules - for cand_set in candidates: + for cand_set in expansion_result["transformations"]: if cand_set: # cand_set is a PredictionResult object that can consist of multiple candidate reactions for cand in cand_set: @@ -1727,10 +1743,6 @@ class SPathway(object): for queued_val in queue: node_and_probs.append((queued_val, node_probs[queued_val])) - from pprint import pprint - - pprint(node_and_probs) - # re-order the queue and only pick smiles queue = [ n[0] for n in sorted(node_and_probs, key=lambda x: x[1], reverse=True) diff --git a/epdb/models.py b/epdb/models.py index a2429c22..cf88cffc 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -23,7 +23,7 @@ from django.db import models, transaction from django.db.models import Count, JSONField, Q, QuerySet from django.utils import timezone from django.utils.functional import cached_property -from envipy_additional_information import EnviPyModel +from envipy_additional_information import EnviPyModel, HalfLife from model_utils.models import TimeStampedModel from polymorphic.models import PolymorphicModel from sklearn.metrics import jaccard_score, precision_score, recall_score @@ -795,9 +795,7 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin @property def related_pathways(self): - pathways = Node.objects.filter(node_labels__in=[self.default_structure]).values_list( - "pathway", flat=True - ) + pathways = self.related_nodes.values_list("pathway", flat=True) return Pathway.objects.filter(package=self.package, id__in=set(pathways)).order_by("name") @property @@ -807,6 +805,12 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin | Reaction.objects.filter(package=self.package, products__in=[self.default_structure]) ).order_by("name") + @property + def related_nodes(self): + return Node.objects.filter( + node_labels__in=[self.default_structure], pathway__package=self.package + ) + @staticmethod @transaction.atomic def create( @@ -1042,6 +1046,17 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin return new_compound + def half_lifes(self): + hls: Dict[Scenario, List[HalfLife]] = defaultdict(list) + + for n in self.related_nodes: + for scen in n.scenarios.all().order_by("name"): + for ai in scen.get_additional_information(): + if isinstance(ai, HalfLife): + hls[scen].append(ai) + + return dict(hls) + class Meta: unique_together = [("uuid", "package")] @@ -1780,6 +1795,9 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): def failed(self): return self.status() == "failed" + def empty_due_to_threshold(self): + return self.kv.get("empty_due_to_threshold", False) + def d3_json(self): # Ideally it would be something like this but # to reduce crossing in edges do a DFS @@ -1887,7 +1905,7 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): "status": self.status(), } - return json.dumps(res) + return res def to_csv(self, include_header=True, include_pathway_url=False) -> str: import csv @@ -3887,33 +3905,48 @@ class Setting(EnviPathModel): rules = sorted(rules, key=lambda x: x.url) return rules - def expand(self, pathway, current_node): + def expand(self, pathway, current_node) -> Dict[str, Any]: + res: Dict[str, Any] = defaultdict(list) + """Decision Method whether to expand on a certain Node or not""" if pathway.num_nodes() >= self.max_nodes: logger.info( f"Pathway has {pathway.num_nodes()} Nodes which exceeds the limit of {self.max_nodes}" ) - return [] + res["expansion_skipped"] = True + return res if pathway.depth() >= self.max_depth: logger.info( f"Pathway has reached depth {pathway.depth()} which exceeds the limit of {self.max_depth}" ) - return [] + res["expansion_skipped"] = True + return res - transformations = [] if self.model is not None: pred_results = self.model.predict(current_node.smiles) + + # Store whether there are results that may be removed as they are below + # the given threshold + if len(pred_results): + res["rule_triggered"] = True + for pred_result in pred_results: - if pred_result.probability >= self.model_threshold: - transformations.append(pred_result) + if ( + len(pred_result.product_sets) + and pred_result.probability >= self.model_threshold + ): + res["transformations"].append(pred_result) else: for rule in self.applicable_rules: tmp_products = rule.apply(current_node.smiles) if tmp_products: - transformations.append(PredictionResult(tmp_products, 1.0, rule)) + res["transformations"].append(PredictionResult(tmp_products, 1.0, rule)) - return transformations + if len(res["transformations"]): + res["rule_triggered"] = True + + return res @transaction.atomic def make_global_default(self): diff --git a/epdb/views.py b/epdb/views.py index 039cb85a..057a357e 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -1937,6 +1937,7 @@ def package_pathway(request, package_uuid, pathway_uuid): { "status": current_pathway.status(), "modified": current_pathway.modified.strftime("%Y-%m-%d %H:%M:%S"), + "emptyDueToThreshold": current_pathway.empty_due_to_threshold(), } ) diff --git a/static/js/alpine/pathway.js b/static/js/alpine/pathway.js index 99f65074..b81de6d1 100644 --- a/static/js/alpine/pathway.js +++ b/static/js/alpine/pathway.js @@ -22,13 +22,16 @@ document.addEventListener('alpine:init', () => { status: config.status, modified: config.modified, statusUrl: config.statusUrl, + emptyDueToThreshold: config.emptyDueToThreshold === "True", showUpdateNotice: false, + showEmptyDueToThresholdNotice: false, + emptyDueToThresholdMessage: 'The Pathway is empty due to the selected threshold. Please try a different threshold.', updateMessage: '', pollInterval: null, get statusTooltip() { const tooltips = { - 'completed': 'Pathway prediction complete.', + 'completed': 'Pathway prediction completed.', 'failed': 'Pathway prediction failed.', 'running': 'Pathway prediction running.' }; @@ -39,9 +42,17 @@ document.addEventListener('alpine:init', () => { if (this.status === 'running') { this.startPolling(); } + + if (this.emptyDueToThreshold) { + this.showEmptyDueToThresholdNotice = true; + } + }, startPolling() { + if (this.pollInterval) { + return; + } this.pollInterval = setInterval(() => this.checkStatus(), 5000); }, @@ -50,9 +61,16 @@ document.addEventListener('alpine:init', () => { const response = await fetch(this.statusUrl); const data = await response.json(); + if (data.emptyDueToThreshold) { + this.emptyDueToThreshold = true; + this.showEmptyDueToThresholdNotice = true; + } + if (data.modified > this.modified) { - this.showUpdateNotice = true; - this.updateMessage = this.getUpdateMessage(data.status); + if (!this.emptyDueToThreshold) { + this.showUpdateNotice = true; + this.updateMessage = this.getUpdateMessage(data.status); + } } if (data.status !== 'running') { diff --git a/templates/modals/search_modal.html b/templates/modals/search_modal.html index cb8d5be1..826289fb 100644 --- a/templates/modals/search_modal.html +++ b/templates/modals/search_modal.html @@ -1,11 +1,10 @@ -{% load static %} - diff --git a/templates/objects/compound.html b/templates/objects/compound.html index 9461e5d4..e920f469 100644 --- a/templates/objects/compound.html +++ b/templates/objects/compound.html @@ -181,6 +181,55 @@ {% endif %} + {% if compound.half_lifes %} +
+ +
Half-lives
+
+
+ + + + + + + + + {% for scenario, half_lifes in compound.half_lifes.items %} + + + + + {% endfor %} + +
ScenarioValues
+ {{ scenario.name }} + ({{ scenario.package.name }}) + + + + + + + + + + + + + + + + +
Scenario Type{{ scenario.scenario_type }}
Half-life (days){{ half_lifes.0.dt50 }}
Model{{ half_lifes.0.model }}
+
+
+
+
+ {% endif %} + {% if compound.get_external_identifiers %}
diff --git a/templates/objects/pathway.html b/templates/objects/pathway.html index 0e996b30..64cdf406 100644 --- a/templates/objects/pathway.html +++ b/templates/objects/pathway.html @@ -216,59 +216,62 @@ x-data="pathwayViewer({ status: '{{ pathway.status }}', modified: '{{ pathway.modified|date:"Y-m-d H:i:s" }}', - statusUrl: '{{ pathway.url }}?status=true' + statusUrl: '{{ pathway.url }}?status=true', + emptyDueToThreshold: '{{ pathway.empty_due_to_threshold }}' })" x-init="init()" > - -
-
-
- - - - - -
- {% include "components/loading-spinner.html" %} + {% include "components/loading-spinner.html" %} +
-
- + {% endif %}
+ +
+ +
+ {% endif %} - {# prettier-ignore-start #} - {# FIXME: This is a hack to get the pathway data into the JavaScript code. #} + {{ pathway.d3_json|json_script:"pathway" }} - {% endblock content %} diff --git a/tests/test_sobjects.py b/tests/test_sobjects.py index 95441e39..1bf93d02 100644 --- a/tests/test_sobjects.py +++ b/tests/test_sobjects.py @@ -51,7 +51,9 @@ class SPathwayTest(TestCase): None, ) - with patch.object(self.mock_setting, "expand", return_value=[pr]): + mock_val = {"rule_triggered": True, "transformations": [pr]} + + with patch.object(self.mock_setting, "expand", return_value=mock_val): spw.predict_step(from_depth=0) self.assertEqual(len(spw.smiles_to_node.keys()), 4) @@ -72,7 +74,9 @@ class SPathwayTest(TestCase): None, ) - with patch.object(self.mock_setting, "expand", return_value=[pr]): + mock_val = {"rule_triggered": True, "transformations": [pr]} + + with patch.object(self.mock_setting, "expand", return_value=mock_val): spw.predict_step(from_depth=0) self.assertEqual(len(spw.smiles_to_node.keys()), 4)