"""Classes for working with compounds"""
import mcol
import mrich
from rdkit import Chem
from .pose import Pose
from .tags import TagSet
from .quote import Quote
from .target import Target
[docs]
class Compound:
"""A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the chemical structure. It's name is always an InChiKey. If a compound is an elaboration it can have a :meth:`.Compound.scaffolds` property which is another :class:`.Compound`. :class:`.Compound` objects are target-agnostic and can be linked to any number of catalogue entries (:class:`.Quote`) or synthetic pathways (:class:`.Reaction`).
.. attention::
:class:`.Compound` objects should not be created directly. Instead use :meth:`.HIPPO.register_compound` or :meth:`.HIPPO.compounds`. See :doc:`getting_started` and :doc:`insert_elaborations`.
"""
_table = "compound"
def __init__(
self,
animal: "HIPPO",
db: "Database",
id: int,
inchikey: str,
alias: str,
smiles: str,
mol: Chem.Mol | bytes | None = None,
metadata: dict | None = None,
):
"""Compound initialisation"""
# from compound table
self._id = id
self._inchikey = inchikey
self._alias = alias
self._smiles = smiles
self._animal = animal
self._scaffolds = None
self._elabs = None
self._alias = alias
self._tags = None
self._metadata = metadata
# computed properties
self._num_heavy_atoms = None
self._num_rings = None
self._formula = None
self._molecular_weight = None
self._total_changes = db.total_changes
if isinstance(mol, bytes):
mol = Chem.Mol(mol)
self._mol = mol
self._db = db
### FACTORIES
### PROPERTIES
@property
def id(self) -> int:
"""Returns the compound's database ID"""
return self._id
@property
def inchikey(self) -> str:
"""Returns the compound's InChiKey"""
return self._inchikey
@property
def name(self) -> str:
"""Returns the compound's InChiKey"""
if self.alias:
return self.alias
return self.inchikey
@property
def smiles(self) -> str:
"""Returns the compound's (flattened) smiles"""
return self._smiles
@property
def alias(self) -> str:
"""Returns the compound's alias"""
return self._alias
@alias.setter
def alias(self, alias: str) -> None:
"""Set the compound's alias"""
self.set_alias(alias)
@property
def mol(self) -> Chem.Mol:
"""Returns the compound's RDKit Molecule"""
if self._mol is None:
(mol,) = self.db.select_where(
query="mol_to_binary_mol(compound_mol)",
table="compound",
key="id",
value=self.id,
multiple=False,
)
self._mol = Chem.Mol(mol)
return self._mol
@property
def num_heavy_atoms(self) -> int:
"""Get the number of heavy atoms"""
if self._num_heavy_atoms is None:
self._num_heavy_atoms = self.db.get_compound_computed_property(
"num_heavy_atoms", self.id
)
return self._num_heavy_atoms
@property
def molecular_weight(self) -> float:
"""Get the molecular weight"""
if self._molecular_weight is None:
self._molecular_weight = self.db.get_compound_computed_property(
"molecular_weight", self.id
)
return self._molecular_weight
@property
def num_rings(self) -> int:
"""Get the number of rings"""
if self._num_rings is None:
self._num_rings = self.db.get_compound_computed_property(
"num_rings", self.id
)
return self._num_rings
@property
def formula(self) -> str:
"""Get the chemical formula"""
if self._formula is None:
self._formula = self.db.get_compound_computed_property("formula", self.id)
return self._formula
@property
def atomtype_dict(self) -> dict[str, int]:
"""Get a dictionary with atomtypes as keys and corresponding quantities/counts as values."""
from molparse.atomtypes import formula_to_atomtype_dict
return formula_to_atomtype_dict(self.formula)
@property
def num_atoms_added(self) -> int | list[int] | None:
"""Calculate the number of atoms added relative to the scaffold compound"""
match self.num_scaffolds:
case 0:
mrich.error(f"{self} has no scaffold")
return None
case 1:
b_id = self.scaffolds.ids[0]
n_e = self.num_heavy_atoms
n_b = self.db.get_compound_computed_property("num_heavy_atoms", b_id)
return n_e - n_b
case _:
mrich.warning(f"{self} has multiple scaffolds")
n_e = self.num_heavy_atoms
return [
n_e
- self.db.get_compound_computed_property("num_heavy_atoms", b_id)
for b_id in self.scaffolds.ids
]
@property
def metadata(self) -> "MetaData":
"""Returns the compound's metadata dict"""
if self._metadata is None:
self._metadata = self.db.get_metadata(table="compound", id=self.id)
return self._metadata
@property
def db(self) -> "Database":
"""Returns a pointer to the parent database"""
return self._db
@property
def tags(self) -> TagSet:
"""Returns the compound's tags"""
if not self._tags:
self._tags = self.get_tags()
return self._tags
@property
def poses(self) -> "PoseSet":
"""Returns the compound's poses"""
return self.get_poses()
@property
def best_placed_pose(self) -> Pose:
"""Returns the compound's pose with the lowest distance score"""
return self.poses.best_placed_pose
@property
def num_poses(self) -> int:
"""Returns the number of associated poses"""
return self.db.count_where(table="pose", key="compound", value=self.id)
@property
def num_reactions(self) -> int:
"""Returns the number of associated reactions (product)"""
return self.db.count_where(table="reaction", key="product", value=self.id)
@property
def num_reactant(self) -> int:
"""Returns the number of associated reactions (reactant)"""
return self.db.count_where(table="reactant", key="compound", value=self.id)
@property
def scaffolds(self) -> "CompoundSet | None":
"""Returns the scaffold compound for this elaboration"""
if self._scaffolds is None or self._db_changed:
ids = self.get_scaffold_ids()
if not ids:
self._scaffolds = None
else:
from .cset import CompoundSet
self._scaffolds = CompoundSet(
self.db, ids, name=f"scaffold scaffolds of {self}"
)
self._total_changes = self.db.total_changes
return self._scaffolds
@property
def num_scaffolds(self) -> int:
"""Get the number of scaffold compounds for this elaboration"""
if scaffolds := self.scaffolds:
return len(scaffolds)
else:
return 0
@property
def elabs(self):
"""Returns the scaffold compound for this elaboration"""
if self._elabs is None or self._db_changed:
ids = self.get_superstructure_ids()
if not ids:
self._elabs = None
else:
from .cset import CompoundSet
self._elabs = CompoundSet(self.db, ids, name=f"elaborations of {self}")
self._total_changes = self.db.total_changes
return self._elabs
@property
def reactions(self) -> "ReactionSet":
"""Returns the reactions resulting in this compound"""
return self.get_reactions(none=False)
@property
def reaction(self) -> "Reaction":
"""Returns the reaction resulting in this compound (will return first if multiple, with a warning)"""
reactions = self.reactions
match len(reactions):
case 0:
mrich.warning(f"{self} has no reactions")
return None
case 1:
mrich.warning(f"{self} has multiple reactions, returning first")
case _:
pass
return reactions[0]
@property
def dict(self) -> dict:
"""Returns a dictionary of this compound. See :meth:`.Compound.get_dict`"""
return self.get_dict()
@property
def is_scaffold(self) -> bool:
"""Is this Compound the basis for any elaborations?"""
return bool(
self.db.select_where(
query="1",
table="scaffold",
key="base",
value=self.id,
multiple=False,
none="quiet",
)
)
@property
def is_elab(self) -> bool:
"""Is this Compound the based on any other compound?"""
return bool(
self.db.select_where(
query="1",
table="scaffold",
key="superstructure",
value=self.id,
multiple=False,
none="quiet",
)
)
@property
def is_product(self) -> bool:
"""Is this Compound a product of at least one reaction"""
return bool(self.get_reactions(none=False))
@property
def table(self):
"""Returns the name of the :class:`.Database` table"""
return self._table
@property
def _db_changed(self) -> bool:
"""Has the database changed?"""
if self._total_changes != self.db.total_changes:
self._total_changes = self.db.total_changes
return True
return False
### METHODS
[docs]
def add_stock(
self,
amount: float,
*,
purity: float | None = None,
entry: str | None = None,
location: str | None = None,
return_quote: bool = True,
) -> int | Quote:
"""Register a certain quantity of compound stock in the Database.
:param amount: Amount in ``mg``
:param purity: Purity fraction ``0 < purity <= 1``, defaults to ``None``
:param location: String describing where this stock is located, defaults to ``None``
:param return_quote: If ``True`` a :class:`.Quote` object is returned instead of its ID, defaults to ``True``
:returns: The inserted :class:`.Quote` object or ID (see ``return_quote``)
"""
assert amount
# search for existing in stock quotes
existing = self.get_quotes(supplier="Stock", df=False)
# supersede old in stock records
if existing:
delete = set()
not_deleted = 0
for quote in existing:
if any(
[
quote.entry != entry,
quote.purity != purity,
quote.catalogue != location,
]
):
not_deleted += 1
continue
delete.add(quote.id)
delete_str = str(tuple(delete)).replace(",)", ")")
self.db.delete_where(table="quote", key=f"quote_id IN {delete_str}")
if delete:
mrich.warning(f"Removed {len(delete)} existing In-Stock Quotes")
if not_deleted:
mrich.warning(
f"Did not remove {not_deleted} existing In-Stock Quotes with differing entry/purity/location"
)
# insert the new quote
quote_id = self.db.insert_quote(
compound=self.id,
price=0,
lead_time=0,
currency=None,
supplier="Stock",
catalogue=location,
entry=entry,
amount=amount,
purity=purity,
)
if return_quote:
return self.db.get_quote(id=quote_id)
else:
return quote_id
[docs]
def add_tag(
self,
tag: str,
) -> None:
"""Add this tag to every member of the set"""
assert isinstance(tag, str)
self.db.insert_tag(name=tag, compound=self.id, commit=True)
[docs]
def get_quotes(
self,
min_amount: float | None = None,
supplier: str | None = None,
max_lead_time: float | None = None,
none: str = "quiet",
pick_cheapest: bool = False,
df: bool = False,
) -> list["Quote"]:
"""Get all quotes associated to this compound
:param min_amount: Only return quotes with amounts greater than this, defaults to ``None``
:param supplier: Only return quotes with the given supplier, defaults to ``None``
:param max_lead_time: Only return quotes with lead times less than this (in days), defaults to ``None``
:param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error.
:param pick_cheapest: If ``True`` only the cheapest :class:`.Quote` is returned, defaults to ``False``
:param df: Returns a ``DataFrame`` of the quoting data, defaults to ``False``
:returns: List of :class:`.Quote` objects, ``DataFrame``, or single :class:`.Quote`. See ``pick_cheapest`` and ``df`` parameters
"""
if not supplier:
quote_ids = self.db.select_where(
query="quote_id",
table="quote",
key="compound",
value=self.id,
multiple=True,
none=none,
)
elif isinstance(supplier, str):
quote_ids = self.db.select_where(
query="quote_id",
table="quote",
key=f'quote_compound = {self.id} AND quote_supplier = "{supplier}"',
multiple=True,
none=none,
)
else:
quote_ids = self.db.select_where(
query="quote_id",
table="quote",
key=f'quote_compound = {self.id} AND quote_supplier IN {str(tuple(supplier)).replace(",)",")")}',
multiple=True,
none=none,
)
if quote_ids:
quotes = [self.db.get_quote(id=q[0]) for q in quote_ids]
else:
return None
if max_lead_time:
quotes = [q for q in quotes if q.lead_time <= max_lead_time]
if min_amount:
suitable_quotes = [q for q in quotes if q.amount >= min_amount]
if not suitable_quotes:
mrich.debug(f"No quote available with amount >= {min_amount} mg")
quotes = [Quote.combination(min_amount, quotes)]
else:
quotes = suitable_quotes
if pick_cheapest:
return sorted(quotes, key=lambda x: x.price)[0]
if df:
from pandas import DataFrame
return DataFrame([q.dict for q in quotes]).drop(columns="compound")
return quotes
[docs]
def get_reactions(
self,
as_reactant: bool = False,
permitted_reactions: "ReactionSet" = None,
none: str = "error",
) -> "ReactionSet":
"""Get the associated :class:`.Reaction` objects. By default this function returns all reaction resulting in this :class:`.Compound` as a product, unless ``as_reactant`` is set to ``True``.
:param as_reactant: Search for :class:`.Reaction` objects using this :class:`.Compound` as a reactant instead of a product, defaults to ``False``
:param permitted_reactions: Provide a :class:`.ReactionSet` by which to filter the results
:param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error, defaults to ``'error'``
"""
from .rset import ReactionSet
if as_reactant:
reaction_ids = self.db.select_where(
query="reactant_reaction",
table="reactant",
key="compound",
value=self.id,
multiple=True,
none=none,
)
else:
reaction_ids = self.db.select_where(
query="reaction_id",
table="reaction",
key="product",
value=self.id,
multiple=True,
none=none,
)
reaction_ids = [q for q, in reaction_ids]
if permitted_reactions:
reaction_ids = [i for i in reaction_ids if i in permitted_reactions]
rset = ReactionSet(self.db, reaction_ids)
if not permitted_reactions:
rset._name = f"reactions resulting in {str(self)}"
return rset
[docs]
def get_poses(self) -> "PoseSet":
"""Get the associated :class:`.Pose` objects."""
pose_ids = self.db.select_where(
query="pose_id",
table="pose",
key="compound",
value=self.id,
multiple=True,
none=False,
)
from .pset import PoseSet
return PoseSet(self.db, [q[0] for q in pose_ids], name=f"{self}'s poses")
[docs]
def get_dict(
self,
*,
mol: bool = True,
alias: bool = True,
inchikey: bool = True,
metadata: bool = True,
poses: bool = True,
count_by_target: bool = False,
num_reactant: bool = True,
num_reactions: bool = True,
scaffolds: bool = True,
elabs: bool = True,
tags: bool = True,
) -> "dict":
"""Returns a dictionary representing this :class:`.Compound`
:param mol: Include a ``rdkit.Chem.Mol object``, defaults to ``True``
:param metadata: Include metadata, defaults to ``True``
:param poses: Include dictionaries of associated :class:`.Pose` objects, defaults to ``True``
:param count_by_target: Include counts by protein :class:`.Target`, defaults to ``False``. Only applicable when ``count_by_target = True``.
:param num_reactant: include num_reactant column
:param num_reactions: include num_reactions column
:param scaffolds: include scaffolds column
:param elabs: include elabs column
:param tags: include tags column
:returns: A dictionary
"""
serialisable_fields = [
"id",
"smiles",
]
if alias:
serialisable_fields.append("alias")
if inchikey:
serialisable_fields.append("inchikey")
if num_reactant:
serialisable_fields.append("num_reactant")
if num_reactions:
serialisable_fields.append("num_reactions")
data = {}
for key in serialisable_fields:
data[key] = getattr(self, key)
if mol:
try:
data["mol"] = self.mol
except InvalidMolError:
data["mol"] = None
if scaffolds:
if self.scaffolds:
data["scaffolds"] = self.scaffolds.ids
else:
data["scaffolds"] = None
if elabs:
if self.elabs:
data["elabs"] = self.elabs.ids
else:
data["elabs"] = None
if tags:
data["tags"] = self.tags
if poses:
poses = self.poses
if poses:
data["poses"] = poses.ids
data["targets"] = poses.target_names
if count_by_target:
target_ids = poses.target_ids
for target in self._animal.targets:
t_poses = poses(target=target.id) or []
data[f"#poses {target.name}"] = len(t_poses)
if metadata and (metadict := self.metadata):
for key in metadict:
data[key] = metadict[key]
return data
[docs]
def get_recipes(
self,
*,
amount: float = 1,
debug: bool = False,
pick_cheapest: bool = False,
quoted_only: bool = False,
supplier: None | str = None,
**kwargs,
):
"""Get :class:`.Recipe` objects that result in this compound. See :meth:`.Recipe.from_compounds`"""
from .recipe import Recipe
from .cset import CompoundSet
return Recipe.from_compounds(
CompoundSet(self.db, [self.id]),
amount=amount,
debug=debug,
pick_cheapest=pick_cheapest,
quoted_only=quoted_only,
supplier=supplier,
**kwargs,
)
[docs]
def get_scaffold_ids(self) -> list[int]:
"""Get a list of :class:`.Compound` ID's that this object is a superstructure of"""
ids = self.db.select_where(
table="scaffold",
query="scaffold_base",
key="superstructure",
value=self.id,
none="quiet",
multiple=True,
)
if not ids:
return None
return [i for i, in ids]
[docs]
def get_superstructure_ids(self) -> list[int]:
"""Get a list of :class:`.Compound` ID's that this object is a substructure of"""
ids = self.db.select_where(
table="scaffold",
query="scaffold_superstructure",
key="base",
value=self.id,
none="quiet",
multiple=True,
)
if not ids:
return None
return [i for i, in ids]
[docs]
def add_scaffold(self, scaffold: "Compound | int", commit: bool = True) -> None:
"""
Add a scaffold :class:`.Compound` this molecule is derived from.
:param scaffold: The scaffold :class:`.Compound` or its ID.
:param commit: Commit the changes to the :class:`.Database`, defaults to ``True``
"""
if not isinstance(scaffold, int):
assert scaffold._table == "compound"
scaffold = scaffold.id
self.db.insert_scaffold(scaffold=scaffold, superstructure=self.id)
[docs]
def set_alias(self, alias: str, commit=True) -> None:
"""
Set this :class:`.Compound`'s alias.
:param alias: The alias
:param commit: Commit the changes to the :class:`.Database`, defaults to ``True``
"""
assert isinstance(alias, str)
self._alias = alias
self.db.update(
table="compound",
id=self.id,
key="compound_alias",
value=alias,
commit=commit,
)
[docs]
def as_ingredient(
self,
amount: float,
max_lead_time: float = None,
supplier: str = None,
get_quote: bool = True,
quote_none: str = "quiet",
) -> "Ingredient":
"""Convert this compound into an :class:`.Ingredient` object with an associated amount (in ``mg``) and :class:`.Quote` if available.
:param amount: Amount in ``mg``
:param supplier: Only search for quotes with the given supplier, defaults to ``None``
:param max_lead_time: Only search for quotes with lead times less than this (in days), defaults to ``None``
"""
if get_quote:
quote = self.get_quotes(
pick_cheapest=True,
min_amount=amount,
max_lead_time=max_lead_time,
supplier=supplier,
none=quote_none,
)
if not quote:
quote = None
else:
quote = None
return Ingredient(
db=self.db,
compound=self.id,
amount=amount,
quote=quote,
supplier=supplier,
max_lead_time=max_lead_time,
)
[docs]
def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None:
"""Display this compound (and its scaffold if it has one)
.. attention::
This method is only intended for use within a Jupyter Notebook.
:param align_substructure: Align the two drawing by their common substructure, defaults to ``False``
"""
if scaffolds and (scaffolds := self.scaffolds):
from molparse.rdkit import draw_mcs
data = {}
for scaffold in scaffolds:
data[scaffold.smiles] = f"{scaffold} (scaffold)"
data[self.smiles] = str(self)
if len(data) > 1:
drawing = draw_mcs(
data,
align_substructure=align_substructure,
show_mcs=False,
highlight=False,
)
display(drawing)
else:
mrich.error(
f"Problem drawing {scaffold.id=} vs {self.id=}, self referential?"
)
display(self.mol)
else:
display(self.mol)
[docs]
def draw_elabs(self):
"""Draw elaborations"""
from molparse.rdkit import draw_highlighted_mol
from rdkit.Chem import rdRGroupDecomposition, MolFromSmarts
elabs = self.elabs
display(self)
display(elabs)
if not elabs:
mrich.error(self, "has no elaborations")
return self.draw()
# set RGD params
params = rdRGroupDecomposition.RGroupDecompositionParameters()
params.removeAllHydrogenRGroups = False
params.removeAllHydrogenRGroupsAndLabels = True
params.removeHydrogensPostMatch = True
# do the RGD
rgd = rdRGroupDecomposition.RGroupDecomposition(
MolFromSmarts(self.smiles), params
)
for mol in elabs.mols:
rgd.Add(mol)
rgd.Process()
# Get the R-group decomposition results
rgroup_table = rgd.GetRGroupsAsColumns()
# get the core and its attachment points
core = rgroup_table["Core"][0]
attachment_points = set()
for rgroup in rgroup_table["Core"]:
for atom in rgroup.GetAtoms():
if atom.GetAtomicNum() == 0: # Dummy atom (R-group attachment point)
attachment_points.add(atom.GetIdx())
# display the annotated core
drawing = draw_highlighted_mol(
core, [(i, (0.5, 1, 0.5)) for i in attachment_points]
)
display(drawing)
[docs]
def classify(
self,
draw: bool = True,
) -> list[tuple[str, int]]:
"""
Find RDKit Fragments within the compound molecule and draw them
:param draw: Draw the annotated molecule, defaults to ``True``
:returns: A list of tuples containing a descriptor (``str``) and count (``int``) pair
"""
# from molparse.rdkit import classify_mol
from molparse.rdkit.classify import classify_mol
return classify_mol(self.mol, draw=draw)
[docs]
def murcko_scaffold(
self,
generic: bool = False,
):
"""Get the rdkit MurckoScaffold for this compound"""
from rdkit.Chem.Scaffolds import MurckoScaffold
scaffold = MurckoScaffold.GetScaffoldForMol(self.mol)
if generic:
scaffold = MurckoScaffold.MakeScaffoldGeneric(scaffold)
return scaffold
[docs]
def summary(
self, metadata: bool = True, draw: bool = True, tags: bool = True
) -> None:
"""
Print a summary of this compound
:param metadata: Include metadata, defaults to ``True``
:param draw: Include a 2D molecule drawing, defaults to ``True``
"""
mrich.header(self)
mrich.var("inchikey", self.inchikey)
mrich.var("alias", self.alias)
mrich.var("smiles", self.smiles)
mrich.var("scaffolds", self.scaffolds)
mrich.var("elabs", self.elabs)
mrich.var("is_scaffold", self.is_scaffold)
mrich.var("is_elab", self.is_elab)
mrich.var("num_heavy_atoms", self.num_heavy_atoms)
mrich.var("num_rings", self.num_rings)
mrich.var("formula", self.formula)
mrich.var("#reactions (product)", self.num_reactions)
mrich.var("#reactions (reactant)", self.num_reactant)
if tags:
mrich.var("tags", self.tags)
poses = self.poses
mrich.var("#poses", len(poses))
if poses:
mrich.var("targets", poses.targets)
if metadata:
mrich.var("metadata", str(self.metadata))
if draw:
self.draw()
[docs]
def place(
self,
*,
reference: Pose,
inspirations: list[Pose] | None = None,
max_ddG: float = 0.0,
max_RMSD: float = 2.0,
output_dir: str = "wictor_place",
tags: list[str] = None,
metadata: dict = None,
overwrite: bool = False,
) -> Pose:
"""
Generate a new pose for this compound using Fragmenstein.
:param reference: Choose the :class:`.Pose` to use as the reference protein conformation
:param inspirations: Choose the (virtual) hits to to define the ligand reference, defaults to the ``reference``'s inspirations
:param max_ddG: Maximum ``ddG`` value permitted for a valid ligand conformation, defaults to ``0.0``
:param max_RMSD: Maximum ``RMSD`` value permitted for a valid ligand conformation, defaults to ``2.0``
:param output_dir: Output directory for Fragmenstein files, defaults to ``wictor_place``
:param tags: Tags to assign to the created pose, defaults to ``[]``
:param metadata: A dictionary of metadata to assign to this compound, defaults to ``{}``
:param overwrite: Delete old poses, defaults to ``False``
"""
from fragmenstein import Monster, Wictor
from pathlib import Path
tags = tags or []
metadata = metadata or {}
# get required data
smiles = self.smiles
inspirations = inspirations or reference.inspirations
target = reference.target.name
inspiration_mols = [c.mol for c in inspirations]
protein_pdb_block = reference.protein_system.pdb_block_with_alt_sites
# create the victor
victor = Wictor(hits=inspiration_mols, pdb_block=protein_pdb_block)
victor.work_path = output_dir
victor.enable_stdout(logging.CRITICAL)
# do the placement
victor.place(smiles, long_name=self.name)
# metadata
metadata["ddG"] = (
victor.energy_score["bound"]["total_score"]
- victor.energy_score["unbound"]["total_score"]
)
metadata["RMSD"] = victor.mrmsd.mrmsd
if metadata["ddG"] > max_ddG:
return None
if metadata["RMSD"] > max_RMSD:
return None
# register the pose
pose = self._animal.register_pose(
compound=self,
target=target,
path=Path(victor.work_path) / self.name / f"{self.name}.minimised.mol",
inspirations=inspirations,
reference=reference,
tags=tags,
metadata=metadata,
)
if overwrite:
ids = [p.id for p in self.poses if p.id != pose.id]
for i in ids:
self.db.delete_where(table="pose", key="id", value=i)
mrich.success(f"Successfully posed {self} (and deleted old poses)")
else:
mrich.success(f"Successfully posed {self}")
return pose
[docs]
def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSet":
"""Since inspirations map :class:`.Pose` objects to each other rather than :class:`.Compound` objects, this only works if there are poses registerd for this compound or it's elaborations/superstructures.
:returns: a :class:`.PoseSet` object
"""
from .pset import PoseSet
sql = """
SELECT pose_id, inspiration_original FROM compound
INNER JOIN scaffold ON compound_id = scaffold_base
INNER JOIN pose ON compound_id = pose_compound
INNER JOIN inspiration ON pose_id = inspiration_derivative
WHERE compound_id = :compound_id
"""
with mrich.spinner(f"Querying inspirations for {self}"):
records = self.db.execute(sql, dict(compound_id=self.id)).fetchall()
if not records and none in ("warning", "warn"):
mrich.warning("Could not determine inspirations for", self)
return None
derivatives = PoseSet(self.db, set(a for a, b in records))
inspirations = PoseSet(self.db, set(b for a, b in records))
if debug:
mrich.debug(f"Inspirations derived from {derivatives.ids}")
inspirations._name = f"Inspirations for {self}"
return inspirations
### DUNDERS
[docs]
def __str__(self) -> str:
"""Unformatted string representation"""
return f"C{self.id}"
[docs]
def __repr__(self) -> str:
"""ANSI Formatted string representation"""
return f'{mcol.bold}{mcol.underline}{self} "{self.name}"{mcol.unbold}{mcol.ununderline}'
def __rich__(self) -> str:
"""Representation for mrich"""
return f'[bold underline]{self} "{self.name}"'
[docs]
def __eq__(self, other) -> bool:
"""Compare compounds"""
assert isinstance(other, Compound)
return self.id == other.id
[docs]
class Ingredient:
"""An ingredient is a :class:`.Compound` with a fixed quanitity and an attached quote.
.. image:: ../images/ingredient.png
:width: 450
:alt: Ingredient schema
.. attention::
:class:`.Ingredient` objects should not be created directly. Instead use :meth:`.Compound.as_ingredient`.
"""
_table = "ingredient"
def __init__(
self,
db: "Database",
compound: "Compound | int",
amount: float,
quote: "Quote | None",
max_lead_time: float | None = None,
supplier: str | None = None,
) -> "Ingredient":
"""Ingredient initialisation"""
assert compound
self._db = db
# don't store inherited compound in memory until needed
self._compound = None
if isinstance(compound, Compound):
self._compound_id = compound.id
self._compound = None
else:
self._compound_id = compound
if isinstance(quote, Quote):
if id := quote.id:
self._quote_id = quote.id
self._quote = None
else:
self._quote_id = None
self._quote = quote
elif quote is None:
self._quote_id = None
self._quote = None
else:
self._quote_id = int(quote)
self._quote = None
self._amount = amount
self._max_lead_time = max_lead_time
self._supplier = supplier
self._total_changes = db.total_changes
### PROPERTIES
@property
def db(self) -> "Database":
"""Returns the parent :class:`.Database`"""
return self._db
@property
def amount(self) -> float:
"""Returns the amount (in ``mg``)"""
return self._amount
@property
def id(self) -> int:
"""Returns the ID of the associated :class:`.Compound`"""
return self._compound_id
@property
def compound_id(self) -> int:
"""Returns the ID of the associated :class:`.Compound`"""
return self._compound_id
@property
def quote_id(self) -> int:
"""Returns the ID of the associated :class:`.Quote`"""
return self._quote_id
@property
def max_lead_time(self) -> float:
"""Returns the max_lead_time (in days) from the original quote query"""
return self._max_lead_time
@property
def supplier(self) -> str:
"""Returns the supplier from the original quote query"""
return self._supplier
@amount.setter
def amount(self, a) -> None:
"""Set the amount and fetch updated :class:`.Quote`s"""
quote_id = self.get_cheapest_quote_id(
min_amount=a,
max_lead_time=self._max_lead_time,
supplier=self._supplier,
none="quiet",
)
self._quote_id = quote_id
self._amount = a
@property
def compound(self) -> Compound:
"""Returns the associated :class:`.Compound`"""
if not self._compound:
self._compound = self.db.get_compound(id=self.compound_id)
return self._compound
@property
def quote(self) -> Quote:
"""Returns the associated :class:`.Quote`"""
if self._quote is None:
if q_id := self.quote_id:
self._quote = self.db.get_quote(id=self.quote_id)
else:
q = self.compound.get_quotes(
pick_cheapest=True,
min_amount=self.amount,
max_lead_time=self.max_lead_time,
supplier=self.supplier,
none="quiet",
)
if not q:
return None
self._quote = q
self._quote_id = q.id
return self._quote
@property
def compound_price_amount_str(self) -> str:
"""String representation including :class:`.Compound`, :class:`.Price`, and amount."""
return f"{self} ({self.amount})"
@property
def smiles(self) -> str:
"""Returns the SMILES of the associated :class:`.Compound`"""
return self.compound.smiles
@property
def price(self) -> "Price | None":
"""Returns the :class:`.Price` of the associated :class:`.Quote`"""
if self.quote:
return self.quote.price
else:
return None
@property
def lead_time(self) -> float | None:
"""Returns the lead time (in days) of the associated :class:`.Quote`"""
if self.quote:
return self.quote.lead_time
else:
return None
@property
def _db_changed(self) -> bool:
"""Has the database changed?"""
if self._total_changes != self.db.total_changes:
self._total_changes = self.db.total_changes
return True
return False
### METHODS
[docs]
def get_cheapest_quote_id(
self,
min_amount: float | None = None,
supplier: str | None = None,
max_lead_time: float | None = None,
none: str = "quiet",
) -> int | None:
"""
Query quotes associated to this ingredient, and return the cheapest
:param min_amount: Only return quotes with amounts greater than this, defaults to ``None``
:param supplier: Only return quotes with the given supplier, defaults to ``None``
:param max_lead_time: Only return quotes with lead times less than this (in days), defaults to ``None``
:param none: Define the behaviour when no quotes are found. Choose `error` to raise print an error.
"""
supplier_str = f' AND quote_supplier IS "{supplier}"' if supplier else ""
lead_time_str = (
f" AND quote_lead_time <= {max_lead_time}" if max_lead_time else ""
)
key_str = f"quote_compound IS {self.compound_id} AND quote_amount >= {min_amount}{supplier_str}{lead_time_str} ORDER BY quote_price"
result = self.db.select_where(
query="quote_id", table="quote", key=key_str, multiple=False, none=none
)
if result:
(quote_id,) = result
return quote_id
else:
return None
[docs]
def get_quotes(self, **kwargs) -> list["Quote"]:
"""Wrapper for :meth:`.Compound.get_quotes()`"""
return self.compound.get_quotes(**kwargs)
### DUNDERS
[docs]
def __str__(self) -> str:
"""Plain string representation"""
return f"{self.amount:.2f}mg of C{self._compound_id}"
[docs]
def __repr__(self) -> str:
"""ANSI Formatted string representation"""
return f"{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}"
def __rich__(self) -> str:
"""Representation for mrich"""
return f"[bold underline]{str(self)}"
[docs]
def __eq__(self, other) -> bool:
"""Equality operator"""
if self.compound_id != other.compound_id:
return False
return self.amount == other.amount
[docs]
def __getattr__(self, key: str):
"""For missing attributes try getting from associated :class:`.Compound`"""
return getattr(self.compound, key)