[Feature] Generate Compounds by Molfile (#423)

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#423
This commit is contained in:
2026-07-15 23:56:24 +12:00
parent 9bc9f86ff1
commit 2c2437e3f5
8 changed files with 439 additions and 44 deletions

View File

@ -1,2 +1,6 @@
class InvalidSMILESException(Exception):
pass
class InvalidMolfileException(Exception):
pass

View File

@ -791,6 +791,7 @@ def get_package_compound_structure(request, package_uuid, compound_uuid, structu
class CreateCompound(Schema):
compoundSmiles: str
compoundMolFile: str | None = None
compoundName: str | None = None
compoundDescription: str | None = None
inchi: str | None = None
@ -804,9 +805,13 @@ def create_package_compound(
):
try:
p = get_package_for_write(request.user, package_uuid)
# inchi is not used atm
c = Compound.create(
p, c.compoundSmiles, c.compoundName, c.compoundDescription, inchi=c.inchi
p,
c.compoundSmiles,
molfile=c.compoundMolFile,
name=c.compoundName,
description=c.compoundDescription,
inchi=c.inchi,
)
return redirect(c.url)
except ValueError as e:
@ -1899,6 +1904,7 @@ def get_package_pathway_node(request, package_uuid, pathway_uuid, node_uuid):
class CreateNode(Schema):
nodeAsSmiles: str
nodeAsMolFile: str | None = None
nodeName: str | None = None
nodeReason: str | None = None
nodeDepth: str | None = None
@ -1918,7 +1924,14 @@ def add_pathway_node(request, package_uuid, pathway_uuid, n: Form[CreateNode]):
else:
node_depth = -1
n = Node.create(pw, n.nodeAsSmiles, node_depth, n.nodeName, n.nodeReason)
n = Node.create(
pw,
n.nodeAsSmiles,
node_depth,
molfile=n.nodeAsMolFile,
name=n.nodeName,
description=n.nodeReason,
)
return redirect(n.url)
except ValueError:

View File

@ -31,7 +31,10 @@ from sklearn.model_selection import ShuffleSplit
from bridge.contracts import Property
from bridge.dto import RunResult, PropertyPrediction
from epdb.exceptions import InvalidSMILESException
from epdb.exceptions import (
InvalidMolfileException,
InvalidSMILESException,
)
from utilities.chem import FormatConverter, IndigoUtils, PredictionResult, ProductSet
from utilities.ml import (
ApplicabilityDomainPCA,
@ -789,6 +792,13 @@ class Compound(
external_identifiers = GenericRelation("ExternalIdentifier")
def get_structure_by_smiles(self, smiles: str) -> "CompoundStructure":
for struct in self.structures.all():
if struct.smiles == smiles:
return struct
raise ValueError(f"No structure with SMILES {smiles} found for {self.get_name()}")
@property
def structures(self) -> QuerySet:
return CompoundStructure.objects.filter(compound=self)
@ -857,8 +867,24 @@ class Compound(
@staticmethod
@transaction.atomic
def create(
package: "Package", smiles: str, name: str = None, description: str = None, *args, **kwargs
package: "Package",
smiles: str,
molfile: str | None = None,
name: str | None = None,
description: str | None = None,
*args,
**kwargs,
) -> "Compound":
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
if smiles is None or smiles.strip() == "":
raise InvalidSMILESException("SMILES is required")
@ -878,11 +904,16 @@ class Compound(
if CompoundStructure.objects.filter(
smiles=standardized_smiles, compound__package=package
).exists():
# TODO should we add a structure?
return CompoundStructure.objects.get(
found_c = CompoundStructure.objects.get(
smiles=standardized_smiles, compound__package=package
).compound
# As a direct match wasn't found add a new structure
# TODO return newly created structure
_ = found_c.add_structure(smiles, molfile=molfile, name=name, description=description)
return found_c
# Generate Compound
c = Compound()
c.package = package
@ -914,7 +945,12 @@ class Compound(
)
cs = CompoundStructure.create(
c, smiles, name=name, description=description, normalized_structure=is_standardized
c,
smiles,
molfile=molfile,
name=name,
description=description,
normalized_structure=is_standardized,
)
c.default_structure = cs
@ -927,11 +963,22 @@ class Compound(
self,
smiles: str,
name: str = None,
molfile: str = None,
description: str = None,
default_structure: bool = False,
*args,
**kwargs,
) -> "CompoundStructure":
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
if smiles is None or smiles == "":
raise ValueError("SMILES is required")
@ -951,16 +998,28 @@ class Compound(
)
if is_standardized:
CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package)
CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
# Check if we find a direct match for a given SMILES and/or its standardized SMILES
if CompoundStructure.objects.filter(
smiles__in=smiles, compound__package=self.package
).exists():
return CompoundStructure.objects.get(smiles__in=smiles, compound__package=self.package)
if CompoundStructure.objects.filter(smiles=smiles, compound__package=self.package).exists():
found_cs = CompoundStructure.objects.get(smiles=smiles, compound__package=self.package)
if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
logger.info(
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
)
found_cs.molfile = molfile
found_cs.save()
return found_cs
cs = CompoundStructure.create(
self, smiles, name=name, description=description, normalized_structure=is_standardized
self,
smiles,
name=name,
molfile=molfile,
description=description,
normalized_structure=is_standardized,
)
if default_structure:
@ -1141,10 +1200,35 @@ class CompoundStructure(
@staticmethod
@transaction.atomic
def create(
compound: Compound, smiles: str, name: str = None, description: str = None, *args, **kwargs
compound: Compound,
smiles: str,
molfile: str = None,
name: str = None,
description: str = None,
*args,
**kwargs,
):
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
if CompoundStructure.objects.filter(compound=compound, smiles=smiles).exists():
return CompoundStructure.objects.get(compound=compound, smiles=smiles)
found_cs = CompoundStructure.objects.get(smiles=smiles, compound=compound)
if found_cs.molfile is None and (molfile is not None and molfile.strip() != ""):
logger.info(
f"Setting molfile for found CompoundStructure(uuid={found_cs.uuid}) as it was empty"
)
found_cs.molfile = molfile
found_cs.save()
return found_cs
if compound.pk is None:
raise ValueError("Unpersisted Compound! Persist compound first!")
@ -2174,11 +2258,12 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMix
def add_node(
self,
smiles: str,
name: Optional[str] = None,
description: Optional[str] = None,
depth: Optional[int] = -1,
molfile: str | None = None,
name: str | None = None,
description: str | None = None,
depth: int = -1,
):
return Node.create(self, smiles, depth, name=name, description=description)
return Node.create(self, smiles, depth, molfile=molfile, name=name, description=description)
@transaction.atomic
def add_edge(
@ -2336,28 +2421,43 @@ class Node(EnviPathModel, AliasMixin, ScenarioMixin, AdditionalInformationMixin)
pathway: "Pathway",
smiles: str,
depth: int,
name: Optional[str] = None,
description: Optional[str] = None,
molfile: str | None = None,
name: str | None = None,
description: str | None = None,
):
# Molfile has precendence over SMILES
if molfile is not None and molfile.strip() != "":
mol = FormatConverter.from_molfile(molfile)
if mol is None:
raise InvalidMolfileException("Given molfile is invalid")
else:
# Overwrite SMILES from molfile
smiles = FormatConverter.to_smiles(mol)
stereo_removed = False
if pathway.predicted and FormatConverter.has_stereo(smiles):
smiles = FormatConverter.standardize(smiles, remove_stereo=True)
stereo_removed = True
c = Compound.create(pathway.package, smiles, name=name, description=description)
c = Compound.create(
pathway.package, smiles, molfile=molfile, name=name, description=description
)
if Node.objects.filter(pathway=pathway, default_node_label=c.default_structure).exists():
return Node.objects.get(pathway=pathway, default_node_label=c.default_structure)
structure = c.get_structure_by_smiles(smiles)
if Node.objects.filter(pathway=pathway, default_node_label=structure).exists():
return Node.objects.get(pathway=pathway, default_node_label=structure)
n = Node()
n.stereo_removed = stereo_removed
n.pathway = pathway
n.depth = depth
n.default_node_label = c.default_structure
n.default_node_label = structure
n.save()
n.node_labels.add(c.default_structure)
n.node_labels.add(structure)
n.save()
return n

View File

@ -19,7 +19,7 @@ from sentry_sdk import capture_exception
from utilities.chem import FormatConverter, IndigoUtils
from utilities.decorators import package_permission_required
from .exceptions import InvalidSMILESException
from .exceptions import InvalidMolfileException, InvalidSMILESException
from .logic import (
EPDBURLParser,
@ -1386,12 +1386,18 @@ def package_compounds(request, package_uuid):
elif request.method == "POST":
compound_name = request.POST.get("compound-name")
compound_smiles = request.POST.get("compound-smiles")
compound_molfile = request.POST.get("compound-molfile")
compound_description = request.POST.get("compound-description")
try:
c = Compound.create(
current_package, compound_smiles, compound_name, compound_description
current_package,
compound_smiles,
molfile=compound_molfile,
name=compound_name,
description=compound_description,
)
except ValueError as e:
except (InvalidSMILESException, InvalidMolfileException) as e:
raise BadRequest(str(e))
return redirect(c.url)
@ -1517,11 +1523,15 @@ def package_compound_structures(request, package_uuid, compound_uuid):
elif request.method == "POST":
structure_name = request.POST.get("structure-name")
structure_smiles = request.POST.get("structure-smiles")
structure_molfile = request.POST.get("structure-molfile")
structure_description = request.POST.get("structure-description")
try:
cs = current_compound.add_structure(
structure_smiles, structure_name, structure_description
structure_smiles,
molfile=structure_molfile,
name=structure_name,
description=structure_description,
)
except ValueError:
return error(
@ -2339,9 +2349,12 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid):
node_description = request.POST.get("node-description")
node_smiles = request.POST.get("node-smiles").strip()
node_molfile = request.POST.get("node-molfile").strip()
try:
current_pathway.add_node(node_smiles, name=node_name, description=node_description)
current_pathway.add_node(
node_smiles, molfile=node_molfile, name=node_name, description=node_description
)
except InvalidSMILESException:
return error(
request, "Node creation failed!", f"Given SMILES ({node_smiles}) is invalid"