Source code for hippo.compound

"""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 get_tags(self) -> "TagSet": """Get the tags assigned to this compound""" tags = self.db.select_where( query="tag_name", table="tag", key="compound", value=self.id, multiple=True, none="quiet", ) return TagSet(self, {t[0] for t in tags}, commit=False)
[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)