3 Commits

Author SHA1 Message Date
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
13 changed files with 344 additions and 34 deletions

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:
""" """

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

@ -46,6 +46,10 @@ from .models import (
Package = s.GET_PACKAGE_MODEL() Package = s.GET_PACKAGE_MODEL()
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):
@ -2291,3 +2295,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

@ -2162,7 +2162,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()
@ -2847,6 +2847,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"]:
""" """
@ -3027,8 +3079,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}
@ -3077,14 +3127,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:
@ -4216,6 +4269,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.
@ -4436,6 +4492,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

@ -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

@ -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

@ -313,6 +313,44 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Model Statistics Panel -->
<div class="collapse-arrow bg-base-200 collapse">
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">Model Statistics for threshold {{ model.threshold }}</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-1/5">Metric</th>
<th>Single Gen Value</th>
{% if model.multigen_eval %}
<th>Multi Gen Value</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for metric, value in model.statistics.items %}
<tr>
<td>{{ metric|upper }}</td>
<td>{{ value.0|floatformat:3 }}</td>
{% if model.multigen_eval %}
<td>{{ value.1|floatformat:3 }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %} {% endif %}
<script> <script>
function makeChart(selector, data) { function makeChart(selector, data) {

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

@ -535,7 +535,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,21 +95,23 @@
</div> </div>
<!-- Other Prediction Settings --> <!-- Other Prediction Settings -->
<div class="collapse-arrow bg-base-200 collapse"> {% if meta.available_settings|length > 1 %}
<input type="checkbox" /> <div class="collapse-arrow bg-base-200 collapse">
<div class="collapse-title text-xl font-medium"> <input type="checkbox" />
Other Prediction Settings <div class="collapse-title text-xl font-medium">
Other Prediction Settings
</div>
<div class="collapse-content space-y-3">
{% for setting in meta.available_settings %}
{% if setting != user.default_setting %}
{% with setting_to_render=setting can_be_default=True %}
{% include "objects/setting_template.html" %}
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div> </div>
<div class="collapse-content space-y-3"> {% endif %}
{% for setting in meta.available_settings %}
{% if setting != user.default_setting %}
{% with setting_to_render=setting can_be_default=True %}
{% include "objects/setting_template.html" %}
{% endwith %}
{% endif %}
{% endfor %}
</div>
</div>
</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

@ -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
@ -132,7 +134,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):
@ -277,15 +279,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.
@ -296,11 +303,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