Files
enviPy-bayer/epiuclid/serializers/pathway_mapper.py
Tim Lorsbach 3b71184631
Some checks failed
CI / test (pull_request) Failing after 27s
API CI / api-tests (pull_request) Failing after 41s
adjusted migration
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 20:57:12 +02:00

498 lines
18 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass, field
from uuid import UUID, uuid4
from epapi.v1.interfaces.iuclid.dto import PathwayExportDTO
from utilities.chem import FormatConverter
logger = logging.getLogger(__name__)
@dataclass
class IUCLIDReferenceSubstanceData:
uuid: UUID
name: str
smiles: str | None = None
cas_number: str | None = None
ec_number: str | None = None
iupac_name: str | None = None
molecular_formula: str | None = None
molecular_weight: float | None = None
inchi: str | None = None
inchi_key: str | None = None
@dataclass
class IUCLIDSubstanceData:
uuid: UUID
name: str
reference_substance_uuid: UUID | None = None
@dataclass
class SoilPropertiesData:
soil_no_code: str | None = None
soil_type: str | None = None
sand: float | None = None
silt: float | None = None
clay: float | None = None
org_carbon: float | None = None
ph_lower: float | None = None
ph_upper: float | None = None
ph_method: str | None = None
cec: float | None = None
moisture_content: float | None = None
soil_classification: str | None = None
@dataclass
class IUCLIDEndpointStudyRecordData:
uuid: UUID
substance_uuid: UUID
name: str
half_lives: list[HalfLifeEntry] = field(default_factory=list)
temperature: tuple[float, float] | None = None
transformation_products: list[IUCLIDTransformationProductEntry] = field(default_factory=list)
model_name_and_version: list[str] = field(default_factory=list)
software_name_and_version: list[str] = field(default_factory=list)
model_remarks: list[str] = field(default_factory=list)
soil_properties: SoilPropertiesData | None = None
soil_properties_entries: list[SoilPropertiesData] = field(default_factory=list)
@dataclass
class HalfLifeEntry:
model: str
dt50_start: float
dt50_end: float
unit: str
source: str
soil_no_code: str | None = None
temperature: tuple[float, float] | None = None
@dataclass
class IUCLIDTransformationProductEntry:
uuid: UUID
product_reference_uuid: UUID
parent_reference_uuids: list[UUID] = field(default_factory=list)
kinetic_formation_fraction: float | None = None
source_edge_uuid: UUID | None = None
@dataclass
class IUCLIDDocumentBundle:
substances: list[IUCLIDSubstanceData] = field(default_factory=list)
reference_substances: list[IUCLIDReferenceSubstanceData] = field(default_factory=list)
endpoint_study_records: list[IUCLIDEndpointStudyRecordData] = field(default_factory=list)
class PathwayMapper:
def map(self, export: PathwayExportDTO) -> IUCLIDDocumentBundle:
bundle = IUCLIDDocumentBundle()
seen_compounds: dict[
int, tuple[UUID, UUID]
] = {} # compound PK -> (substance UUID, ref UUID)
compound_names: dict[int, str] = {}
for compound in export.compounds:
if compound.pk in seen_compounds:
continue
derived = self._compute_derived_properties(compound.smiles)
ref_sub_uuid = uuid4()
sub_uuid = uuid4()
seen_compounds[compound.pk] = (sub_uuid, ref_sub_uuid)
compound_names[compound.pk] = compound.name
ref_sub = IUCLIDReferenceSubstanceData(
uuid=ref_sub_uuid,
name=compound.name,
smiles=compound.smiles,
cas_number=compound.cas_number,
molecular_formula=derived["molecular_formula"],
molecular_weight=derived["molecular_weight"],
inchi=derived["inchi"],
inchi_key=derived["inchi_key"],
)
bundle.reference_substances.append(ref_sub)
if not export.compounds:
return bundle
root_compound_pks: list[int] = []
seen_root_pks: set[int] = set()
for root_pk in export.root_compound_pks:
if root_pk in seen_compounds and root_pk not in seen_root_pks:
root_compound_pks.append(root_pk)
seen_root_pks.add(root_pk)
if not root_compound_pks:
fallback_root_pk = export.compounds[0].pk
if fallback_root_pk in seen_compounds:
root_compound_pks = [fallback_root_pk]
if not root_compound_pks:
return bundle
for root_pk in root_compound_pks:
root_sub_uuid, root_ref_uuid = seen_compounds[root_pk]
bundle.substances.append(
IUCLIDSubstanceData(
uuid=root_sub_uuid,
name=compound_names[root_pk],
reference_substance_uuid=root_ref_uuid,
)
)
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]] = []
for edge in sorted(export.edges, key=lambda item: str(item.edge_uuid)):
parent_compound_pks = sorted(
{pk for pk in edge.start_compound_pks if pk in seen_compounds}
)
product_compound_pks = sorted(
{pk for pk in edge.end_compound_pks if pk in seen_compounds}
)
if not parent_compound_pks or not product_compound_pks:
continue
parent_ref_uuids = tuple(
sorted({seen_compounds[pk][1] for pk in parent_compound_pks}, key=str)
)
edge_templates.append(
(
edge.edge_uuid,
frozenset(parent_compound_pks),
tuple(product_compound_pks),
parent_ref_uuids,
)
)
model_names: list[str] = []
software_names: list[str] = []
model_remarks: list[str] = []
if export.model_info:
if export.model_info.model_name:
model_names.append(export.model_info.model_name)
if export.model_info.model_uuid:
model_remarks.append(f"Model UUID: {export.model_info.model_uuid}")
if export.model_info.software_name:
if export.model_info.software_version:
software_names.append(
f"{export.model_info.software_name} {export.model_info.software_version}"
)
else:
software_names.append(export.model_info.software_name)
# Aggregate scenario-aware AI from all root nodes for each root compound.
# Each entry is (scenario_uuid, scenario_name, effective_ai_list).
root_node_ai_by_scenario: dict[int, dict[str, tuple[UUID | None, str | None, list]]] = {}
for node in export.nodes:
if node.depth == 0 and node.compound_pk in seen_root_pks:
scenario_bucket = root_node_ai_by_scenario.setdefault(node.compound_pk, {})
if node.scenarios:
for scenario in node.scenarios:
scenario_key = str(scenario.scenario_uuid)
existing = scenario_bucket.get(scenario_key)
if existing is None:
scenario_bucket[scenario_key] = (
scenario.scenario_uuid,
scenario.name,
list(scenario.additional_info),
)
else:
existing[2].extend(scenario.additional_info)
else:
# Backward compatibility path for callers that only provide node.additional_info.
fallback_key = f"fallback:{node.node_uuid}"
scenario_bucket[fallback_key] = (None, None, list(node.additional_info))
has_multiple_roots = len(root_compound_pks) > 1
for root_pk in root_compound_pks:
substance_uuid, _ = seen_compounds[root_pk]
esr_name = f"Biodegradation in soil - {export.pathway_name}"
if has_multiple_roots:
root_name = compound_names.get(root_pk)
if root_name:
esr_name = f"{esr_name} ({root_name})"
transformation_entries: list[IUCLIDTransformationProductEntry] = []
reachable_compound_pks = self._reachable_compounds_from_root(root_pk, edge_templates)
seen_transformations: set[tuple[UUID, tuple[UUID, ...]]] = set()
for (
edge_uuid,
parent_compound_pks,
product_compound_pks,
parent_reference_uuids,
) in edge_templates:
if not parent_compound_pks.issubset(reachable_compound_pks):
continue
for product_compound_pk in product_compound_pks:
if product_compound_pk not in reachable_compound_pks:
continue
product_ref_uuid = seen_compounds[product_compound_pk][1]
dedupe_key = (product_ref_uuid, parent_reference_uuids)
if dedupe_key in seen_transformations:
continue
seen_transformations.add(dedupe_key)
transformation_entries.append(
IUCLIDTransformationProductEntry(
uuid=uuid4(),
product_reference_uuid=product_ref_uuid,
parent_reference_uuids=list(parent_reference_uuids),
source_edge_uuid=edge_uuid,
)
)
scenarios_for_root = list(root_node_ai_by_scenario.get(root_pk, {}).values())
if not scenarios_for_root:
scenarios_for_root = [(None, None, [])]
soil_entries: list[SoilPropertiesData] = []
soil_no_by_signature: dict[tuple, str] = {}
half_lives: list[HalfLifeEntry] = []
merged_ai_for_root: list = []
for _, _, ai_for_scenario in scenarios_for_root:
merged_ai_for_root.extend(ai_for_scenario)
soil = self._extract_soil_properties(ai_for_scenario)
temperature = self._extract_temperature(ai_for_scenario)
soil_no_code: str | None = None
if soil is not None:
soil_signature = self._soil_signature(soil)
soil_no_code = soil_no_by_signature.get(soil_signature)
if soil_no_code is None:
soil_no_code = self._soil_no_code_for_index(len(soil_entries))
if soil_no_code is not None:
soil.soil_no_code = soil_no_code
soil_entries.append(soil)
soil_no_by_signature[soil_signature] = soil_no_code
for hl in self._extract_half_lives(ai_for_scenario):
hl.soil_no_code = soil_no_code
hl.temperature = temperature
half_lives.append(hl)
esr = IUCLIDEndpointStudyRecordData(
uuid=uuid4(),
substance_uuid=substance_uuid,
name=esr_name,
half_lives=half_lives,
temperature=self._extract_temperature(merged_ai_for_root),
transformation_products=transformation_entries,
model_name_and_version=model_names,
software_name_and_version=software_names,
model_remarks=model_remarks,
soil_properties=soil_entries[0] if soil_entries else None,
soil_properties_entries=soil_entries,
)
bundle.endpoint_study_records.append(esr)
return bundle
@staticmethod
def _extract_half_lives(ai_list: list) -> list[HalfLifeEntry]:
from envipy_additional_information.information import HalfLife
entries = []
for ai in ai_list:
if not isinstance(ai, HalfLife):
continue
start = ai.dt50.start
end = ai.dt50.end
if start is None or end is None:
continue
entries.append(
HalfLifeEntry(
model=ai.model,
dt50_start=start,
dt50_end=end,
unit="d",
source=ai.source,
)
)
return entries
@staticmethod
def _extract_temperature(ai_list: list) -> tuple[float, float] | None:
from envipy_additional_information.information import Temperature
for ai in ai_list:
if not isinstance(ai, Temperature):
continue
lower = ai.interval.start
upper = ai.interval.end
if lower is None or upper is None:
continue
return (lower, upper)
return None
@staticmethod
def _extract_soil_properties(ai_list: list) -> SoilPropertiesData | None:
from envipy_additional_information.information import (
Acidity,
BulkDensity,
CEC,
Humidity,
OMContent,
SoilClassification,
SoilTexture1,
SoilTexture2,
)
props = SoilPropertiesData()
for ai_obj in ai_list:
ai = ai_obj.get()
if isinstance(ai, SoilTexture1) and props.soil_type is None:
props.soil_type = ai.type.value
elif isinstance(ai, SoilTexture2):
if props.sand is None:
props.sand = ai.sand
if props.silt is None:
props.silt = ai.silt
if props.clay is None:
props.clay = ai.clay
elif isinstance(ai, OMContent) and props.org_carbon is None:
props.org_carbon = ai.in_oc
elif isinstance(ai, Acidity) and props.ph_lower is None:
props.ph_lower = ai.interval.start
props.ph_upper = ai.interval.end
if isinstance(ai.method, str):
props.ph_method = ai.method.strip() or None
else:
props.ph_method = ai.method
elif isinstance(ai, CEC) and props.cec is None:
props.cec = ai.capacity
elif isinstance(ai, Humidity) and props.moisture_content is None:
props.moisture_content = ai.humiditiy
elif isinstance(ai, SoilClassification) and props.soil_classification is None:
props.soil_classification = ai.system.value
elif isinstance(ai, BulkDensity):
pass # BulkDensity.data is a free-text string; not mapped to SoilPropertiesData
all_none = all(
v is None
for v in (
props.soil_type,
props.sand,
props.silt,
props.clay,
props.org_carbon,
props.ph_lower,
props.ph_upper,
props.ph_method,
props.cec,
props.moisture_content,
props.soil_classification,
)
)
return None if all_none else props
@staticmethod
def _reachable_compounds_from_root(
root_compound_pk: int,
edge_templates: list[tuple[UUID, frozenset[int], tuple[int, ...], tuple[UUID, ...]]],
) -> set[int]:
reachable: set[int] = {root_compound_pk}
changed = True
while changed:
changed = False
for _, parent_compound_pks, product_compound_pks, _ in edge_templates:
if not parent_compound_pks.issubset(reachable):
continue
for product_compound_pk in product_compound_pks:
if product_compound_pk in reachable:
continue
reachable.add(product_compound_pk)
changed = True
return reachable
@staticmethod
def _soil_signature(props: SoilPropertiesData) -> tuple:
return (
props.soil_type,
props.sand,
props.silt,
props.clay,
props.org_carbon,
props.ph_lower,
props.ph_upper,
props.ph_method,
props.cec,
props.moisture_content,
props.soil_classification,
)
@staticmethod
def _soil_no_code_for_index(index: int) -> str | None:
f137_codes = [
"2",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"3",
"4070",
"4071",
"4072",
"4073",
"4074",
"4075",
"4076",
"4077",
"4078",
"4079",
]
if 0 <= index < len(f137_codes):
return f137_codes[index]
return None
@staticmethod
def _compute_derived_properties(smiles: str | None) -> dict:
molecular_formula = None
molecular_weight = None
inchi = None
inchi_key = None
if smiles:
try:
molecular_formula = FormatConverter.formula(smiles)
except Exception:
logger.debug("Could not compute formula for %s", smiles)
try:
molecular_weight = FormatConverter.mass(smiles)
except Exception:
logger.debug("Could not compute mass for %s", smiles)
try:
inchi = FormatConverter.InChI(smiles)
except Exception:
logger.debug("Could not compute InChI for %s", smiles)
try:
inchi_key = FormatConverter.InChIKey(smiles)
except Exception:
logger.debug("Could not compute InChIKey for %s", smiles)
return {
"molecular_formula": molecular_formula,
"molecular_weight": molecular_weight,
"inchi": inchi,
"inchi_key": inchi_key,
}