From 6e37afac4d241b7e927297b6f1b204da96623d4e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 25 Nov 2025 14:49:19 +0000 Subject: [PATCH 001/163] get_df: also fill missing mol --- hippo/pset.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hippo/pset.py b/hippo/pset.py index fced646..98eab3b 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -1472,14 +1472,15 @@ def get_df( records = self.db.select_where( table="pose", - query="pose_id, pose_smiles, pose_inchikey", + query="pose_id, pose_smiles, pose_inchikey, pose_mol", key=f"pose_id IN {empty_poses.str_ids}", multiple=True, ) - for pose_id, pose_smiles, pose_inchikey in records: + for pose_id, pose_smiles, pose_inchikey, pose_mol in records: df.loc[pose_id, "smiles"] = pose_smiles df.loc[pose_id, "inchikey"] = pose_inchikey + df.loc[pose_id, "mol"] = Mol(pose_mol) assert not df["smiles"].isna().any() assert not df["inchikey"].isna().any() From 727434d008eff9ae45460f78ca6b96ed45f69291 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 25 Nov 2025 14:49:46 +0000 Subject: [PATCH 002/163] more fingerprint types --- hippo/db.py | 62 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index e3b6d31..18b44b2 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -2324,6 +2324,8 @@ def register_compounds( inchikey = inchikey_from_smiles(new_smiles) values.append((inchikey, new_smiles)) + return values + sql = """ INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol, compound_pattern_bfp, compound_morgan_bfp) VALUES(?1, ?2, mol_from_smiles(?2), mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048)) @@ -4163,6 +4165,9 @@ def query_most_similar( self, query: str, subset: "CompoundSet", + fp = "pattern", + bits = 2048, + morgan_radius = 1, return_similarity: bool = False, none="error", ) -> "Compound | (Compound, float)": @@ -4177,19 +4182,50 @@ def query_most_similar( from .compound import Compound - sql = f""" - WITH subset AS ( - SELECT compound_id, fp - FROM compound - JOIN compound_pattern_bfp USING (compound_id) - WHERE compound_id IN {subset.str_ids} - ) - - SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), fp) AS similarity - FROM subset - ORDER BY similarity DESC - LIMIT 1 - """ + if fp == "pattern" and bits == 2048: + sql = f""" + WITH subset AS ( + SELECT compound_id, fp + FROM compound + JOIN compound_pattern_bfp USING (compound_id) + WHERE compound_id IN {subset.str_ids} + ) + + SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity + FROM subset + ORDER BY similarity DESC + LIMIT 1 + """ + + elif fp == "morgan": + + sql = f""" + WITH subset AS ( + SELECT compound_id, mol_{fp}_bfp(compound_mol, {morgan_radius}, {bits}) AS fp + FROM compound + WHERE compound_id IN {subset.str_ids} + ) + + SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) AS similarity + FROM subset + ORDER BY similarity DESC + LIMIT 1 + """ + + else: + + sql = f""" + WITH subset AS ( + SELECT compound_id, mol_{fp}_bfp(compound_mol, {bits}) AS fp + FROM compound + WHERE compound_id IN {subset.str_ids} + ) + + SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity + FROM subset + ORDER BY similarity DESC + LIMIT 1 + """ try: self.execute(sql, (query,)) From 27bc5f17d6fdf1ff22c4046756b8887612eddc93 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 25 Nov 2025 14:51:08 +0000 Subject: [PATCH 003/163] register_compounds: update aliases --- hippo/animal.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 5a462e2..5aacdc2 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1912,9 +1912,10 @@ def add_soakdb_compounds( f"Could not determine file type from extension, use '.csv' or '.sqlite' {path}" ) - smiles_alias_tuples = [] + unique = df[df["CompoundSMILES"] != "-"].drop_duplicates(subset=[smiles_col, alias_col]) - for i, row in df.iterrows(): + smiles_alias_tuples = [] + for j, (i, row) in enumerate(unique.iterrows()): smiles = row[smiles_col] alias = row[alias_col] @@ -1927,10 +1928,10 @@ def add_soakdb_compounds( smiles_alias_tuples.append((smiles, alias)) - if stop_after and i > stop_after: + if stop_after and j > stop_after: break - smiles_alias_tuples = set(smiles_alias_tuples) + mrich.var("#unique compounds", len(smiles_alias_tuples)) old_smiles = [s for s, a in smiles_alias_tuples] @@ -1943,6 +1944,7 @@ def add_soakdb_compounds( inchikey: old_s for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) } + alias_lookup = {s: a for s, a in smiles_alias_tuples} alias_dicts = [ dict(compound_inchikey=inchikey, compound_alias=alias_lookup[old_s]) @@ -1959,6 +1961,7 @@ def add_soakdb_compounds( mrich.debug("Updating aliases...") self.db.executemany(sql, alias_dicts) + self.db.commit() inchikeys = [d["compound_inchikey"] for d in alias_dicts] From 73ae64078fe708ec0f2b9eff2a0ab2afd7715a66 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 25 Nov 2025 14:51:23 +0000 Subject: [PATCH 004/163] lint --- hippo/animal.py | 6 ++++-- hippo/db.py | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 5aacdc2..69c564c 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1912,7 +1912,9 @@ def add_soakdb_compounds( f"Could not determine file type from extension, use '.csv' or '.sqlite' {path}" ) - unique = df[df["CompoundSMILES"] != "-"].drop_duplicates(subset=[smiles_col, alias_col]) + unique = df[df["CompoundSMILES"] != "-"].drop_duplicates( + subset=[smiles_col, alias_col] + ) smiles_alias_tuples = [] for j, (i, row) in enumerate(unique.iterrows()): @@ -1944,7 +1946,7 @@ def add_soakdb_compounds( inchikey: old_s for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) } - + alias_lookup = {s: a for s, a in smiles_alias_tuples} alias_dicts = [ dict(compound_inchikey=inchikey, compound_alias=alias_lookup[old_s]) diff --git a/hippo/db.py b/hippo/db.py index 18b44b2..136e613 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -4165,9 +4165,9 @@ def query_most_similar( self, query: str, subset: "CompoundSet", - fp = "pattern", - bits = 2048, - morgan_radius = 1, + fp="pattern", + bits=2048, + morgan_radius=1, return_similarity: bool = False, none="error", ) -> "Compound | (Compound, float)": From f12bd46b3772ec0452172f97785f7b3106b94a76 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 25 Nov 2025 14:59:09 +0000 Subject: [PATCH 005/163] RouteSet.reactants --- hippo/recipe.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/hippo/recipe.py b/hippo/recipe.py index 9a0ff98..af88b93 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -2561,12 +2561,25 @@ def product_ids(self) -> list[int]: """Get the :class:`.Compound` ID's of the products""" ids = self.db.select_where( table="route", - query="route_product", + query="DISTINCT route_product", key=f"route_id IN {self.str_ids}", multiple=True, ) return [i for i, in ids] + @property + def reactant_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the reactants""" + sql = f""" + SELECT DISTINCT component_ref FROM component + INNER JOIN route ON component_route = route_id + WHERE component_type = 2 + AND route_id IN {self.str_ids} + """ + + c = self.db.execute(sql) + return [i for i, in c] + @property def products(self) -> "CompoundSet": """Return a :class:`.CompoundSet` of all the route products""" @@ -2574,6 +2587,13 @@ def products(self) -> "CompoundSet": return CompoundSet(self.db, self.product_ids) + @property + def reactants(self) -> "CompoundSet": + """Return a :class:`.CompoundSet` of all the route reactants""" + from .cset import CompoundSet + + return CompoundSet(self.db, self.reactant_ids) + @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Route` ID's""" From d0c50c576078cf7278e5c21e732cb89942daa09c Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 26 Nov 2025 13:42:15 +0000 Subject: [PATCH 006/163] interaction resolution in memory --- hippo/db.py | 164 ++++++++++++++++++++++++++++++++++++++++---------- hippo/iset.py | 70 +++++++++++++++------ hippo/pose.py | 52 ++++++++++++---- 3 files changed, 222 insertions(+), 64 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 795de05..eef48a8 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -44,12 +44,17 @@ def __init__( animal: "HIPPO", update_legacy: bool = False, auto_compute_bfps: bool = True, + create_blank: bool = True, + check_legacy: bool = True, + debug: bool = True, ) -> None: """Database initialisation""" - assert isinstance(path, Path) + self._in_memory = path == ":memory:" + assert isinstance(path, Path) or self.in_memory - mrich.debug("hippo.Database.__init__()") + if debug: + mrich.debug("hippo.Database.__init__()") self._path = path self._connection = None @@ -57,19 +62,32 @@ def __init__( self._animal = animal self._auto_compute_bfps = auto_compute_bfps - mrich.debug(f"Database.path = {self.path}") + if debug: + mrich.debug(f"Database.path = {self.path}") - try: - path = path.resolve(strict=True) + if not self.in_memory: + try: + path = path.resolve(strict=True) - except FileNotFoundError: - # create a blank database - self.connect() - self.create_blank_db() + except FileNotFoundError: + # create a blank database + + if create_blank: + self.connect(debug=debug) + self.create_blank_db() + else: + raise + else: + # connect to existing database + self.connect(debug=debug) else: - # connect to existing database - self.connect() + self.connect(debug=debug) + if create_blank: + self.create_blank_db() + + if not check_legacy: + return if "interaction" not in self.table_names: if not update_legacy: @@ -187,6 +205,11 @@ def path(self) -> Path: """Returns the path to the database file""" return self._path + @property + def in_memory(self) -> bool: + """Is this database stored in memory""" + return self._in_memory + @property def connection(self) -> "sqlite3.connection": """Returns a ``sqlite3.connection`` to the database""" @@ -226,12 +249,14 @@ def auto_compute_bfps(self, b: bool): ### PUBLIC METHODS / API CALLS - def close(self) -> None: + def close(self, debug: bool = False) -> None: """Close the connection""" - mrich.debug("hippo.Database.close()") + if debug: + mrich.debug("hippo.Database.close()") if self.connection: self.connection.close() - mrich.success(f"Closed connection to {self.path}") + if debug: + mrich.success(f"Closed connection to {self.path}") def backup( self, @@ -243,22 +268,26 @@ def backup( ### GENERAL SQL - def connect(self) -> None: + def connect(self, debug: bool = True) -> None: """Connect to the database""" - mrich.debug("hippo.Database.connect()") + + if debug: + mrich.debug("hippo.Database.connect()") conn = None try: conn = sqlite3.connect(self.path) - mrich.debug(f"{sqlite3.version=}") + if debug: + mrich.debug(f"{sqlite3.version=}") conn.enable_load_extension(True) conn.load_extension("chemicalite") conn.enable_load_extension(False) - mrich.success("Database connected @", f"[file]{self.path}") + if debug: + mrich.success("Database connected @", f"[file]{self.path}") except sqlite3.OperationalError as e: @@ -605,6 +634,7 @@ def create_table_interaction( if debug: mrich.debug(f"HIPPO.Database.create_table_interaction({table=})") + sql = f"""CREATE TABLE {table}( interaction_id INTEGER PRIMARY KEY, interaction_feature INTEGER NOT NULL, @@ -2037,14 +2067,64 @@ def update_all( ### COPYING / MIGRATION - def copy_temp_interactions(self) -> int: + def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: """Copy the records from the 'temp_interaction' table to the 'interaction' table :returns: ID of the last inserted :class:`.Interaction` """ - cursor = self.execute( + if source_db is not None: + + sql = """ + SELECT + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy + FROM temp_interaction """ + + cursor = source_db.execute(sql) + records = cursor.fetchall() + + sql = """ + INSERT OR IGNORE INTO interaction( + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy + ) + VALUES( + ?1, + ?2, + ?3, + ?4, + ?5, + ?6, + ?7, + ?8, + ?9, + ?10 + ) + """ + + cursor = self.executemany(sql, records) + + else: + + sql = """ INSERT OR IGNORE INTO interaction( interaction_feature, interaction_pose, @@ -2068,13 +2148,14 @@ def copy_temp_interactions(self) -> int: interaction_angle, interaction_energy FROM temp_interaction - """ - ) + """ + + cursor = self.execute(sql) return cursor.lastrowid def copy_interactions_to_temp(self, pose_id: int) -> int: - """Copy the records from the 'temp_interaction' table to the 'interaction' table + """Copy the records from the 'interaction' table to the 'temp_interaction' table for a given pose_id :returns: ID of the last inserted :class:`.Interaction` """ @@ -4639,21 +4720,35 @@ def print_table( """ - # mrich.print(self.cursor.fetchall()) + mrich.print(self.table_df(table)) - from rich.table import Table + def table_df( + self, + table: str, + ) -> "pandas.DataFrame": + """Get a DataFrame of a table - tab = Table() + :param table: the table to get + """ - for col in self.column_names(table): - tab.add_column(col.removeprefix(table).removeprefix("_")) + from pandas import DataFrame + + data = [] + + column_names = self.column_names(table) self.execute(f"SELECT * FROM {table}") - for record in self.cursor.fetchall(): - record = [str(v) for v in record] - tab.add_row(*record) - mrich.print(tab) + for record in self.cursor: + d = {} + for key, value in zip(column_names, record): + d[key] = value + data.append(d) + + df = DataFrame(data) + df = df.set_index(column_names[0]) + + return df def table_info( self, @@ -4677,7 +4772,10 @@ def column_names(self, table: str) -> list[str]: def __str__(self): """Unformatted string representation""" - return f"Database @ {self.path.resolve()}" + if self.in_memory: + return f"Database [IN-MEMORY]" + else: + return f"Database @ {self.path.resolve()}" def __repr__(self): """ANSI Formatted string representation""" diff --git a/hippo/iset.py b/hippo/iset.py index 71b991d..efe11a7 100644 --- a/hippo/iset.py +++ b/hippo/iset.py @@ -102,17 +102,23 @@ def __init__( @classmethod def from_pose( - cls, pose: "Pose | PoseSet", table: str = "interaction" + cls, + pose: "Pose | PoseSet", + table: str = "interaction", + db: "Database | None" = None, ) -> "InteractionSet": """Construct a :class:`.InteractionSet` from one or more poses. :param pose: a :class:`.Pose` or :class:`.PoseSet` object + :param table: Database table name + :param db: Use this instead of Pose's Database :returns: an :class:`.InteractionSet` - """ self = cls.__new__(cls) + db = db or pose.db + ### get the ID's from .pset import PoseSet @@ -120,7 +126,7 @@ def from_pose( if isinstance(pose, PoseSet): # check if all poses have fingerprints - (has_invalid_fps,) = pose.db.select_where( + (has_invalid_fps,) = db.select_where( query="COUNT(1)", table="pose", key=f"pose_id IN {pose.str_ids} AND pose_fingerprint = 0", @@ -141,11 +147,11 @@ def from_pose( WHERE interaction_pose = {pose.id} """ - ids = pose.db.execute(sql).fetchall() + ids = db.execute(sql).fetchall() ids = [i for i, in ids] - self.__init__(pose.db, ids, table=table) + self.__init__(db, ids, table=table) return self @@ -197,7 +203,7 @@ def from_residue( target = target.id sql = f""" - SELECT interaction_id FROM interaction + SELECT interaction_id FROM {self.table} INNER JOIN feature ON interaction_feature = feature_id WHERE feature_target = {target} @@ -232,7 +238,7 @@ def types(self) -> list[str]: """Returns the ids of interactions in this set""" records = self.db.select_where( query="interaction_type", - table="interaction", + table=self.table, key=f"interaction_id IN {self.str_ids}", multiple=True, ) @@ -253,6 +259,17 @@ def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" return str(tuple(self.ids)).replace(",)", ")") + @property + def feature_ids(self) -> list[int]: + """Return a list of :class:`.Feature` ID's""" + records = self.db.select_where( + query="DISTINCT interaction_feature", + table=self.table, + key=f"interaction_id IN {self.str_ids}", + multiple=True, + ) + return [r for r, in records] + @property def classic_fingerprint(self) -> dict: """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" @@ -465,12 +482,14 @@ def resolve( self, debug: bool = False, commit: bool = True, + feature_cache: dict | None = None, # table: str = 'interaction', ) -> "InteractionSet": """Resolve into predicted key interactions. In place modification. :param debug: Increased verbosity for debugging (Default value = False) :param commit: commit the changes (Default value = True) + :param feature_cache: lookup dictionary for feature data :returns: a filtered :class:`.InteractionSet` """ @@ -478,6 +497,12 @@ def resolve( table = self.table + # get feature cache + + feature_cache = feature_cache or { + i: self.db.get_feature(id=i) for i in self.feature_ids + } + ### H-Bonds (closest) sql = f""" @@ -497,12 +522,13 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) FROM {table} - INNER JOIN feature - ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} AND interaction_type = "π-stacking" - GROUP BY feature_atom_names + GROUP BY interaction_feature """ + # INNER JOIN feature + # ON feature_id = interaction_feature + # GROUP BY feature_atom_names # GROUP BY interaction_atom_ids records = self.db.execute(sql).fetchall() @@ -571,10 +597,13 @@ def resolve( lumped_hydrophobic_in_lumped_lumped = {} for interaction in subset: - families = (interaction.feature.family, interaction.family) + + feature = feature_cache[interaction.feature_id] + + families = (feature.family, interaction.family) if families == ("LumpedHydrophobe", "Hydrophobe"): - for name in interaction.feature.atom_names.split(): + for name in feature.atom_names.split(): key = (name, interaction.atom_ids[0]) if key not in hydrophobic_interactions_in_lumped: hydrophobic_interactions_in_lumped[key] = [] @@ -582,20 +611,20 @@ def resolve( elif families == ("Hydrophobe", "LumpedHydrophobe"): for atom_id in interaction.atom_ids: - key = (interaction.feature.atom_names, atom_id) + key = (feature.atom_names, atom_id) if key not in hydrophobic_interactions_in_lumped: hydrophobic_interactions_in_lumped[key] = [] hydrophobic_interactions_in_lumped[key].append(interaction.id) elif families == ("LumpedHydrophobe", "LumpedHydrophobe"): - for name in interaction.feature.atom_names.split(): + for name in feature.atom_names.split(): for atom_id in interaction.atom_ids: key = (name, atom_id) if key not in hydrophobic_interactions_in_lumped: hydrophobic_interactions_in_lumped[key] = [] hydrophobic_interactions_in_lumped[key].append(interaction.id) - key = interaction.feature.atom_names + key = feature.atom_names lumped_hydrophobic_in_lumped_lumped[key] = tuple(interaction.atom_ids) keep_hydrophobic_ids = set(subset.ids) @@ -606,17 +635,20 @@ def resolve( # modify keep list by those covered in lumped for interaction in subset: - families = (interaction.feature.family, interaction.family) + + feature = feature_cache[interaction.feature_id] + + families = (feature.family, interaction.family) if families == ("Hydrophobe", "Hydrophobe"): - key = (interaction.feature.atom_names, interaction.atom_ids[0]) + key = (feature.atom_names, interaction.atom_ids[0]) if key in hydrophobic_interactions_in_lumped: keep_hydrophobic_ids -= set([interaction.id]) elif families == ("LumpedHydrophobe", "Hydrophobe"): - key = interaction.feature.atom_names + key = feature.atom_names if key in lumped_hydrophobic_in_lumped_lumped: atom_id = interaction.atom_ids[0] @@ -630,7 +662,7 @@ def resolve( if key in rev_hydrophobic_in_lumped_lumped: - atom_name = interaction.feature.atom_names + atom_name = feature.atom_names value = rev_hydrophobic_in_lumped_lumped[key] if atom_name in value: diff --git a/hippo/pose.py b/hippo/pose.py index 402fd8d..a70c32a 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -936,21 +936,36 @@ def angle_between(v1, v2): ### clear old interactions - self.db.delete_where( - table="interaction", key="pose", value=self.id, commit=commit - ) self.set_has_fingerprint(False, commit=commit) self._interactions = None + ### IN-MEMORY DB + + from .db import Database + + temp_db = Database( + ":memory:", + animal=None, + create_blank=False, + check_legacy=False, + debug=False, + ) + + temp_db.create_table_interaction(debug=False) + temp_db.commit() + ### create temporary table - if "temp_interaction" in self.db.table_names: + if "temp_interaction" in temp_db.table_names: self.db.execute("DROP TABLE temp_interaction") - self.db.create_table_interaction(table="temp_interaction", debug=False) + temp_db.create_table_interaction(table="temp_interaction", debug=False) ### load the ligand structure + if debug: + mrich.debug("path", self.path) + if self.path.endswith(".pdb"): from molparse import parse @@ -974,6 +989,9 @@ def angle_between(v1, v2): ### get features comp_features = self.features + + if debug: + mrich.debug("Getting protein features...") protein_features = self.target.calculate_features( protein_system, reference_id=self.reference_id ) @@ -1140,7 +1158,7 @@ def angle_between(v1, v2): print("Prot:", prot_feature, "Lig:", lig_feature) # insert into the Database - self.db.insert_interaction( + temp_db.insert_interaction( feature=prot_feature.id, pose=self.id, type=interaction_type, @@ -1151,7 +1169,7 @@ def angle_between(v1, v2): distance=distance, angle=angle, energy=None, - commit=commit, + commit=False, table="temp_interaction", ) @@ -1165,18 +1183,28 @@ def angle_between(v1, v2): if resolve: from .iset import InteractionSet - interactions = InteractionSet.from_pose(self, table="temp_interaction") - interactions.resolve(debug=debug) - # self.interactions.resolve(debug=debug, table='temp_interaction') + interactions = InteractionSet.from_pose( + self, table="temp_interaction", db=temp_db + ) + + feature_ids = interactions.feature_ids + + feature_cache = {i: self.db.get_feature(id=i) for i in feature_ids} + + interactions.resolve(debug=debug, feature_cache=feature_cache) ### transfer interactions from temporary table - self.db.copy_temp_interactions() + self.db.delete_where( + table="interaction", key="pose", value=self.id, commit=commit + ) + + self.db.copy_temp_interactions(source_db=temp_db) self.set_has_fingerprint(True, commit=commit) ### delete temporary table if delete_temp_table: - self.db.execute("DROP TABLE temp_interaction") + temp_db.close(debug=False) elif debug: mrich.warning(f"{self} is already fingerprinted, no new calculation") From f3f4929127dd84811ff3eebc255e8b708887037e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 26 Nov 2025 13:53:32 +0000 Subject: [PATCH 007/163] SQL formatting --- hippo/db.py | 175 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 148 insertions(+), 27 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index eef48a8..28a1924 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -623,7 +623,10 @@ def create_table_pattern_bfp(self) -> None: """Create the pattern_bfp table""" mrich.debug("HIPPO.Database.create_table_pattern_bfp()") - sql = "CREATE VIRTUAL TABLE compound_pattern_bfp USING rdtree(compound_id, fp bits(2048))" + sql = """ + CREATE VIRTUAL TABLE compound_pattern_bfp + USING rdtree(compound_id, fp bits(2048)) + """ self.execute(sql) @@ -635,7 +638,8 @@ def create_table_interaction( if debug: mrich.debug(f"HIPPO.Database.create_table_interaction({table=})") - sql = f"""CREATE TABLE {table}( + sql = f""" + CREATE TABLE {table}( interaction_id INTEGER PRIMARY KEY, interaction_feature INTEGER NOT NULL, interaction_pose INTEGER NOT NULL, @@ -647,7 +651,13 @@ def create_table_interaction( interaction_distance REAL NOT NULL, interaction_angle REAL, interaction_energy REAL, - CONSTRAINT UC_interaction UNIQUE (interaction_feature, interaction_pose, interaction_type, interaction_family, interaction_atom_ids) + CONSTRAINT UC_interaction UNIQUE ( + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids + ) ); """ @@ -713,8 +723,22 @@ def insert_compound( inchikey = inchikey or inchikey_from_smiles(smiles) sql = """ - INSERT INTO compound(compound_inchikey, compound_smiles, compound_mol, compound_pattern_bfp, compound_morgan_bfp, compound_alias) - VALUES(?1, ?2, mol_from_smiles(?2), mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048), ?3) + INSERT INTO compound( + compound_inchikey, + compound_smiles, + compound_mol, + compound_pattern_bfp, + compound_morgan_bfp, + compound_alias + ) + VALUES( + ?1, + ?2, + mol_from_smiles(?2), + mol_pattern_bfp(mol_from_smiles(?2), 2048), + mol_morgan_bfp(mol_from_smiles(?2), 2, 2048), + ?3 + ) """ try: @@ -866,7 +890,17 @@ def insert_pose( raise sql = """ - INSERT INTO pose(pose_inchikey, pose_alias, pose_smiles, pose_compound, pose_target, pose_path, pose_reference, pose_energy_score, pose_distance_score) + INSERT INTO pose( + pose_inchikey, + pose_alias, + pose_smiles, + pose_compound, + pose_target, + pose_path, + pose_reference, + pose_energy_score, + pose_distance_score + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) """ @@ -1341,7 +1375,14 @@ def insert_feature( family = "Unknown" sql = """ - INSERT INTO feature(feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names) + INSERT INTO feature( + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, + feature_atom_names + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6) """ @@ -1627,7 +1668,6 @@ def insert_interaction( except sqlite3.IntegrityError as e: mrich.error(e) if warn_duplicate: - # mrich.warning(f"Skipping existing interaction: {(feature, pose, family, atom_ids, prot_coord, lig_coord, distance, energy)}") mrich.warning( f"Skipping existing interaction: {feature=} {pose=} {family=} {atom_ids=}" ) @@ -2429,8 +2469,20 @@ def register_compounds( if self.auto_compute_bfps: sql = """ - INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol, compound_pattern_bfp, compound_morgan_bfp) - VALUES(?1, ?2, mol_from_smiles(?2), mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048)) + INSERT OR IGNORE INTO compound( + compound_inchikey, + compound_smiles, + compound_mol, + compound_pattern_bfp, + compound_morgan_bfp + ) + VALUES( + ?1, + ?2, + mol_from_smiles(?2), + mol_pattern_bfp(mol_from_smiles(?2), 2048), + mol_morgan_bfp(mol_from_smiles(?2), 2, 2048) + ) """ else: @@ -2761,7 +2813,7 @@ def calculate_all_murcko_scaffolds( mrich.var("#generic murcko scaffold relations", len(pairs)) self.executemany( - """INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)""", + "INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)", pairs, ) @@ -3019,7 +3071,21 @@ def get_quote( """ - query = "quote_compound, quote_supplier, quote_catalogue, quote_entry, quote_amount, quote_price, quote_currency, quote_lead_time, quote_purity, quote_date, quote_smiles, quote_id " + query = ", ".join( + "quote_compound", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_amount", + "quote_price", + "quote_currency", + "quote_lead_time", + "quote_purity", + "quote_date", + "quote_smiles", + "quote_id", + ) + entry = self.select_where( query=query, table="quote", key="id", value=id, none=none ) @@ -3047,7 +3113,20 @@ def get_quote_df(self, ids: list[int]) -> "pd.DataFrame": str_ids = str(tuple(ids)).replace(",)", ")") - query = "quote_compound, quote_supplier, quote_catalogue, quote_entry, quote_amount, quote_price, quote_currency, quote_lead_time, quote_purity, quote_date, quote_smiles, quote_id " + query = ", ".join( + "quote_compound", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_amount", + "quote_price", + "quote_currency", + "quote_lead_time", + "quote_purity", + "quote_date", + "quote_smiles", + "quote_id", + ) records = self.select_where( query=query, table="quote", @@ -3674,13 +3753,28 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": """Get a dictionary mapping :class:`.Pose` ID's to their objects""" - query = "pose_id, pose_inchikey, pose_alias, pose_smiles, pose_reference, pose_path, pose_compound, pose_target, pose_mol, pose_fingerprint, pose_energy_score, pose_distance_score" + query = ", ".join( + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + ) + records = self.select_where( query=query, table="pose", key=f"pose_id IN {pset.str_ids}", multiple=True ) d = {} for entry in records: + ( pose_id, pose_inchikey, @@ -3695,6 +3789,7 @@ def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": pose_energy_score, pose_distance_score, ) = entry + d[pose_id] = Pose( self, pose_id, @@ -3710,6 +3805,7 @@ def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": pose_energy_score, pose_distance_score, ) + return d def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set]: @@ -3904,11 +4000,18 @@ def get_possible_reaction_ids( f""" WITH possible_reactants AS ( - SELECT reactant_reaction, CASE WHEN reactant_compound IN {compound_ids_str} THEN reactant_compound END AS [possible_reactant] FROM reactant + SELECT reactant_reaction, CASE + WHEN reactant_compound IN {compound_ids_str} + THEN reactant_compound END AS [possible_reactant] + FROM reactant ) , possible_reactions AS ( - SELECT reactant_reaction, COUNT(CASE WHEN possible_reactant IS NULL THEN 1 END) AS [count_null] FROM possible_reactants + SELECT reactant_reaction, COUNT( + CASE + WHEN possible_reactant IS NULL + THEN 1 END) AS [count_null] + FROM possible_reactants GROUP BY reactant_reaction ) @@ -3997,7 +4100,12 @@ def get_unsolved_reaction_tree( # all intermediates ids = self.execute( - "SELECT DISTINCT reaction_product FROM reaction INNER JOIN reactant ON reaction.reaction_product = reactant.reactant_compound" + """ + SELECT DISTINCT reaction_product + FROM reaction + INNER JOIN reactant + ON reaction.reaction_product = reactant.reactant_compound + """ ).fetchall() ids = [q for q, in ids] intermediates = CompoundSet(self, ids) @@ -4331,7 +4439,9 @@ def query_most_similar( WHERE compound_id IN {subset.str_ids} ) - SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity + SELECT compound_id, + bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) + AS similarity FROM subset ORDER BY similarity DESC LIMIT 1 @@ -4346,7 +4456,9 @@ def query_most_similar( WHERE compound_id IN {subset.str_ids} ) - SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) AS similarity + SELECT compound_id, + bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) + AS similarity FROM subset ORDER BY similarity DESC LIMIT 1 @@ -4361,7 +4473,8 @@ def query_most_similar( WHERE compound_id IN {subset.str_ids} ) - SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity + SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) + AS similarity FROM subset ORDER BY similarity DESC LIMIT 1 @@ -4412,7 +4525,8 @@ def query_similarity( FROM compound JOIN compound_pattern_bfp AS mfp USING(compound_id) - WHERE mfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) + WHERE mfp.compound_id + MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) AND compound_id IN {subset.str_ids} ORDER BY t DESC """ @@ -4420,7 +4534,8 @@ def query_similarity( sql = f""" SELECT compound_id FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) + WHERE bfp.compound_id + MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) AND compound_id IN {subset.str_ids} """ @@ -4434,14 +4549,16 @@ def query_similarity( FROM compound JOIN compound_pattern_bfp AS mfp USING(compound_id) - WHERE mfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) + WHERE mfp.compound_id + MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) ORDER BY t DESC """ else: sql = f""" SELECT compound_id FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id match rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) + WHERE bfp.compound_id + MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) """ else: raise NotImplementedError @@ -4492,7 +4609,11 @@ def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: """ pairs = self.execute( - f"""SELECT {table}_id, {table}_metadata FROM {table} WHERE {table}_metadata LIKE '%"{key}": "%'""" + f""" + SELECT {table}_id, {table}_metadata + FROM {table} + WHERE {table}_metadata LIKE '%"{key}": "%' + """ ).fetchall() from json import loads @@ -4514,7 +4635,7 @@ def count( """ - sql = f"""SELECT COUNT(1) FROM {table}; """ + sql = f"SELECT COUNT(1) FROM {table};" self.execute(sql) return self.cursor.fetchone()[0] @@ -4536,7 +4657,7 @@ def count_where( else: where_str = key - sql = f"""SELECT COUNT(1) FROM {table} WHERE {where_str};""" + sql = f"SELECT COUNT(1) FROM {table} WHERE {where_str};" self.execute(sql) return self.cursor.fetchone()[0] From 70548c8311252339ef3ae44841951af3165d63ed Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 27 Nov 2025 13:31:39 +0000 Subject: [PATCH 008/163] get_poses: first implementation --- hippo/db.py | 51 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 28a1924..e217b79 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -28,6 +28,22 @@ "molecular_weight": "mol_amw", } +POSE_FIELDS = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", +] + class Database: """Wrapper to connect to the HIPPO sqlite database. @@ -2969,28 +2985,29 @@ def get_pose( mrich.error(f"Invalid {id=}") return None - fields = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", - ] - - query = ", ".join(fields) + query = ", ".join(POSE_FIELDS) entry = self.select_where(query=query, table="pose", key="id", value=id) pose = Pose(self, *entry) return pose + def get_poses( + self, + *, + ids: list[int], + ) -> list[Pose]: + """Get list of initialised :class:`.Pose` objects with given ID's""" + + query = ", ".join(POSE_FIELDS) + + str_ids = str(tuple(ids)).replace(",)", ")") + + records = self.select_where(query=query, table="pose", key=f"pose_id IN {str_ids}", multiple=True) + + poses = [Pose(self, *entry) for entry in records] + + return poses + def get_pose_id( self, *, From c7f6c096f1b279310d3fad3646aad8d1b3b581a4 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 27 Nov 2025 13:31:47 +0000 Subject: [PATCH 009/163] get_poses: first implementation --- hippo/db.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index e217b79..bea3da5 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3002,10 +3002,12 @@ def get_poses( str_ids = str(tuple(ids)).replace(",)", ")") - records = self.select_where(query=query, table="pose", key=f"pose_id IN {str_ids}", multiple=True) - + records = self.select_where( + query=query, table="pose", key=f"pose_id IN {str_ids}", multiple=True + ) + poses = [Pose(self, *entry) for entry in records] - + return poses def get_pose_id( From 91a55ee507dd8910e2e36b24070c17971138e43b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 27 Nov 2025 13:33:33 +0000 Subject: [PATCH 010/163] calculate-interactions: parallelisation scaffolding --- hippo/__main__.py | 63 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/hippo/__main__.py b/hippo/__main__.py index 6153300..079af6e 100644 --- a/hippo/__main__.py +++ b/hippo/__main__.py @@ -90,6 +90,7 @@ def calculate_interactions( prolif: bool = False, backup: bool = True, force: bool = False, + #n_tasks: int = 1, ) -> None: """Calculate interactions for all poses""" @@ -103,26 +104,61 @@ def calculate_interactions( animal = setup_animal(database=database, backup=backup) mrich.h3("State Before") - mrich.var("#poses", animal.num_poses) + mrich.var("#total poses", animal.num_poses) mrich.var("#fingerprinted", animal.poses.num_fingerprinted) mrich.h3("Calculation") - n = len(animal.poses) - for i, pose in mrich.track(enumerate(animal.poses), total=n): + n_tasks = 1 - mrich.set_progress_prefix(f"{i}/{n}") + if not force: + pose_ids = animal.db.select_id_where( + table="pose", key="pose_fingerprint != 1", multiple=True + ) + else: + pose_ids = animal.db.execte("SELECT pose_id FROM pose").fetchall() - try: - if prolif: - pose.calculate_prolif_interactions(force=force) - else: - pose.calculate_interactions(force=force) + mrich.var("#poses", len(pose_ids)) - except Exception as e: - mrich.error(e) - mrich.error("Could not fingerprint pose") - continue + if n_tasks == 1: + + poses = animal.poses[pose_ids] + + n = len(poses) + for i, pose in mrich.track(enumerate(poses), total=n): + + mrich.set_progress_prefix(f"{i}/{n}") + + try: + if prolif: + pose.calculate_prolif_interactions(force=force) + else: + pose.calculate_interactions(force=force) + + except Exception as e: + mrich.error(e) + mrich.error("Could not fingerprint pose") + continue + + else: + + from joblib import Parallel, delayed + + poses = animal.db.get_poses(ids=pose_ids) + + if prolif: + raise NotImplementedError( + "ProLIF fingerprint calculation does not support in-memory resolution" + ) + + def calculate_interactions(pose: "Pose") -> None: + pose.calculate_interactions(force=force) + + tasks = [] + for pose in poses: + tasks.append(delayed(calculate_interactions)(pose)) + + Parallel(verbose=100, n_jobs=n_tasks)(task for task in tasks) mrich.h3("State After") mrich.var("#fingerprinted", animal.poses.num_fingerprinted) @@ -206,6 +242,7 @@ def add_hits( def main() -> None: """CLI entry point""" + app() if __name__ == "__main__": From ade736f0915dcf712556e2435023b2bb79cbf0a9 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 1 Dec 2025 16:28:07 +0000 Subject: [PATCH 011/163] calculate_interactions --- hippo/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hippo/__main__.py b/hippo/__main__.py index 079af6e..122b0d2 100644 --- a/hippo/__main__.py +++ b/hippo/__main__.py @@ -90,7 +90,7 @@ def calculate_interactions( prolif: bool = False, backup: bool = True, force: bool = False, - #n_tasks: int = 1, + # n_tasks: int = 1, ) -> None: """Calculate interactions for all poses""" @@ -118,6 +118,8 @@ def calculate_interactions( else: pose_ids = animal.db.execte("SELECT pose_id FROM pose").fetchall() + pose_ids = [i for i, in pose_ids] + mrich.var("#poses", len(pose_ids)) if n_tasks == 1: From e7d623117b5cf517e4f659c9c1aa1428003369df Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 1 Dec 2025 16:28:33 +0000 Subject: [PATCH 012/163] add_enamine_quote: more options --- hippo/animal.py | 50 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 92562fc..fbfbf78 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1594,8 +1594,10 @@ def add_enamine_quote( delete_unavailable: bool = True, overwrite_existing_quotes: bool = False, supplier_name: str = "Enamine", + warn_nan_orig_name: bool = True, currency: str = None, dry_run: bool = False, + debug: bool = False, ): """ Load an Enamine quote provided as an excel file @@ -1681,24 +1683,31 @@ def unexpected_column(key: str, value: str | float) -> str: for i, row in generator: smiles = row[smiles_col] + if debug: + mrich.debug("smiles", smiles) + if not isinstance(smiles, str): - break + if debug: + mrich.debug("SKIPPING smiles!=str", smiles) + continue compound = self.register_compound(smiles=smiles) if orig_name_is_hippo_id: - try: - expected_id = int(row[orig_name_col]) - if expected_id != compound.id: - mrich.error("Compound registration mismatch:") - mrich.var("expected_id", expected_id) - mrich.var("new_id", compound.id) - mrich.var("original_smiles", self.compounds[expected_id].smiles) - mrich.var("new_smiles", smiles) + if pd.isna(row[orig_name_col]): + if warn_nan_orig_name: + mrich.warning(f"row {i} has NaN {orig_name_col}") + continue + + expected_id = int(row[orig_name_col]) - except ValueError: - pass + if expected_id != compound.id: + mrich.error("Compound registration mismatch:") + mrich.var("expected_id", expected_id) + mrich.var("new_id", compound.id) + mrich.var("original_smiles", self.compounds[expected_id].smiles) + mrich.var("new_smiles", smiles) if catalogue_col and (catalogue := row[catalogue_col]) in [ "No starting material", @@ -1718,6 +1727,19 @@ def unexpected_column(key: str, value: str | float) -> str: continue if (price := row[price_col]) == 0.0: + + if not dry_run and delete_unavailable: + + mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) + + self.db.delete_where( + table="quote", + key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", + ) + + if debug: + mrich.debug("Skipping NULL price", compound, i) + continue if fixed_amount is None: @@ -1753,6 +1775,9 @@ def unexpected_column(key: str, value: str | float) -> str: smiles=smiles, ) + if debug: + mrich.print(quote_data) + if dry_run: mrich.warning("Dry-run, stopping before any database modifications") return quote_data @@ -1765,6 +1790,9 @@ def unexpected_column(key: str, value: str | float) -> str: q_id = self.db.insert_quote(**quote_data) + if debug: + mrich.debug("inserted quote", q_id) + ingredients.add( compound_id=compound.id, amount=amount, From 7ca75d45c3e0c32e9eb4da969bacd753bedf966a Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 1 Dec 2025 16:28:51 +0000 Subject: [PATCH 013/163] fix sql --- hippo/db.py | 52 ++++++++++++++++++++++++++++------------------------ 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index bea3da5..720ee0b 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3091,18 +3091,20 @@ def get_quote( """ query = ", ".join( - "quote_compound", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_amount", - "quote_price", - "quote_currency", - "quote_lead_time", - "quote_purity", - "quote_date", - "quote_smiles", - "quote_id", + [ + "quote_compound", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_amount", + "quote_price", + "quote_currency", + "quote_lead_time", + "quote_purity", + "quote_date", + "quote_smiles", + "quote_id", + ] ) entry = self.select_where( @@ -3133,18 +3135,20 @@ def get_quote_df(self, ids: list[int]) -> "pd.DataFrame": str_ids = str(tuple(ids)).replace(",)", ")") query = ", ".join( - "quote_compound", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_amount", - "quote_price", - "quote_currency", - "quote_lead_time", - "quote_purity", - "quote_date", - "quote_smiles", - "quote_id", + [ + "quote_compound", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_amount", + "quote_price", + "quote_currency", + "quote_lead_time", + "quote_purity", + "quote_date", + "quote_smiles", + "quote_id", + ] ) records = self.select_where( query=query, From 032cc62cc703c21c8fbf7937716c20b6f29225d1 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 1 Dec 2025 16:29:13 +0000 Subject: [PATCH 014/163] better debugging --- hippo/compound.py | 4 +++- hippo/quote.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/hippo/compound.py b/hippo/compound.py index b3b23ed..1cbd779 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -478,7 +478,9 @@ def get_quotes( 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") + mrich.debug( + f"No quote available for C{self.id} with amount >= {min_amount} mg. Estimating price..." + ) quotes = [Quote.combination(min_amount, quotes)] else: diff --git a/hippo/quote.py b/hippo/quote.py index b71ba2c..f899e58 100644 --- a/hippo/quote.py +++ b/hippo/quote.py @@ -66,6 +66,7 @@ def combination( cls, required_amount: float, quotes: list["Quote"], + debug: bool = False, ) -> "Quote": """Combine a list of quotes into one :class:`.Quote` object. @@ -81,8 +82,7 @@ def combination( unit_price = biggest_pack.price / biggest_pack.amount estimated_price = unit_price * required_amount - self = cls.__new__(cls) - self.__init__( + quote_data = dict( db=biggest_pack.db, id=None, compound=biggest_pack.compound, @@ -99,6 +99,19 @@ def combination( type=f"estimate from quote={biggest_pack.id}", ) + if debug: + mrich.debug(f"Quote.combination()") + mrich.debug(f"{required_amount=}") + for quote in quotes: + mrich.debug(quote) + mrich.debug(f"{biggest_pack=}") + mrich.debug(f"{unit_price=}") + mrich.debug(f"{estimated_price=}") + mrich.print(quote_data) + + self = cls.__new__(cls) + self.__init__(**quote_data) + return self ### PROPERTIES From e69dbffca0820fcf695051b73fa07ac5cb7b753d Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 1 Dec 2025 16:29:38 +0000 Subject: [PATCH 015/163] write_reactant_csv: estimate quotes --- hippo/recipe.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/hippo/recipe.py b/hippo/recipe.py index af88b93..63cfc18 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1605,6 +1605,32 @@ def write_reactant_csv( df = df[[c for c in cols if c in df.columns]] + ### Add estimated quotes + + unquoted = df[df["quote_id"].isna()] + + if len(unquoted): + + for i, row in unquoted.iterrows(): + + compound = self.db.get_compound(id=row["compound_id"]) + ingredient = compound.as_ingredient( + amount=row["required_amount_mg"], get_quote=False + ) + + quote = ingredient.quote + + df.loc[i, "quoted_amount_mg"] = quote.amount + df.loc[i, "quote_supplier"] = quote.supplier + df.loc[i, "quote_catalogue"] = quote.catalogue + df.loc[i, "quote_entry"] = quote.entry + df.loc[i, "quote_price"] = quote.price.amount + df.loc[i, "quote_currency"] = quote.price.currency + df.loc[i, "quote_lead_time_days"] = quote.lead_time + df.loc[i, "quoted_purity"] = quote.purity + df.loc[i, "quoted_smiles"] = quote.smiles + df.loc[i, "quote_date"] = quote.date + ### N.B. scaffold series no longer output mrich.writing(file) From 8e7dbed4676ca07e0bee52e8085cb68c7d28e0cf Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 10:33:44 +0000 Subject: [PATCH 016/163] fix broken tests --- tests/test_feature.py | 1 - tests/test_interaction.py | 1 - tests/test_pose.py | 1 - tests/test_subsite.py | 1 - tests/test_target.py | 1 - 5 files changed, 5 deletions(-) diff --git a/tests/test_feature.py b/tests/test_feature.py index 895913a..7929526 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -1,5 +1,4 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ "id", diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 7f812a0..415c4c2 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,5 +1,4 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ "id", diff --git a/tests/test_pose.py b/tests/test_pose.py index 51861c5..2720cfc 100644 --- a/tests/test_pose.py +++ b/tests/test_pose.py @@ -1,5 +1,4 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ "db", diff --git a/tests/test_subsite.py b/tests/test_subsite.py index c4fed79..c1a7071 100644 --- a/tests/test_subsite.py +++ b/tests/test_subsite.py @@ -1,5 +1,4 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ "db", diff --git a/tests/test_target.py b/tests/test_target.py index 5d60a39..9321770 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -1,5 +1,4 @@ from config import * -from common import animal NOT_NULL_PROPERTIES = [ "id", From f4fedca690bdf63cfff966615a37a6dd3fc927fe Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 10:32:14 +0000 Subject: [PATCH 017/163] postgres testing --- README.md | 26 ++++++++++++++++++++++++++ hippo/db.py | 19 +++++++++++++++++++ tests/config.py | 14 ++++++++++---- 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 035e1d4..5a730f5 100644 --- a/README.md +++ b/README.md @@ -99,4 +99,30 @@ N.B. the numbered tests, e.g. `test_00_cleanup.py` need to run in sequential ord - [HIPPO/dev](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's development branch - [HIPPO/django_lean](https://github.com/mwinokan/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app +### Local Postgres development (Mac) + +Install via homebrew + +``` +brew install postgresql@18 +``` + +Initialise database + +``` +/opt/homebrew/opt/postgresql@18/bin/initdb -D /opt/homebrew/var/postgresql@18 -U postgres -W +``` + +Run in foreground + +``` +/opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18 +``` + +Install psycopg + +``` +pip install psycopg[binary] +``` + diff --git a/hippo/db.py b/hippo/db.py index 720ee0b..897b9a5 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -4930,6 +4930,25 @@ def __rich__(self) -> str: return f"[bold underline]{self}" +class PostgresDatabase(Database): + """Wrapper to connect to a HIPPO Postgres database. + + .. attention:: + + :class:`.PostGresDatabase` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. + + """ + + def __init__( + self, + postgres_user: str, + postgres_password: str, + postgres_db: str, + ) -> None: + + raise NotImplementedError + + class LegacyDatabaseError(Exception): """This database is in a legacy format""" diff --git a/tests/config.py b/tests/config.py index 6ab467e..f6f3dc1 100644 --- a/tests/config.py +++ b/tests/config.py @@ -2,13 +2,19 @@ TARGET = "SARS2_Nprot" PROPOSAL = "lb32627-93" STACK = "production" -DB = "db_test.sqlite" + +DB = dict( + username="postgres", + password="hippo", + port=5432, +) + TARGET = "SARS2_Nprot" ## DISABLE TESTS CLEANUP = True -DOWNLOAD = True +DOWNLOAD = False SETUP = True ADD_HITS = True SCAFFOLDS = True @@ -18,9 +24,9 @@ CLEANUP_FILES = [ DB, - f"{TARGET}.tar.gz", + # f"{TARGET}.tar.gz", ] CLEANUP_DIRS = [ - TARGET, + # TARGET, ] From 8b225d83f8c7b1f5b5e00d0cb59628de30c1f6eb Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 11:28:23 +0000 Subject: [PATCH 018/163] postgres development --- hippo/animal.py | 50 +++++--- hippo/db.py | 223 +++++++++++++++++++++++++++++++--- tests/config.py | 3 +- tests/test_02_setup_animal.py | 3 +- 4 files changed, 244 insertions(+), 35 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index fbfbf78..6a99672 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -10,7 +10,6 @@ from rdkit.Chem import Mol from .pose import Pose -from .db import Database from .tags import TagTable from .target import Target from .compound import Compound @@ -43,7 +42,7 @@ class HIPPO: def __init__( self, name: str, - db_path: str | Path, + db: str | Path | dict, copy_from: str | Path | None = None, overwrite_existing: bool = False, update_legacy: bool = False, @@ -56,23 +55,38 @@ def __init__( mrich.var("name", name, color="arg") - if not isinstance(db_path, Path): - db_path = Path(db_path) + if isinstance(db, dict): - mrich.var("db_path", db_path, color="file") + ### POSTGRES - self._db_path = db_path + from .db import PostgresDatabase - if copy_from: - self._db = Database.copy_from( - source=copy_from, - destination=self.db_path, - animal=self, - update_legacy=update_legacy, - overwrite_existing=overwrite_existing, - ) - else: - self._db = Database(self.db_path, animal=self, update_legacy=update_legacy) + self._db = PostgresDatabase(animal=self, **db) + + elif not isinstance(db, Path): + + ### INITIALISE SQLITE DATABASE + + from .db import Database + + db_path = Path(db) + + mrich.var("db_path", db_path, color="file") + + self._db_path = db_path + + if copy_from: + self._db = Database.copy_from( + source=copy_from, + destination=self.db_path, + animal=self, + update_legacy=update_legacy, + overwrite_existing=overwrite_existing, + ) + else: + self._db = Database( + self.db_path, animal=self, update_legacy=update_legacy + ) self._compounds = CompoundTable(self.db) self._poses = PoseTable(self.db) @@ -101,10 +115,10 @@ def name(self) -> str: @property def db_path(self) -> str: """Returns the database path""" - return self._db_path + return self.db.path @property - def db(self) -> Database: + def db(self) -> "Database": """Returns the Database object""" return self._db diff --git a/hippo/db.py b/hippo/db.py index 897b9a5..bd965aa 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -105,8 +105,12 @@ def __init__( if not check_legacy: return + self.check_schema(update=update_legacy) + + def check_schema(self, update: bool = False): + if "interaction" not in self.table_names: - if not update_legacy: + if not update: mrich.error("This is a legacy format database (hippo-db < 0.3.23)") mrich.error("Existing fingerprints will not be compatible") mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") @@ -123,7 +127,7 @@ def __init__( self.create_table_subsite_tag() if "scaffold" not in self.table_names: - if not update_legacy: + if not update: mrich.error("This is a legacy format database (hippo-db < 0.3.25)") mrich.error("Existing base-elab relationships will not be compatible") mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") @@ -139,7 +143,7 @@ def __init__( self.create_table_component() elif "component_amount" not in self.column_names("component"): - if not update_legacy: + if not update: mrich.error("This is a legacy format database (hippo-db < 0.3.29)") mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") raise LegacyDatabaseError("hippo-db < 0.3.29") @@ -150,7 +154,7 @@ def __init__( self.update_legacy_routes() if "reaction_metadata" not in self.column_names("reaction"): - if not update_legacy: + if not update: mrich.error("This is a legacy format database (hippo-db < 0.3.32)") mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") raise LegacyDatabaseError("hippo-db < 0.3.32") @@ -161,7 +165,7 @@ def __init__( self.update_legacy_reaction_metadata() if "pose_inspiration_score" not in self.column_names("pose"): - if not update_legacy: + if not update: mrich.error("This is a legacy format database (hippo-db < 0.3.36)") mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") raise LegacyDatabaseError("hippo-db < 0.3.36") @@ -406,10 +410,10 @@ def create_blank_db(self) -> None: with mrich.loading("Creating blank database..."): self.create_table_compound() + self.create_table_pose() self.create_table_inspiration() self.create_table_reaction() self.create_table_reactant() - self.create_table_pose() self.create_table_tag() self.create_table_quote() self.create_table_target() @@ -532,10 +536,6 @@ def create_table_pose(self) -> None: ); """ - ### snippet to convert python metadata dictionary with JSON - # json.dumps(variables).encode('utf-8') - # json.loads(s.decode('utf-8')) - self.execute(sql) def create_table_tag(self) -> None: @@ -548,7 +548,7 @@ def create_table_tag(self) -> None: tag_pose INTEGER, FOREIGN KEY (tag_compound) REFERENCES compound(compound_id), FOREIGN KEY (tag_pose) REFERENCES pose(pose_id), - CONSTRAINT UC_tag_compound UNIQUE (tag_name, tag_compound) + CONSTRAINT UC_tag_compound UNIQUE (tag_name, tag_compound), CONSTRAINT UC_tag_pose UNIQUE (tag_name, tag_pose) ); """ @@ -4941,12 +4941,205 @@ class PostgresDatabase(Database): def __init__( self, - postgres_user: str, - postgres_password: str, - postgres_db: str, + animal: "HIPPO", + username: str, + password: str, + host: str = "localhost", + port: int = 5432, + update_legacy: bool = False, + auto_compute_bfps: bool = True, + create_blank: bool = True, + check_legacy: bool = False, + debug: bool = True, ) -> None: + """PostgresDatabase initialisation""" + + assert isinstance(username, str) + assert isinstance(password, str) + assert isinstance(port, int) + + if debug: + mrich.debug("hippo.PostgresDatabase.__init__()") + + self._username = username + self._password = password + self._port = port + self._host = host + + self._connection = None + self._cursor = None + self._animal = animal + self._auto_compute_bfps = auto_compute_bfps + + if debug: + mrich.debug(f"PostgresDatabase.username = {self.username}") + mrich.debug(f"PostgresDatabase.password = {self.password}") + mrich.debug(f"PostgresDatabase.host = {self.host}") + mrich.debug(f"PostgresDatabase.port = {self.port}") + + self.connect() + + if not self.table_names: + + if create_blank: + self.create_blank_db() + else: + mrich.error("Database is empty!", self.path) + raise ValueError( + "Database is empty! Check connection or run with create_blank=True" + ) + + if not check_legacy: + return + + self.check_schema(update=update_legacy) + + ### PROPERTIES + + @property + def path(self) -> None: + """PostgresDatabase path""" + # raise NotImplementedError("PostgresDatabase has no path") + return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}" + + @property + def username(self) -> str: + """PostgresDatabase username""" + return self._username + + @property + def password(self) -> str: + """PostgresDatabase password""" + return self._password + + @property + def host(self) -> str: + """PostgresDatabase host""" + return self._host + + @property + def port(self) -> int: + """PostgresDatabase port""" + return self._port + + @property + def table_names(self) -> list[str]: + """List of all the table names in the database""" + results = self.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE'; + """ + ).fetchall() + return [n for n, in results] + + ### GENERAL SQL + + def connect(self, debug: bool = True) -> None: + """Connect to the database""" + + if debug: + mrich.debug("hippo.PostgresDatabase.connect()") + + conn = None + + import psycopg + + try: + conn = psycopg.connect( + user=self.username, + host=self.host, + password=self.password, + port=self.port, + ) - raise NotImplementedError + except Exception as e: + mrich.error("Could not connect to", self.path) + mrich.error(e) + raise + + self._connection = conn + self._cursor = conn.cursor() + + ### CREATE TABLES + + def create_table_compound(self) -> None: + """Create the compound table""" + mrich.debug("HIPPO.PostgresDatabase.create_table_compound()") + mrich.warning( + "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented(MOL)" + ) + + sql = """CREATE TABLE compound( + compound_id INTEGER PRIMARY KEY, + compound_inchikey TEXT, + compound_alias TEXT, + compound_smiles TEXT, + compound_base INTEGER, + -- compound_mol MOL, + compound_pattern_bfp bit(2048), + compound_morgan_bfp bit(2048), + compound_metadata TEXT, + FOREIGN KEY (compound_base) REFERENCES compound(compound_id), + CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT UC_compound_alias UNIQUE (compound_alias), + CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) + ); + """ + self.execute(sql) + + def create_table_pose(self) -> None: + """Create the pose table""" + mrich.debug("HIPPO.PostgresDatabase.create_table_pose()") + mrich.warning( + "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented(MOL)" + ) + + sql = """CREATE TABLE pose( + pose_id INTEGER PRIMARY KEY, + pose_inchikey TEXT, + pose_alias TEXT, + pose_smiles TEXT, + pose_reference INTEGER, + pose_path TEXT, + pose_compound INTEGER, + pose_target INTEGER, + -- pose_mol BLOB, + pose_fingerprint INTEGER, + pose_energy_score REAL, + pose_distance_score REAL, + pose_inspiration_score REAL, + pose_metadata TEXT, + FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), + CONSTRAINT UC_pose_alias UNIQUE (pose_alias), + CONSTRAINT UC_pose_path UNIQUE (pose_path) + ); + """ + + self.execute(sql) + + def create_table_pattern_bfp(self) -> None: + """Create the pattern_bfp table""" + mrich.warning( + "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented" + ) + + return + + mrich.debug("HIPPO.PostgresDatabase.create_table_pattern_bfp()") + + sql = """ + CREATE VIRTUAL TABLE compound_pattern_bfp + USING rdtree(compound_id, fp bits(2048)) + """ + + self.execute(sql) + + ### METHODS + + ### DUNDERS class LegacyDatabaseError(Exception): diff --git a/tests/config.py b/tests/config.py index f6f3dc1..2672ed7 100644 --- a/tests/config.py +++ b/tests/config.py @@ -6,6 +6,7 @@ DB = dict( username="postgres", password="hippo", + host="localhost", port=5432, ) @@ -23,7 +24,7 @@ ## CONFIGURE CLEANUP CLEANUP_FILES = [ - DB, + # DB, # f"{TARGET}.tar.gz", ] diff --git a/tests/test_02_setup_animal.py b/tests/test_02_setup_animal.py index 26752ee..1dfa367 100644 --- a/tests/test_02_setup_animal.py +++ b/tests/test_02_setup_animal.py @@ -12,7 +12,8 @@ def test_setup_animal(): animal = hippo.HIPPO("test", DB) animal.summary() - assert Path(DB).exists() + if isinstance(DB, str): + assert Path(DB).exists() animal.db.close() From 63102f4281588bb1ef7f45f6e373f65755fb5446 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 11:29:47 +0000 Subject: [PATCH 019/163] variable configuration --- tests/config.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/config.py b/tests/config.py index 2672ed7..b942c35 100644 --- a/tests/config.py +++ b/tests/config.py @@ -3,6 +3,26 @@ PROPOSAL = "lb32627-93" STACK = "production" +## CONFIGURE CLEANUP + +CLEANUP_FILES = [ + # f"{TARGET}.tar.gz", +] + +CLEANUP_DIRS = [ + # TARGET, +] + +## CONFIGURE DATABASE + +### SQLITE + +DB = "db_test.sqlite" + +CLEANUP_FILES.append(DB) + +### POSTGRES + DB = dict( username="postgres", password="hippo", @@ -10,8 +30,6 @@ port=5432, ) -TARGET = "SARS2_Nprot" - ## DISABLE TESTS CLEANUP = True @@ -20,14 +38,3 @@ ADD_HITS = True SCAFFOLDS = True SUBSITES = True - -## CONFIGURE CLEANUP - -CLEANUP_FILES = [ - # DB, - # f"{TARGET}.tar.gz", -] - -CLEANUP_DIRS = [ - # TARGET, -] From 8938e910058180f9182d83820c1e586d156659c5 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 13:31:40 +0000 Subject: [PATCH 020/163] postgres development --- hippo/animal.py | 2 +- hippo/db.py | 380 ++++++++++++---------------------------------- hippo/postgres.py | 249 ++++++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 281 deletions(-) create mode 100644 hippo/postgres.py diff --git a/hippo/animal.py b/hippo/animal.py index 6a99672..5920d2a 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -59,7 +59,7 @@ def __init__( ### POSTGRES - from .db import PostgresDatabase + from .postgres import PostgresDatabase self._db = PostgresDatabase(animal=self, **db) diff --git a/hippo/db.py b/hippo/db.py index bd965aa..eda7f30 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -54,6 +54,49 @@ class Database: """ + SQL_STRING_PLACEHOLDER = "?" + SQL_PK_DATATYPE = "INTEGER" + + ERROR_UNIQUE_VIOLATION = sqlite3.IntegrityError + + SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE compound( + compound_id INTEGER PRIMARY KEY, + compound_inchikey TEXT, + compound_alias TEXT, + compound_smiles TEXT, + compound_base INTEGER, + compound_mol MOL, + compound_pattern_bfp bits(2048), + compound_morgan_bfp bits(2048), + compound_metadata TEXT, + FOREIGN KEY (compound_base) REFERENCES compound(compound_id), + CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey) + CONSTRAINT UC_compound_alias UNIQUE (compound_alias) + CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) + ); + """ + + SQL_CREATE_TABLE_POSE = """CREATE TABLE pose( + pose_id INTEGER PRIMARY KEY, + pose_inchikey TEXT, + pose_alias TEXT, + pose_smiles TEXT, + pose_reference INTEGER, + pose_path TEXT, + pose_compound INTEGER, + pose_target INTEGER, + pose_mol BLOB, + pose_fingerprint BLOB, + pose_energy_score REAL, + pose_distance_score REAL, + pose_inspiration_score REAL, + pose_metadata TEXT, + FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), + CONSTRAINT UC_pose_alias UNIQUE (pose_alias) + CONSTRAINT UC_pose_path UNIQUE (pose_path) + ); + """ + def __init__( self, path: Path, @@ -77,6 +120,7 @@ def __init__( self._cursor = None self._animal = animal self._auto_compute_bfps = auto_compute_bfps + self._engine = "sqlite" if debug: mrich.debug(f"Database.path = {self.path}") @@ -225,6 +269,11 @@ def path(self) -> Path: """Returns the path to the database file""" return self._path + @property + def engine(self) -> str: + """Returns the Database engine""" + return self._engine + @property def in_memory(self) -> bool: """Is this database stored in memory""" @@ -403,6 +452,11 @@ def commit(self, *, retry: float | None = 1) -> None: else: raise + def rollback(self) -> None: + """rollback (not relevant for sqlite)""" + # self.connection.rollback() + pass + ### CREATE TABLES def create_blank_db(self) -> None: @@ -431,22 +485,8 @@ def create_table_compound(self) -> None: """Create the compound table""" mrich.debug("HIPPO.Database.create_table_compound()") - sql = """CREATE TABLE compound( - compound_id INTEGER PRIMARY KEY, - compound_inchikey TEXT, - compound_alias TEXT, - compound_smiles TEXT, - compound_base INTEGER, - compound_mol MOL, - compound_pattern_bfp bits(2048), - compound_morgan_bfp bits(2048), - compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES compound(compound_id), - CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey) - CONSTRAINT UC_compound_alias UNIQUE (compound_alias) - CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) - ); - """ + sql = self.SQL_CREATE_TABLE_COMPOUND + self.execute(sql) def create_table_inspiration(self) -> None: @@ -483,8 +523,8 @@ def create_table_reaction(self) -> None: """Create the reaction table""" mrich.debug("HIPPO.Database.create_table_reaction()") - sql = """CREATE TABLE reaction( - reaction_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE reaction( + reaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, reaction_type TEXT, reaction_product INTEGER, reaction_product_yield REAL, @@ -515,26 +555,7 @@ def create_table_pose(self) -> None: """Create the pose table""" mrich.debug("HIPPO.Database.create_table_pose()") - sql = """CREATE TABLE pose( - pose_id INTEGER PRIMARY KEY, - pose_inchikey TEXT, - pose_alias TEXT, - pose_smiles TEXT, - pose_reference INTEGER, - pose_path TEXT, - pose_compound INTEGER, - pose_target INTEGER, - pose_mol BLOB, - pose_fingerprint BLOB, - pose_energy_score REAL, - pose_distance_score REAL, - pose_inspiration_score REAL, - pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_pose_alias UNIQUE (pose_alias) - CONSTRAINT UC_pose_path UNIQUE (pose_path) - ); - """ + sql = self.SQL_CREATE_TABLE_POSE self.execute(sql) @@ -559,8 +580,8 @@ def create_table_quote(self) -> None: """Create the quote table""" mrich.debug("HIPPO.Database.create_table_quote()") - sql = """CREATE TABLE quote( - quote_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE quote( + quote_id {self.SQL_PK_DATATYPE} PRIMARY KEY, quote_smiles TEXT, quote_amount REAL, quote_supplier TEXT, @@ -582,8 +603,8 @@ def create_table_quote(self) -> None: def create_table_target(self) -> None: """Create the target table""" mrich.debug("HIPPO.Database.create_table_target()") - sql = """CREATE TABLE target( - target_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE target( + target_id {self.SQL_PK_DATATYPE} PRIMARY KEY, target_name TEXT, target_metadata TEXT, CONSTRAINT UC_target UNIQUE (target_name) @@ -595,8 +616,8 @@ def create_table_target(self) -> None: def create_table_feature(self) -> None: """Create the feature table""" mrich.debug("HIPPO.Database.create_table_feature()") - sql = """CREATE TABLE feature( - feature_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE feature( + feature_id {self.SQL_PK_DATATYPE} PRIMARY KEY, feature_family TEXT, feature_target INTEGER, feature_chain_name TEXT, @@ -612,8 +633,8 @@ def create_table_feature(self) -> None: def create_table_route(self) -> None: """Create the route table""" mrich.debug("HIPPO.Database.create_table_route()") - sql = """CREATE TABLE route( - route_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE route( + route_id {self.SQL_PK_DATATYPE} PRIMARY KEY, route_product INTEGER ); """ @@ -623,8 +644,8 @@ def create_table_route(self) -> None: def create_table_component(self) -> None: """Create the component table""" mrich.debug("HIPPO.Database.create_table_component()") - sql = """CREATE TABLE component( - component_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE component( + component_id {self.SQL_PK_DATATYPE} PRIMARY KEY, component_route INTEGER, component_type INTEGER, component_ref INTEGER, @@ -656,7 +677,7 @@ def create_table_interaction( sql = f""" CREATE TABLE {table}( - interaction_id INTEGER PRIMARY KEY, + interaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, interaction_feature INTEGER NOT NULL, interaction_pose INTEGER NOT NULL, interaction_type TEXT NOT NULL, @@ -683,8 +704,8 @@ def create_table_subsite(self) -> None: """Create the subsite table""" mrich.debug("HIPPO.Database.create_table_subsite()") - sql = """CREATE TABLE subsite( - subsite_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE subsite( + subsite_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_target INTEGER NOT NULL, subsite_name TEXT NOT NULL, subsite_metadata TEXT, @@ -698,8 +719,8 @@ def create_table_subsite_tag(self) -> None: """Create the subsite_tag table""" mrich.debug("HIPPO.Database.create_table_subsite_tag()") - sql = """CREATE TABLE subsite_tag( - subsite_tag_id INTEGER PRIMARY KEY, + sql = f"""CREATE TABLE subsite_tag( + subsite_tag_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_tag_ref INTEGER NOT NULL, subsite_tag_pose INTEGER NOT NULL, subsite_tag_metadata TEXT, @@ -709,6 +730,9 @@ def create_table_subsite_tag(self) -> None: self.execute(sql) + def sql_return_id_str(self, key: str) -> str: + return "" + ### INSERTION def insert_compound( @@ -760,7 +784,7 @@ def insert_compound( try: self.execute(sql, (inchikey, smiles, alias)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if "UNIQUE constraint failed: compound.compound_inchikey" in str(e): if warn_duplicate: mrich.warning( @@ -936,7 +960,7 @@ def insert_pose( ), ) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if "UNIQUE constraint failed: pose.pose_path" in str(e): if warn_duplicate: mrich.warning(f'Could not insert pose with duplicate path "{path}"') @@ -1001,7 +1025,7 @@ def insert_tag( try: self.execute(sql, (name, compound, pose)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: return None except Exception as e: @@ -1050,7 +1074,7 @@ def insert_inspiration( try: self.execute(sql, (original, derivative)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if warn_duplicate: mrich.warning( f"Skipping existing inspiration: {original=} {derivative=}" @@ -1108,7 +1132,7 @@ def insert_scaffold( try: self.execute(sql, (scaffold, superstructure)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if warn_duplicate: mrich.warning( f"Skipping existing scaffold: {scaffold=} {superstructure=}" @@ -1199,7 +1223,7 @@ def insert_reactant( try: self.execute(sql, (amount, reaction.id, compound.id)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: mrich.warning(f"Skipping existing reactant: {reaction=} {compound=}") except Exception as e: @@ -1329,23 +1353,31 @@ def insert_target( """ - sql = """ + sql = f""" INSERT INTO target(target_name) - VALUES(?1) + VALUES({self.SQL_STRING_PLACEHOLDER}) + {self.sql_return_id_str("target")} """ try: self.execute(sql, (name,)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if warn_duplicate: mrich.warning(f"Skipping existing target with {name=}") + self.rollback() return None except Exception as e: mrich.error(e) + raise + + match self.engine: + case "sqlite3": + target_id = self.cursor.lastrowid + case "psycopg": + target_id = self.cursor.fetchone()[0] - target_id = self.cursor.lastrowid self.commit() return target_id @@ -1410,7 +1442,7 @@ def insert_feature( (family, target, chain_name, residue_name, residue_number, atom_names), ) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if warn_duplicate: mrich.warning(str(e)) @@ -1540,7 +1572,7 @@ def insert_component( ), ) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: if "UNIQUE constraint failed: component" in str(e): mrich.warning( @@ -1681,7 +1713,7 @@ def insert_interaction( ), ) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: mrich.error(e) if warn_duplicate: mrich.warning( @@ -1719,7 +1751,7 @@ def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: try: self.execute(sql, (target, name)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: mrich.warning(f"Skipping existing subsite for {target=} with {name=}") return None @@ -1779,7 +1811,7 @@ def insert_subsite_tag( try: self.execute(sql, (subsite_id, pose_id)) - except sqlite3.IntegrityError as e: + except self.ERROR_UNIQUE_VIOLATION as e: mrich.warning( f"Skipping existing subsite_tag for {subsite_id=} with {pose_id=}" ) @@ -4930,218 +4962,6 @@ def __rich__(self) -> str: return f"[bold underline]{self}" -class PostgresDatabase(Database): - """Wrapper to connect to a HIPPO Postgres database. - - .. attention:: - - :class:`.PostGresDatabase` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. - - """ - - def __init__( - self, - animal: "HIPPO", - username: str, - password: str, - host: str = "localhost", - port: int = 5432, - update_legacy: bool = False, - auto_compute_bfps: bool = True, - create_blank: bool = True, - check_legacy: bool = False, - debug: bool = True, - ) -> None: - """PostgresDatabase initialisation""" - - assert isinstance(username, str) - assert isinstance(password, str) - assert isinstance(port, int) - - if debug: - mrich.debug("hippo.PostgresDatabase.__init__()") - - self._username = username - self._password = password - self._port = port - self._host = host - - self._connection = None - self._cursor = None - self._animal = animal - self._auto_compute_bfps = auto_compute_bfps - - if debug: - mrich.debug(f"PostgresDatabase.username = {self.username}") - mrich.debug(f"PostgresDatabase.password = {self.password}") - mrich.debug(f"PostgresDatabase.host = {self.host}") - mrich.debug(f"PostgresDatabase.port = {self.port}") - - self.connect() - - if not self.table_names: - - if create_blank: - self.create_blank_db() - else: - mrich.error("Database is empty!", self.path) - raise ValueError( - "Database is empty! Check connection or run with create_blank=True" - ) - - if not check_legacy: - return - - self.check_schema(update=update_legacy) - - ### PROPERTIES - - @property - def path(self) -> None: - """PostgresDatabase path""" - # raise NotImplementedError("PostgresDatabase has no path") - return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}" - - @property - def username(self) -> str: - """PostgresDatabase username""" - return self._username - - @property - def password(self) -> str: - """PostgresDatabase password""" - return self._password - - @property - def host(self) -> str: - """PostgresDatabase host""" - return self._host - - @property - def port(self) -> int: - """PostgresDatabase port""" - return self._port - - @property - def table_names(self) -> list[str]: - """List of all the table names in the database""" - results = self.execute( - """ - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_type = 'BASE TABLE'; - """ - ).fetchall() - return [n for n, in results] - - ### GENERAL SQL - - def connect(self, debug: bool = True) -> None: - """Connect to the database""" - - if debug: - mrich.debug("hippo.PostgresDatabase.connect()") - - conn = None - - import psycopg - - try: - conn = psycopg.connect( - user=self.username, - host=self.host, - password=self.password, - port=self.port, - ) - - except Exception as e: - mrich.error("Could not connect to", self.path) - mrich.error(e) - raise - - self._connection = conn - self._cursor = conn.cursor() - - ### CREATE TABLES - - def create_table_compound(self) -> None: - """Create the compound table""" - mrich.debug("HIPPO.PostgresDatabase.create_table_compound()") - mrich.warning( - "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented(MOL)" - ) - - sql = """CREATE TABLE compound( - compound_id INTEGER PRIMARY KEY, - compound_inchikey TEXT, - compound_alias TEXT, - compound_smiles TEXT, - compound_base INTEGER, - -- compound_mol MOL, - compound_pattern_bfp bit(2048), - compound_morgan_bfp bit(2048), - compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES compound(compound_id), - CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey), - CONSTRAINT UC_compound_alias UNIQUE (compound_alias), - CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) - ); - """ - self.execute(sql) - - def create_table_pose(self) -> None: - """Create the pose table""" - mrich.debug("HIPPO.PostgresDatabase.create_table_pose()") - mrich.warning( - "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented(MOL)" - ) - - sql = """CREATE TABLE pose( - pose_id INTEGER PRIMARY KEY, - pose_inchikey TEXT, - pose_alias TEXT, - pose_smiles TEXT, - pose_reference INTEGER, - pose_path TEXT, - pose_compound INTEGER, - pose_target INTEGER, - -- pose_mol BLOB, - pose_fingerprint INTEGER, - pose_energy_score REAL, - pose_distance_score REAL, - pose_inspiration_score REAL, - pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_pose_alias UNIQUE (pose_alias), - CONSTRAINT UC_pose_path UNIQUE (pose_path) - ); - """ - - self.execute(sql) - - def create_table_pattern_bfp(self) -> None: - """Create the pattern_bfp table""" - mrich.warning( - "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented" - ) - - return - - mrich.debug("HIPPO.PostgresDatabase.create_table_pattern_bfp()") - - sql = """ - CREATE VIRTUAL TABLE compound_pattern_bfp - USING rdtree(compound_id, fp bits(2048)) - """ - - self.execute(sql) - - ### METHODS - - ### DUNDERS - - class LegacyDatabaseError(Exception): """This database is in a legacy format""" diff --git a/hippo/postgres.py b/hippo/postgres.py new file mode 100644 index 0000000..887efb8 --- /dev/null +++ b/hippo/postgres.py @@ -0,0 +1,249 @@ +import mcol +import mrich + +import psycopg + +from .db import Database + + +class PostgresDatabase(Database): + """Wrapper to connect to a HIPPO Postgres database. + + .. attention:: + + :class:`.PostGresDatabase` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. + + """ + + SQL_STRING_PLACEHOLDER = "%s" + SQL_PK_DATATYPE = "SERIAL" + + ERROR_UNIQUE_VIOLATION = psycopg.errors.UniqueViolation + + SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE compound( + compound_id SERIAL PRIMARY KEY, + compound_inchikey TEXT, + compound_alias TEXT, + compound_smiles TEXT, + compound_base INTEGER, + -- compound_mol MOL, + compound_pattern_bfp bit(2048), + compound_morgan_bfp bit(2048), + compound_metadata TEXT, + FOREIGN KEY (compound_base) REFERENCES compound(compound_id), + CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT UC_compound_alias UNIQUE (compound_alias), + CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) + ); + """ + + SQL_CREATE_TABLE_POSE = """CREATE TABLE pose( + pose_id SERIAL PRIMARY KEY, + pose_inchikey TEXT, + pose_alias TEXT, + pose_smiles TEXT, + pose_reference INTEGER, + pose_path TEXT, + pose_compound INTEGER, + pose_target INTEGER, + -- pose_mol BLOB, + pose_fingerprint INTEGER, + pose_energy_score REAL, + pose_distance_score REAL, + pose_inspiration_score REAL, + pose_metadata TEXT, + FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), + CONSTRAINT UC_pose_alias UNIQUE (pose_alias), + CONSTRAINT UC_pose_path UNIQUE (pose_path) + ); + """ + + def __init__( + self, + animal: "HIPPO", + username: str, + password: str, + host: str = "localhost", + port: int = 5432, + update_legacy: bool = False, + auto_compute_bfps: bool = True, + create_blank: bool = True, + check_legacy: bool = False, + debug: bool = True, + ) -> None: + """PostgresDatabase initialisation""" + + assert isinstance(username, str) + assert isinstance(password, str) + assert isinstance(port, int) + + if debug: + mrich.debug("hippo.PostgresDatabase.__init__()") + + self._username = username + self._password = password + self._port = port + self._host = host + + self._connection = None + self._cursor = None + self._animal = animal + self._auto_compute_bfps = auto_compute_bfps + self._engine = "psycopg" + + if debug: + mrich.debug(f"PostgresDatabase.username = {self.username}") + mrich.debug(f"PostgresDatabase.password = {self.password}") + mrich.debug(f"PostgresDatabase.host = {self.host}") + mrich.debug(f"PostgresDatabase.port = {self.port}") + + self.connect() + + if not self.table_names: + + if create_blank: + self.create_blank_db() + else: + mrich.error("Database is empty!", self.path) + raise ValueError( + "Database is empty! Check connection or run with create_blank=True" + ) + + if not check_legacy: + return + + self.check_schema(update=update_legacy) + + ### PROPERTIES + + @property + def path(self) -> None: + """PostgresDatabase path""" + # raise NotImplementedError("PostgresDatabase has no path") + return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}" + + @property + def username(self) -> str: + """PostgresDatabase username""" + return self._username + + @property + def password(self) -> str: + """PostgresDatabase password""" + return self._password + + @property + def host(self) -> str: + """PostgresDatabase host""" + return self._host + + @property + def port(self) -> int: + """PostgresDatabase port""" + return self._port + + @property + def table_names(self) -> list[str]: + """List of all the table names in the database""" + results = self.execute( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type = 'BASE TABLE'; + """ + ).fetchall() + return [n for n, in results] + + ### GENERAL SQL + + def connect(self, debug: bool = True) -> None: + """Connect to the database""" + + if debug: + mrich.debug("hippo.PostgresDatabase.connect()") + + conn = None + + try: + conn = psycopg.connect( + user=self.username, + host=self.host, + password=self.password, + port=self.port, + ) + + except Exception as e: + mrich.error("Could not connect to", self.path) + mrich.error(e) + raise + + self._connection = conn + self._cursor = conn.cursor() + + def execute( + self, sql, payload=None, *, retry: float | None = 1, debug: bool = False + ): + """Execute arbitrary SQL with retry if database is locked.""" + if debug: + mrich.debug(sql) + + # while True: + try: + if payload: + return self.cursor.execute(sql, payload) + else: + return self.cursor.execute(sql) + # except sqlite3.OperationalError as e: + # if "database is locked" in str(e) and retry: + # with mrich.clock( + # f"SQLite Database is locked, waiting {retry} second(s)..." + # ): + # time.sleep(retry) + # mrich.print("[debug]SQLite Database was locked, retrying...") + # continue # retry without recursion + # elif "syntax error" in str(e): + # mrich.error(sql) + # mrich.error(payload) + # raise + # else: + # raise + except Exception as e: + mrich.print(sql) + mrich.print(payload) + raise + + def rollback(self) -> None: + """rollback (not relevant for sqlite)""" + self.connection.rollback() + + def sql_return_id_str(self, key: str) -> str: + """Add this to SQL queries to return the entry primary key""" + return f"RETURNING {key}_id" + + ### CREATE TABLES + + def create_table_pattern_bfp(self) -> None: + """Create the pattern_bfp table""" + mrich.warning( + "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented" + ) + + return + + mrich.debug("HIPPO.PostgresDatabase.create_table_pattern_bfp()") + + sql = """ + CREATE VIRTUAL TABLE compound_pattern_bfp + USING rdtree(compound_id, fp bits(2048)) + """ + + self.execute(sql) + + ### METHODS + + ### DUNDERS + + def __str__(self): + """Unformatted string representation""" + return f"Database @ {self.path}" From b1ba4e426eeb877d1e60c200f2a834bd386a0d6c Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 13:35:01 +0000 Subject: [PATCH 021/163] fix sqlite --- hippo/animal.py | 8 ++------ hippo/db.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 5920d2a..2fce512 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -73,20 +73,16 @@ def __init__( mrich.var("db_path", db_path, color="file") - self._db_path = db_path - if copy_from: self._db = Database.copy_from( source=copy_from, - destination=self.db_path, + destination=db_path, animal=self, update_legacy=update_legacy, overwrite_existing=overwrite_existing, ) else: - self._db = Database( - self.db_path, animal=self, update_legacy=update_legacy - ) + self._db = Database(db_path, animal=self, update_legacy=update_legacy) self._compounds = CompoundTable(self.db) self._poses = PoseTable(self.db) diff --git a/hippo/db.py b/hippo/db.py index eda7f30..5c66bcc 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -120,7 +120,7 @@ def __init__( self._cursor = None self._animal = animal self._auto_compute_bfps = auto_compute_bfps - self._engine = "sqlite" + self._engine = "sqlite3" if debug: mrich.debug(f"Database.path = {self.path}") From 23bbc0c7cf993375c31140d644facc74a4053254 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 13:50:20 +0000 Subject: [PATCH 022/163] docstrings --- hippo/db.py | 9 +++++++-- hippo/postgres.py | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 5c66bcc..ec1b3be 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1,4 +1,4 @@ -"""Sqlite database wrapper class""" +"""SQLite database wrapper class""" import mcol import mrich @@ -151,7 +151,11 @@ def __init__( self.check_schema(update=update_legacy) - def check_schema(self, update: bool = False): + def check_schema(self, update: bool = False) -> None: + """Check the database for legacy schema and optionally update + + :param update: update the legacy database? + """ if "interaction" not in self.table_names: if not update: @@ -731,6 +735,7 @@ def create_table_subsite_tag(self) -> None: self.execute(sql) def sql_return_id_str(self, key: str) -> str: + """SQL suffix to return the lastrowid (for sqlite returns an empty string)""" return "" ### INSERTION diff --git a/hippo/postgres.py b/hippo/postgres.py index 887efb8..ff6a0cb 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -1,3 +1,5 @@ +"""PostgreSQL database wrapper class using psycopg3""" + import mcol import mrich From c61ac22957dfb8e6420547dad8e890872130355f Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 13:50:33 +0000 Subject: [PATCH 023/163] docstrings --- hippo/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hippo/__main__.py b/hippo/__main__.py index 122b0d2..6df65e2 100644 --- a/hippo/__main__.py +++ b/hippo/__main__.py @@ -154,6 +154,7 @@ def calculate_interactions( ) def calculate_interactions(pose: "Pose") -> None: + """Joblib wrapper for the calculation""" pose.calculate_interactions(force=force) tasks = [] From 5c9bc1e77c8da8573e9711688d5242ec717920df Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 14:26:41 +0000 Subject: [PATCH 024/163] postgres dev --- hippo/db.py | 181 +++++++++++++++++++++++++++++----------------- hippo/postgres.py | 36 ++++++++- 2 files changed, 147 insertions(+), 70 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index ec1b3be..a269325 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -59,7 +59,8 @@ class Database: ERROR_UNIQUE_VIOLATION = sqlite3.IntegrityError - SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE compound( + SQL_CREATE_TABLE_COMPOUND = """ + CREATE TABLE compound( compound_id INTEGER PRIMARY KEY, compound_inchikey TEXT, compound_alias TEXT, @@ -76,7 +77,8 @@ class Database: ); """ - SQL_CREATE_TABLE_POSE = """CREATE TABLE pose( + SQL_CREATE_TABLE_POSE = """ + CREATE TABLE pose( pose_id INTEGER PRIMARY KEY, pose_inchikey TEXT, pose_alias TEXT, @@ -97,6 +99,25 @@ class Database: ); """ + SQL_INSERT_COMPOUND = """ + INSERT INTO compound( + compound_inchikey, + compound_smiles, + compound_mol, + compound_pattern_bfp, + compound_morgan_bfp, + compound_alias + ) + VALUES( + :inchikey, + :smiles, + mol_from_smiles(:smiles), + mol_pattern_bfp(mol_from_smiles(:smiles), 2048), + mol_morgan_bfp(mol_from_smiles(:smiles), 2, 2048), + :alias + ) + """ + def __init__( self, path: Path, @@ -461,6 +482,10 @@ def rollback(self) -> None: # self.connection.rollback() pass + def get_lastrowid(self) -> int: + """Get ID of last inserted row""" + return self.cursor.lastrowid + ### CREATE TABLES def create_blank_db(self) -> None: @@ -767,47 +792,41 @@ def insert_compound( # generate the inchikey name inchikey = inchikey or inchikey_from_smiles(smiles) - sql = """ - INSERT INTO compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_pattern_bfp, - compound_morgan_bfp, - compound_alias - ) - VALUES( - ?1, - ?2, - mol_from_smiles(?2), - mol_pattern_bfp(mol_from_smiles(?2), 2048), - mol_morgan_bfp(mol_from_smiles(?2), 2, 2048), - ?3 - ) - """ - try: - self.execute(sql, (inchikey, smiles, alias)) + self.execute( + self.SQL_INSERT_COMPOUND, + dict(inchikey=inchikey, smiles=smiles, alias=alias), + ) except self.ERROR_UNIQUE_VIOLATION as e: - if "UNIQUE constraint failed: compound.compound_inchikey" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing inchikey "{inchikey}"' - ) - elif "UNIQUE constraint failed: compound.compound_smiles" in str(e): - if warn_duplicate: - mrich.warning(f'Skipping compound with existing smiles "{smiles}"') - elif "UNIQUE constraint failed: compound.compound_pattern_bfp" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing pattern binary fingerprint "{smiles}"' - ) - elif "UNIQUE constraint failed: compound.compound_morgan_bfp" in str(e): - if warn_duplicate: - mrich.warning( - f'Skipping compound with existing morgan binary fingerprint "{smiles}"' - ) + + constraints = [ + "compound_inchikey", + "compound_smiles", + "compound_pattern_bfp", + "compound_morgan_bfp", + ] + + message = str(e) + + for constraint in constraints: + + match self.engine: + case "sqlite3": + test_str = f"UNIQUE constraint failed: compound.{constraint}" + case "psycopg": + test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' + case _: + raise NotImplementedError + + if test_str in message: + if warn_duplicate: + mrich.warning( + f"Skipping compound with duplicate {constraint}, {smiles=}" + ) + self.rollback() + return None + else: mrich.error(e) @@ -816,7 +835,8 @@ def insert_compound( except Exception as e: mrich.error(e) - compound_id = self.cursor.lastrowid + compound_id = self.get_lastrowid() + if commit: self.commit() @@ -934,7 +954,7 @@ def insert_pose( mrich.error(f"Path cannot be resolved: {mcol.file}{path}") raise - sql = """ + sql = f""" INSERT INTO pose( pose_inchikey, pose_alias, @@ -946,7 +966,18 @@ def insert_pose( pose_energy_score, pose_distance_score ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + VALUES( + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER} + ) + {self.sql_return_id_str('pose')} """ try: @@ -966,23 +997,43 @@ def insert_pose( ) except self.ERROR_UNIQUE_VIOLATION as e: - if "UNIQUE constraint failed: pose.pose_path" in str(e): - if warn_duplicate: - mrich.warning(f'Could not insert pose with duplicate path "{path}"') - elif "UNIQUE constraint failed: pose.pose_alias" in str(e): - if warn_duplicate: - mrich.warning( - f'Could not insert pose with duplicate alias "{alias}"' - ) + + constraints = [ + "pose_path", + "pose_alias", + ] + + message = str(e) + + for constraint in constraints: + + match self.engine: + case "sqlite3": + test_str = f"UNIQUE constraint failed: pose.{constraint}" + case "psycopg": + test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' + case _: + raise NotImplementedError + + if test_str in message: + if warn_duplicate: + mrich.warning( + f"Skipping pose with duplicate {constraint}, {alias=}, {path=}" + ) + self.rollback() + return None + else: mrich.error(e) + return None except Exception as e: mrich.error(e) raise - pose_id = self.cursor.lastrowid + pose_id = self.get_lastrowid() + if commit: self.commit() @@ -1004,7 +1055,7 @@ def insert_tag( compound: int = None, pose: int = None, commit: bool = True, - ) -> int: + ) -> None: """Insert an entry into the tag table. .. attention:: @@ -1014,17 +1065,15 @@ def insert_tag( :param compound: associated :class:`.Compound` ID :param pose: associated :class:`.Pose` ID :param commit: commit the changes to the database (Default value = True) - :returns: the tag ID - """ assert bool(compound) ^ bool( pose ), "Exactly one of compound or pose arguments must have a value" - sql = """ + sql = f""" INSERT INTO tag(tag_name, tag_compound, tag_pose) - VALUES(?1, ?2, ?3) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -1036,10 +1085,8 @@ def insert_tag( except Exception as e: mrich.error(e) - tag_id = self.cursor.lastrowid if commit: self.commit() - return tag_id def insert_inspiration( self, @@ -1377,11 +1424,7 @@ def insert_target( mrich.error(e) raise - match self.engine: - case "sqlite3": - target_id = self.cursor.lastrowid - case "psycopg": - target_id = self.cursor.fetchone()[0] + target_id = self.get_lastrowid() self.commit() return target_id @@ -2112,19 +2155,23 @@ def update( sql = f""" UPDATE {table} - SET {key} = ? + SET {key} = {self.SQL_STRING_PLACEHOLDER} WHERE {table}_id = {id}; + {self.sql_return_id_str} """ try: self.execute(sql, (value,)) - except sqlite3.OperationalError as e: + except self.ERROR_UNIQUE_VIOLATION as e: mrich.var("sql", sql) + self.rollback() raise - id = self.cursor.lastrowid + id = self.get_lastrowid() + if commit: self.commit() + return id def update_all( diff --git a/hippo/postgres.py b/hippo/postgres.py index ff6a0cb..8b69cce 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -60,6 +60,26 @@ class PostgresDatabase(Database): ); """ + SQL_INSERT_COMPOUND = """ + INSERT INTO compound( + compound_inchikey, + compound_smiles, + -- compound_mol, + -- compound_pattern_bfp, + -- compound_morgan_bfp, + compound_alias + ) + VALUES( + %(inchikey)s, + %(smiles)s, + -- mol_from_smiles(%(smiles)s), + -- mol_pattern_bfp(mol_from_smiles(%(smiles)s), 2048), + -- mol_morgan_bfp(mol_from_smiles(%(smiles)s), 2, 2048), + %(alias)s + ) + RETURNING compound_id; + """ + def __init__( self, animal: "HIPPO", @@ -68,7 +88,7 @@ def __init__( host: str = "localhost", port: int = 5432, update_legacy: bool = False, - auto_compute_bfps: bool = True, + auto_compute_bfps: bool = False, create_blank: bool = True, check_legacy: bool = False, debug: bool = True, @@ -157,6 +177,12 @@ def table_names(self) -> list[str]: ).fetchall() return [n for n, in results] + @property + def total_changes(self) -> int: + """Return the current transaction ID as a proxy of sqlite's total_changes.""" + cursor = self.execute("SELECT txid_current()") + return cursor.fetchone()[0] + ### GENERAL SQL def connect(self, debug: bool = True) -> None: @@ -211,8 +237,8 @@ def execute( # else: # raise except Exception as e: - mrich.print(sql) - mrich.print(payload) + # mrich.print(sql) + # mrich.print(payload) raise def rollback(self) -> None: @@ -223,6 +249,10 @@ def sql_return_id_str(self, key: str) -> str: """Add this to SQL queries to return the entry primary key""" return f"RETURNING {key}_id" + def get_lastrowid(self) -> int: + """Get ID of last inserted row""" + return self.cursor.fetchone()[0] + ### CREATE TABLES def create_table_pattern_bfp(self) -> None: From 2ceeda92897274d317d669440e71f9014bd923dd Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 2 Dec 2025 14:45:41 +0000 Subject: [PATCH 025/163] fix sqlite test behaviour --- hippo/db.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index a269325..07a9805 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -399,7 +399,12 @@ def connect(self, debug: bool = True) -> None: self._cursor = conn.cursor() def execute( - self, sql, payload=None, *, retry: float | None = 1, debug: bool = False + self, + sql: str, + payload: tuple | list | dict | None = None, + *, + retry: float | None = 1, + debug: bool = False, ): """Execute arbitrary SQL with retry if database is locked.""" if debug: @@ -2157,7 +2162,7 @@ def update( UPDATE {table} SET {key} = {self.SQL_STRING_PLACEHOLDER} WHERE {table}_id = {id}; - {self.sql_return_id_str} + {self.sql_return_id_str(table)} """ try: From 940a530888c28d15afd89f88060f10e67084e420 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 5 Dec 2025 09:28:49 +0000 Subject: [PATCH 026/163] Create indexes #246 --- hippo/db.py | 147 ++++++++++++++++++++++++++++++++++++++++++++-- hippo/pose.py | 1 + hippo/postgres.py | 22 ++++++- 3 files changed, 162 insertions(+), 8 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 07a9805..1421e8c 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -126,6 +126,8 @@ def __init__( auto_compute_bfps: bool = True, create_blank: bool = True, check_legacy: bool = True, + create_indexes: bool = True, + update_indexes: bool = True, debug: bool = True, ) -> None: """Database initialisation""" @@ -167,10 +169,11 @@ def __init__( if create_blank: self.create_blank_db() - if not check_legacy: - return + if check_legacy: + self.check_schema(update=update_legacy) - self.check_schema(update=update_legacy) + if create_indexes: + self.create_indexes(update=update_indexes, debug=debug) def check_schema(self, update: bool = False) -> None: """Check the database for legacy schema and optionally update @@ -244,6 +247,127 @@ def check_schema(self, update: bool = False) -> None: self.update_legacy_pose_inspiration_score() + def create_indexes(self, update: bool = True, debug: bool = True) -> None: + """Create and optionally update indexes""" + + INDEXES = [ + ("pose", "pose_inchikey"), + ( + "pose", + "pose_smiles", + ), + ( + "pose", + "pose_reference", + ), + ( + "pose", + "pose_target", + ), + ( + "inspiration", + "inspiration_original", + ), + ( + "inspiration", + "inspiration_derivative", + ), + ( + "scaffold", + "scaffold_superstructure", + ), + ( + "reaction", + "reaction_type", + ), + ( + "reaction", + "reaction_product", + ), + ( + "reactant", + "reactant_compound", + ), + ( + "tag", + "tag_compound", + ), + ( + "tag", + "tag_pose", + ), + ( + "quote", + "quote_supplier", + ), + ( + "quote", + "quote_catalogue", + ), + ( + "quote", + "quote_entry", + ), + ( + "quote", + "quote_compound", + ), + ( + "route", + "route_product", + ), + # ("subsite", "subsite_name",), # not enough rows to matter? + ( + "subsite_tag", + "subsite_tag_pose", + ), + ( + "interaction", + "interaction_pose", + ), + # ("interaction", "interaction_type",), # mainly done on interaction_temp + ( + "component", + "component_route", + ), + ( + "component", + "component_ref", + ), + ( + "component", + ("component_type", "component_ref", "component_route"), + ), + ] + + existing = set(self.index_names()) + + for table, column in INDEXES: + + if isinstance(column, tuple): + name = ["index_", table, *(c.removeprefix(table) for c in column)] + name = "".join(name) + col_str = f"({', '.join(column)})" + + else: + assert column.startswith(table) + name = f"index_{column}" + col_str = f"({column})" + + if name in existing: + continue + + if debug: + mrich.debug(f"Creating {name}") + + self.execute(f"CREATE INDEX {name} ON {table} {col_str}") + + if update: + if debug: + mrich.debug("Updating indexes") + self.execute("ANALYZE") + self.commit() + @classmethod def copy_from( cls, @@ -545,8 +669,8 @@ def create_table_scaffold(self) -> None: sql = """CREATE TABLE scaffold( scaffold_base INTEGER, scaffold_superstructure INTEGER, - FOREIGN KEY (scaffold_base) REFERENCES pose(pose_id), - FOREIGN KEY (scaffold_superstructure) REFERENCES pose(pose_id), + FOREIGN KEY (scaffold_base) REFERENCES compound(compound_id), + FOREIGN KEY (scaffold_superstructure) REFERENCES compound(compound_id), CONSTRAINT UC_scaffold UNIQUE (scaffold_base, scaffold_superstructure) ); """ @@ -5001,6 +5125,19 @@ def column_names(self, table: str) -> list[str]: table_info = self.table_info(table) return [i[1] for i in table_info] + def index_names(self) -> list[str]: + """Get the index names""" + + cursor = self.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'index'; + """ + ) + + return [n for n, in cursor] + ### DUNDERS def __str__(self): diff --git a/hippo/pose.py b/hippo/pose.py index a70c32a..fd362a5 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -948,6 +948,7 @@ def angle_between(v1, v2): animal=None, create_blank=False, check_legacy=False, + create_indexes=False, debug=False, ) diff --git a/hippo/postgres.py b/hippo/postgres.py index 8b69cce..84d8220 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -91,6 +91,8 @@ def __init__( auto_compute_bfps: bool = False, create_blank: bool = True, check_legacy: bool = False, + create_indexes: bool = True, + update_indexes: bool = True, debug: bool = True, ) -> None: """PostgresDatabase initialisation""" @@ -131,10 +133,11 @@ def __init__( "Database is empty! Check connection or run with create_blank=True" ) - if not check_legacy: - return + if check_legacy: + self.check_schema(update=update_legacy) - self.check_schema(update=update_legacy) + if create_indexes: + self.create_indexes(update=update_indexes, debug=debug) ### PROPERTIES @@ -177,6 +180,19 @@ def table_names(self) -> list[str]: ).fetchall() return [n for n, in results] + def index_names(self) -> list[str]: + """Get the index names""" + + cursor = self.execute( + """ + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public'; + """ + ) + + return [n for n, in cursor] + @property def total_changes(self) -> int: """Return the current transaction ID as a proxy of sqlite's total_changes.""" From 3d4aa1ba3fcb52135822e3ddeaca53f96509fa6b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 5 Dec 2025 10:49:27 +0000 Subject: [PATCH 027/163] postgres dev #245 --- hippo/db.py | 322 +++++++++++++++++++++++++--------------------- hippo/postgres.py | 47 +++++-- 2 files changed, 212 insertions(+), 157 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 1421e8c..7c55225 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -56,6 +56,7 @@ class Database: SQL_STRING_PLACEHOLDER = "?" SQL_PK_DATATYPE = "INTEGER" + SQL_SCHEMA_PREFIX = "" ERROR_UNIQUE_VIOLATION = sqlite3.IntegrityError @@ -247,6 +248,8 @@ def check_schema(self, update: bool = False) -> None: self.update_legacy_pose_inspiration_score() + self.commit() + def create_indexes(self, update: bool = True, debug: bool = True) -> None: """Create and optionally update indexes""" @@ -360,7 +363,9 @@ def create_indexes(self, update: bool = True, debug: bool = True) -> None: if debug: mrich.debug(f"Creating {name}") - self.execute(f"CREATE INDEX {name} ON {table} {col_str}") + self.execute( + f"CREATE INDEX {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}" + ) if update: if debug: @@ -651,11 +656,11 @@ def create_table_inspiration(self) -> None: """Create the inspiration table""" mrich.debug("HIPPO.Database.create_table_inspiration()") - sql = """CREATE TABLE inspiration( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}inspiration( inspiration_original INTEGER, inspiration_derivative INTEGER, - FOREIGN KEY (inspiration_original) REFERENCES pose(pose_id), - FOREIGN KEY (inspiration_derivative) REFERENCES pose(pose_id), + FOREIGN KEY (inspiration_original) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), + FOREIGN KEY (inspiration_derivative) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), CONSTRAINT UC_inspiration UNIQUE (inspiration_original, inspiration_derivative) ); """ @@ -666,11 +671,11 @@ def create_table_scaffold(self) -> None: """Create the scaffold table""" mrich.debug("HIPPO.Database.create_table_scaffold()") - sql = """CREATE TABLE scaffold( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}scaffold( scaffold_base INTEGER, scaffold_superstructure INTEGER, - FOREIGN KEY (scaffold_base) REFERENCES compound(compound_id), - FOREIGN KEY (scaffold_superstructure) REFERENCES compound(compound_id), + FOREIGN KEY (scaffold_base) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), + FOREIGN KEY (scaffold_superstructure) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), CONSTRAINT UC_scaffold UNIQUE (scaffold_base, scaffold_superstructure) ); """ @@ -681,13 +686,13 @@ def create_table_reaction(self) -> None: """Create the reaction table""" mrich.debug("HIPPO.Database.create_table_reaction()") - sql = f"""CREATE TABLE reaction( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reaction( reaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, reaction_type TEXT, reaction_product INTEGER, reaction_product_yield REAL, reaction_metadata TEXT, - FOREIGN KEY (reaction_product) REFERENCES compound(compound_id) + FOREIGN KEY (reaction_product) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id) ); """ @@ -697,12 +702,12 @@ def create_table_reactant(self) -> None: """Create the reactant table""" mrich.debug("HIPPO.Database.create_table_reactant()") - sql = """CREATE TABLE reactant( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reactant( reactant_amount REAL, reactant_reaction INTEGER, reactant_compound INTEGER, - FOREIGN KEY (reactant_reaction) REFERENCES reaction(reaction_id), - FOREIGN KEY (reactant_compound) REFERENCES compound(compound_id), + FOREIGN KEY (reactant_reaction) REFERENCES {self.SQL_SCHEMA_PREFIX}reaction(reaction_id), + FOREIGN KEY (reactant_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), CONSTRAINT UC_reactant UNIQUE (reactant_reaction, reactant_compound) ); """ @@ -721,12 +726,12 @@ def create_table_tag(self) -> None: """Create the tag table""" mrich.debug("HIPPO.Database.create_table_tag()") - sql = """CREATE TABLE tag( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}tag( tag_name TEXT, tag_compound INTEGER, tag_pose INTEGER, - FOREIGN KEY (tag_compound) REFERENCES compound(compound_id), - FOREIGN KEY (tag_pose) REFERENCES pose(pose_id), + FOREIGN KEY (tag_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), + FOREIGN KEY (tag_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), CONSTRAINT UC_tag_compound UNIQUE (tag_name, tag_compound), CONSTRAINT UC_tag_pose UNIQUE (tag_name, tag_pose) ); @@ -738,7 +743,7 @@ def create_table_quote(self) -> None: """Create the quote table""" mrich.debug("HIPPO.Database.create_table_quote()") - sql = f"""CREATE TABLE quote( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}quote( quote_id {self.SQL_PK_DATATYPE} PRIMARY KEY, quote_smiles TEXT, quote_amount REAL, @@ -751,7 +756,7 @@ def create_table_quote(self) -> None: quote_purity REAL, quote_date TEXT, quote_compound INTEGER, - FOREIGN KEY (quote_compound) REFERENCES compound(compound_id), + FOREIGN KEY (quote_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), CONSTRAINT UC_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) ); """ @@ -761,7 +766,7 @@ def create_table_quote(self) -> None: def create_table_target(self) -> None: """Create the target table""" mrich.debug("HIPPO.Database.create_table_target()") - sql = f"""CREATE TABLE target( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}target( target_id {self.SQL_PK_DATATYPE} PRIMARY KEY, target_name TEXT, target_metadata TEXT, @@ -774,7 +779,7 @@ def create_table_target(self) -> None: def create_table_feature(self) -> None: """Create the feature table""" mrich.debug("HIPPO.Database.create_table_feature()") - sql = f"""CREATE TABLE feature( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}feature( feature_id {self.SQL_PK_DATATYPE} PRIMARY KEY, feature_family TEXT, feature_target INTEGER, @@ -782,7 +787,14 @@ def create_table_feature(self) -> None: feature_residue_name TEXT, feature_residue_number INTEGER, feature_atom_names TEXT, - CONSTRAINT UC_feature UNIQUE (feature_family, feature_target, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_names) + CONSTRAINT UC_feature UNIQUE ( + feature_family, + feature_target, + feature_chain_name, + feature_residue_number, + feature_residue_name, + feature_atom_names + ) ); """ @@ -791,9 +803,10 @@ def create_table_feature(self) -> None: def create_table_route(self) -> None: """Create the route table""" mrich.debug("HIPPO.Database.create_table_route()") - sql = f"""CREATE TABLE route( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}route( route_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - route_product INTEGER + route_product INTEGER, + FOREIGN KEY (route_product) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id) ); """ @@ -802,12 +815,13 @@ def create_table_route(self) -> None: def create_table_component(self) -> None: """Create the component table""" mrich.debug("HIPPO.Database.create_table_component()") - sql = f"""CREATE TABLE component( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}component( component_id {self.SQL_PK_DATATYPE} PRIMARY KEY, component_route INTEGER, component_type INTEGER, component_ref INTEGER, component_amount REAL, + FOREIGN KEY (component_route) REFERENCES {self.SQL_SCHEMA_PREFIX}route(route_id), CONSTRAINT UC_component UNIQUE (component_route, component_ref, component_type) ); """ @@ -834,7 +848,7 @@ def create_table_interaction( mrich.debug(f"HIPPO.Database.create_table_interaction({table=})") sql = f""" - CREATE TABLE {table}( + CREATE TABLE {self.SQL_SCHEMA_PREFIX}{table}( interaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, interaction_feature INTEGER NOT NULL, interaction_pose INTEGER NOT NULL, @@ -846,6 +860,8 @@ def create_table_interaction( interaction_distance REAL NOT NULL, interaction_angle REAL, interaction_energy REAL, + FOREIGN KEY (interaction_feature) REFERENCES {self.SQL_SCHEMA_PREFIX}feature(feature_id), + FOREIGN KEY (interaction_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), CONSTRAINT UC_interaction UNIQUE ( interaction_feature, interaction_pose, @@ -862,11 +878,12 @@ def create_table_subsite(self) -> None: """Create the subsite table""" mrich.debug("HIPPO.Database.create_table_subsite()") - sql = f"""CREATE TABLE subsite( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite( subsite_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_target INTEGER NOT NULL, subsite_name TEXT NOT NULL, subsite_metadata TEXT, + FOREIGN KEY (subsite_target) REFERENCES {self.SQL_SCHEMA_PREFIX}target(target_id), CONSTRAINT UC_subsite UNIQUE (subsite_target, subsite_name) ); """ @@ -877,11 +894,13 @@ def create_table_subsite_tag(self) -> None: """Create the subsite_tag table""" mrich.debug("HIPPO.Database.create_table_subsite_tag()") - sql = f"""CREATE TABLE subsite_tag( + sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite_tag( subsite_tag_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_tag_ref INTEGER NOT NULL, subsite_tag_pose INTEGER NOT NULL, subsite_tag_metadata TEXT, + FOREIGN KEY (subsite_tag_ref) REFERENCES {self.SQL_SCHEMA_PREFIX}subsite(subsite_id), + FOREIGN KEY (subsite_tag_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), CONSTRAINT UC_subsite_tag UNIQUE (subsite_tag_ref, subsite_tag_pose) ); """ @@ -1001,8 +1020,8 @@ def insert_compound_pattern_bfp(self, compound_id: int, commit: bool = True) -> """ - sql = """ - INSERT INTO compound_pattern_bfp(compound_id, fp) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}compound_pattern_bfp(compound_id, fp) VALUES(?1, ?2) """ @@ -1084,7 +1103,7 @@ def insert_pose( raise sql = f""" - INSERT INTO pose( + INSERT INTO {self.SQL_SCHEMA_PREFIX}pose( pose_inchikey, pose_alias, pose_smiles, @@ -1201,7 +1220,7 @@ def insert_tag( ), "Exactly one of compound or pose arguments must have a value" sql = f""" - INSERT INTO tag(tag_name, tag_compound, tag_pose) + INSERT INTO {self.SQL_SCHEMA_PREFIX}tag(tag_name, tag_compound, tag_pose) VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ @@ -1396,8 +1415,8 @@ def insert_reactant( assert isinstance(compound, Compound), f"incompatible {compound=}" assert isinstance(reaction, Reaction), f"incompatible {reaction=}" - sql = """ - INSERT INTO reactant(reactant_amount, reactant_reaction, reactant_compound) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}reactant(reactant_amount, reactant_reaction, reactant_compound) VALUES(?1, ?2, ?3) """ @@ -1485,7 +1504,7 @@ def insert_quote( date_str = "date()" sql = f""" - INSERT or REPLACE INTO quote( + INSERT or REPLACE INTO {self.SQL_SCHEMA_PREFIX}quote( quote_smiles, quote_amount, quote_supplier, @@ -1535,7 +1554,7 @@ def insert_target( """ sql = f""" - INSERT INTO target(target_name) + INSERT INTO {self.SQL_SCHEMA_PREFIX}target(target_name) VALUES({self.SQL_STRING_PLACEHOLDER}) {self.sql_return_id_str("target")} """ @@ -1599,8 +1618,8 @@ def insert_feature( else: family = "Unknown" - sql = """ - INSERT INTO feature( + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}feature( feature_family, feature_target, feature_chain_name, @@ -1723,8 +1742,8 @@ def insert_component( """ - sql = """ - INSERT INTO component(component_route, component_type, component_ref, component_amount) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}component(component_route, component_type, component_ref, component_amount) VALUES(:component_route, :component_type, :component_ref, :component_amount) """ @@ -1858,7 +1877,7 @@ def insert_interaction( # insertion sql = f""" - INSERT INTO {table}( + INSERT INTO {self.SQL_SCHEMA_PREFIX}{table}( interaction_feature, interaction_pose, interaction_type, @@ -1920,8 +1939,8 @@ def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: assert isinstance(target, int) assert isinstance(name, str) - sql = """ - INSERT INTO subsite(subsite_target, subsite_name) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite(subsite_target, subsite_name) VALUES(?1, ?2) """ @@ -1980,8 +1999,8 @@ def insert_subsite_tag( assert isinstance(subsite_id, int) - sql = """ - INSERT INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite_tag(subsite_tag_ref, subsite_tag_pose) VALUES(?1, ?2) """ @@ -2024,7 +2043,7 @@ def select( """ - sql = f"SELECT {query} FROM {table}" + sql = f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table}" try: self.execute(sql) @@ -2109,9 +2128,11 @@ def select_where( where_str = key if sort: - sql = f"SELECT {query} FROM {table} WHERE {where_str} ORDER BY {sort}" + sql = f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str} ORDER BY {sort}" else: - sql = f"SELECT {query} FROM {table} WHERE {where_str}" + sql = ( + f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str}" + ) try: self.execute(sql) @@ -2125,10 +2146,12 @@ def select_where( result = self.cursor.fetchone() if not result and none == "error": - mrich.error(f"No entry in {table} with {where_str}") + mrich.error(f"No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}") return None elif not result and none == "exception": - raise ValueError(f"No entry in {table} with {where_str}") + raise ValueError( + f"No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}" + ) # if not result: # raise ValueError(f"No entry in {table} with {where_str}") @@ -2207,11 +2230,11 @@ def delete_where( if isinstance(value, str): value = f"'{value}'" - sql = f"DELETE FROM {table} WHERE {table}_{key}={value}" + sql = f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_{key}={value}" else: - sql = f"DELETE FROM {table} WHERE {key}" + sql = f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {key}" try: result = self.execute(sql) @@ -2251,7 +2274,7 @@ def delete_reactions(self) -> None: tables = ["reaction", "reactant", "route", "component"] for table in tables: - self.execute(f"DELETE FROM {table};") + self.execute(f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table};") self.commit() def delete_subsites(self) -> None: @@ -2283,10 +2306,10 @@ def update( """ sql = f""" - UPDATE {table} + UPDATE {self.SQL_SCHEMA_PREFIX}{table} SET {key} = {self.SQL_STRING_PLACEHOLDER} - WHERE {table}_id = {id}; - {self.sql_return_id_str(table)} + WHERE {table}_id = {id} + {self.sql_return_id_str(table)}; """ try: @@ -2321,7 +2344,7 @@ def update_all( """ sql = f""" - UPDATE {table} + UPDATE {self.SQL_SCHEMA_PREFIX}{table} SET {key} = ? """ @@ -2362,8 +2385,8 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: cursor = source_db.execute(sql) records = cursor.fetchall() - sql = """ - INSERT OR IGNORE INTO interaction( + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}interaction( interaction_feature, interaction_pose, interaction_type, @@ -2393,8 +2416,8 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: else: - sql = """ - INSERT OR IGNORE INTO interaction( + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}interaction( interaction_feature, interaction_pose, interaction_type, @@ -2406,7 +2429,7 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: interaction_angle, interaction_energy ) - SELECT interaction_feature, + SELECT {self.SQL_SCHEMA_PREFIX}interaction_feature, interaction_pose, interaction_type, interaction_family, @@ -2431,7 +2454,7 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: cursor = self.execute( f""" - INSERT OR IGNORE INTO temp_interaction( + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}temp_interaction( interaction_feature, interaction_pose, interaction_type, @@ -2453,7 +2476,7 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: interaction_distance, interaction_angle, interaction_energy - FROM interaction + FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose = {pose_id} """ ) @@ -2469,8 +2492,8 @@ def migrate_legacy_scaffolds(self) -> int: mrich.debug("HIPPO.Database.migrate_legacy_scaffolds()") cursor = self.execute( - """ - INSERT INTO scaffold(scaffold_base, scaffold_superstructure) + f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) SELECT compound_base, compound_id FROM compound WHERE compound_base IS NOT NULL """ @@ -2485,8 +2508,8 @@ def update_legacy_routes(self) -> None: # add column - sql = """ - ALTER TABLE component + sql = f""" + ALTER TABLE {self.SQL_SCHEMA_PREFIX}component ADD component_amount REAL; """ @@ -2494,8 +2517,8 @@ def update_legacy_routes(self) -> None: # set values - sql = """ - UPDATE component + sql = f""" + UPDATE {self.SQL_SCHEMA_PREFIX}component SET component_amount = :component_amount WHERE component_type = :component_type; """ @@ -2507,8 +2530,8 @@ def update_legacy_routes(self) -> None: def update_legacy_reaction_metadata(self) -> None: """Add reaction_metadata column""" - sql = """ - ALTER TABLE reaction + sql = f""" + ALTER TABLE {self.SQL_SCHEMA_PREFIX}reaction ADD reaction_metadata TEXT; """ @@ -2517,8 +2540,8 @@ def update_legacy_reaction_metadata(self) -> None: def update_legacy_pose_inspiration_score(self) -> None: """Add pose_inspiration_score column""" - sql = """ - ALTER TABLE pose + sql = f""" + ALTER TABLE {self.SQL_SCHEMA_PREFIX}pose ADD pose_inspiration_score REAL; """ @@ -2527,9 +2550,9 @@ def update_legacy_pose_inspiration_score(self) -> None: def update_compound_pattern_bfp_table(self): """Update the compound pattern BFP table""" self.execute( - """ - INSERT INTO compound_pattern_bfp - SELECT c.compound_id, c.compound_pattern_bfp FROM compound AS c + f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}compound_pattern_bfp + SELECT c.compound_id, c.compound_pattern_bfp FROM {self.SQL_SCHEMA_PREFIX}compound AS c LEFT JOIN compound_pattern_bfp as fp ON c.compound_id = fp.compound_id WHERE fp.compound_id IS NULL @@ -2543,9 +2566,9 @@ def prune_duplicate_routes(self) -> None: from collections import Counter - sql = """ - SELECT route_id, route_product, component_ref, component_type FROM route - INNER JOIN component ON route_id = component_route + sql = f""" + SELECT route_id, route_product, component_ref, component_type FROM {self.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.SQL_SCHEMA_PREFIX}component ON route_id = component_route """ records = self.execute(sql).fetchall() @@ -2596,8 +2619,8 @@ def reinitialise_molecules(self): mrich.var("#compounds", self.count("compound")) - sql = """ - UPDATE your_table_name + sql = f""" + UPDATE {self.SQL_SCHEMA_PREFIX}compound SET compound_mol = mol_from_smiles(compound_smiles); """ @@ -2614,9 +2637,9 @@ def fix_incorrect_pose_compound_assignments(self): count = self.count_where(table="pose", key="mol", value="NOT null") - sql = """ + sql = f""" SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) - FROM pose + FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_mol IS NOT null """ @@ -2697,8 +2720,8 @@ def register_compounds( if self.auto_compute_bfps: - sql = """ - INSERT OR IGNORE INTO compound( + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}compound( compound_inchikey, compound_smiles, compound_mol, @@ -2716,8 +2739,8 @@ def register_compounds( else: - sql = """ - INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol) + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}compound(compound_inchikey, compound_smiles, compound_mol) VALUES(?1, ?2, mol_from_smiles(?2)) """ @@ -2760,8 +2783,8 @@ def register_poses(self, dicts: list[dict]) -> set[int]: ### POSES - sql = """ - INSERT OR IGNORE INTO pose( + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}pose( pose_inchikey, pose_smiles, pose_alias, @@ -2856,10 +2879,10 @@ def calculate_all_scaffolds(self) -> None: self.commit() - sql = """ - INSERT OR IGNORE INTO scaffold + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}scaffold SELECT ?1, c.compound_id - FROM compound AS c, compound_pattern_bfp AS fp + FROM {self.SQL_SCHEMA_PREFIX}compound AS c, compound_pattern_bfp AS fp WHERE c.compound_id = fp.compound_id AND c.compound_id <> ?1 AND mol_is_substruct(c.compound_mol, ?2) @@ -3013,7 +3036,10 @@ def calculate_all_murcko_scaffolds( mrich.var("#murcko scaffold relations", len(pairs)) self.executemany( - """INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)""", + f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}scaffold (scaffold_base, scaffold_superstructure) + VALUES (?,?) + """, pairs, ) @@ -3056,10 +3082,10 @@ def calculate_all_murcko_scaffolds( def set_derivative_subsites(self, commit: bool = True) -> None: """Propagate all subsite assignments from inspirations to their derivatives""" - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - SELECT subsite_tag_ref, inspiration_derivative FROM subsite_tag - INNER JOIN inspiration + sql = f""" + INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}subsite_tag(subsite_tag_ref, subsite_tag_pose) + SELECT subsite_tag_ref, inspiration_derivative FROM {self.SQL_SCHEMA_PREFIX}subsite_tag + INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON subsite_tag_pose = inspiration_original """ @@ -3576,7 +3602,9 @@ def get_route_products(self) -> "CompoundSet | None": """Get a :class:`.CompoundSet` of all route products""" from .cset import CompoundSet - records = self.execute("SELECT DISTINCT route_product FROM route").fetchall() + records = self.execute( + f"SELECT DISTINCT route_product FROM {self.SQL_SCHEMA_PREFIX}route" + ).fetchall() if not records: return None return CompoundSet(self, [i for i, in records]) @@ -3604,7 +3632,7 @@ def get_product_id_routes_dict(self) -> dict[int, set[int]]: def get_compound_id_pose_ids_dict(self, cset: "CompoundSet") -> dict[int, set]: """Get a dictionary mapping :class:`.Compound` ID's to their associated :class:`.Pose` ID's""" records = self.execute( - f"SELECT pose_compound, pose_id FROM pose WHERE pose_compound IN {cset.str_ids}" + f"SELECT pose_compound, pose_id FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_compound IN {cset.str_ids}" ).fetchall() d = {} @@ -3619,7 +3647,7 @@ def get_compound_id_suppliers_dict( ) -> dict[int, set[str]]: """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" records = self.execute( - f"SELECT quote_compound, quote_supplier FROM quote WHERE quote_compound IN {cset.str_ids}" + f"SELECT quote_compound, quote_supplier FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {cset.str_ids}" ).fetchall() d = {} @@ -3637,7 +3665,7 @@ def get_compound_id_smiles_dict( """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" if cset: - sql = f"SELECT compound_id, compound_smiles FROM compound WHERE compound_id IN {cset.str_ids}" + sql = f"SELECT compound_id, compound_smiles FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {cset.str_ids}" else: sql = "SELECT compound_id, compound_smiles FROM compound" @@ -3727,12 +3755,12 @@ def get_compound_cluster_dict( if (cset is not None and not fractions) or (fraction_reference is not None): sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM scaffold + SELECT scaffold_superstructure, scaffold_base FROM {self.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {cset.str_ids} """ else: sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM scaffold + SELECT scaffold_superstructure, scaffold_base FROM {self.SQL_SCHEMA_PREFIX}scaffold """ records = self.execute(sql).fetchall() @@ -3886,7 +3914,7 @@ def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: def get_pose_id_interaction_ids_dict(self, pset: "PoseSet") -> dict[int, set]: """Get a dictionary mapping :class:`.Pose` ID's to their associated :class:`.Interaction` ID's""" records = self.execute( - f"SELECT interaction_pose, interaction_id FROM interaction WHERE interaction_pose IN {pset.str_ids}" + f"SELECT interaction_pose, interaction_id FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose IN {pset.str_ids}" ).fetchall() d = {} @@ -3902,7 +3930,7 @@ def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int if pset: records = self.execute( f""" - SELECT pose_id, pose_alias FROM pose + SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() @@ -3925,7 +3953,7 @@ def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, s if pset: records = self.execute( f""" - SELECT pose_alias, pose_path FROM pose + SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {pset.str_ids}""" ).fetchall() @@ -3946,7 +3974,7 @@ def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int if pset: records = self.execute( f""" - SELECT pose_id, pose_alias FROM pose + SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() @@ -3969,7 +3997,7 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] if pset: records = self.execute( f""" - SELECT pose_id, pose_path FROM pose + SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() @@ -4048,7 +4076,7 @@ def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set] """Get a dictionary mapping :class:`.Pose` ID's to lists of `(interaction_type, feature_id)` tuples describing their interactions""" sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM interaction + SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM {self.SQL_SCHEMA_PREFIX}interaction INNER JOIN feature ON interaction_feature = feature_id WHERE interaction_pose IN {pset.str_ids} """ @@ -4066,10 +4094,10 @@ def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set] def get_compound_id_inspiration_ids_dict(self) -> dict[int, set]: """Get a dictionary mapping :class:`.Compound` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - sql = """ - SELECT compound_id, pose_id, inspiration_original FROM compound - INNER JOIN pose ON compound_id = pose_compound - INNER JOIN inspiration ON pose_id = inspiration_derivative + sql = f""" + SELECT compound_id, pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}compound + INNER JOIN {self.SQL_SCHEMA_PREFIX}pose ON compound_id = pose_compound + INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative """ with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): @@ -4091,16 +4119,16 @@ def get_pose_id_inspiration_ids_dict( if pset: sql = f""" - SELECT pose_id, inspiration_original FROM pose - INNER JOIN inspiration ON pose_id = inspiration_derivative + SELECT pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}pose + INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative WHERE pose_id IN {pset.str_ids} """ else: - sql = """ - SELECT pose_id, inspiration_original FROM pose - INNER JOIN inspiration ON pose_id = inspiration_derivative + sql = f""" + SELECT pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}pose + INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative """ with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): @@ -4116,7 +4144,7 @@ def get_pose_id_inspiration_ids_dict( def get_inspiration_tuples(self) -> list[int, int]: """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - sql = """SELECT inspiration_original, inspiration_derivative FROM inspiration""" + sql = f"""SELECT inspiration_original, inspiration_derivative FROM {self.SQL_SCHEMA_PREFIX}inspiration""" return self.execute(sql).fetchall() def get_compound_id_obj_dict(self, cset: "CompoundSet") -> "dict[id, Compound]": @@ -4197,7 +4225,7 @@ def get_reaction_map_from_products( records = self.execute( f""" SELECT reaction_type, reaction_product, reaction_id, reactant_compound - FROM reaction INNER JOIN reactant + FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_id = reactant_reaction WHERE reaction_product IN {str_ids} """ @@ -4239,7 +4267,7 @@ def get_possible_reaction_ids( SELECT reactant_reaction, CASE WHEN reactant_compound IN {compound_ids_str} THEN reactant_compound END AS [possible_reactant] - FROM reactant + FROM {self.SQL_SCHEMA_PREFIX}reactant ) , possible_reactions AS ( @@ -4336,11 +4364,11 @@ def get_unsolved_reaction_tree( # all intermediates ids = self.execute( - """ + f""" SELECT DISTINCT reaction_product - FROM reaction - INNER JOIN reactant - ON reaction.reaction_product = reactant.reactant_compound + FROM {self.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant + ON reaction_product = reactant_compound """ ).fetchall() ids = [q for q, in ids] @@ -4386,7 +4414,8 @@ def get_reaction_price_estimate( f""" WITH unit_prices AS ( - SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price FROM quote + SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price + FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {reactants.str_ids} GROUP BY quote_compound ) @@ -4507,9 +4536,9 @@ def get_scaffold_similarity_dict( ) -> list[dict]: """Get a dictionary mapping scaffold :class:`.Compound` IDs to their superstructure's IDs""" - sql = """ + sql = f""" SELECT scaffold_base as a, scaffold_superstructure as b, bfp_tanimoto(c.fp, d.fp) AS t - FROM scaffold + FROM {self.SQL_SCHEMA_PREFIX}scaffold INNER JOIN compound_pattern_bfp AS c ON a = c.compound_id INNER JOIN compound_pattern_bfp AS d ON b = d.compound_id """ @@ -4531,9 +4560,11 @@ def get_reactant_product_tuples( ) -> set[tuple[int, int]]: """Get tuples of (reactant, product) :class:`.Compound` IDs""" - sql = """ - SELECT reactant_compound, reaction_product FROM reactant - INNER JOIN reaction ON reactant_reaction = reaction_id + sql = f""" + SELECT reactant_compound, reaction_product + FROM {self.SQL_SCHEMA_PREFIX}reactant + INNER JOIN {self.SQL_SCHEMA_PREFIX}reaction + ON reactant_reaction = reaction_id """ if compound_ids: @@ -4553,8 +4584,9 @@ def get_scaffold_tuples( ) -> set[tuple[int, int]]: """Get tuples of (reactant, product) :class:`.Compound` IDs""" - sql = """ - SELECT scaffold_base, scaffold_superstructure FROM scaffold + sql = f""" + SELECT scaffold_base, scaffold_superstructure + FROM {self.SQL_SCHEMA_PREFIX}scaffold """ if compound_ids: @@ -4594,14 +4626,14 @@ def query_substructure( if fast: sql = f""" SELECT compound.compound_id, compound.compound_inchikey - FROM compound, compound_pattern_bfp AS bfp - WHERE compound.compound_id = bfp.compound_id + FROM {self.SQL_SCHEMA_PREFIX}compound, compound_pattern_bfp AS bfp + WHERE {self.SQL_SCHEMA_PREFIX}compound.compound_id = {self.SQL_SCHEMA_PREFIX}bfp.compound_id AND mol_is_substruct(compound.compound_mol, {func}(?)) """ else: sql = f""" - SELECT compound_id, compound_inchikey FROM compound + SELECT compound_id, compound_inchikey FROM {self.SQL_SCHEMA_PREFIX}compound WHERE mol_is_substruct(compound_mol, {func}(?)) """ @@ -4670,7 +4702,7 @@ def query_most_similar( sql = f""" WITH subset AS ( SELECT compound_id, fp - FROM compound + FROM {self.SQL_SCHEMA_PREFIX}compound JOIN compound_pattern_bfp USING (compound_id) WHERE compound_id IN {subset.str_ids} ) @@ -4688,7 +4720,7 @@ def query_most_similar( sql = f""" WITH subset AS ( SELECT compound_id, mol_{fp}_bfp(compound_mol, {morgan_radius}, {bits}) AS fp - FROM compound + FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {subset.str_ids} ) @@ -4705,7 +4737,7 @@ def query_most_similar( sql = f""" WITH subset AS ( SELECT compound_id, mol_{fp}_bfp(compound_mol, {bits}) AS fp - FROM compound + FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {subset.str_ids} ) @@ -4758,7 +4790,7 @@ def query_similarity( SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM compound + FROM {self.SQL_SCHEMA_PREFIX}compound JOIN compound_pattern_bfp AS mfp USING(compound_id) WHERE mfp.compound_id @@ -4782,7 +4814,7 @@ def query_similarity( SELECT compound_id, bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM compound + FROM {self.SQL_SCHEMA_PREFIX}compound JOIN compound_pattern_bfp AS mfp USING(compound_id) WHERE mfp.compound_id @@ -4847,7 +4879,7 @@ def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: pairs = self.execute( f""" SELECT {table}_id, {table}_metadata - FROM {table} + FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_metadata LIKE '%"{key}": "%' """ ).fetchall() @@ -4871,7 +4903,7 @@ def count( """ - sql = f"SELECT COUNT(1) FROM {table};" + sql = f"SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table};" self.execute(sql) return self.cursor.fetchone()[0] @@ -4893,7 +4925,7 @@ def count_where( else: where_str = key - sql = f"SELECT COUNT(1) FROM {table} WHERE {where_str};" + sql = f"SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str};" self.execute(sql) return self.cursor.fetchone()[0] @@ -5094,7 +5126,7 @@ def table_df( column_names = self.column_names(table) - self.execute(f"SELECT * FROM {table}") + self.execute(f"SELECT * FROM {self.SQL_SCHEMA_PREFIX}{table}") for record in self.cursor: d = {} @@ -5117,7 +5149,7 @@ def table_info( """ - self.execute(f"PRAGMA table_info({table})") + self.execute(f"PRAGMA table_info({self.SQL_SCHEMA_PREFIX}{table})") return self.cursor.fetchall() def column_names(self, table: str) -> list[str]: diff --git a/hippo/postgres.py b/hippo/postgres.py index 84d8220..e71894c 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -19,27 +19,29 @@ class PostgresDatabase(Database): SQL_STRING_PLACEHOLDER = "%s" SQL_PK_DATATYPE = "SERIAL" + SQL_SCHEMA = "hippo" + SQL_SCHEMA_PREFIX = f"{SQL_SCHEMA}." ERROR_UNIQUE_VIOLATION = psycopg.errors.UniqueViolation - SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE compound( + SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE hippo.compound( compound_id SERIAL PRIMARY KEY, compound_inchikey TEXT, compound_alias TEXT, compound_smiles TEXT, compound_base INTEGER, - -- compound_mol MOL, + compound_mol MOL, compound_pattern_bfp bit(2048), compound_morgan_bfp bit(2048), compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES compound(compound_id), + FOREIGN KEY (compound_base) REFERENCES hippo.compound(compound_id), CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey), CONSTRAINT UC_compound_alias UNIQUE (compound_alias), CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) ); """ - SQL_CREATE_TABLE_POSE = """CREATE TABLE pose( + SQL_CREATE_TABLE_POSE = """CREATE TABLE hippo.pose( pose_id SERIAL PRIMARY KEY, pose_inchikey TEXT, pose_alias TEXT, @@ -48,23 +50,23 @@ class PostgresDatabase(Database): pose_path TEXT, pose_compound INTEGER, pose_target INTEGER, - -- pose_mol BLOB, + pose_mol MOL, pose_fingerprint INTEGER, pose_energy_score REAL, pose_distance_score REAL, pose_inspiration_score REAL, pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), + FOREIGN KEY (pose_compound) REFERENCES hippo.compound(compound_id), CONSTRAINT UC_pose_alias UNIQUE (pose_alias), CONSTRAINT UC_pose_path UNIQUE (pose_path) ); """ SQL_INSERT_COMPOUND = """ - INSERT INTO compound( + INSERT INTO hippo.compound( compound_inchikey, compound_smiles, - -- compound_mol, + compound_mol, -- compound_pattern_bfp, -- compound_morgan_bfp, compound_alias @@ -72,7 +74,7 @@ class PostgresDatabase(Database): VALUES( %(inchikey)s, %(smiles)s, - -- mol_from_smiles(%(smiles)s), + mol_from_smiles(%(smiles)s), -- mol_pattern_bfp(mol_from_smiles(%(smiles)s), 2048), -- mol_morgan_bfp(mol_from_smiles(%(smiles)s), 2, 2048), %(alias)s @@ -87,12 +89,13 @@ def __init__( password: str, host: str = "localhost", port: int = 5432, + dbname: str = "hippo", update_legacy: bool = False, auto_compute_bfps: bool = False, create_blank: bool = True, check_legacy: bool = False, create_indexes: bool = True, - update_indexes: bool = True, + update_indexes: bool = False, debug: bool = True, ) -> None: """PostgresDatabase initialisation""" @@ -114,6 +117,7 @@ def __init__( self._animal = animal self._auto_compute_bfps = auto_compute_bfps self._engine = "psycopg" + self._dbname = dbname if debug: mrich.debug(f"PostgresDatabase.username = {self.username}") @@ -126,6 +130,7 @@ def __init__( if not self.table_names: if create_blank: + self.execute("CREATE SCHEMA IF NOT EXISTS hippo;") self.create_blank_db() else: mrich.error("Database is empty!", self.path) @@ -152,6 +157,11 @@ def username(self) -> str: """PostgresDatabase username""" return self._username + @property + def dbname(self) -> str: + """PostgresDatabase dbname""" + return self._dbname + @property def password(self) -> str: """PostgresDatabase password""" @@ -171,10 +181,10 @@ def port(self) -> int: def table_names(self) -> list[str]: """List of all the table names in the database""" results = self.execute( - """ + f""" SELECT table_name FROM information_schema.tables - WHERE table_schema = 'public' + WHERE table_schema = '{self.SQL_SCHEMA}' AND table_type = 'BASE TABLE'; """ ).fetchall() @@ -215,6 +225,7 @@ def connect(self, debug: bool = True) -> None: host=self.host, password=self.password, port=self.port, + dbname=self.dbname, ) except Exception as e: @@ -290,6 +301,18 @@ def create_table_pattern_bfp(self) -> None: ### METHODS + def _clear_schema(self) -> None: + """Empty the Database schema entirely and recreate it""" + + self.execute( + f""" + DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE; + CREATE SCHEMA {self.SQL_SCHEMA}; + """ + ) + + self.commit() + ### DUNDERS def __str__(self): From ae6662e5cd258b700c54cf3d6df8dc6b56b8007d Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 5 Dec 2025 18:48:18 +0000 Subject: [PATCH 028/163] postgres dev #245 --- hippo/db.py | 108 ++++++++++++++++++++++++++-------------------- hippo/pose.py | 2 +- hippo/postgres.py | 36 +++++++++++++++- 3 files changed, 97 insertions(+), 49 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 7c55225..09defdc 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -28,22 +28,6 @@ "molecular_weight": "mol_amw", } -POSE_FIELDS = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", -] - class Database: """Wrapper to connect to the HIPPO sqlite database. @@ -119,6 +103,22 @@ class Database: ) """ + POSE_FIELDS = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", + ] + def __init__( self, path: Path, @@ -364,7 +364,7 @@ def create_indexes(self, update: bool = True, debug: bool = True) -> None: mrich.debug(f"Creating {name}") self.execute( - f"CREATE INDEX {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}" + f"CREATE INDEX IF NOT EXISTS {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}" ) if update: @@ -1627,7 +1627,15 @@ def insert_feature( feature_residue_number, feature_atom_names ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6) + VALUES( + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER} + ) + {self.sql_return_id_str('feature')} """ atom_names = " ".join(sorted(atom_names)) @@ -1649,12 +1657,15 @@ def insert_feature( mrich.var("residue_number", residue_number) mrich.var("atom_names", atom_names) + self.rollback() + return None except Exception as e: mrich.error(e) - feature_id = self.cursor.lastrowid + feature_id = self.get_lastrowid() + if commit: self.commit() return feature_id @@ -1745,6 +1756,7 @@ def insert_component( sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}component(component_route, component_type, component_ref, component_amount) VALUES(:component_route, :component_type, :component_ref, :component_amount) + {self.sql_return_id_str('component')} """ route = int(route) @@ -1770,15 +1782,14 @@ def insert_component( except self.ERROR_UNIQUE_VIOLATION as e: - if "UNIQUE constraint failed: component" in str(e): - mrich.warning( - f"Did not add existing component={ref} (type={component_type}) to {route=}" - ) - return None - else: - raise + mrich.warning( + f"Did not add existing component={ref} (type={component_type}) to {route=}" + ) - component_id = self.cursor.lastrowid + self.rollback() + return None + + component_id = self.get_lastrowid() if commit: self.commit() @@ -2357,13 +2368,15 @@ def update_all( if commit: self.commit() - ### COPYING / MIGRATION + def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: + """Update the molecule stored for a specific pose""" - def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: - """Copy the records from the 'temp_interaction' table to the 'interaction' table + self.update(table="pose", id=pose_id, key="pose_mol", value=mol.ToBinary()) - :returns: ID of the last inserted :class:`.Interaction` - """ + ### COPYING / MIGRATION + + def copy_temp_interactions(self, source_db: "Database | None" = None) -> None: + """Copy the records from the 'temp_interaction' table to the 'interaction' table""" if source_db is not None: @@ -2399,16 +2412,16 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: interaction_energy ) VALUES( - ?1, - ?2, - ?3, - ?4, - ?5, - ?6, - ?7, - ?8, - ?9, - ?10 + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER} ) """ @@ -2444,8 +2457,6 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> int: cursor = self.execute(sql) - return cursor.lastrowid - def copy_interactions_to_temp(self, pose_id: int) -> int: """Copy the records from the 'interaction' table to the 'temp_interaction' table for a given pose_id @@ -3202,6 +3213,7 @@ def get_pose( id: int | None = None, inchikey: str = None, alias: str = None, + debug: bool = False, ) -> Pose: """Get a pose using one of the following fields: ['id', 'inchikey', 'alias'] @@ -3224,9 +3236,13 @@ def get_pose( mrich.error(f"Invalid {id=}") return None - query = ", ".join(POSE_FIELDS) + query = ", ".join(self.POSE_FIELDS) entry = self.select_where(query=query, table="pose", key="id", value=id) + + if debug: + mrich.print(entry) + pose = Pose(self, *entry) return pose @@ -3237,7 +3253,7 @@ def get_poses( ) -> list[Pose]: """Get list of initialised :class:`.Pose` objects with given ID's""" - query = ", ".join(POSE_FIELDS) + query = ", ".join(self.POSE_FIELDS) str_ids = str(tuple(ids)).replace(",)", ")") diff --git a/hippo/pose.py b/hippo/pose.py index fd362a5..5e4270e 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -350,7 +350,7 @@ def mol(self, m): from .tools import sanitise_mol self._mol = sanitise_mol(m) - self.db.update(table="pose", id=self.id, key="pose_mol", value=m.ToBinary()) + self.db.update_pose_mol(pose_id=self.id, mol=self._mol) @property def protonated_mol(self) -> "rdkit.Chem.Mol": diff --git a/hippo/postgres.py b/hippo/postgres.py index e71894c..3536a73 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -82,6 +82,22 @@ class PostgresDatabase(Database): RETURNING compound_id; """ + POSE_FIELDS = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "mol_to_pkl(pose_mol)", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", + ] + def __init__( self, animal: "HIPPO", @@ -194,10 +210,10 @@ def index_names(self) -> list[str]: """Get the index names""" cursor = self.execute( - """ + f""" SELECT indexname FROM pg_indexes - WHERE schemaname = 'public'; + WHERE schemaname = '{self.SQL_SCHEMA}'; """ ) @@ -228,6 +244,8 @@ def connect(self, debug: bool = True) -> None: dbname=self.dbname, ) + conn.execute("SET client_encoding TO 'UTF8'") + except Exception as e: mrich.error("Could not connect to", self.path) mrich.error(e) @@ -301,6 +319,20 @@ def create_table_pattern_bfp(self) -> None: ### METHODS + def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: + """Update the molecule stored for a specific pose""" + + from rdkit.Chem import MolToMolBlock + + sql = f""" + UPDATE hippo.pose + SET pose_mol = mol_from_pkl(%s) + WHERE pose_id = %s; + """ + + self.execute(sql, (mol.ToBinary(), pose_id)) + self.commit() + def _clear_schema(self) -> None: """Empty the Database schema entirely and recreate it""" From b972b7b6e40bfb20a47dee68d49ab871e1dd3be3 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 5 Dec 2025 18:53:13 +0000 Subject: [PATCH 029/163] copy_temp_interactions: support OR IGNORE #245 --- hippo/db.py | 56 +++++++++++++++++++++++------------------------ hippo/postgres.py | 28 ++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 09defdc..185892d 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -103,6 +103,33 @@ class Database: ) """ + SQL_BULK_INSERT_INTERACTIONS = """ + INSERT OR IGNORE INTO interaction( + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy + ) + VALUES( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ? + ) + """ + POSE_FIELDS = [ "pose_id", "pose_inchikey", @@ -2398,34 +2425,7 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> None: cursor = source_db.execute(sql) records = cursor.fetchall() - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER} - ) - """ - - cursor = self.executemany(sql, records) + cursor = self.executemany(self.SQL_BULK_INSERT_INTERACTIONS, records) else: diff --git a/hippo/postgres.py b/hippo/postgres.py index 3536a73..bdbd5c3 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -82,6 +82,34 @@ class PostgresDatabase(Database): RETURNING compound_id; """ + SQL_BULK_INSERT_INTERACTIONS = """ + INSERT INTO interaction( + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy + ) + VALUES( + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s + ) + ON CONFLICT ON CONSTRAINT UC_interaction DO NOTHING + """ + POSE_FIELDS = [ "pose_id", "pose_inchikey", From 69f9da9a66cf6e644f6f36958140733f6ad23025 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Sun, 7 Dec 2025 18:12:02 +0000 Subject: [PATCH 030/163] insert_features --- hippo/db.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/hippo/db.py b/hippo/db.py index 185892d..efabaa2 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1697,6 +1697,95 @@ def insert_feature( self.commit() return feature_id + def insert_features( + self, + dicts: list[dict], + commit: bool = True, + ) -> None: + """Bulk insert entries into the feature table""" + + from .prolif import FEATURE_FAMILIES + + FEATURE_FAMILIES = set(FEATURE_FAMILIES) + + payload = [] + + for d in dicts: + + chain_name = d["chain_name"] + family = d["family"] + target = d["target"] + atom_names = d["atom_names"] + residue_name = d["residue_name"] + residue_number = d["residue_number"] + + assert len(chain_name) == 1 + assert len(residue_name) <= 4 + for a in atom_names: + assert len(a) <= 4 + assert isinstance(target, int) + + atom_names = " ".join(sorted(atom_names)) + + if family: + assert family in FEATURE_FAMILIES, f"Unsupported {family=}" + else: + family = "Unknown" + + payload.append( + (family, target, chain_name, residue_name, residue_number, atom_names) + ) + + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}feature( + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, + feature_atom_names + ) + VALUES( + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER} + ) + """ + + if self.engine == "psycopg": + sql += "\nON CONFLICT ON CONSTRAINT uc_feature DO NOTHING;" + + try: + self.executemany( + sql, + payload, + ) + + # except self.ERROR_UNIQUE_VIOLATION as e: + + # if warn_duplicate: + # mrich.warning(str(e)) + # mrich.var("family", family) + # mrich.var("target", target) + # mrich.var("chain_name", chain_name) + # mrich.var("residue_name", residue_name) + # mrich.var("residue_number", residue_number) + # mrich.var("atom_names", atom_names) + + # self.rollback() + + # return None + + except Exception as e: + mrich.error(e) + raise + + if commit: + self.commit() + def insert_metadata( self, *, From e3b5743596b183d63d43cbada84d26c7769464ae Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Sun, 7 Dec 2025 18:12:18 +0000 Subject: [PATCH 031/163] resolve: summary is broken with in-memory DB --- hippo/iset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hippo/iset.py b/hippo/iset.py index efe11a7..b48b1be 100644 --- a/hippo/iset.py +++ b/hippo/iset.py @@ -710,8 +710,8 @@ def resolve( ### Summary - if debug: - self.summary() + # if debug: + # self.summary() ### DUNDERS From 8934cecabaf52be1babd6a2e159ad20dec33491c Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Sun, 7 Dec 2025 18:12:46 +0000 Subject: [PATCH 032/163] improve interaction profiling performance --- hippo/pose.py | 21 ++++++- hippo/postgres.py | 148 ++++++++++++++++++++++++++++++++++++++-------- hippo/target.py | 37 +++++++++--- 3 files changed, 169 insertions(+), 37 deletions(-) diff --git a/hippo/pose.py b/hippo/pose.py index 5e4270e..a5cc170 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -993,6 +993,7 @@ def angle_between(v1, v2): if debug: mrich.debug("Getting protein features...") + protein_features = self.target.calculate_features( protein_system, reference_id=self.reference_id ) @@ -1182,15 +1183,31 @@ def angle_between(v1, v2): mrich.warning(mutation) if resolve: + from .feature import Feature from .iset import InteractionSet interactions = InteractionSet.from_pose( self, table="temp_interaction", db=temp_db ) - feature_ids = interactions.feature_ids + feature_ids = str(tuple(interactions.feature_ids)).replace(",)", ")") + + records = self.db.select_all_where( + table="feature", key=f"feature_id IN {feature_ids}", multiple=True + ) - feature_cache = {i: self.db.get_feature(id=i) for i in feature_ids} + feature_cache = { + pk: Feature( + id=pk, + family=family, + target=target, + chain_name=chain_name, + residue_name=residue_name, + residue_number=residue_number, + atom_names=atom_names, + ) + for pk, family, target, chain_name, residue_name, residue_number, atom_names in records + } interactions.resolve(debug=debug, feature_cache=feature_cache) diff --git a/hippo/postgres.py b/hippo/postgres.py index bdbd5c3..cca462e 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -4,6 +4,7 @@ import mrich import psycopg +from pathlib import Path from .db import Database @@ -83,7 +84,7 @@ class PostgresDatabase(Database): """ SQL_BULK_INSERT_INTERACTIONS = """ - INSERT INTO interaction( + INSERT INTO hippo.interaction( interaction_feature, interaction_pose, interaction_type, @@ -283,36 +284,59 @@ def connect(self, debug: bool = True) -> None: self._cursor = conn.cursor() def execute( - self, sql, payload=None, *, retry: float | None = 1, debug: bool = False + self, + sql, + payload=None, + *, + debug: bool = False, + time: bool = False, ): """Execute arbitrary SQL with retry if database is locked.""" if debug: mrich.debug(sql) - # while True: - try: - if payload: - return self.cursor.execute(sql, payload) - else: - return self.cursor.execute(sql) - # except sqlite3.OperationalError as e: - # if "database is locked" in str(e) and retry: - # with mrich.clock( - # f"SQLite Database is locked, waiting {retry} second(s)..." - # ): - # time.sleep(retry) - # mrich.print("[debug]SQLite Database was locked, retrying...") - # continue # retry without recursion - # elif "syntax error" in str(e): - # mrich.error(sql) - # mrich.error(payload) - # raise - # else: - # raise - except Exception as e: - # mrich.print(sql) - # mrich.print(payload) - raise + if time: + import re + from time import perf_counter + + start = perf_counter() + + if payload: + records = self.cursor.execute(sql, payload) + else: + records = self.cursor.execute(sql) + + if time: + sql = re.sub(r"\s+", " ", sql).strip() + mrich.debug(f"{perf_counter()-start:.2}s: ", sql) + + return records + + def executemany( + self, + sql, + payload=None, + *, + debug: bool = False, + time: bool = False, + ): + """Execute arbitrary SQL with retry if database is locked.""" + if debug: + mrich.debug(sql) + + if time: + import re + from time import perf_counter + + start = perf_counter() + + records = self.cursor.executemany(sql, payload) + + if time: + sql = re.sub(r"\s+", " ", sql).strip() + mrich.debug(f"{perf_counter()-start:.2}s: ", sql) + + return records def rollback(self) -> None: """rollback (not relevant for sqlite)""" @@ -347,6 +371,78 @@ def create_table_pattern_bfp(self) -> None: ### METHODS + def migrate( + cls, + source: Path, + batch_size: int = 10000, + ) -> None: + """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase`""" + + raise NotImplementedError + + from .animal import HIPPO + + source_path = Path(source) + + assert source_path.exists() + + source = HIPPO("source", source) + + ### compounds + + # source data + + compound_records = source.select( + table="compound", + query="compound_id, compound_inchikey, compound_smiles, compound_alias", + multiple=True, + ) + + sql = """ + INSERT INTO hippo.compound( + compound_inchikey, + compound_smiles, + compound_mol, + compound_alias + ) + VALUES( + %(inchikey)s, + %(smiles)s, + mol_from_smiles(%(smiles)s), + %(alias)s + ) + """ + + self.execute(sql, compound_records) + + ### scaffolds + + ### targets + + ### poses + + ### inspirations + + ### tags + + ### reactions + + ### quotes + + ### reactants + + ### routes + + ### components + + ### features + + ### interactions + + ### subsites + + ### subsite_tags + def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: """Update the molecule stored for a specific pose""" diff --git a/hippo/target.py b/hippo/target.py index b832fba..86a4505 100644 --- a/hippo/target.py +++ b/hippo/target.py @@ -47,7 +47,8 @@ def name(self) -> str: @property def feature_ids(self) -> list[int]: """Returns the target's feature ID's""" - feature_ids = self.db.select_where( + + records = self.db.select_where( query="feature_id", table="feature", key="target", @@ -57,16 +58,26 @@ def feature_ids(self) -> list[int]: sort="feature_chain_name, feature_residue_number", ) - if feature_ids: - feature_ids = [v for v, in feature_ids] + if not records: + return None - return feature_ids + return [v for v, in records] @property def features(self) -> list["Feature"]: """Returns the target's features""" if feature_ids := self.feature_ids: - return [self.db.get_feature(id=i) for i in feature_ids] + + from .feature import Feature + + feature_ids = str(tuple(feature_ids)).replace(",)", ")") + + records = self.db.select_all_where( + table="feature", key=f"feature_id IN {feature_ids}", multiple=True + ) + + return [Feature(*record) for record in records] + return None @property @@ -101,6 +112,7 @@ def calculate_features( protein: "mp.System", reference_id: int | None = None, force: bool = False, + debug: bool = False, ) -> list["Feature"]: """Calculate features from a protein system @@ -114,20 +126,27 @@ def calculate_features( else: + if debug: + mrich.debug("protein.get_protein_features()") + features = protein.get_protein_features() - for f in features: - self.db.insert_feature( + if debug: + mrich.debug("inserting features...") + + records = [ + dict( family=f.family, target=self.id, atom_names=[a.name for a in f.atoms], residue_name=f.res_name, residue_number=f.res_number, chain_name=f.res_chain, - commit=False, ) + for f in features + ] - self.db.commit() + self.db.insert_features(records) features = self.features From 3621f23767ab0ed83f99766a3898e5b6a0f59095 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 08:44:26 +0000 Subject: [PATCH 033/163] postgres subsite support #245 --- hippo/db.py | 85 ++++++++++++++++++++++++++++++++++++++++++++++- hippo/postgres.py | 10 +++++- hippo/pset.py | 58 +------------------------------- hippo/tools.py | 5 +++ 4 files changed, 99 insertions(+), 59 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index efabaa2..a3f69aa 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -18,7 +18,7 @@ from .reaction import Reaction from .metadata import MetaData from .recipe import Recipe, Route -from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError +from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError, strip_sql CHEMICALITE_COMPOUND_PROPERTY_MAP = { @@ -3194,6 +3194,89 @@ def set_derivative_subsites(self, commit: bool = True) -> None: if commit: self.commit() + def set_subsites_from_metadata_field( + self, pose_str_ids: str, field="CanonSites alias" + ) -> None: + """Create and assign subsite entries from a metadata field + + :param pose_str_ids: pose_str_ids + :param field: the metadata field to use + + """ + + from json import loads + + records = self.select_where( + table="pose", + query="pose_id, pose_target, pose_metadata", + key=f"pose_id IN {pose_str_ids}", + multiple=True, + ) + + subsites = set() + subsite_tags = set() + + for pose_id, pose_target, metadata in records: + + metadata = loads(metadata) + + key = metadata.get(field) + + if not key: + mrich.warning(field, "not in metadata pose_id=", pose_id) + continue + + subsites.add((pose_target, key)) + subsite_tags.add((pose_target, key, pose_id)) + + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO subsite(subsite_target, subsite_name) + VALUES(?, ?) + """ + case "psycopg": + sql = strip_sql( + """ + INSERT INTO hippo.subsite(subsite_target, subsite_name) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ + ) + + self.executemany(sql, sorted(list(subsites))) + + subsite_records = self.select( + table="subsite", + query="subsite_id, subsite_target, subsite_name", + multiple=True, + ) + + subsite_lookup = {(t, name): i for i, t, name in subsite_records} + + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) + VALUES(?, ?) + """ + case "psycopg": + sql = strip_sql( + """ + INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ + ) + + subsite_tags = [ + (subsite_lookup[(t, name)], pose_id) for t, name, pose_id in subsite_tags + ] + + self.executemany(sql, subsite_tags) + + self.commit() + ### GETTERS def get_compound( diff --git a/hippo/postgres.py b/hippo/postgres.py index cca462e..ae2e03a 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -322,7 +322,9 @@ def executemany( ): """Execute arbitrary SQL with retry if database is locked.""" if debug: - mrich.debug(sql) + from .tools import strip_sql + + mrich.debug(strip_sql(sql)) if time: import re @@ -443,6 +445,12 @@ def migrate( ### subsite_tags + def calculate_all_scaffolds(self) -> None: + raise NotImplementedError + + def calculate_all_murcko_scaffolds(self) -> None: + raise NotImplementedError + def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: """Update the molecule stored for a specific pose""" diff --git a/hippo/pset.py b/hippo/pset.py index d73e57f..fe02c2b 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -1748,63 +1748,7 @@ def set_subsites_from_metadata_field(self, field="CanonSites alias") -> None: """ - from json import loads - - records = self.db.select_where( - table="pose", - query="pose_id, pose_target, pose_metadata", - key=f"pose_id IN {self.str_ids}", - multiple=True, - ) - - subsites = set() - subsite_tags = set() - - for pose_id, pose_target, metadata in records: - - metadata = loads(metadata) - - key = metadata.get(field) - - if not key: - mrich.warning(field, "not in metadata pose_id=", pose_id) - continue - - subsites.add((pose_target, key)) - subsite_tags.add((key, pose_id)) - - sql = """ - INSERT OR IGNORE INTO subsite(subsite_target, subsite_name) - VALUES(?1, ?2) - RETURNING subsite_id - """ - - records = self.db.executemany(sql, sorted(list(subsites))) - subsite_ids = [i for i, in records] - subsite_lookup = {name: i for (t, name), i in zip(subsites, subsite_ids)} - - # supplement existing subsites - subsite_lookup.update( - { - n: i - for i, n in self.db.select( - table="subsite", query="subsite_id, subsite_name", multiple=True - ) - } - ) - - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(?1, ?2) - """ - - subsite_tags = [ - (subsite_lookup[subsite], pose_id) for subsite, pose_id in subsite_tags - ] - - self.db.executemany(sql, subsite_tags) - - self.db.commit() + self.db.set_subsites_from_metadata_field(pose_str_ids=self.str_ids, field=field) def calculate_inspiration_scores( self, diff --git a/hippo/tools.py b/hippo/tools.py index 65a72a5..c2d7bad 100644 --- a/hippo/tools.py +++ b/hippo/tools.py @@ -12,6 +12,11 @@ import mrich +def strip_sql(sql) -> str: + """Reduce unecessary whitespace in SQL""" + return re.sub(r"\s+", " ", sql).strip() + + def df_row_to_dict(df_row) -> dict: """Convert a dataframe row to a dictionary From c2e77194b55b523bbd871f422cef06fe3d956b1e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:12:45 +0000 Subject: [PATCH 034/163] fix test_compound #245 --- hippo/compound.py | 9 +---- hippo/db.py | 42 ++++++++++++++++------ hippo/postgres.py | 88 +++++++++++++++++++++++++++++++++-------------- hippo/pset.py | 4 +++ 4 files changed, 99 insertions(+), 44 deletions(-) diff --git a/hippo/compound.py b/hippo/compound.py index 3ef78a6..e42b1af 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -101,14 +101,7 @@ def alias(self, alias: str) -> None: 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) + self._mol = self.db.get_compound_mol(self.id) return self._mol @property diff --git a/hippo/db.py b/hippo/db.py index a3f69aa..2272a16 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -21,14 +21,6 @@ from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError, strip_sql -CHEMICALITE_COMPOUND_PROPERTY_MAP = { - "num_heavy_atoms": "mol_num_hvyatms", - "formula": "mol_formula", - "num_rings": "mol_num_rings", - "molecular_weight": "mol_amw", -} - - class Database: """Wrapper to connect to the HIPPO sqlite database. @@ -146,6 +138,13 @@ class Database: "pose_inspiration_score", ] + COMPOUND_PROPERTY_FUNCTIONS = { + "num_heavy_atoms": "mol_num_hvyatms", + "formula": "mol_formula", + "num_rings": "mol_num_rings", + "molecular_weight": "mol_amw", + } + def __init__( self, path: Path, @@ -3356,6 +3355,21 @@ def get_compound_id( return None + def get_compound_mol( + self, + compound_id: int, + ) -> "Chem.Mol": + """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" + + (bytestr,) = self.select_where( + query="mol_to_binary_mol(compound_mol)", + table="compound", + key="id", + value=compound_id, + ) + + return Chem.Mol(bytestr) + def get_compound_computed_property( self, prop: str, @@ -3369,7 +3383,8 @@ def get_compound_computed_property( """ - function = CHEMICALITE_COMPOUND_PROPERTY_MAP[prop] + function = self.COMPOUND_PROPERTY_FUNCTIONS[prop] + (val,) = self.select_where( query=f"{function}(compound_mol)", table="compound", @@ -5108,8 +5123,15 @@ def count_where( :param value: the value to match (Default value = None) """ + + if isinstance(value, str): + if "'" in value: + value = f'"{value}"' + else: + value = f"'{value}'" + if value is not None: - where_str = f"{table}_{key} is {value}" + where_str = f"{table}_{key}={value}" else: where_str = key diff --git a/hippo/postgres.py b/hippo/postgres.py index ae2e03a..fff82f5 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -7,6 +7,7 @@ from pathlib import Path from .db import Database +from .tools import strip_sql class PostgresDatabase(Database): @@ -127,6 +128,13 @@ class PostgresDatabase(Database): "pose_inspiration_score", ] + COMPOUND_PROPERTY_FUNCTIONS = { + "num_heavy_atoms": "mol_numheavyatoms", + "formula": "mol_formula", + "num_rings": "mol_numrings", + "molecular_weight": "mol_amw", + } + def __init__( self, animal: "HIPPO", @@ -301,14 +309,17 @@ def execute( start = perf_counter() - if payload: - records = self.cursor.execute(sql, payload) - else: - records = self.cursor.execute(sql) + try: + if payload: + records = self.cursor.execute(sql, payload) + else: + records = self.cursor.execute(sql) + except Exception as e: + mrich.error(e) + mrich.print(strip_sql(sql)) if time: - sql = re.sub(r"\s+", " ", sql).strip() - mrich.debug(f"{perf_counter()-start:.2}s: ", sql) + mrich.debug(f"{perf_counter()-start:.2}s: ", strip_sql(sql)) return records @@ -371,7 +382,50 @@ def create_table_pattern_bfp(self) -> None: self.execute(sql) - ### METHODS + ### GETTERS + + def get_compound_mol( + self, + compound_id: int, + ) -> "Chem.Mol": + """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" + + from rdkit.Chem import Mol + + (bytestr,) = self.select_where( + query="mol_to_pkl(compound_mol)", + table="compound", + key="id", + value=compound_id, + ) + + return Mol(bytestr) + + ### SINGLE UPDATES + + def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: + """Update the molecule stored for a specific pose""" + + from rdkit.Chem import MolToMolBlock + + sql = f""" + UPDATE hippo.pose + SET pose_mol = mol_from_pkl(%s) + WHERE pose_id = %s; + """ + + self.execute(sql, (mol.ToBinary(), pose_id)) + self.commit() + + ### BULK CALCULATIONS + + def calculate_all_scaffolds(self) -> None: + raise NotImplementedError + + def calculate_all_murcko_scaffolds(self) -> None: + raise NotImplementedError + + ### MIGRATIONS def migrate( cls, @@ -445,25 +499,7 @@ def migrate( ### subsite_tags - def calculate_all_scaffolds(self) -> None: - raise NotImplementedError - - def calculate_all_murcko_scaffolds(self) -> None: - raise NotImplementedError - - def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: - """Update the molecule stored for a specific pose""" - - from rdkit.Chem import MolToMolBlock - - sql = f""" - UPDATE hippo.pose - SET pose_mol = mol_from_pkl(%s) - WHERE pose_id = %s; - """ - - self.execute(sql, (mol.ToBinary(), pose_id)) - self.commit() + ### MAINTENANCE def _clear_schema(self) -> None: """Empty the Database schema entirely and recreate it""" diff --git a/hippo/pset.py b/hippo/pset.py index fe02c2b..dad1cc3 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -858,6 +858,10 @@ def best_placed_pose(self) -> Pose: @property def best_placed_pose_id(self) -> int: """Get the id of the pose with the best distance_score in this subset""" + + if len(self) == 1: + return self.ids[0] + query = f"pose_id, MIN(pose_distance_score)" query = self.db.select_where( table="pose", query=query, key=f"pose_id in {self.str_ids}", multiple=False From 513590a9ad08d8fd396009ba4a934eb419916c48 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:18:14 +0000 Subject: [PATCH 035/163] sqlite backwards compatibility #245 --- hippo/db.py | 65 ++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 2272a16..a3993d7 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -108,18 +108,7 @@ class Database: interaction_angle, interaction_energy ) - VALUES( - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ? - ) + VALUES(?,?,?,?,?,?,?,?,?,?) """ POSE_FIELDS = [ @@ -1735,27 +1724,35 @@ def insert_features( (family, target, chain_name, residue_name, residue_number, atom_names) ) - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, - feature_atom_names - ) - VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER} - ) - """ + match self.engine: + case "sqlite3": + + sql = """ + INSERT OR IGNORE INTO feature( + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, + feature_atom_names + ) + VALUES(?,?,?,?,?,?) + """ + + case "psycopg": - if self.engine == "psycopg": - sql += "\nON CONFLICT ON CONSTRAINT uc_feature DO NOTHING;" + sql = """ + INSERT INTO hippo.feature( + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, + feature_atom_names + ) + VALUES(%s,%s,%s,%s,%s,%s) + ON CONFLICT ON CONSTRAINT uc_feature DO NOTHING; + """ try: self.executemany( @@ -3361,6 +3358,8 @@ def get_compound_mol( ) -> "Chem.Mol": """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" + from rdkit.Chem import Mol + (bytestr,) = self.select_where( query="mol_to_binary_mol(compound_mol)", table="compound", @@ -3368,7 +3367,7 @@ def get_compound_mol( value=compound_id, ) - return Chem.Mol(bytestr) + return Mol(bytestr) def get_compound_computed_property( self, From 59d7added678380274b5c3d4f489b7cd349aee8e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:24:27 +0000 Subject: [PATCH 036/163] execute: raise error --- hippo/postgres.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hippo/postgres.py b/hippo/postgres.py index fff82f5..6722092 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -317,6 +317,7 @@ def execute( except Exception as e: mrich.error(e) mrich.print(strip_sql(sql)) + raise if time: mrich.debug(f"{perf_counter()-start:.2}s: ", strip_sql(sql)) From 3548dd4652f2aa267bddb5885188d9324a2de899 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:53:32 +0000 Subject: [PATCH 037/163] select_where: debug option --- hippo/db.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hippo/db.py b/hippo/db.py index a3993d7..a4167da 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -2190,6 +2190,7 @@ def select_where( multiple: bool = False, none: str | None = "error", sort: str = None, + debug: bool = False, ) -> tuple | list[tuple]: """Select entries where ``key == value`` @@ -2257,10 +2258,13 @@ def select_where( f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str}" ) + if debug: + mrich.print(strip_sql(sql)) + try: self.execute(sql) except sqlite3.OperationalError as e: - mrich.var("sql", sql) + mrich.var("sql", strip_sql(sql)) raise if multiple: @@ -3717,6 +3721,7 @@ def get_feature( """ entry = self.select_all_where(table="feature", key="id", value=id) + return Feature(*entry) def get_route( From 5177baabecca0a52e009aee041374b105efa7cf6 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:53:48 +0000 Subject: [PATCH 038/163] InteractionSet: postgres support #245 --- hippo/iset.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hippo/iset.py b/hippo/iset.py index b48b1be..906427c 100644 --- a/hippo/iset.py +++ b/hippo/iset.py @@ -136,14 +136,14 @@ def from_pose( mrich.warning(f"{has_invalid_fps} Poses have not been fingerprinted") sql = f""" - SELECT interaction_id FROM {table} + SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_pose IN {pose.str_ids} """ else: sql = f""" - SELECT interaction_id FROM {table} + SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_pose = {pose.id} """ @@ -470,7 +470,7 @@ def get_classic_fingerprint(self) -> dict: pairs = self.db.execute( f""" - SELECT interaction_feature, COUNT(1) FROM {self.table} + SELECT interaction_feature, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature """ From 593181c0fa9a461225a6af40a3c523e897c1fac2 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 09:54:01 +0000 Subject: [PATCH 039/163] column_names --- hippo/postgres.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hippo/postgres.py b/hippo/postgres.py index 6722092..12ad3a8 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -364,6 +364,19 @@ def get_lastrowid(self) -> int: """Get ID of last inserted row""" return self.cursor.fetchone()[0] + def column_names(self, table: str) -> list[str]: + """Get the column names of the given table""" + + sql = f""" + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'hippo' + AND table_name = '{table}' + ORDER BY ordinal_position; + """ + + return [n for n, in self.execute(sql).fetchall()] + ### CREATE TABLES def create_table_pattern_bfp(self) -> None: From d4d4c115a7de596fb612428619b6a5ade43655e9 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 8 Dec 2025 15:12:12 +0000 Subject: [PATCH 040/163] towards migration #245 --- hippo/postgres.py | 163 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 146 insertions(+), 17 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index 12ad3a8..34fbd75 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -97,18 +97,7 @@ class PostgresDatabase(Database): interaction_angle, interaction_energy ) - VALUES( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s - ) + VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT ON CONSTRAINT UC_interaction DO NOTHING """ @@ -248,9 +237,9 @@ def index_names(self) -> list[str]: cursor = self.execute( f""" - SELECT indexname - FROM pg_indexes - WHERE schemaname = '{self.SQL_SCHEMA}'; + SELECT indexname + FROM pg_indexes + WHERE schemaname = '{self.SQL_SCHEMA}'; """ ) @@ -461,13 +450,13 @@ def migrate( ### compounds # source data - compound_records = source.select( table="compound", query="compound_id, compound_inchikey, compound_smiles, compound_alias", multiple=True, ) + # insertion query sql = """ INSERT INTO hippo.compound( compound_inchikey, @@ -481,16 +470,156 @@ def migrate( mol_from_smiles(%(smiles)s), %(alias)s ) + ON CONFLICT DO NOTHING; """ - self.execute(sql, compound_records) + # format the data + new_compound_records = [ + dict(inchikey=b, smiles=c, alias=d) for a, b, c, d in compound_records + ] + + # do the insertion + self.execute(sql, new_compound_records) + + # map to the destination records + destination_inchikey_map = self.get_compound_inchikey_id_dict( + inchikeys=[b for a, b, c, d in compound_records] + ) + compound_id_map = { + a: destination_inchikey_map[b] for a, b, c, d in compound_records + } ### scaffolds + # source data + scaffold_records = source.select( + table="scaffold", + query="scaffold_base, scaffold_superstructure", + multiple=True, + ) + + # map to new IDs + scaffold_records = [ + (compound_id_map[a], compound_id_map[b]) for a, b in scaffold_records + ] + + # insert new data + + sql = """ + INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ + + self.execute(sql, scaffold_records) + ### targets + # source data + target_records = source.select( + table="target", query="target_id, target_name", multiple=True + ) + + # do the insertion + for i, name in target_records: + self.insert_target(name, warn_duplicate=False) + + # map to the destination records + destination_target_name_map = { + name: i + for i, name in self.select( + table="target", query="target_id, target_name", multiple=True + ) + } + target_id_map = { + i: destination_target_name_map[name] for i, name in target_records + } + ### poses + pose_fields = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_path", + "pose_compound", + "pose_target", + "mol_to_pkl(pose_mol)", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", + "pose_metadata", + ] + + # source data + pose_records = source.select( + table="pose", query=", ".join(pose_fields), multiple=True + ) + + # insertion query + sql = """ + INSERT INTO hippo.pose( + pose_inchikey, + pose_alias, + pose_smiles, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_fingerprint, + pose_energy_score, + pose_distance_score, + pose_inspiration_score, + pose_metadata + ) + VALUES( + %(inchikey)s, + %(alias)s, + %(smiles)s, + %(path)s, + %(compound)s, + %(target)s, + mol_frok_pkl(%(mol)s), + %(fingerprint)s, + %(energy_score)s, + %(distance_score)s, + %(inspiration_score)s, + %(metadata)s, + ) + ON CONFLICT DO NOTHING; + """ + + # massage the data + pose_dicts = [ + dict( + id=i, + inchikey=inchikey, + alias=alias, + smiles=smiles, + path=path, + compound=compound_id_map[compound_id], + target=target_id_map[target_id], + mol=mol, + fingerprint=fingerprint, + energy_score=energy_score, + distance_score=distance_score, + inspiration_score=inspiration_score, + metadata=metadata, + ) + for i, inchikey, alias, smiles, path, compound_id, target_id, mol, fingerprint, energy_score, distance_score, inspiration_score, metadata in pose_records + ] + + # do the insertion + self.execute(sql, [p[1:] for p in pose_records]) + + # map to the destination records + destination_pose_path_map = self.get_pose_path_id_dict() + pose_id_map = {p[0]: destination_pose_path_map[p[5]] for p in pose_records} + + ### pose references + ### inspirations ### tags From 9e9cb16e546cd213d07c11729ee39a2117a31e37 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 08:35:38 +0000 Subject: [PATCH 041/163] postgres readme --- README.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5a730f5..18deb2b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Please see the [documentation](https://hippo-docs.winokan.com) to get started HIPPO is pip-installable, but use of a `conda` environment is recommended for the rdkit and chemicalite dependencies: -``` +```bash pip install --upgrade hippo-db conda install -c conda-forge chemicalite=2024.05.1 ``` @@ -31,7 +31,7 @@ For more information see the [installation guide](https://hippo-docs.winokan.com You can verify the installation: -``` +```bash python -m hippo verify ``` @@ -47,7 +47,7 @@ Or by running the full suite of tests (see Developer information) To develop on HIPPO please fork this repository and then install locally: -``` +```bash git clone https://github.com/YOUR_USER/HIPPO cd HIPPO pip install -e . @@ -61,7 +61,7 @@ HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as HIPPO is linted using [black](https://pypi.org/project/black/) and commits are automatically linted using the [black](https://github.com/mwinokan/HIPPO/actions/workflows/black.yml) workflow. The use of [pre-commit](https://pre-commit.com/) is encouraged for local development to automatically run the linting at git commit time: -``` +```bash pip install pre-commit pre-commit install ``` @@ -70,14 +70,14 @@ pre-commit install Documentation is automatically built off the [HIPPO/main](https://github.com/mwinokan/HIPPO/tree/main) branch using readthedocs. For local building using sphinx: -``` +```bash cd docs make html ``` To check API reference coverage use [docstr-coverage](https://pypi.org/project/docstr-coverage/) -``` +```bash pip install docstr-coverage docstr-coverage hippo ``` @@ -86,7 +86,7 @@ docstr-coverage hippo Some tests are provided in the tests directory, which can be run with pytest: -``` +```bash cd tests pytest ``` @@ -99,30 +99,76 @@ N.B. the numbered tests, e.g. `test_00_cleanup.py` need to run in sequential ord - [HIPPO/dev](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's development branch - [HIPPO/django_lean](https://github.com/mwinokan/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app + + +
+ + Postgres specific instructions + ### Local Postgres development (Mac) Install via homebrew -``` +```bash brew install postgresql@18 ``` Initialise database -``` +```bash /opt/homebrew/opt/postgresql@18/bin/initdb -D /opt/homebrew/var/postgresql@18 -U postgres -W ``` Run in foreground -``` +```bash /opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18 ``` Install psycopg -``` +```bash pip install psycopg[binary] ``` +### Connecting to a remote deployment + +Check port availability: + +```bash +nc -zv IP_ADDRESS 5432 +``` + +Success will look something like this: + +``` +Ncat: Version 7.92 ( https://nmap.org/ncat ) +Ncat: Connected to IP_ADDRESS:5432. +Ncat: 0 bytes sent, 0 bytes received in 0.01 seconds. +``` + +To ssh tunnel to a host which has the correct exposed port and forward the correct port: + +```bash +ssh -L 5432:IP_ADDRESS:5432 USER@GATEWAY_HOST +``` + +To test your connection (from your local machine) + +``` +pg_isready -h localhost -p 5432 +``` + +To list available databases with `psql` + +```bash +psql -h localhost -U USER -p 5432 -l +``` + +To connect to a specific database with `psql` + +```bash +psql -h localhost -U USER -p 5432 -n DATABASE +``` +
From fbc909478bf268b8b6d7de695e457d03214f866b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 08:36:03 +0000 Subject: [PATCH 042/163] test config --- tests/config.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/tests/config.py b/tests/config.py index b942c35..5465753 100644 --- a/tests/config.py +++ b/tests/config.py @@ -6,23 +6,36 @@ ## CONFIGURE CLEANUP CLEANUP_FILES = [ - # f"{TARGET}.tar.gz", + f"{TARGET}.tar.gz", ] CLEANUP_DIRS = [ - # TARGET, + TARGET, ] ## CONFIGURE DATABASE +## CONFIGURE TESTS + +CLEANUP = True +DOWNLOAD = False +SETUP = True +ADD_HITS = True +SCAFFOLDS = True +SUBSITES = True + ### SQLITE -DB = "db_test.sqlite" +# DB = "db_test.sqlite" -CLEANUP_FILES.append(DB) +# CLEANUP_FILES.append(DB) ### POSTGRES +SCAFFOLDS = False + +# local testing + DB = dict( username="postgres", password="hippo", @@ -30,11 +43,13 @@ port=5432, ) -## DISABLE TESTS +# DLS deployment -CLEANUP = True -DOWNLOAD = False -SETUP = True -ADD_HITS = True -SCAFFOLDS = True -SUBSITES = True +# from os import environ + +# DB = dict( +# username=environ["HIPPO_POSTGRES_USERNAME"], +# password=environ["HIPPO_POSTGRES_PASSWORD"], +# host="localhost", +# port=5555, +# ) From 69d9b133e0d0da131bbc71a4fd727a0a05ca3d9e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 08:36:57 +0000 Subject: [PATCH 043/163] get_pose_id_obj_dict: fix syntax --- hippo/db.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 720ee0b..001062b 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3777,18 +3777,20 @@ def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": """Get a dictionary mapping :class:`.Pose` ID's to their objects""" query = ", ".join( - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", + [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + ] ) records = self.select_where( From 82c3620170b642d85eb563fd5d7ce581683f07a7 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 08:37:35 +0000 Subject: [PATCH 044/163] fix syntax --- hippo/db.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index a4167da..c8a9190 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -4225,18 +4225,20 @@ def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": """Get a dictionary mapping :class:`.Pose` ID's to their objects""" query = ", ".join( - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", + [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_reference", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + ] ) records = self.select_where( From d72363099f2f6ec31e46a9792d46916f13e1928b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 09:39:38 +0000 Subject: [PATCH 045/163] list of tables --- hippo/db.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/hippo/db.py b/hippo/db.py index c8a9190..3088d1a 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -30,6 +30,25 @@ class Database: """ + TABLES = [ + "compound" + "inspiration" + "scaffold" + "reaction" + "reactant" + "pose" + "tag" + "quote" + "target" + "feature" + "route" + "component" + "compound_pattern_bfp" + "interaction" + "subsite" + "subsite_tag" + ] + SQL_STRING_PLACEHOLDER = "?" SQL_PK_DATATYPE = "INTEGER" SQL_SCHEMA_PREFIX = "" From 5c2812260c41cce0d2f3afa0093f4f2a408f7dab Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 09:39:58 +0000 Subject: [PATCH 046/163] schema helper functions --- hippo/postgres.py | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index 34fbd75..d7242c0 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -172,7 +172,7 @@ def __init__( if not self.table_names: if create_blank: - self.execute("CREATE SCHEMA IF NOT EXISTS hippo;") + self.create_schema() self.create_blank_db() else: mrich.error("Database is empty!", self.path) @@ -368,6 +368,26 @@ def column_names(self, table: str) -> list[str]: ### CREATE TABLES + def create_schema(self) -> None: + """Create postgres schema if it does not exist""" + + sql = """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.schemata + WHERE schema_name = %s + ); + """ + + c = self.execute(sql, (self.SQL_SCHEMA,)) + + exists = c.fetchone()[0] + + if exists: + return None + + self.execute("CREATE SCHEMA IF NOT EXISTS hippo;") + self.commit() + def create_table_pattern_bfp(self) -> None: """Create the pattern_bfp table""" mrich.warning( @@ -644,15 +664,17 @@ def migrate( ### MAINTENANCE - def _clear_schema(self) -> None: + def _drop_schema(self) -> None: """Empty the Database schema entirely and recreate it""" - self.execute( - f""" - DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE; - CREATE SCHEMA {self.SQL_SCHEMA}; - """ - ) + self.execute(f"DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE;") + self.commit() + + def _drop_tables(self) -> None: + """Delete all HIPPO tables""" + + for table in self.TABLES: + self.execute(f"DROP TABLE IF EXISTS {table};") self.commit() From 0e92390b19acf05a183d17bba6520e3732b97c72 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 09:40:17 +0000 Subject: [PATCH 047/163] raise error if 'mol' datatype is missing --- hippo/postgres.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/hippo/postgres.py b/hippo/postgres.py index d7242c0..e8b28a4 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -272,6 +272,24 @@ def connect(self, debug: bool = True) -> None: conn.execute("SET client_encoding TO 'UTF8'") + with conn.cursor() as c: + c.execute( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'mol' + ); + """ + ) + (exists,) = c.fetchone() + + if not exists: + raise ValueError( + "'mol' datatype not defined, is the rdkit postgres cartridge installed correctly?" + ) + except Exception as e: mrich.error("Could not connect to", self.path) mrich.error(e) From ad64bec25306923ad4d703b48b81a9ea39b22c56 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:34:31 +0000 Subject: [PATCH 048/163] get_price: handle deleted quotes --- hippo/cset.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/hippo/cset.py b/hippo/cset.py index af3cece..f26104c 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -2647,8 +2647,14 @@ def get_price( none=none, ) - prices = [Price(a, b) for a, b in result] - quoted = sum(prices, Price.null()) + if result: + prices = [Price(a, b) for a, b in result] + quoted = sum(prices, Price.null()) + + else: + quoted = Price.null() + self.df["quote_id"] = None + pairs = {i: q for i, q in enumerate(self.df["quote_id"])} else: From f4c9938a0ec4caebc13abf6d59cfccb56982918b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:35:08 +0000 Subject: [PATCH 049/163] PoseTable.__getitem__: support numpy arrays --- hippo/pset.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/hippo/pset.py b/hippo/pset.py index d73e57f..7852b8d 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -443,6 +443,7 @@ def __getitem__( """ from pandas import Series + from numpy import ndarray, int64 match key: @@ -468,18 +469,21 @@ def __getitem__( or isinstance(key, tuple) or isinstance(key, set) or isinstance(key, Series) + or isinstance(key, ndarray) ): indices = [] for i in key: if isinstance(i, int): index = i + elif isinstance(i, int64): + index = int(i) elif isinstance(i, str): index = self.db.get_pose_id(alias=i) if not index: index = self.db.get_pose_id(inchikey=i) else: - raise NotImplementedError + raise NotImplementedError(type(i)) assert index indices.append(index) From f74601357fe62159332d54e29a9bb874db52b85a Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:36:00 +0000 Subject: [PATCH 050/163] move route registration to db file --- hippo/animal.py | 30 +----------------------------- hippo/db.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index fbfbf78..15a8fb6 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -2704,35 +2704,7 @@ def register_route( :returns: The :class:`.Route` ID """ - assert recipe.num_products == 1 - - # register the route - route_id = self.db.insert_route(product_id=recipe.product.id, commit=False) - - assert route_id - - # reactions - for ref in recipe.reactions.ids: - self.db.insert_component( - component_type=1, ref=ref, route=route_id, commit=False - ) - - # reactants - for ref, amount in recipe.reactants.id_amount_pairs: - self.db.insert_component( - component_type=2, ref=ref, route=route_id, amount=amount, commit=False - ) - - # intermediates - for ref, amount in recipe.intermediates.id_amount_pairs: - self.db.insert_component( - component_type=3, ref=ref, route=route_id, amount=amount, commit=False - ) - - if commit: - self.db.commit() - - return route_id + return self.db.register_route(recipe=recipe, commit=commit) ### QUOTING diff --git a/hippo/db.py b/hippo/db.py index 001062b..d3c15b9 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1794,6 +1794,50 @@ def insert_subsite_tag( return subsite_tag_id + def register_route( + self, + *, + recipe: "Recipe", + commit: bool = True, + ) -> int: + """ + Insert a single-product :class:`.Recipe` into the :class:`.Database`. + + :param recipe: The :class:`.Recipe` object to be registered + :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` + :returns: The :class:`.Route` ID + """ + + assert recipe.num_products == 1 + + # register the route + route_id = self.insert_route(product_id=recipe.product.id, commit=False) + + assert route_id + + # reactions + for ref in recipe.reactions.ids: + self.insert_component( + component_type=1, ref=ref, route=route_id, commit=False + ) + + # reactants + for ref, amount in recipe.reactants.id_amount_pairs: + self.insert_component( + component_type=2, ref=ref, route=route_id, amount=amount, commit=False + ) + + # intermediates + for ref, amount in recipe.intermediates.id_amount_pairs: + self.insert_component( + component_type=3, ref=ref, route=route_id, amount=amount, commit=False + ) + + if commit: + self.commit() + + return route_id + ### SELECTION def select( From 34fb40ab90dd96039b1502943fdbb13d63c0d060 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:36:20 +0000 Subject: [PATCH 051/163] handle missing routes --- hippo/recipe.py | 54 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/hippo/recipe.py b/hippo/recipe.py index 63cfc18..9ea310a 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1335,6 +1335,35 @@ def get_routes(self, return_ids: bool = False) -> "RouteSet": permitted_reactions=self.reactions, return_ids=return_ids ) + def calculate_missing_routes(self, supplier: str = "Enamine") -> None: + """Calculate missing routes to products of this Recipe""" + + products = self.products.compounds + + for i, c in mrich.track(enumerate(products), total=len(products)): + + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + + route = self.db.register_route(recipe=recipe) + + mrich.print(f"registered {route=}") + + self.db.prune_duplicate_routes() + def write_CAR_csv( self, file: "str | Path", return_df: bool = False ) -> "DataFrame | None": @@ -1554,16 +1583,21 @@ def write_reactant_csv( ### Downstream info - df["downstream_product_ids"] = df["compound_id"].apply( - lambda x: product_lookup.get(x, set()) - ) - - df["downstream_reaction_ids"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["ids"] - ) - df["downstream_reaction_types"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["types"] - ) + try: + df["downstream_product_ids"] = df["compound_id"].apply( + lambda x: product_lookup.get(x, set()) + ) + + df["downstream_reaction_ids"] = df["compound_id"].apply( + lambda x: reaction_lookup[x]["ids"] + ) + df["downstream_reaction_types"] = df["compound_id"].apply( + lambda x: reaction_lookup[x]["types"] + ) + except KeyError as e: + mrich.error(f"Reactant C{e} is missing downstream reaction") + mrich.error("Are all routes enumerated? Try running calculate_missing_routes()") + return None df["num_downstream_reactions"] = df["downstream_reaction_ids"].apply(len) df["num_downstream_reaction_types"] = df["downstream_reaction_types"].apply(len) From 62f155de80cfb38323a1b5ae6175f633bdc78ce7 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:36:34 +0000 Subject: [PATCH 052/163] lint --- hippo/db.py | 2 +- hippo/recipe.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index d3c15b9..b3d7447 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1807,7 +1807,7 @@ def register_route( :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` :returns: The :class:`.Route` ID """ - + assert recipe.num_products == 1 # register the route diff --git a/hippo/recipe.py b/hippo/recipe.py index 9ea310a..407ea94 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1341,27 +1341,27 @@ def calculate_missing_routes(self, supplier: str = "Enamine") -> None: products = self.products.compounds for i, c in mrich.track(enumerate(products), total=len(products)): - + try: reactions = c.reactions except Exception as e: mrich.error(f"Error getting {c}'s reactions", e) continue - + for reaction in reactions: - + try: recipes = reaction.get_recipes(supplier=supplier) except Exception as e: mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) continue - + for recipe in recipes: - + route = self.db.register_route(recipe=recipe) - + mrich.print(f"registered {route=}") - + self.db.prune_duplicate_routes() def write_CAR_csv( @@ -1587,7 +1587,7 @@ def write_reactant_csv( df["downstream_product_ids"] = df["compound_id"].apply( lambda x: product_lookup.get(x, set()) ) - + df["downstream_reaction_ids"] = df["compound_id"].apply( lambda x: reaction_lookup[x]["ids"] ) @@ -1596,7 +1596,9 @@ def write_reactant_csv( ) except KeyError as e: mrich.error(f"Reactant C{e} is missing downstream reaction") - mrich.error("Are all routes enumerated? Try running calculate_missing_routes()") + mrich.error( + "Are all routes enumerated? Try running calculate_missing_routes()" + ) return None df["num_downstream_reactions"] = df["downstream_reaction_ids"].apply(len) From 0fa2c9a07588f77f5542a3183ba8694bddcf2c22 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:51:11 +0000 Subject: [PATCH 053/163] rdkit functions must be in hippo schema --- hippo/postgres.py | 46 ++++++++++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index e8b28a4..f371ec2 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -19,6 +19,24 @@ class PostgresDatabase(Database): """ + TABLES = [ + "subsite", + "subsite_tag", + "scaffold", + "compound", + "pose", + "inspiration", + "reaction", + "reactant", + "tag", + "quote", + "route", + "component", + "feature", + "interaction", + "target", + ] + SQL_STRING_PLACEHOLDER = "%s" SQL_PK_DATATYPE = "SERIAL" SQL_SCHEMA = "hippo" @@ -32,7 +50,7 @@ class PostgresDatabase(Database): compound_alias TEXT, compound_smiles TEXT, compound_base INTEGER, - compound_mol MOL, + compound_mol hippo.MOL, compound_pattern_bfp bit(2048), compound_morgan_bfp bit(2048), compound_metadata TEXT, @@ -52,7 +70,7 @@ class PostgresDatabase(Database): pose_path TEXT, pose_compound INTEGER, pose_target INTEGER, - pose_mol MOL, + pose_mol hippo.MOL, pose_fingerprint INTEGER, pose_energy_score REAL, pose_distance_score REAL, @@ -69,16 +87,12 @@ class PostgresDatabase(Database): compound_inchikey, compound_smiles, compound_mol, - -- compound_pattern_bfp, - -- compound_morgan_bfp, compound_alias ) VALUES( %(inchikey)s, %(smiles)s, - mol_from_smiles(%(smiles)s), - -- mol_pattern_bfp(mol_from_smiles(%(smiles)s), 2048), - -- mol_morgan_bfp(mol_from_smiles(%(smiles)s), 2, 2048), + hippo.mol_from_smiles(%(smiles)s), %(alias)s ) RETURNING compound_id; @@ -110,7 +124,7 @@ class PostgresDatabase(Database): "pose_path", "pose_compound", "pose_target", - "mol_to_pkl(pose_mol)", + "hippo.mol_to_pkl(pose_mol)", "pose_fingerprint", "pose_energy_score", "pose_distance_score", @@ -118,10 +132,10 @@ class PostgresDatabase(Database): ] COMPOUND_PROPERTY_FUNCTIONS = { - "num_heavy_atoms": "mol_numheavyatoms", - "formula": "mol_formula", - "num_rings": "mol_numrings", - "molecular_weight": "mol_amw", + "num_heavy_atoms": "hippo.mol_numheavyatoms", + "formula": "hippo.mol_formula", + "num_rings": "hippo.mol_numrings", + "molecular_weight": "hippo.mol_amw", } def __init__( @@ -434,7 +448,7 @@ def get_compound_mol( from rdkit.Chem import Mol (bytestr,) = self.select_where( - query="mol_to_pkl(compound_mol)", + query="hippo.mol_to_pkl(compound_mol)", table="compound", key="id", value=compound_id, @@ -451,7 +465,7 @@ def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: sql = f""" UPDATE hippo.pose - SET pose_mol = mol_from_pkl(%s) + SET pose_mol = hippo.mol_from_pkl(%s) WHERE pose_id = %s; """ @@ -505,7 +519,7 @@ def migrate( VALUES( %(inchikey)s, %(smiles)s, - mol_from_smiles(%(smiles)s), + hippo.mol_from_smiles(%(smiles)s), %(alias)s ) ON CONFLICT DO NOTHING; @@ -583,7 +597,7 @@ def migrate( "pose_path", "pose_compound", "pose_target", - "mol_to_pkl(pose_mol)", + "hippo.mol_to_pkl(pose_mol)", "pose_fingerprint", "pose_energy_score", "pose_distance_score", From 65bfb25303b8a14ab71b01c7536376499900c3dc Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 13:51:31 +0000 Subject: [PATCH 054/163] PoseTable.__iter__: fix iteration following pose deletion --- hippo/pset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/pset.py b/hippo/pset.py index dad1cc3..e7f18fb 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -530,7 +530,7 @@ def __len__(self) -> int: def __iter__(self): """Iterate through all compounds""" - return iter(self[i + 1] for i in range(len(self))) + return iter(self[i] for i in self.ids) class PoseSet: From d1743eb90543415aa527a5d5829a58b7d42548bc Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 14:02:39 +0000 Subject: [PATCH 055/163] Recipe.calculate_missing_routes: only process products with no routes at all --- hippo/recipe.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/hippo/recipe.py b/hippo/recipe.py index 407ea94..6ac6e66 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1335,11 +1335,28 @@ def get_routes(self, return_ids: bool = False) -> "RouteSet": permitted_reactions=self.reactions, return_ids=return_ids ) - def calculate_missing_routes(self, supplier: str = "Enamine") -> None: + def calculate_missing_routes( + self, missing_only: bool = True, supplier: str = "Enamine" + ) -> None: """Calculate missing routes to products of this Recipe""" products = self.products.compounds + if missing_only: + from .cset import CompoundSet + + records = self.db.select_where( + table="route", + key=f"route_product IN {products.str_ids}", + query="route_product", + multiple=True, + ) + existing = set(i for i, in records) + missing = set(products.ids) - existing + products = CompoundSet(self.db, missing) + + mrich.var("#products", len(products)) + for i, c in mrich.track(enumerate(products), total=len(products)): try: From c470f6fcd831f25f1ce678032ab99f47c0c2a8d8 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 21:26:31 +0000 Subject: [PATCH 056/163] get_df: expand tags option --- hippo/pset.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/hippo/pset.py b/hippo/pset.py index 7852b8d..72480d4 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -1244,6 +1244,7 @@ def get_df( inspiration_aliases: bool = False, derivative_ids: bool = False, tags: bool = False, + expand_tags: bool = False, subsites: bool = False, # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs ) -> "pandas.DataFrame": @@ -1442,7 +1443,14 @@ def get_df( if debug: mrich.debug("adding tag column") lookup = self.db.get_pose_tag_dict() - df["tags"] = df["id"].apply(lambda x: lookup.get(x, {})) + + if not expand_tags: + df["tags"] = df["id"].apply(lambda x: lookup.get(x, set())) + + else: + for i, row in df.iterrows(): + for tag in lookup.get(row["id"], set()): + df.loc[i, tag] = True if subsites: if debug: @@ -2003,6 +2011,7 @@ def to_fragalysis( tags: bool = True, subsites: bool = True, extra_cols: dict[str, list] = None, + inspiration_score: bool = True, # name_col: str = "name", **kwargs, ): @@ -2096,8 +2105,9 @@ def to_fragalysis( subsites=subsites, energy_score=True, distance_score=True, - inspiration_score=True, + inspiration_score=inspiration_score, # sanitise_null_metadata_values=True, + expand_tags=False, # sanitise_tag_list_separator=";", # sanitise_metadata_list_separator=";", # skip_metadata=skip_metadata, From d35d3c549d3257e6d66bfee921bcdc436b22dc97 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Tue, 9 Dec 2025 21:26:43 +0000 Subject: [PATCH 057/163] ReactionSet.__sub__ --- hippo/rset.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hippo/rset.py b/hippo/rset.py index a2f8cb2..49df27f 100644 --- a/hippo/rset.py +++ b/hippo/rset.py @@ -726,3 +726,18 @@ def __add__(self, other: "ReactionSet") -> "ReactionSet": self.add(reaction) self._name = None return self + + def __sub__( + self, + other: "ReactionSet", + ) -> "ReactionSet": + """Substract a :class:`.ReactionSet` from this set""" + match other: + case ReactionSet(): + ids = set(self.ids) - set(other.ids) + return ReactionSet(self.db, ids, sort=False) + case int(): + # assert other in set(self.ids) + return ReactionSet( + self.db, [i for i in self.ids if i != other], sort=False + ) From 261fe46a3a3845633b42da234bb349b6169a949d Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:00:16 +0000 Subject: [PATCH 058/163] Compound.scaffolds: fix name --- hippo/compound.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/compound.py b/hippo/compound.py index 3ef78a6..31663e9 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -228,7 +228,7 @@ def scaffolds(self) -> "CompoundSet | None": from .cset import CompoundSet self._scaffolds = CompoundSet( - self.db, ids, name=f"scaffold scaffolds of {self}" + self.db, ids, name=f"scaffolds of {self}" ) self._total_changes = self.db.total_changes return self._scaffolds From 1e30a76138d15d3b34d09120f59f60aeaae667b3 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:31:03 +0000 Subject: [PATCH 059/163] Compound.scaffolds: fix name --- hippo/compound.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hippo/compound.py b/hippo/compound.py index 31663e9..c3ae58a 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -227,9 +227,7 @@ def scaffolds(self) -> "CompoundSet | None": else: from .cset import CompoundSet - self._scaffolds = CompoundSet( - self.db, ids, name=f"scaffolds of {self}" - ) + self._scaffolds = CompoundSet(self.db, ids, name=f"scaffolds of {self}") self._total_changes = self.db.total_changes return self._scaffolds From 306eeb96134827b2ef2c8dc07b1d8d900ea4ea9e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:31:28 +0000 Subject: [PATCH 060/163] RouteSet: progress bar option in factories --- hippo/recipe.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/hippo/recipe.py b/hippo/recipe.py index 6ac6e66..410308a 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -2550,23 +2550,24 @@ def __init__(self, db: "Database", routes: "list[Route]") -> None: ### FACTORIES @classmethod - def from_ids(cls, db: "Database", ids: list | set): + def from_ids(cls, db: "Database", ids: list | set, progress: bool = True): """Generate a routeset from a set of :class:`.Route` IDs :param db: database to link :param ids: :class:`.Route` database IDs + :param progress: show progress bar """ - routes = [ - db.get_route(id=route_id) - for route_id in mrich.track(ids, prefix="Getting routes") - ] + if progress: + ids = mrich.track(ids, prefix="Getting routes") + + routes = [db.get_route(id=route_id) for route_id in ids] self = cls.__new__(cls) return RouteSet(db, routes) @classmethod - def from_product_ids(cls, db: "Database", ids: list | set): + def from_product_ids(cls, db: "Database", ids: list | set, progress: bool = True): """Generate a routeset from a set of product :class:`.Compound` IDs :param db: database to link @@ -2584,7 +2585,7 @@ def from_product_ids(cls, db: "Database", ids: list | set): route_ids = [i for i, in records] - return cls.from_ids(db, route_ids) + return cls.from_ids(db, route_ids, progress=progress) @classmethod def from_json( @@ -2957,6 +2958,10 @@ def __iter__(self): """Iterate over routes in this set""" return iter(self.data.values()) + def __getitem__(self, key): + """Get a specific route in this set""" + return list(self.data.values())[key] + class RecipeSet: """A set of recipes stored on disk""" From cb02d431dc540fba65aa838c304de4d4e7d85f30 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:31:50 +0000 Subject: [PATCH 061/163] CompoundSet.split_by_scaffolds and .despaghettify --- hippo/cset.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/hippo/cset.py b/hippo/cset.py index f26104c..90a5981 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -2165,6 +2165,62 @@ def as_ingredientset( compounds=self, amount=amount, supplier=supplier ) + def split_by_scaffolds(self) -> "dict[CompoundSet, CompoundSet]": + """Split this set into subsets clustered by scaffold compound""" + + cluster_dict = self.db.get_compound_cluster_dict(cset=self) + + subsets = {} + for cluster, elabs in cluster_dict.items(): + cluster = CompoundSet(self.db, list(cluster)) + subsets[cluster] = CompoundSet(self.db, list(elabs)) + + return subsets + + def despaghettify(self) -> "CompoundSet": + """Reduce this set to only compounds that elaborate a single reactant at a time. + Requires routes to be present in the database.""" + + from .recipe import RouteSet + + clustered = self.split_by_scaffolds() + + mrich.var("#clusters", len(clustered)) + + keep = set() + + route_lookup = self.db.get_product_id_routes_dict() + + for cluster, elabs in clustered.items(): + + assert len(cluster) == 1 + + # routes = RouteSet.from_product_ids(self.db, ids=[cluster[0].id], progress=False) + routes = RouteSet.from_ids( + self.db, route_lookup[cluster[0].id], progress=False + ) + + assert len(routes) == 1 + scaffold_reactants = set(routes[0].reactants.ids) + + for elab in elabs: + + # routes = RouteSet.from_product_ids(self.db, ids=[elab.id], progress=False) + routes = RouteSet.from_ids( + self.db, route_lookup[elab.id], progress=False + ) + + assert len(routes) == 1 + + reactants = set(routes[0].reactants.ids) + + common = scaffold_reactants & reactants + + if common: + keep.add(elab.id) + + return CompoundSet(self.db, keep) + ### DUNDERS def __len__(self) -> int: From 552b0154fd6bb4b4e25a2ef79ff985be6c81381d Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:51:04 +0000 Subject: [PATCH 062/163] get_route_id_reactant_ids_dict --- hippo/db.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/hippo/db.py b/hippo/db.py index b3d7447..f31c07a 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3432,6 +3432,25 @@ def get_product_id_routes_dict(self) -> dict[int, set[int]]: return lookup + def get_route_id_reactant_ids_dict(self) -> dict[int, set[int]]: + """Get a dictionary mapping :class:`.Route` ID's to their reactant :class:`.Compound` IDs""" + + sql = """ + SELECT route_id, component_ref FROM route + INNER JOIN component + ON component_route = route_id + WHERE component_type = 2 + """ + + c = self.execute(sql) + + lookup = {} + for route_id, route_reactant in c: + lookup.setdefault(route_id, set()) + lookup[route_id].add(route_reactant) + + return lookup + def get_compound_id_pose_ids_dict(self, cset: "CompoundSet") -> dict[int, set]: """Get a dictionary mapping :class:`.Compound` ID's to their associated :class:`.Pose` ID's""" records = self.execute( From 5a6a8818694bce0d300188094f60d0280a2a6e44 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 09:51:23 +0000 Subject: [PATCH 063/163] CompoundSet.despaghettify: improved performance --- hippo/cset.py | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/hippo/cset.py b/hippo/cset.py index 90a5981..c40963f 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -2187,37 +2187,46 @@ def despaghettify(self) -> "CompoundSet": mrich.var("#clusters", len(clustered)) - keep = set() - + mrich.debug("getting route lookup..." "") route_lookup = self.db.get_product_id_routes_dict() + mrich.debug("getting reactant lookup..." "") + reactant_lookup = self.db.get_route_id_reactant_ids_dict() + + keep = set() for cluster, elabs in clustered.items(): - assert len(cluster) == 1 + for scaffold in cluster: - # routes = RouteSet.from_product_ids(self.db, ids=[cluster[0].id], progress=False) - routes = RouteSet.from_ids( - self.db, route_lookup[cluster[0].id], progress=False - ) + mrich.debug(scaffold.id, len(elabs), len(keep)) - assert len(routes) == 1 - scaffold_reactants = set(routes[0].reactants.ids) + route_ids = route_lookup[scaffold.id] - for elab in elabs: + if len(route_ids) > 1: + mrich.warning(f"scaffold {scaffold} has multiple routes") - # routes = RouteSet.from_product_ids(self.db, ids=[elab.id], progress=False) - routes = RouteSet.from_ids( - self.db, route_lookup[elab.id], progress=False - ) + for route_id in route_ids: + + scaffold_reactants = reactant_lookup[route_id] + + for elab in elabs: + + routes = RouteSet.from_ids( + self.db, route_lookup[elab.id], progress=False + ) + + route_ids = route_lookup[elab.id] - assert len(routes) == 1 + if len(route_ids) != 1: + mrich.error(f"elab {elab.id} has {route_ids=}") + continue - reactants = set(routes[0].reactants.ids) + reactants = reactant_lookup[list(route_ids)[0]] - common = scaffold_reactants & reactants + common = scaffold_reactants & reactants - if common: - keep.add(elab.id) + if common: + keep.add(elab.id) return CompoundSet(self.db, keep) From e08fad0795e8027cf020d3676bb7d2fa33545a8f Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 10:39:24 +0000 Subject: [PATCH 064/163] CompoundSet.despaghettify: register_missing_routes --- hippo/cset.py | 90 ++++++++++++++++++++++++++++++++++++++++++------- hippo/recipe.py | 6 ++-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/hippo/cset.py b/hippo/cset.py index c40963f..d0f0fa9 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -2177,15 +2177,27 @@ def split_by_scaffolds(self) -> "dict[CompoundSet, CompoundSet]": return subsets - def despaghettify(self) -> "CompoundSet": + def despaghettify( + self, + register_missing_routes: bool = True, + supplier="Enamine", + ) -> "CompoundSet": """Reduce this set to only compounds that elaborate a single reactant at a time. Requires routes to be present in the database.""" from .recipe import RouteSet + if register_missing_routes: + mrich.debug("registering_missing_routes...") + route_lookup = self.register_missing_routes( + missing_only=True, supplier=supplier + ) + + mrich.debug("clustering by scaffold...") clustered = self.split_by_scaffolds() - mrich.var("#clusters", len(clustered)) + n = len(clustered) + mrich.var("#clusters", n) mrich.debug("getting route lookup..." "") route_lookup = self.db.get_product_id_routes_dict() @@ -2194,15 +2206,27 @@ def despaghettify(self) -> "CompoundSet": reactant_lookup = self.db.get_route_id_reactant_ids_dict() keep = set() - for cluster, elabs in clustered.items(): + for i, (cluster, elabs) in enumerate(clustered.items()): for scaffold in cluster: - mrich.debug(scaffold.id, len(elabs), len(keep)) + mrich.debug( + f"{i}/{n}", + "scaffold:", + scaffold.id, + "#elabs:", + len(elabs), + "#kept:", + len(keep), + ) + + route_ids = route_lookup.get(scaffold.id) - route_ids = route_lookup[scaffold.id] + if not route_ids: + mrich.error(f"scaffold {scaffold} has no routes") + continue - if len(route_ids) > 1: + elif len(route_ids) > 1: mrich.warning(f"scaffold {scaffold} has multiple routes") for route_id in route_ids: @@ -2211,11 +2235,7 @@ def despaghettify(self) -> "CompoundSet": for elab in elabs: - routes = RouteSet.from_ids( - self.db, route_lookup[elab.id], progress=False - ) - - route_ids = route_lookup[elab.id] + route_ids = route_lookup.get(elab.id, set()) if len(route_ids) != 1: mrich.error(f"elab {elab.id} has {route_ids=}") @@ -2225,11 +2245,57 @@ def despaghettify(self) -> "CompoundSet": common = scaffold_reactants & reactants - if common: + if len(common) == len(scaffold_reactants) - 1: keep.add(elab.id) return CompoundSet(self.db, keep) + def register_missing_routes( + self, missing_only: bool = True, supplier: str = "Enamine" + ) -> None: + """Calculate missing routes to compounds in this set""" + + if missing_only: + from .cset import CompoundSet + + records = self.db.select_where( + table="route", + key=f"route_product IN {self.str_ids}", + query="route_product", + multiple=True, + ) + existing = set(i for i, in records) + missing = set(self.ids) - existing + return CompoundSet(self.db, missing).register_missing_routes( + missing_only=False, supplier=supplier + ) + + mrich.var("#compounds", len(self)) + + for i, c in mrich.track(enumerate(self), total=len(self)): + + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + + route = self.db.register_route(recipe=recipe) + + mrich.print(f"registered {route=}") + + self.db.prune_duplicate_routes() + ### DUNDERS def __len__(self) -> int: diff --git a/hippo/recipe.py b/hippo/recipe.py index 410308a..fb91e84 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1335,12 +1335,14 @@ def get_routes(self, return_ids: bool = False) -> "RouteSet": permitted_reactions=self.reactions, return_ids=return_ids ) - def calculate_missing_routes( + def register_missing_routes( self, missing_only: bool = True, supplier: str = "Enamine" ) -> None: """Calculate missing routes to products of this Recipe""" - products = self.products.compounds + return products.compounds.register_missing_routes( + missing_only=missing_only, supplier=supplier + ) if missing_only: from .cset import CompoundSet From 540ca5b9a9d36146f8b7902bb74bbe647b11322f Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 10:47:04 +0000 Subject: [PATCH 065/163] Compound.formula: fix postgres implementation --- hippo/db.py | 9 +++++++-- hippo/postgres.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 3088d1a..1662bb5 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -148,7 +148,7 @@ class Database: COMPOUND_PROPERTY_FUNCTIONS = { "num_heavy_atoms": "mol_num_hvyatms", - "formula": "mol_formula", + "formula": ("mol_formula", ", false, false"), "num_rings": "mol_num_rings", "molecular_weight": "mol_amw", } @@ -3407,8 +3407,13 @@ def get_compound_computed_property( function = self.COMPOUND_PROPERTY_FUNCTIONS[prop] + if not isinstance(function, str): + function, extra = function + else: + extra = "" + (val,) = self.select_where( - query=f"{function}(compound_mol)", + query=f"{function}(compound_mol{extra})", table="compound", key="id", value=compound_id, diff --git a/hippo/postgres.py b/hippo/postgres.py index f371ec2..ec75dc6 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -133,7 +133,7 @@ class PostgresDatabase(Database): COMPOUND_PROPERTY_FUNCTIONS = { "num_heavy_atoms": "hippo.mol_numheavyatoms", - "formula": "hippo.mol_formula", + "formula": ("hippo.mol_formula", ", false, false"), "num_rings": "hippo.mol_numrings", "molecular_weight": "hippo.mol_amw", } From 2eea5e49195b9ab9f149f89b81c47d70dc720076 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Wed, 10 Dec 2025 18:30:46 +0000 Subject: [PATCH 066/163] Recipe.write_reactant_csv: reaction_type_counts option --- hippo/recipe.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/hippo/recipe.py b/hippo/recipe.py index fb91e84..191bb32 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -1484,7 +1484,10 @@ def write_CAR_csv( return df def write_reactant_csv( - self, file: "str | Path", return_df: bool = False + self, + file: "str | Path", + reaction_type_counts: bool = True, + return_df: bool = False, ) -> "DataFrame | None": """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use @@ -1564,6 +1567,9 @@ def write_reactant_csv( reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) reaction_lookup[reactant_id]["ids"].add(reaction_id) reaction_lookup[reactant_id]["types"].add(reaction_type) + reaction_lookup[reactant_id].setdefault("counts", {}) + reaction_lookup[reactant_id]["counts"].setdefault(reaction_type, 0) + reaction_lookup[reactant_id]["counts"][reaction_type] += 1 smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) @@ -1650,12 +1656,26 @@ def write_reactant_csv( "quoted_purity", "quoted_smiles", "quote_date", + "num_downstream_products", "num_downstream_reaction_types", "num_downstream_reactions", - "num_downstream_products", + ] + + if reaction_type_counts: + for i, row in df.iterrows(): + + counts = reaction_lookup[row["compound_id"]]["counts"] + + for reaction_type, count in counts.items(): + key = f"num_downstream ({reaction_type})" + df.loc[i, key] = count + if key not in cols: + cols.append(key) + + cols += [ + "downstream_product_ids", "downstream_reaction_types", "downstream_reaction_ids", - "downstream_product_ids", ] df = df[[c for c in cols if c in df.columns]] From 7efd5179fa185093109b14e988a0dc93af72eb2b Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 11 Dec 2025 10:02:38 +0000 Subject: [PATCH 067/163] migration progress #245 --- hippo/db.py | 10 +- hippo/postgres.py | 598 ++++++++++++++++++++++++++++++++-------------- 2 files changed, 431 insertions(+), 177 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 1662bb5..77dc101 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -148,7 +148,7 @@ class Database: COMPOUND_PROPERTY_FUNCTIONS = { "num_heavy_atoms": "mol_num_hvyatms", - "formula": ("mol_formula", ", false, false"), + "formula": "mol_formula", "num_rings": "mol_num_rings", "molecular_weight": "mol_amw", } @@ -571,6 +571,8 @@ def execute( ): """Execute arbitrary SQL with retry if database is locked.""" if debug: + from .tools import strip_sql + mrich.debug(sql) while True: @@ -594,6 +596,9 @@ def execute( else: raise except Exception as e: + from .tools import strip_sql + + mrich.error(strip_sql(sql)) raise def executemany(self, sql, payload, *, retry: float | None = 1) -> None: @@ -4235,7 +4240,8 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] else: records = self.execute( - """SELECT pose_id, pose_path FROM pose + f""" + SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL""" ).fetchall() diff --git a/hippo/postgres.py b/hippo/postgres.py index ec75dc6..b0067ab 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -284,8 +284,6 @@ def connect(self, debug: bool = True) -> None: dbname=self.dbname, ) - conn.execute("SET client_encoding TO 'UTF8'") - with conn.cursor() as c: c.execute( """ @@ -304,6 +302,8 @@ def connect(self, debug: bool = True) -> None: "'mol' datatype not defined, is the rdkit postgres cartridge installed correctly?" ) + conn.execute("SET client_encoding TO 'UTF8'") + except Exception as e: mrich.error("Could not connect to", self.path) mrich.error(e) @@ -336,8 +336,9 @@ def execute( else: records = self.cursor.execute(sql) except Exception as e: - mrich.error(e) - mrich.print(strip_sql(sql)) + # mrich.error(e) + # mrich.print(strip_sql(sql)) + self.rollback() raise if time: @@ -350,14 +351,16 @@ def executemany( sql, payload=None, *, - debug: bool = False, + debug: bool = True, time: bool = False, + batch_size: int = None, ): """Execute arbitrary SQL with retry if database is locked.""" if debug: from .tools import strip_sql mrich.debug(strip_sql(sql)) + mrich.debug("len(payload):", len(payload)) if time: import re @@ -365,7 +368,34 @@ def executemany( start = perf_counter() - records = self.cursor.executemany(sql, payload) + if batch_size: + + from itertools import batched, chain + + batches = list(batched(payload, batch_size)) + + n = len(batches) + + results = [] + for i, batch in enumerate(mrich.track(batches, prefix="executing")): + mrich.set_progress_field("i", i) + mrich.set_progress_field("n", n) + result = self.cursor.executemany(sql, batch) + + if result: + results.append(result) + + else: + mrich.set_progress_field("i", n) + + if results: + records = list(chain.from_iterable(results)) + else: + records = None + + else: + + records = self.cursor.executemany(sql, payload) if time: sql = re.sub(r"\s+", " ", sql).strip() @@ -376,6 +406,7 @@ def executemany( def rollback(self) -> None: """rollback (not relevant for sqlite)""" self.connection.rollback() + self.connection.execute("SET client_encoding TO 'UTF8'") def sql_return_id_str(self, key: str) -> str: """Add this to SQL queries to return the entry primary key""" @@ -482,217 +513,434 @@ def calculate_all_murcko_scaffolds(self) -> None: ### MIGRATIONS - def migrate( - cls, - source: Path, - batch_size: int = 10000, - ) -> None: - """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase`""" + def migrate_sqlite( + self, + source: str | Path, + batch_size: int = 5_000, + tag_compound_id_regex: list[tuple[str, str]] | None = None, + # tag_name_map: "Callable" = None, + # rename_tag_compound_shortcodes: bool = True + ) -> dict: + """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase` - raise NotImplementedError + :param source: path to source sqlite database + :param batch_size: SQL insertion batch size + :param tag_compound_id_regex: Provide regex to identify compound ID's to replace in tag names, defaults to `[(r"^C([0-9]+)", "C{new_compound_id}")]` + + The default tag_compound_id_regex means that tags such as "C123 85 percent analogues" are replaced with "C234 85 percent analogues", + where 123 is the compound ID in the source database, and 234 in the destination. + """ + + # from itertools import batched + from json import dump from .animal import HIPPO + from rdkit.Chem import Mol + import re - source_path = Path(source) + mrich.var("source", source) + mrich.var("batch_size", batch_size) + source_path = Path(source).resolve() assert source_path.exists() - source = HIPPO("source", source) + json_file_name = f"{source_path.name.removesuffix('.sqlite')}_migration.json" + xlsx_file_name = f"{source_path.name.removesuffix('.sqlite')}_migration.xlsx" + mrich.var("json_file_name", json_file_name) + mrich.var("xlsx_file_name", xlsx_file_name) - ### compounds + if not tag_compound_id_regex: + tag_compound_id_regex = [ + (r"^C([0-9]+)", "C{new_compound_id}"), + ] - # source data - compound_records = source.select( - table="compound", - query="compound_id, compound_inchikey, compound_smiles, compound_alias", - multiple=True, - ) + mrich.var("tag_compound_id_regex", tag_compound_id_regex) - # insertion query - sql = """ - INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_alias - ) - VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s), - %(alias)s - ) - ON CONFLICT DO NOTHING; - """ + source = HIPPO("source", source_path) - # format the data - new_compound_records = [ - dict(inchikey=b, smiles=c, alias=d) for a, b, c, d in compound_records - ] + try: - # do the insertion - self.execute(sql, new_compound_records) + migration_data = { + "source": str(source_path.resolve()), + "destination": self.path, + } - # map to the destination records - destination_inchikey_map = self.get_compound_inchikey_id_dict( - inchikeys=[b for a, b, c, d in compound_records] - ) - compound_id_map = { - a: destination_inchikey_map[b] for a, b, c, d in compound_records - } + ### compounds - ### scaffolds + # source data + compound_records = source.db.select( + table="compound", + query="compound_id, compound_inchikey, compound_smiles, compound_alias", + multiple=True, + ) - # source data - scaffold_records = source.select( - table="scaffold", - query="scaffold_base, scaffold_superstructure", - multiple=True, - ) + mrich.var("source: #compounds", len(compound_records)) - # map to new IDs - scaffold_records = [ - (compound_id_map[a], compound_id_map[b]) for a, b in scaffold_records - ] + # insertion query + sql = """ + INSERT INTO hippo.compound( + compound_inchikey, + compound_smiles, + compound_mol, + compound_alias + ) + VALUES( + %(inchikey)s, + %(smiles)s, + hippo.mol_from_smiles(%(smiles)s), + %(alias)s + ) + ON CONFLICT DO NOTHING; + """ - # insert new data + # format the data + compound_dicts = [ + dict(inchikey=b, smiles=c, alias=d) for a, b, c, d in compound_records + ] - sql = """ - INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ + # self.executemany(sql, compound_dicts, batch_size=batch_size) - self.execute(sql, scaffold_records) + # map to the destination records + destination_inchikey_map = self.get_compound_inchikey_id_dict( + inchikeys=[b for a, b, c, d in compound_records] + ) - ### targets + compound_id_map = { + a: destination_inchikey_map.get(b) for a, b, c, d in compound_records + } - # source data - target_records = source.select( - table="target", query="target_id, target_name", multiple=True - ) + migration_data["compound_id_map"] = compound_id_map + + ### scaffolds + + # source data + scaffold_records = source.db.select( + table="scaffold", + query="scaffold_base, scaffold_superstructure", + multiple=True, + ) - # do the insertion - for i, name in target_records: - self.insert_target(name, warn_duplicate=False) + # map to new IDs + scaffold_records = [ + (compound_id_map[a], compound_id_map[b]) for a, b in scaffold_records + ] - # map to the destination records - destination_target_name_map = { - name: i - for i, name in self.select( + mrich.var("source: #scaffolds", len(scaffold_records)) + + # insert new data + + sql = """ + INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ + + # self.executemany(sql, scaffold_records, batch_size=batch_size) + + ### targets + + # source data + target_records = source.db.select( table="target", query="target_id, target_name", multiple=True ) - } - target_id_map = { - i: destination_target_name_map[name] for i, name in target_records - } - - ### poses - - pose_fields = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_path", - "pose_compound", - "pose_target", - "hippo.mol_to_pkl(pose_mol)", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", - "pose_metadata", - ] - - # source data - pose_records = source.select( - table="pose", query=", ".join(pose_fields), multiple=True - ) - # insertion query - sql = """ - INSERT INTO hippo.pose( - pose_inchikey, - pose_alias, - pose_smiles, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - pose_inspiration_score, - pose_metadata - ) - VALUES( - %(inchikey)s, - %(alias)s, - %(smiles)s, - %(path)s, - %(compound)s, - %(target)s, - mol_frok_pkl(%(mol)s), - %(fingerprint)s, - %(energy_score)s, - %(distance_score)s, - %(inspiration_score)s, - %(metadata)s, - ) - ON CONFLICT DO NOTHING; - """ + # do the insertion + for i, name in target_records: + self.insert_target(name=name, warn_duplicate=False) + + # map to the destination records + destination_target_name_map = { + name: i + for i, name in self.select( + table="target", query="target_id, target_name", multiple=True + ) + } + + target_id_map = { + i: destination_target_name_map[name] for i, name in target_records + } + + migration_data["target_id_map"] = target_id_map + + ### poses + + pose_fields = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_path", + "pose_compound", + "pose_target", + # "CASE WHEN pose_mol IS NOT NULL THEN mol_to_binary_mol(pose_mol) ELSE pose_mol END", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", + "pose_metadata", + ] + + # source data + pose_records = source.db.select( + table="pose", query=", ".join(pose_fields), multiple=True + ) + + # insertion query + sql = """ + INSERT INTO hippo.pose( + pose_inchikey, + pose_alias, + pose_smiles, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_fingerprint, + pose_energy_score, + pose_distance_score, + pose_inspiration_score, + pose_metadata + ) + VALUES( + %(inchikey)s, + %(alias)s, + %(smiles)s, + %(path)s, + %(compound)s, + %(target)s, + hippo.mol_from_pkl(%(mol)s), + %(fingerprint)s, + %(energy_score)s, + %(distance_score)s, + %(inspiration_score)s, + %(metadata)s + ) + ON CONFLICT DO NOTHING; + """ + + # massage the data + pose_dicts = [ + dict( + id=i, + inchikey=inchikey, + alias=alias, + smiles=smiles, + path=path, + compound=compound_id_map[compound_id], + target=target_id_map[target_id], + mol=Mol(mol).ToBinary() if mol else None, + fingerprint=fingerprint, + energy_score=energy_score, + distance_score=distance_score, + inspiration_score=inspiration_score, + metadata=metadata, + ) + for i, inchikey, alias, smiles, path, compound_id, target_id, mol, fingerprint, energy_score, distance_score, inspiration_score, metadata in pose_records + ] + + # do the insertion + # self.executemany(sql, pose_dicts, batch_size=batch_size) + + # map to the destination records + destination_pose_path_map = self.get_pose_path_id_dict() + + # return destination_pose_path_map + + pose_id_map = { + p["id"]: destination_pose_path_map[p["path"]] for p in pose_dicts + } + + migration_data["pose_id_map"] = pose_id_map + + ### pose references + + # source data + reference_records = source.db.select( + table="pose", + query="pose_id, pose_reference", + multiple=True, + ) + + # map to new IDs + reference_dicts = [ + dict(pose=pose_id_map[a], reference=pose_id_map[b]) + for a, b in reference_records + if b + ] + + mrich.var("source: #references", len(reference_dicts)) + + # insert new data + + sql = """ + UPDATE hippo.pose + SET pose_reference = %(reference)s + WHERE pose_id = %(pose)s; + """ + + # self.executemany(sql, reference_dicts, batch_size=batch_size) + + ### inspirations + + # source data + inspiration_records = source.db.select( + table="inspiration", + query="inspiration_original, inspiration_derivative", + multiple=True, + ) + + # map to new IDs + inspiration_dicts = [ + dict(original=pose_id_map[a], derivative=pose_id_map[b]) + for a, b in inspiration_records + if b + ] + + mrich.var("source: #inspirations", len(inspiration_dicts)) + + # insert new data + + sql = """ + INSERT INTO hippo.inspiration( + inspiration_original, + inspiration_derivative + ) + VALUES ( + %(original)s, + %(derivative)s + ) + ON CONFLICT DO NOTHING; + """ + + # self.executemany(sql, inspiration_dicts, batch_size=batch_size) + + ### tags + + # unique tag names + + tag_names = source.db.select( + table="tag", query="DISTINCT tag_name", multiple=True + ) + + tag_names = sorted([t for t, in tag_names]) + + # rename tags based on regex + + tag_name_map = {} + for tag in tag_names: + for pattern, template in tag_compound_id_regex: + + match = re.match(pattern, tag) + + if not match: + continue + + groups = match.groups() + + assert ( + len(groups) == 1 + ), f"tag_compound_id_regex replacement not supported with multiple groups, {pattern=}" - # massage the data - pose_dicts = [ - dict( - id=i, - inchikey=inchikey, - alias=alias, - smiles=smiles, - path=path, - compound=compound_id_map[compound_id], - target=target_id_map[target_id], - mol=mol, - fingerprint=fingerprint, - energy_score=energy_score, - distance_score=distance_score, - inspiration_score=inspiration_score, - metadata=metadata, + groups = [g for g in groups] + + compound_id = int(groups[0]) + new_compound_id = compound_id_map[compound_id] + + replacement = template.format(new_compound_id=new_compound_id) + + new_tag = re.sub(pattern, replacement, tag) + + tag_name_map[tag] = new_tag + + break + + # source data + tag_records = source.db.select( + table="tag", + query="tag_name, tag_compound, tag_pose", + multiple=True, ) - for i, inchikey, alias, smiles, path, compound_id, target_id, mol, fingerprint, energy_score, distance_score, inspiration_score, metadata in pose_records - ] - # do the insertion - self.execute(sql, [p[1:] for p in pose_records]) + mrich.var("source: #tag records", len(tag_records)) - # map to the destination records - destination_pose_path_map = self.get_pose_path_id_dict() - pose_id_map = {p[0]: destination_pose_path_map[p[5]] for p in pose_records} + if tag_name_map: + mrich.warning("renamed", len(tag_name_map), "tags") - ### pose references + # insertion query + sql = """ + INSERT INTO hippo.tag( + tag_name, + tag_compound, + tag_pose + ) + VALUES( + %(name)s, + %(compound)s, + %(pose)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + tag_dicts = [ + dict( + name=tag_name_map.get(a, a), + compound=compound_id_map[b] if b else None, + pose=pose_id_map[c] if c else None, + ) + for a, b, c in tag_records + ] + + # add unchanged tags + for tag in tag_names: + if tag not in tag_name_map: + tag_name_map[tag] = tag + + migration_data["tag_name_map"] = tag_name_map + + # do the insertion + # self.executemany(sql, tag_dicts, batch_size=batch_size) + + ### reactions + + ### quotes - ### inspirations + ### reactants - ### tags + ### routes - ### reactions + ### components - ### quotes + ### features - ### reactants + ### interactions - ### routes + ### subsites - ### components + ### subsite_tags - ### features + raise NotImplementedError - ### interactions + mrich.success( + "Migration staged. Don't forget to review and commit or rollback the changes!" + ) + + except Exception as e: + self.rollback() + mrich.error(e) + + json_file_name = ( + f"{source.db.path.name.removesuffix('.sqlite')}_migration_partial.json" + ) + xlsx_file_name = ( + f"{source.db.path.name.removesuffix('.sqlite')}_migration_partial.xlsx" + ) - ### subsites + mrich.writing(json_file_name) + dump(migration_data, open(json_file_name, "wt")) - ### subsite_tags + return migration_data ### MAINTENANCE From 3387537c51c4be6532cbe85021f99ec100cfd7aa Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 11 Dec 2025 12:17:52 +0000 Subject: [PATCH 068/163] fix init error --- hippo/animal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/animal.py b/hippo/animal.py index 2fce512..6c8dab4 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -63,7 +63,7 @@ def __init__( self._db = PostgresDatabase(animal=self, **db) - elif not isinstance(db, Path): + else: ### INITIALISE SQLITE DATABASE From 2715d666a2e9d9aaeb59a14a67af3065eaf6970d Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 11 Dec 2025 12:18:09 +0000 Subject: [PATCH 069/163] migration dev #245 --- hippo/postgres.py | 287 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 264 insertions(+), 23 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index b0067ab..113ffe6 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -206,7 +206,7 @@ def __init__( def path(self) -> None: """PostgresDatabase path""" # raise NotImplementedError("PostgresDatabase has no path") - return f"postgresql://{self.username}:{self.password}@{self.host}:{self.port}" + return f"postgresql://{self.username}@{self.host}:{self.port}" @property def username(self) -> str: @@ -356,11 +356,15 @@ def executemany( batch_size: int = None, ): """Execute arbitrary SQL with retry if database is locked.""" + + returning = "RETURNING" in sql + if debug: from .tools import strip_sql mrich.debug(strip_sql(sql)) mrich.debug("len(payload):", len(payload)) + mrich.debug(f"{returning=}") if time: import re @@ -377,13 +381,17 @@ def executemany( n = len(batches) results = [] - for i, batch in enumerate(mrich.track(batches, prefix="executing")): + for i, batch in enumerate(mrich.track(batches, prefix="batch execution")): mrich.set_progress_field("i", i) mrich.set_progress_field("n", n) - result = self.cursor.executemany(sql, batch) - if result: - results.append(result) + self.cursor.executemany(sql, batch, returning=returning) + + if returning: + result = [self.cursor.fetchone() for _ in self.cursor.results()] + + if result: + results.append(result) else: mrich.set_progress_field("i", n) @@ -395,7 +403,12 @@ def executemany( else: - records = self.cursor.executemany(sql, payload) + self.cursor.executemany(sql, payload, returning=returning) + + if returning: + records = [self.cursor.fetchone() for _ in self.cursor.results()] + else: + records = None if time: sql = re.sub(r"\s+", " ", sql).strip() @@ -532,11 +545,13 @@ def migrate_sqlite( """ - # from itertools import batched + import re + import pandas as pd from json import dump - from .animal import HIPPO from rdkit.Chem import Mol - import re + from datetime import datetime + + from .animal import HIPPO mrich.var("source", source) mrich.var("batch_size", batch_size) @@ -558,11 +573,61 @@ def migrate_sqlite( source = HIPPO("source", source_path) + ### helper functions + + def executemany(table, sql, payload): + + n = self.count(table) + mrich.var(f"destination: #{table}s", n) + + result = self.executemany(sql, payload, batch_size=batch_size) + + if d := self.count(table) - n: + mrich.success("Inserted", d, f"new {table}s") + else: + mrich.warning("Inserted", d, f"new {table}s") + + return result + + def dump_json(data, file): + mrich.writing(file) + dump(data, open(file, "wt")) + + def dump_xlsx(data, file): + mrich.writing(file) + + meta = [] + for key, value in data.items(): + if not isinstance(value, dict): + meta.append(dict(key=key, value=value)) + + meta_df = pd.DataFrame(meta).set_index("key") + + source = meta_df.loc["source", "value"] + destination = meta_df.loc["destination", "value"] + + sheets = {} + for key, value in data.items(): + if isinstance(value, dict): + + df = pd.DataFrame( + [{source: k, destination: v} for k, v in value.items()] + ) + sheets[key] = df.set_index(source) + + with pd.ExcelWriter(file) as writer: + + meta_df.to_excel(writer, sheet_name="meta") + + for name, df in sheets.items(): + df.to_excel(writer, sheet_name=name, index=True) + try: migration_data = { "source": str(source_path.resolve()), "destination": self.path, + "time": str(datetime.now()), } ### compounds @@ -598,7 +663,8 @@ def migrate_sqlite( dict(inchikey=b, smiles=c, alias=d) for a, b, c, d in compound_records ] - # self.executemany(sql, compound_dicts, batch_size=batch_size) + # do the insertion + # executemany("compound", sql, compound_dicts) # map to the destination records destination_inchikey_map = self.get_compound_inchikey_id_dict( @@ -635,7 +701,7 @@ def migrate_sqlite( ON CONFLICT DO NOTHING; """ - # self.executemany(sql, scaffold_records, batch_size=batch_size) + # executemany("scaffold", sql, scaffold_records) ### targets @@ -739,8 +805,10 @@ def migrate_sqlite( for i, inchikey, alias, smiles, path, compound_id, target_id, mol, fingerprint, energy_score, distance_score, inspiration_score, metadata in pose_records ] + mrich.var("source: #poses", len(pose_dicts)) + # do the insertion - # self.executemany(sql, pose_dicts, batch_size=batch_size) + # executemany("pose", sql, pose_dicts) # map to the destination records destination_pose_path_map = self.get_pose_path_id_dict() @@ -813,7 +881,7 @@ def migrate_sqlite( ON CONFLICT DO NOTHING; """ - # self.executemany(sql, inspiration_dicts, batch_size=batch_size) + # executemany("inspiration", sql, inspiration_dicts) ### tags @@ -862,7 +930,7 @@ def migrate_sqlite( multiple=True, ) - mrich.var("source: #tag records", len(tag_records)) + mrich.var("source: #tags", len(tag_records)) if tag_name_map: mrich.warning("renamed", len(tag_name_map), "tags") @@ -900,17 +968,180 @@ def migrate_sqlite( migration_data["tag_name_map"] = tag_name_map # do the insertion - # self.executemany(sql, tag_dicts, batch_size=batch_size) + # executemany("tag", sql, tag_dicts) - ### reactions + ### reactions & reactants - ### quotes + def get_reaction_id_reaction_dict_map(db, compound_id_map=None): + + # reactions + reaction_records = db.select( + table="reaction", + query="reaction_id, reaction_type, reaction_product, reaction_product_yield", + multiple=True, + ) + + reaction_id_reaction_dict_map = { + i: dict( + id=i, + type=t, + product=( + compound_id_map[product_id] + if compound_id_map + else product_id + ), + product_yield=product_yield, + ) + for i, t, product_id, product_yield in reaction_records + } + + # reactants + reactant_records = db.select( + table="reactant", + query="reactant_amount, reactant_reaction, reactant_compound", + multiple=True, + ) + + # combine + for amount, reaction_id, compound_id in reactant_records: + compound_id = ( + compound_id_map[compound_id] if compound_id_map else compound_id + ) + + reaction_id_reaction_dict_map[reaction_id].setdefault( + "reactants", set() + ) + reaction_id_reaction_dict_map[reaction_id]["reactants"].add( + (compound_id, amount) + ) + + reaction_id_reaction_dict_map[reaction_id].setdefault( + "reactant_ids", set() + ) + reaction_id_reaction_dict_map[reaction_id]["reactant_ids"].add( + compound_id + ) + + return reaction_id_reaction_dict_map, reactant_records + + # get source reaction data + source_reaction_dicts, reactant_records = get_reaction_id_reaction_dict_map( + source.db, compound_id_map + ) + mrich.var("source: #reactions", len(source_reaction_dicts)) + + # get destination reaction data + destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(self) + mrich.var("destination: #reactions", len(destination_reaction_dicts)) + + # create keyed lookups + + source_reaction_lookup = { + (d["product"], d["type"], tuple(sorted(list(d["reactant_ids"])))): d[ + "id" + ] + for d in source_reaction_dicts.values() + } + + destination_reaction_lookup = { + (d["product"], d["type"], tuple(sorted(list(d["reactant_ids"])))): d[ + "id" + ] + for d in destination_reaction_dicts.values() + } - ### reactants + # work out which source reactions are not in the destination and create a map for existing reactions - ### routes + reaction_id_map = {} + new_reaction_dicts = [] - ### components + for key, reaction_id in list(source_reaction_lookup.items()): + + if key in destination_reaction_lookup: + # EXISTING REACTION + reaction_id_map[reaction_id] = destination_reaction_lookup[key] + + else: + + # NEW REACTION + new_reaction_dicts.append(source_reaction_dicts[reaction_id]) + + mrich.var("existing #reactions:", len(reaction_id_map)) + mrich.var("new #reactions:", len(new_reaction_dicts)) + + # reaction insertion query + sql = """ + INSERT INTO hippo.reaction( + reaction_type, + reaction_product, + reaction_product_yield + ) + VALUES( + %(type)s, + %(product)s, + %(product_yield)s + ) + ON CONFLICT DO NOTHING + RETURNING reaction_id; + """ + + # massage the data + reaction_dicts = [ + dict( + type=d["type"], + product=d["product"], + product_yield=d["product_yield"], + ) + for d in new_reaction_dicts + ] + + # do the insertion + inserted_reaction_ids = executemany("reaction", sql, reaction_dicts) + + if inserted_reaction_ids: + inserted_reaction_ids = [i for i, in inserted_reaction_ids] + else: + inserted_reaction_ids = [] + + # add to the map + for reaction_dict, new_reaction_id in zip( + new_reaction_dicts, inserted_reaction_ids + ): + reaction_id = reaction_dict["id"] + reaction_id_map[reaction_id] = new_reaction_id + + migration_data["reaction_id_map"] = reaction_id_map + + # reactant insertion query + sql = """ + INSERT INTO hippo.reactant( + reactant_amount, + reactant_reaction, + reactant_compound + ) + VALUES( + %(amount)s, + %(reaction)s, + %(compound)s + ) + ON CONFLICT DO NOTHING; + """ + + reactant_dicts = [ + dict( + amount=amount, + reaction=reaction_id_map[reaction_id], + compound=compound_id_map[compound_id], + ) + for amount, reaction_id, compound_id in reactant_records + ] + + mrich.var("source: #reactants", len(reactant_dicts)) + + # do the insertion + # executemany("reactant", sql, reactant_dicts) + + ### quotes ### features @@ -920,14 +1151,19 @@ def migrate_sqlite( ### subsite_tags + ### routes (skip?) + + ### components (skip?) + raise NotImplementedError mrich.success( - "Migration staged. Don't forget to review and commit or rollback the changes!" + "Migration staged. Please review and db.commit() or db.rollback() the changes" ) except Exception as e: self.rollback() + mrich.error(e) json_file_name = ( @@ -937,8 +1173,13 @@ def migrate_sqlite( f"{source.db.path.name.removesuffix('.sqlite')}_migration_partial.xlsx" ) - mrich.writing(json_file_name) - dump(migration_data, open(json_file_name, "wt")) + dump_json(migration_data, json_file_name) + dump_xlsx(migration_data, xlsx_file_name) + + # raise + + dump_json(migration_data, json_file_name) + dump_xlsx(migration_data, xlsx_file_name) return migration_data From e5c778942b15729ac8a52828fd0343f2be70e9b7 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 11 Dec 2025 12:18:33 +0000 Subject: [PATCH 070/163] format --- hippo/postgres.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index 113ffe6..332d392 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -802,7 +802,21 @@ def dump_xlsx(data, file): inspiration_score=inspiration_score, metadata=metadata, ) - for i, inchikey, alias, smiles, path, compound_id, target_id, mol, fingerprint, energy_score, distance_score, inspiration_score, metadata in pose_records + for ( + i, + inchikey, + alias, + smiles, + path, + compound_id, + target_id, + mol, + fingerprint, + energy_score, + distance_score, + inspiration_score, + metadata, + ) in pose_records ] mrich.var("source: #poses", len(pose_dicts)) From b13d046522e929868d46a3e2af5faaaea4926ad9 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 11 Dec 2025 17:26:54 +0000 Subject: [PATCH 071/163] first draft of migration #245 --- hippo/db.py | 13 + hippo/migration.py | 1244 ++++++++++++++++++++++++++++++++++++++++++++ hippo/postgres.py | 667 ++++-------------------- 3 files changed, 1372 insertions(+), 552 deletions(-) create mode 100644 hippo/migration.py diff --git a/hippo/db.py b/hippo/db.py index 77dc101..7181ba7 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3930,6 +3930,19 @@ def get_compound_inchikey_id_dict(self, inchikeys: list[str]) -> dict[str, int]: compound_inchikey: compound_id for compound_inchikey, compound_id in records } + def get_compound_smiles_id_dict(self) -> dict[str, int]: + """Get a dictionary mapping :class:`.Compound` smiles to their ID's""" + + records = self.select( + table="compound", + query="compound_smiles, compound_id", + multiple=True, + ) + + return { + compound_smiles: compound_id for compound_smiles, compound_id in records + } + def get_compound_id_inchikey_dict( self, cset: "CompoundSet | None" = None ) -> dict[int, str]: diff --git a/hippo/migration.py b/hippo/migration.py new file mode 100644 index 0000000..64bbf44 --- /dev/null +++ b/hippo/migration.py @@ -0,0 +1,1244 @@ +"""Functions to perform the SQLite -> PostgreSQL migration, called by :meth:`.PostgresDatabase.migrate_sqlite`""" + +import mrich + + +def migrate_compounds( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate compounds""" + + # source data + compound_records = source.select( + table="compound", + query="compound_id, compound_inchikey, compound_smiles", + multiple=True, + ) + + mrich.var("source: #compounds", len(compound_records)) + + # insertion query + sql = """ + INSERT INTO hippo.compound( + compound_inchikey, + compound_smiles, + compound_mol + ) + VALUES( + %(inchikey)s, + %(smiles)s, + hippo.mol_from_smiles(%(smiles)s) + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + compound_dicts = [ + dict(smiles=smiles, inchikey=inchikey) + for i, inchikey, smiles in compound_records + ] + + # do the insertion + if execute: + executemany(destination, "compound", sql, compound_dicts, batch_size) + + # map to the destination records + destination_inchikey_map = destination.get_compound_inchikey_id_dict( + inchikeys=[inchikey for i, inchikey, smiles in compound_records] + ) + + compound_id_map = { + i: destination_inchikey_map[inchikey] + for i, inchikey, smiles in compound_records + } + + migration_data["compound_id_map"] = compound_id_map + + return migration_data + + +def migrate_scaffolds( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate scaffolds""" + + # source data + scaffold_records = source.select( + table="scaffold", + query="scaffold_base, scaffold_superstructure", + multiple=True, + ) + + # map to new IDs + scaffold_records = [ + ( + migration_data["compound_id_map"][base_id], + migration_data["compound_id_map"][superstructure_id], + ) + for (base_id, superstructure_id) in scaffold_records + ] + + mrich.var("source: #scaffolds", len(scaffold_records)) + + # insert new data + + sql = """ + INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ + + if execute: + executemany(destination, "scaffold", sql, scaffold_records, batch_size) + + return migration_data + + +def migrate_targets( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate targets""" + + # source data + target_records = source.select( + table="target", query="target_id, target_name", multiple=True + ) + + # do the insertion + for i, name in target_records: + destination.insert_target(name=name, warn_duplicate=False) + + # map to the destination records + destination_target_name_map = { + name: i + for i, name in destination.select( + table="target", query="target_id, target_name", multiple=True + ) + } + + target_id_map = {i: destination_target_name_map[name] for i, name in target_records} + + migration_data["target_id_map"] = target_id_map + + return migration_data + + +def migrate_poses( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate poses""" + + from rdkit.Chem import Mol + + pose_fields = [ + "pose_id", + "pose_inchikey", + "pose_alias", + "pose_smiles", + "pose_path", + "pose_compound", + "pose_target", + "pose_mol", + "pose_fingerprint", + "pose_energy_score", + "pose_distance_score", + "pose_inspiration_score", + "pose_metadata", + ] + + # source data + pose_records = source.select( + table="pose", query=", ".join(pose_fields), multiple=True + ) + + # insertion query + sql = """ + INSERT INTO hippo.pose( + pose_inchikey, + pose_alias, + pose_smiles, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_fingerprint, + pose_energy_score, + pose_distance_score, + pose_inspiration_score, + pose_metadata + ) + VALUES( + %(inchikey)s, + %(alias)s, + %(smiles)s, + %(path)s, + %(compound)s, + %(target)s, + hippo.mol_from_pkl(%(mol)s), + %(fingerprint)s, + %(energy_score)s, + %(distance_score)s, + %(inspiration_score)s, + %(metadata)s + ) + ON CONFLICT DO NOTHING; + """ + + # massage the data + pose_dicts = [ + dict( + id=i, + inchikey=inchikey, + alias=alias, + smiles=smiles, + path=path, + compound=migration_data["compound_id_map"][compound_id], + target=migration_data["target_id_map"][target_id], + mol=Mol(mol).ToBinary() if mol else None, + fingerprint=fingerprint, + energy_score=energy_score, + distance_score=distance_score, + inspiration_score=inspiration_score, + metadata=metadata, + ) + for ( + i, + inchikey, + alias, + smiles, + path, + compound_id, + target_id, + mol, + fingerprint, + energy_score, + distance_score, + inspiration_score, + metadata, + ) in pose_records + ] + + mrich.var("source: #poses", len(pose_dicts)) + + # do the insertion + if execute: + executemany(destination, "pose", sql, pose_dicts, batch_size) + + # map to the destination records + destination_pose_path_map = destination.get_pose_path_id_dict() + + # return destination_pose_path_map + + pose_id_map = {p["id"]: destination_pose_path_map[p["path"]] for p in pose_dicts} + + migration_data["pose_id_map"] = pose_id_map + + return migration_data + + +def migrate_pose_references( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate pose references""" + + # source data + reference_records = source.select( + table="pose", + query="pose_id, pose_reference", + multiple=True, + ) + + # map to new IDs + reference_dicts = [ + dict( + pose=migration_data["pose_id_map"][pose_id], + reference=migration_data["pose_id_map"][reference_id], + ) + for pose_id, reference_id in reference_records + if reference_id + ] + + mrich.var("source: #references", len(reference_dicts)) + + # insert new data + + sql = """ + UPDATE hippo.pose + SET pose_reference = %(reference)s + WHERE pose_id = %(pose)s; + """ + + if execute: + destination.executemany(sql, reference_dicts, batch_size=batch_size) + + return migration_data + + +def migrate_inspirations( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate inspirations""" + + # source data + inspiration_records = source.select( + table="inspiration", + query="inspiration_original, inspiration_derivative", + multiple=True, + ) + + # map to new IDs + inspiration_dicts = [ + dict( + original=migration_data["pose_id_map"][a], + derivative=migration_data["pose_id_map"][b], + ) + for a, b in inspiration_records + if b + ] + + mrich.var("source: #inspirations", len(inspiration_dicts)) + + # insert new data + + sql = """ + INSERT INTO hippo.inspiration( + inspiration_original, + inspiration_derivative + ) + VALUES ( + %(original)s, + %(derivative)s + ) + ON CONFLICT DO NOTHING; + """ + + if execute: + executemany(destination, "inspiration", sql, inspiration_dicts, batch_size) + + return migration_data + + +def migrate_tags( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate reactions and reactants""" + + import re + + # unique tag names + + tag_names = source.select(table="tag", query="DISTINCT tag_name", multiple=True) + + tag_names = sorted([t for t, in tag_names]) + + # rename tags based on regex + + tag_name_map = {} + for tag in tag_names: + for pattern, template in migration_data["tag_compound_id_regex"]: + + match = re.match(pattern, tag) + + if not match: + continue + + groups = match.groups() + + assert ( + len(groups) == 1 + ), f"tag_compound_id_regex replacement not supported with multiple groups, {pattern=}" + + groups = [g for g in groups] + + compound_id = int(groups[0]) + new_compound_id = migration_data["compound_id_map"][compound_id] + + replacement = template.format(new_compound_id=new_compound_id) + + new_tag = re.sub(pattern, replacement, tag) + + tag_name_map[tag] = new_tag + + break + + # source data + tag_records = source.select( + table="tag", + query="tag_name, tag_compound, tag_pose", + multiple=True, + ) + + mrich.var("source: #tags", len(tag_records)) + + if tag_name_map: + mrich.warning("renamed", len(tag_name_map), "tags") + + # insertion query + sql = """ + INSERT INTO hippo.tag( + tag_name, + tag_compound, + tag_pose + ) + VALUES( + %(name)s, + %(compound)s, + %(pose)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + tag_dicts = [ + dict( + name=tag_name_map.get(name, name), + compound=( + migration_data["compound_id_map"][compound_id] if compound_id else None + ), + pose=migration_data["pose_id_map"][pose_id] if pose_id else None, + ) + for name, compound_id, pose_id in tag_records + ] + + # add unchanged tags + for tag in tag_names: + if tag not in tag_name_map: + tag_name_map[tag] = tag + + migration_data["tag_name_map"] = tag_name_map + + # do the insertion + if execute: + executemany(destination, "tag", sql, tag_dicts, batch_size) + + return migration_data + + +def migrate_reactions_and_reactants( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, +) -> dict: + """migrate inspirations""" + + # get source reaction data + source_reaction_dicts, reactant_records = get_reaction_id_reaction_dict_map( + source, migration_data["compound_id_map"] + ) + mrich.var("source: #reactions", len(source_reaction_dicts)) + + # get destination reaction data + destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(destination) + mrich.var("destination: #reactions", len(destination_reaction_dicts)) + + # create keyed lookups + + source_reaction_lookup = { + ( + d["product"], + d["type"], + tuple(sorted(list(d["reactant_ids"]))), + ): d["id"] + for d in source_reaction_dicts.values() + } + + destination_reaction_lookup = { + ( + d["product"], + d["type"], + tuple(sorted(list(d["reactant_ids"]))), + ): d["id"] + for d in destination_reaction_dicts.values() + } + + # work out which source reactions are not in the destination and create a map for existing reactions + + reaction_id_map = {} + new_reaction_dicts = [] + + for key, reaction_id in list(source_reaction_lookup.items()): + + if key in destination_reaction_lookup: + # EXISTING REACTION + reaction_id_map[reaction_id] = destination_reaction_lookup[key] + + else: + + # NEW REACTION + new_reaction_dicts.append(source_reaction_dicts[reaction_id]) + + mrich.var("existing #reactions:", len(reaction_id_map)) + mrich.var("new #reactions:", len(new_reaction_dicts)) + + # reaction insertion query + sql = """ + INSERT INTO hippo.reaction( + reaction_type, + reaction_product, + reaction_product_yield + ) + VALUES( + %(type)s, + %(product)s, + %(product_yield)s + ) + ON CONFLICT DO NOTHING + RETURNING reaction_id; + """ + + # massage the data + reaction_dicts = [ + dict( + type=d["type"], + product=d["product"], + product_yield=d["product_yield"], + ) + for d in new_reaction_dicts + ] + + # do the insertion + inserted_reaction_ids = executemany( + destination, "reaction", sql, reaction_dicts, batch_size + ) + + if inserted_reaction_ids: + inserted_reaction_ids = [i for i, in inserted_reaction_ids] + else: + inserted_reaction_ids = [] + + # add to the map + for reaction_dict, new_reaction_id in zip( + new_reaction_dicts, inserted_reaction_ids + ): + reaction_id = reaction_dict["id"] + reaction_id_map[reaction_id] = new_reaction_id + + migration_data["reaction_id_map"] = reaction_id_map + + # reactant insertion query + sql = """ + INSERT INTO hippo.reactant( + reactant_amount, + reactant_reaction, + reactant_compound + ) + VALUES( + %(amount)s, + %(reaction)s, + %(compound)s + ) + ON CONFLICT DO NOTHING; + """ + + reactant_dicts = [ + dict( + amount=amount, + reaction=reaction_id_map[reaction_id], + compound=migration_data["compound_id_map"][compound_id], + ) + for amount, reaction_id, compound_id in reactant_records + ] + + mrich.var("source: #reactants", len(reactant_dicts)) + + # do the insertion + executemany(destination, "reactant", sql, reactant_dicts, batch_size) + + return migration_data + + +def migrate_features( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate features""" + + # source data + feature_records = source.select( + table="feature", + query="feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names", + multiple=True, + ) + + mrich.var("source: #features", len(feature_records)) + + # insertion query + sql = """ + INSERT INTO hippo.feature( + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, + feature_atom_names + ) + VALUES( + %(family)s, + %(target)s, + %(chain_name)s, + %(residue_name)s, + %(residue_number)s, + %(atom_names)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + feature_dicts = [ + dict( + family=family, + target=migration_data["target_id_map"][target_id], + chain_name=chain_name, + residue_name=residue_name, + residue_number=residue_number, + atom_names=atom_names, + ) + for ( + i, + family, + target_id, + chain_name, + residue_name, + residue_number, + atom_names, + ) in feature_records + ] + + # do the insertion + if execute: + executemany(destination, "feature", sql, feature_dicts, batch_size) + + # get destination values + feature_map = { + ( + family, + target_id, + chain_name, + residue_name, + residue_number, + atom_names, + ): i + for ( + i, + family, + target_id, + chain_name, + residue_name, + residue_number, + atom_names, + ) in destination.select( + table="feature", + query="feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names", + multiple=True, + ) + } + + # map to the destination records + feature_id_map = { + i: feature_map[ + ( + family, + migration_data["target_id_map"][target_id], + chain_name, + residue_name, + residue_number, + atom_names, + ) + ] + for ( + i, + family, + target_id, + chain_name, + residue_name, + residue_number, + atom_names, + ) in feature_records + } + + migration_data["feature_id_map"] = feature_id_map + + return migration_data + + +def migrate_interactions( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate interactions""" + + interaction_fields = [ + "interaction_id", + "interaction_feature", + "interaction_pose", + "interaction_type", + "interaction_family", + "interaction_atom_ids", + "interaction_prot_coord", + "interaction_lig_coord", + "interaction_distance", + "interaction_angle", + "interaction_energy", + ] + + # source data + interaction_records = source.select( + table="interaction", + query=", ".join(interaction_fields), + multiple=True, + ) + + mrich.var("source: #interactions", len(interaction_records)) + + # insertion query + sql = """ + INSERT INTO hippo.interaction( + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy + ) + VALUES( + %(feature)s, + %(pose)s, + %(type)s, + %(family)s, + %(atom_ids)s, + %(prot_coord)s, + %(lig_coord)s, + %(distance)s, + %(angle)s, + %(energy)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + interaction_dicts = [ + dict( + feature=migration_data["feature_id_map"][feature_id], + pose=migration_data["pose_id_map"][pose_id], + type=type, + family=family, + atom_ids=atom_ids, + prot_coord=prot_coord, + lig_coord=lig_coord, + distance=distance, + angle=angle, + energy=energy, + ) + for ( + i, + feature_id, + pose_id, + type, + family, + atom_ids, + prot_coord, + lig_coord, + distance, + angle, + energy, + ) in interaction_records + ] + + # do the insertion + if execute: + executemany(destination, "interaction", sql, interaction_dicts, batch_size) + + # get destination values + interaction_map = { + ( + feature_id, + pose_id, + type, + family, + ): i + for ( + i, + feature_id, + pose_id, + type, + family, + atom_ids, + prot_coord, + lig_coord, + distance, + angle, + energy, + ) in destination.select( + table="interaction", + query=", ".join(interaction_fields), + multiple=True, + ) + } + + # map to the destination records + interaction_id_map = { + i: interaction_map[ + ( + migration_data["feature_id_map"][feature_id], + migration_data["pose_id_map"][pose_id], + type, + family, + ) + ] + for ( + i, + feature_id, + pose_id, + type, + family, + atom_ids, + prot_coord, + lig_coord, + distance, + angle, + energy, + ) in interaction_records + } + + migration_data["interaction_id_map"] = interaction_id_map + + return migration_data + + +def migrate_subsites( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate subsites and subsite_tags""" + + # source data + subsite_records = source.select( + table="subsite", + query="subsite_id, subsite_target, subsite_name, subsite_metadata", + multiple=True, + ) + + mrich.var("source: #subsites", len(subsite_records)) + + # insertion query + sql = """ + INSERT INTO hippo.subsite( + subsite_target, + subsite_name, + subsite_metadata + ) + VALUES( + %(target)s, + %(name)s, + %(metadata)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + subsite_dicts = [ + dict( + target=migration_data["target_id_map"][target_id], + name=name, + metadata=metadata, + ) + for i, target_id, name, metadata in subsite_records + ] + + # do the insertion + if execute: + executemany(destination, "subsite", sql, subsite_dicts, batch_size) + + # map to the destination records + subsite_map = { + (target_id, name): i + for i, target_id, name, metadata in destination.select( + table="subsite", + query="subsite_id, subsite_target, subsite_name, subsite_metadata", + multiple=True, + ) + } + + subsite_id_map = { + i: subsite_map[(migration_data["target_id_map"][target_id], name)] + for i, target_id, name, metadata in subsite_records + } + + migration_data["subsite_id_map"] = subsite_id_map + + ### subsite_tags + + # source data + subsite_tag_records = source.select( + table="subsite_tag", + query="subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata", + multiple=True, + ) + + mrich.var("source: #subsite_tags", len(subsite_tag_records)) + + # insertion query + sql = """ + INSERT INTO hippo.subsite_tag( + subsite_tag_ref, + subsite_tag_pose, + subsite_tag_metadata + ) + VALUES( + %(subsite)s, + %(pose)s, + %(metadata)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + subsite_tag_dicts = [ + dict( + subsite=migration_data["subsite_id_map"][subsite_id], + pose=migration_data["pose_id_map"][pose_id], + metadata=metadata, + ) + for i, subsite_id, pose_id, metadata in subsite_tag_records + ] + + # do the insertion + if execute: + executemany(destination, "subsite_tag", sql, subsite_tag_dicts, batch_size) + + # map to the destination records + subsite_tag_map = { + (subsite_id, pose_id): i + for i, subsite_id, pose_id, metadata in destination.select( + table="subsite_tag", + query="subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata", + multiple=True, + ) + } + + subsite_tag_id_map = { + i: subsite_tag_map[ + ( + migration_data["subsite_id_map"][subsite_id], + migration_data["pose_id_map"][pose_id], + ) + ] + for i, subsite_id, pose_id, metadata in subsite_tag_records + } + + migration_data["subsite_tag_id_map"] = subsite_tag_id_map + + return migration_data + + +def migrate_quotes( + *, + source: "Database", + destination: "PostgresDatabase", + migration_data: dict, + batch_size: int, + execute: bool = True, +) -> dict: + """migrate quotes""" + + quote_fields = [ + "quote_id", + "quote_smiles", + "quote_amount", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_lead_time", + "quote_price", + "quote_currency", + "quote_purity", + "quote_date", + "quote_compound", + ] + + # source data + quote_records = source.select( + table="quote", + query=", ".join(quote_fields), + multiple=True, + ) + + mrich.var("source: #quotes", len(quote_records)) + + # insertion query + sql = """ + INSERT INTO hippo.quote( + quote_smiles, + quote_amount, + quote_supplier, + quote_catalogue, + quote_entry, + quote_lead_time, + quote_price, + quote_currency, + quote_purity, + quote_date, + quote_compound + ) + VALUES( + %(smiles)s, + %(amount)s, + %(supplier)s, + %(catalogue)s, + %(entry)s, + %(lead_time)s, + %(price)s, + %(currency)s, + %(purity)s, + %(date)s, + %(compound)s + ) + ON CONFLICT DO NOTHING; + """ + + # format the data + quote_dicts = [ + dict( + smiles=smiles, + amount=amount, + supplier=supplier, + catalogue=catalogue, + entry=entry, + lead_time=lead_time, + price=price, + currency=currency, + purity=purity, + date=date, + compound=migration_data["compound_id_map"][compound_id], + ) + for ( + i, + smiles, + amount, + supplier, + catalogue, + entry, + lead_time, + price, + currency, + purity, + date, + compound_id, + ) in quote_records + ] + + # do the insertion + if execute: + executemany(destination, "quote", sql, quote_dicts, batch_size) + + # map to the destination records + quote_map = { + (amount, supplier, catalogue, entry): i + for ( + i, + smiles, + amount, + supplier, + catalogue, + entry, + lead_time, + price, + currency, + purity, + date, + compound_id, + ) in destination.select( + table="quote", + query=", ".join(quote_fields), + multiple=True, + ) + } + + quote_id_map = { + i: quote_map[(amount, supplier, catalogue, entry)] + for ( + i, + smiles, + amount, + supplier, + catalogue, + entry, + lead_time, + price, + currency, + purity, + date, + compound_id, + ) in quote_records + } + + migration_data["quote_id_map"] = quote_id_map + + return migration_data + + +def get_reaction_id_reaction_dict_map( + db: "Database | PostgresDatabase", compound_id_map: dict = None +) -> (dict, list): + """Get serialised reaction and reactant data""" + + # reactions + reaction_records = db.select( + table="reaction", + query="reaction_id, reaction_type, reaction_product, reaction_product_yield", + multiple=True, + ) + + reaction_id_reaction_dict_map = { + i: dict( + id=i, + type=t, + product=(compound_id_map[product_id] if compound_id_map else product_id), + product_yield=product_yield, + ) + for i, t, product_id, product_yield in reaction_records + } + + # reactants + reactant_records = db.select( + table="reactant", + query="reactant_amount, reactant_reaction, reactant_compound", + multiple=True, + ) + + # combine + for amount, reaction_id, compound_id in reactant_records: + compound_id = compound_id_map[compound_id] if compound_id_map else compound_id + + reaction_id_reaction_dict_map[reaction_id].setdefault("reactants", set()) + reaction_id_reaction_dict_map[reaction_id]["reactants"].add( + (compound_id, amount) + ) + + reaction_id_reaction_dict_map[reaction_id].setdefault("reactant_ids", set()) + reaction_id_reaction_dict_map[reaction_id]["reactant_ids"].add(compound_id) + + return reaction_id_reaction_dict_map, reactant_records + + +def executemany( + db: "PostgresDatabase", table: str, sql: str, payload: list, batch_size: int +) -> None | list: + """Bulk execution with console logging""" + + n = db.count(table) + mrich.var(f"destination: #{table}s", n) + + result = db.executemany(sql, payload, batch_size=batch_size) + + if d := db.count(table) - n: + mrich.success("Inserted", d, f"new {table}s") + else: + mrich.warning("Inserted", d, f"new {table}s") + + return result + + +def dump_json(data: dict, file: str) -> None: + """Dump migration data to JSON""" + from json import dump + + mrich.writing(file) + dump(data, open(file, "wt")) + + +def dump_xlsx(data: dict, file: str) -> None: + """Dump migration data to Excel""" + + import pandas as pd + + mrich.writing(file) + + meta = [] + for key, value in data.items(): + if not isinstance(value, dict): + meta.append(dict(key=key, value=value)) + + meta_df = pd.DataFrame(meta).set_index("key") + + source = meta_df.loc["source", "value"] + destination = meta_df.loc["destination", "value"] + + sheets = {} + for key, value in data.items(): + if isinstance(value, dict): + + data = [{source: k, destination: v} for k, v in value.items()] + + if len(data) > 1_000_000: + from itertools import batched + + batches = batched(data, 1_000_000) + + for i, batch in enumerate(batches): + df = pd.DataFrame(batch) + sheets[f"{key} ({i+1})"] = df.set_index(source) + + else: + df = pd.DataFrame(data) + sheets[key] = df.set_index(source) + + with pd.ExcelWriter(file) as writer: + + meta_df.to_excel(writer, sheet_name="meta") + + for name, df in sheets.items(): + df.to_excel(writer, sheet_name=name, index=True) diff --git a/hippo/postgres.py b/hippo/postgres.py index 332d392..d348648 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -351,7 +351,7 @@ def executemany( sql, payload=None, *, - debug: bool = True, + debug: bool = False, time: bool = False, batch_size: int = None, ): @@ -372,7 +372,7 @@ def executemany( start = perf_counter() - if batch_size: + if batch_size and batch_size < len(payload): from itertools import batched, chain @@ -529,6 +529,13 @@ def calculate_all_murcko_scaffolds(self) -> None: def migrate_sqlite( self, source: str | Path, + *, + reactions: bool = True, + scaffolds: bool = True, + features: bool = True, + interactions: bool = True, + subsites: bool = True, + quotes: bool = True, batch_size: int = 5_000, tag_compound_id_regex: list[tuple[str, str]] | None = None, # tag_name_map: "Callable" = None, @@ -545,13 +552,25 @@ def migrate_sqlite( """ - import re - import pandas as pd - from json import dump - from rdkit.Chem import Mol from datetime import datetime from .animal import HIPPO + from .migration import ( + migrate_compounds, + migrate_scaffolds, + migrate_targets, + migrate_poses, + migrate_pose_references, + migrate_inspirations, + migrate_tags, + migrate_reactions_and_reactants, + migrate_features, + migrate_interactions, + migrate_subsites, + migrate_quotes, + dump_xlsx, + dump_json, + ) mrich.var("source", source) mrich.var("batch_size", batch_size) @@ -575,605 +594,145 @@ def migrate_sqlite( ### helper functions - def executemany(table, sql, payload): - - n = self.count(table) - mrich.var(f"destination: #{table}s", n) - - result = self.executemany(sql, payload, batch_size=batch_size) - - if d := self.count(table) - n: - mrich.success("Inserted", d, f"new {table}s") - else: - mrich.warning("Inserted", d, f"new {table}s") - - return result - - def dump_json(data, file): - mrich.writing(file) - dump(data, open(file, "wt")) - - def dump_xlsx(data, file): - mrich.writing(file) - - meta = [] - for key, value in data.items(): - if not isinstance(value, dict): - meta.append(dict(key=key, value=value)) - - meta_df = pd.DataFrame(meta).set_index("key") - - source = meta_df.loc["source", "value"] - destination = meta_df.loc["destination", "value"] - - sheets = {} - for key, value in data.items(): - if isinstance(value, dict): - - df = pd.DataFrame( - [{source: k, destination: v} for k, v in value.items()] - ) - sheets[key] = df.set_index(source) - - with pd.ExcelWriter(file) as writer: - - meta_df.to_excel(writer, sheet_name="meta") - - for name, df in sheets.items(): - df.to_excel(writer, sheet_name=name, index=True) - try: migration_data = { "source": str(source_path.resolve()), "destination": self.path, "time": str(datetime.now()), + "tag_compound_id_regex": tag_compound_id_regex, } ### compounds - # source data - compound_records = source.db.select( - table="compound", - query="compound_id, compound_inchikey, compound_smiles, compound_alias", - multiple=True, - ) - - mrich.var("source: #compounds", len(compound_records)) - - # insertion query - sql = """ - INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_alias - ) - VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s), - %(alias)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - compound_dicts = [ - dict(inchikey=b, smiles=c, alias=d) for a, b, c, d in compound_records - ] - - # do the insertion - # executemany("compound", sql, compound_dicts) - - # map to the destination records - destination_inchikey_map = self.get_compound_inchikey_id_dict( - inchikeys=[b for a, b, c, d in compound_records] + migration_data = migrate_compounds( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - compound_id_map = { - a: destination_inchikey_map.get(b) for a, b, c, d in compound_records - } - - migration_data["compound_id_map"] = compound_id_map - ### scaffolds - # source data - scaffold_records = source.db.select( - table="scaffold", - query="scaffold_base, scaffold_superstructure", - multiple=True, - ) - - # map to new IDs - scaffold_records = [ - (compound_id_map[a], compound_id_map[b]) for a, b in scaffold_records - ] + if scaffolds: - mrich.var("source: #scaffolds", len(scaffold_records)) - - # insert new data - - sql = """ - INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - - # executemany("scaffold", sql, scaffold_records) + migration_data = migrate_scaffolds( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, + ) ### targets - # source data - target_records = source.db.select( - table="target", query="target_id, target_name", multiple=True + migration_data = migrate_targets( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - # do the insertion - for i, name in target_records: - self.insert_target(name=name, warn_duplicate=False) - - # map to the destination records - destination_target_name_map = { - name: i - for i, name in self.select( - table="target", query="target_id, target_name", multiple=True - ) - } - - target_id_map = { - i: destination_target_name_map[name] for i, name in target_records - } - - migration_data["target_id_map"] = target_id_map - ### poses - pose_fields = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_path", - "pose_compound", - "pose_target", - # "CASE WHEN pose_mol IS NOT NULL THEN mol_to_binary_mol(pose_mol) ELSE pose_mol END", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", - "pose_metadata", - ] - - # source data - pose_records = source.db.select( - table="pose", query=", ".join(pose_fields), multiple=True + migration_data = migrate_poses( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - # insertion query - sql = """ - INSERT INTO hippo.pose( - pose_inchikey, - pose_alias, - pose_smiles, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - pose_inspiration_score, - pose_metadata - ) - VALUES( - %(inchikey)s, - %(alias)s, - %(smiles)s, - %(path)s, - %(compound)s, - %(target)s, - hippo.mol_from_pkl(%(mol)s), - %(fingerprint)s, - %(energy_score)s, - %(distance_score)s, - %(inspiration_score)s, - %(metadata)s - ) - ON CONFLICT DO NOTHING; - """ - - # massage the data - pose_dicts = [ - dict( - id=i, - inchikey=inchikey, - alias=alias, - smiles=smiles, - path=path, - compound=compound_id_map[compound_id], - target=target_id_map[target_id], - mol=Mol(mol).ToBinary() if mol else None, - fingerprint=fingerprint, - energy_score=energy_score, - distance_score=distance_score, - inspiration_score=inspiration_score, - metadata=metadata, - ) - for ( - i, - inchikey, - alias, - smiles, - path, - compound_id, - target_id, - mol, - fingerprint, - energy_score, - distance_score, - inspiration_score, - metadata, - ) in pose_records - ] - - mrich.var("source: #poses", len(pose_dicts)) - - # do the insertion - # executemany("pose", sql, pose_dicts) - - # map to the destination records - destination_pose_path_map = self.get_pose_path_id_dict() - - # return destination_pose_path_map - - pose_id_map = { - p["id"]: destination_pose_path_map[p["path"]] for p in pose_dicts - } - - migration_data["pose_id_map"] = pose_id_map - ### pose references - # source data - reference_records = source.db.select( - table="pose", - query="pose_id, pose_reference", - multiple=True, + migration_data = migrate_pose_references( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - # map to new IDs - reference_dicts = [ - dict(pose=pose_id_map[a], reference=pose_id_map[b]) - for a, b in reference_records - if b - ] - - mrich.var("source: #references", len(reference_dicts)) - - # insert new data - - sql = """ - UPDATE hippo.pose - SET pose_reference = %(reference)s - WHERE pose_id = %(pose)s; - """ - - # self.executemany(sql, reference_dicts, batch_size=batch_size) - ### inspirations - # source data - inspiration_records = source.db.select( - table="inspiration", - query="inspiration_original, inspiration_derivative", - multiple=True, + migration_data = migrate_inspirations( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - # map to new IDs - inspiration_dicts = [ - dict(original=pose_id_map[a], derivative=pose_id_map[b]) - for a, b in inspiration_records - if b - ] - - mrich.var("source: #inspirations", len(inspiration_dicts)) - - # insert new data - - sql = """ - INSERT INTO hippo.inspiration( - inspiration_original, - inspiration_derivative - ) - VALUES ( - %(original)s, - %(derivative)s - ) - ON CONFLICT DO NOTHING; - """ - - # executemany("inspiration", sql, inspiration_dicts) - ### tags - # unique tag names - - tag_names = source.db.select( - table="tag", query="DISTINCT tag_name", multiple=True + migration_data = migrate_tags( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - tag_names = sorted([t for t, in tag_names]) - - # rename tags based on regex - - tag_name_map = {} - for tag in tag_names: - for pattern, template in tag_compound_id_regex: - - match = re.match(pattern, tag) - - if not match: - continue - - groups = match.groups() - - assert ( - len(groups) == 1 - ), f"tag_compound_id_regex replacement not supported with multiple groups, {pattern=}" - - groups = [g for g in groups] - - compound_id = int(groups[0]) - new_compound_id = compound_id_map[compound_id] - - replacement = template.format(new_compound_id=new_compound_id) - - new_tag = re.sub(pattern, replacement, tag) - - tag_name_map[tag] = new_tag - - break - - # source data - tag_records = source.db.select( - table="tag", - query="tag_name, tag_compound, tag_pose", - multiple=True, - ) - - mrich.var("source: #tags", len(tag_records)) - - if tag_name_map: - mrich.warning("renamed", len(tag_name_map), "tags") - - # insertion query - sql = """ - INSERT INTO hippo.tag( - tag_name, - tag_compound, - tag_pose - ) - VALUES( - %(name)s, - %(compound)s, - %(pose)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - tag_dicts = [ - dict( - name=tag_name_map.get(a, a), - compound=compound_id_map[b] if b else None, - pose=pose_id_map[c] if c else None, - ) - for a, b, c in tag_records - ] - - # add unchanged tags - for tag in tag_names: - if tag not in tag_name_map: - tag_name_map[tag] = tag - - migration_data["tag_name_map"] = tag_name_map - - # do the insertion - # executemany("tag", sql, tag_dicts) - ### reactions & reactants - def get_reaction_id_reaction_dict_map(db, compound_id_map=None): - - # reactions - reaction_records = db.select( - table="reaction", - query="reaction_id, reaction_type, reaction_product, reaction_product_yield", - multiple=True, - ) + if reactions: - reaction_id_reaction_dict_map = { - i: dict( - id=i, - type=t, - product=( - compound_id_map[product_id] - if compound_id_map - else product_id - ), - product_yield=product_yield, - ) - for i, t, product_id, product_yield in reaction_records - } - - # reactants - reactant_records = db.select( - table="reactant", - query="reactant_amount, reactant_reaction, reactant_compound", - multiple=True, + migration_data = migrate_reactions_and_reactants( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, ) - # combine - for amount, reaction_id, compound_id in reactant_records: - compound_id = ( - compound_id_map[compound_id] if compound_id_map else compound_id - ) - - reaction_id_reaction_dict_map[reaction_id].setdefault( - "reactants", set() - ) - reaction_id_reaction_dict_map[reaction_id]["reactants"].add( - (compound_id, amount) - ) - - reaction_id_reaction_dict_map[reaction_id].setdefault( - "reactant_ids", set() - ) - reaction_id_reaction_dict_map[reaction_id]["reactant_ids"].add( - compound_id - ) - - return reaction_id_reaction_dict_map, reactant_records - - # get source reaction data - source_reaction_dicts, reactant_records = get_reaction_id_reaction_dict_map( - source.db, compound_id_map - ) - mrich.var("source: #reactions", len(source_reaction_dicts)) - - # get destination reaction data - destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(self) - mrich.var("destination: #reactions", len(destination_reaction_dicts)) - - # create keyed lookups - - source_reaction_lookup = { - (d["product"], d["type"], tuple(sorted(list(d["reactant_ids"])))): d[ - "id" - ] - for d in source_reaction_dicts.values() - } - - destination_reaction_lookup = { - (d["product"], d["type"], tuple(sorted(list(d["reactant_ids"])))): d[ - "id" - ] - for d in destination_reaction_dicts.values() - } - - # work out which source reactions are not in the destination and create a map for existing reactions - - reaction_id_map = {} - new_reaction_dicts = [] - - for key, reaction_id in list(source_reaction_lookup.items()): - - if key in destination_reaction_lookup: - # EXISTING REACTION - reaction_id_map[reaction_id] = destination_reaction_lookup[key] - - else: - - # NEW REACTION - new_reaction_dicts.append(source_reaction_dicts[reaction_id]) + ### features - mrich.var("existing #reactions:", len(reaction_id_map)) - mrich.var("new #reactions:", len(new_reaction_dicts)) + if features or interactions: - # reaction insertion query - sql = """ - INSERT INTO hippo.reaction( - reaction_type, - reaction_product, - reaction_product_yield - ) - VALUES( - %(type)s, - %(product)s, - %(product_yield)s - ) - ON CONFLICT DO NOTHING - RETURNING reaction_id; - """ - - # massage the data - reaction_dicts = [ - dict( - type=d["type"], - product=d["product"], - product_yield=d["product_yield"], + migration_data = migrate_features( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, ) - for d in new_reaction_dicts - ] - # do the insertion - inserted_reaction_ids = executemany("reaction", sql, reaction_dicts) - - if inserted_reaction_ids: - inserted_reaction_ids = [i for i, in inserted_reaction_ids] - else: - inserted_reaction_ids = [] - - # add to the map - for reaction_dict, new_reaction_id in zip( - new_reaction_dicts, inserted_reaction_ids - ): - reaction_id = reaction_dict["id"] - reaction_id_map[reaction_id] = new_reaction_id - - migration_data["reaction_id_map"] = reaction_id_map - - # reactant insertion query - sql = """ - INSERT INTO hippo.reactant( - reactant_amount, - reactant_reaction, - reactant_compound - ) - VALUES( - %(amount)s, - %(reaction)s, - %(compound)s - ) - ON CONFLICT DO NOTHING; - """ - - reactant_dicts = [ - dict( - amount=amount, - reaction=reaction_id_map[reaction_id], - compound=compound_id_map[compound_id], - ) - for amount, reaction_id, compound_id in reactant_records - ] - - mrich.var("source: #reactants", len(reactant_dicts)) - - # do the insertion - # executemany("reactant", sql, reactant_dicts) - - ### quotes + ### interactions - ### features + if interactions: - ### interactions + migration_data = migrate_interactions( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, + ) ### subsites - ### subsite_tags + if subsites: - ### routes (skip?) + migration_data = migrate_subsites( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, + ) - ### components (skip?) + ### quotes - raise NotImplementedError + if quotes: - mrich.success( - "Migration staged. Please review and db.commit() or db.rollback() the changes" - ) + migration_data = migrate_quotes( + source=source.db, + destination=self, + migration_data=migration_data, + batch_size=batch_size, + # execute=False, + ) except Exception as e: self.rollback() @@ -1190,11 +749,15 @@ def get_reaction_id_reaction_dict_map(db, compound_id_map=None): dump_json(migration_data, json_file_name) dump_xlsx(migration_data, xlsx_file_name) - # raise + raise dump_json(migration_data, json_file_name) dump_xlsx(migration_data, xlsx_file_name) + mrich.success( + "Migration staged. Please review and db.commit() or db.rollback() the changes" + ) + return migration_data ### MAINTENANCE From 456ee1ea65e94deade8d9018709cbcbe45c58746 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:33:45 +0000 Subject: [PATCH 072/163] postgres dev #245 --- hippo/postgres.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index d348648..65fecd1 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -538,9 +538,7 @@ def migrate_sqlite( quotes: bool = True, batch_size: int = 5_000, tag_compound_id_regex: list[tuple[str, str]] | None = None, - # tag_name_map: "Callable" = None, - # rename_tag_compound_shortcodes: bool = True - ) -> dict: + ) -> None: """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase` :param source: path to source sqlite database @@ -758,8 +756,6 @@ def migrate_sqlite( "Migration staged. Please review and db.commit() or db.rollback() the changes" ) - return migration_data - ### MAINTENANCE def _drop_schema(self) -> None: @@ -769,10 +765,31 @@ def _drop_schema(self) -> None: self.commit() def _drop_tables(self) -> None: - """Delete all HIPPO tables""" + """Delete all HIPPO tables and restart sequences""" for table in self.TABLES: - self.execute(f"DROP TABLE IF EXISTS {table};") + self.execute(f"DROP TABLE IF EXISTS {self.SQL_SCHEMA}.{table} CASCADE;") + + # sql = f""" + # DO $$ + # DECLARE + # seq RECORD; + # BEGIN + # FOR seq IN + # SELECT sequence_schema, sequence_name + # FROM information_schema.sequences + # WHERE sequence_schema = '{self.SQL_SCHEMA}' + # LOOP + # EXECUTE format( + # 'ALTER SEQUENCE %I.%I RESTART WITH 1;', + # seq.sequence_schema, + # seq.sequence_name + # ); + # END LOOP; + # END $$; + # """ + + # self.execute(sql) self.commit() From 0903031fa55c661be2a42d3adc1b5f93e2ed31ad Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:41:48 +0000 Subject: [PATCH 073/163] add psycopg requirement --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index cadfc09..ac5e693 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "openmm", "apsw", "python-louvain", + "psycopg[binary]", ] [project.urls] "Homepage" = "https://hippo.winokan.com" From df02eebcd700c3b9c82e6b11704d0f59d1846ff6 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:44:20 +0000 Subject: [PATCH 074/163] TagTable.summary: postgres support #245 --- hippo/tags.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hippo/tags.py b/hippo/tags.py index 3dca85e..873ac4f 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -52,8 +52,8 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": from pandas import DataFrame - sql = """ - SELECT tag_name, + sql = f""" + SELECT {self.db.SQL_SCHEMA}tag_name, COUNT(DISTINCT tag_compound), COUNT(DISTINCT tag_pose) FROM tag @@ -72,8 +72,8 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": # compounds with poses - sql = """ - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM tag + sql = f""" + SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA}tag INNER JOIN pose ON tag_pose = pose_id GROUP BY tag_name From 0e4de7a063e03ad9a2caedf39a022755e7c50425 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:45:04 +0000 Subject: [PATCH 075/163] TagTable.summary: postgres support #245 --- hippo/tags.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hippo/tags.py b/hippo/tags.py index 873ac4f..28c5e68 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -53,10 +53,10 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": from pandas import DataFrame sql = f""" - SELECT {self.db.SQL_SCHEMA}tag_name, + SELECT tag_name, COUNT(DISTINCT tag_compound), COUNT(DISTINCT tag_pose) - FROM tag + FROM {self.db.SQL_SCHEMA}tag GROUP BY tag_name ORDER BY tag_name; """ @@ -73,7 +73,8 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": # compounds with poses sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA}tag + SELECT tag_name, COUNT(DISTINCT pose_compound) + FROM {self.db.SQL_SCHEMA}tag INNER JOIN pose ON tag_pose = pose_id GROUP BY tag_name From ebfc886cd691b56fb9811e75b2cbd5a895faebe1 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:45:42 +0000 Subject: [PATCH 076/163] TagTable.summary: postgres support #245 --- hippo/tags.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hippo/tags.py b/hippo/tags.py index 28c5e68..6de4e51 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -56,7 +56,7 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": SELECT tag_name, COUNT(DISTINCT tag_compound), COUNT(DISTINCT tag_pose) - FROM {self.db.SQL_SCHEMA}tag + FROM {self.db.SQL_SCHEMA_PREFIX}tag GROUP BY tag_name ORDER BY tag_name; """ @@ -74,7 +74,7 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": sql = f""" SELECT tag_name, COUNT(DISTINCT pose_compound) - FROM {self.db.SQL_SCHEMA}tag + FROM {self.db.SQL_SCHEMA_PREFIX}tag INNER JOIN pose ON tag_pose = pose_id GROUP BY tag_name From 00d388af319254b40d8b1c183e70896ef46ce19a Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 09:52:09 +0000 Subject: [PATCH 077/163] TagTable.summary & tests --- hippo/tags.py | 2 +- tests/test_tags.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/test_tags.py diff --git a/hippo/tags.py b/hippo/tags.py index 6de4e51..af818e3 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -75,7 +75,7 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": sql = f""" SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN pose + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON tag_pose = pose_id GROUP BY tag_name ORDER BY tag_name; diff --git a/tests/test_tags.py b/tests/test_tags.py new file mode 100644 index 0000000..0b991fc --- /dev/null +++ b/tests/test_tags.py @@ -0,0 +1,40 @@ +from config import * + +NOT_NULL_PROPERTIES = [ + "unique", +] + +PROPERTIES = [] + + +def test_properties(): + + import hippo + + animal = hippo.HIPPO("test", DB) + tag_table = animal.tags + + for prop in NOT_NULL_PROPERTIES: + value = getattr(tag_table, prop) + print(prop, value) + assert value is not None, f"{prop} is None" + + for prop in PROPERTIES: + value = getattr(tag_table, prop) + print(prop, value) + + animal.db.close() + + +def test_summary(): + + import hippo + + animal = hippo.HIPPO("test", DB) + tag_table = animal.tags + tag_table.summary() + + +if __name__ == "__main__": + test_properties() + test_summary() From 953e0dabcfe0d694703172ecbb7b4a71058b60a8 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 10:55:36 +0000 Subject: [PATCH 078/163] attempt at renaming pose paths with regex #245 --- hippo/migration.py | 100 ++++++++++++++++++++++++++++++++++++++++++++- hippo/postgres.py | 25 +++++++++++- 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/hippo/migration.py b/hippo/migration.py index 64bbf44..7d545a1 100644 --- a/hippo/migration.py +++ b/hippo/migration.py @@ -240,6 +240,12 @@ def migrate_poses( mrich.var("source: #poses", len(pose_dicts)) + ### THIS DEVELOPMENT WAS NOT COMPLETED, + ### TO IMPLEMENT WOULD REQUIRE FIRST INSERTING ALL + ### UPSTREAM REFERENCES AND INSPIRATIONS SO THEIR IDS + ### ARE IN THE POSE_ID_MAP + # pose_dicts = rename_pose_paths(pose_dicts, migration_data) + # do the insertion if execute: executemany(destination, "pose", sql, pose_dicts, batch_size) @@ -392,7 +398,8 @@ def migrate_tags( new_tag = re.sub(pattern, replacement, tag) - tag_name_map[tag] = new_tag + if new_tag != tag: + tag_name_map[tag] = new_tag break @@ -1192,6 +1199,97 @@ def executemany( return result +def rename_pose_paths( + pose_dicts: list[dict], + migration_data: dict, +) -> list[dict]: + """Uses regex to rename ID's in pose paths""" + + import re + + mrich.var( + "pose_path_compound_id_regex", migration_data["pose_path_compound_id_regex"] + ) + mrich.var("pose_path_pose_id_regex", migration_data["pose_path_pose_id_regex"]) + + # compound IDs + + pose_path_map = {} + # pose_path_map_log = {} + + for pose_dict in pose_dicts: + + orig_path = pose_dict["path"] + + path = orig_path + + for pattern, template in migration_data["pose_path_compound_id_regex"]: + + if orig_path in pose_path_map: + path = pose_path_map[orig_path] + + match = re.match(pattern, path) + + if not match: + # if "fake.mol" in path: + # print("NO MATCH", pattern, path) + # raise NotImplementedError + continue + + groups = match.groups() + + assert ( + len(groups) == 1 + ), f"pose_path_compound_id_regex replacement not supported with multiple groups, {pattern=}" + + groups = [g for g in groups] + + compound_id = int(groups[0]) + new_compound_id = migration_data["compound_id_map"][compound_id] + + replacement = template.format(new_compound_id=new_compound_id) + + new_path = re.sub(pattern, replacement, path) + + if new_path != path: + pose_path_map[orig_path] = new_path + + raise NotImplementedError("pose_path_pose_id_regex development was not completed") + + # for pattern, template in migration_data["pose_path_pose_id_regex"]: + + # if orig_path in pose_path_map: + # path = pose_path_map[orig_path] + + # match = re.match(pattern, path) + + # if not match: + # # if "fake.mol" in path: + # # print("NO MATCH", pattern, path) + # # raise NotImplementedError + # continue + + # groups = match.groups() + + # assert ( + # len(groups) == 1 + # ), f"pose_path_pose_id_regex replacement not supported with multiple groups, {pattern=}" + + # groups = [g for g in groups] + + # pose_id = int(groups[0]) + # new_pose_id = migration_data["pose_id_map"][pose_id] + + # replacement = template.format(new_pose_id=new_pose_id) + + # new_path = re.sub(pattern, replacement, path) + + # if new_path != path: + # pose_path_map[orig_path] = new_path + + return pose_dicts + + def dump_json(data: dict, file: str) -> None: """Dump migration data to JSON""" from json import dump diff --git a/hippo/postgres.py b/hippo/postgres.py index 65fecd1..f30c00a 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -538,6 +538,9 @@ def migrate_sqlite( quotes: bool = True, batch_size: int = 5_000, tag_compound_id_regex: list[tuple[str, str]] | None = None, + # pose_path_compound_id_regex: list[tuple[str, str]] | None = None, + # pose_path_pose_id_regex: list[tuple[str, str]] | None = None, + # overwrite_quotes: bool = True, ) -> None: """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase` @@ -583,11 +586,27 @@ def migrate_sqlite( if not tag_compound_id_regex: tag_compound_id_regex = [ - (r"^C([0-9]+)", "C{new_compound_id}"), + (r"^C([0-9]+).*$", "C{new_compound_id}"), ] - mrich.var("tag_compound_id_regex", tag_compound_id_regex) + ### THIS DEV WAS NOT COMPLETED + + # if not pose_path_compound_id_regex: + # pose_path_compound_id_regex = [ + # (r"\/.*\/C([0-9]+)-P[0-9]+\.fake\.mol$", "C{new_compound_id}"), + # ] + # mrich.var("pose_path_compound_id_regex", pose_path_compound_id_regex) + + # if not pose_path_pose_id_regex: + # pose_path_pose_id_regex = [ + # (r"\/.*\/C[0-9]+-P([0-9]+)\.fake\.mol$", "P{new_pose_id}"), + # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P([0-9]+)-P[0-9]+-P[0-9]+-[0-9]{6}.fake.mol$", "P{new_pose_id}"), + # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P[0-9]+-P([0-9]+)-P[0-9]+-[0-9]{6}.fake.mol$", "P{new_pose_id}"), + # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P[0-9]+-P[0-9]+-P([0-9]+)-[0-9]{6}.fake.mol$", "P{new_pose_id}"), + # ] + # mrich.var("pose_path_pose_id_regex", pose_path_pose_id_regex) + source = HIPPO("source", source_path) ### helper functions @@ -599,6 +618,8 @@ def migrate_sqlite( "destination": self.path, "time": str(datetime.now()), "tag_compound_id_regex": tag_compound_id_regex, + # "pose_path_compound_id_regex": pose_path_compound_id_regex, + # "pose_path_pose_id_regex": pose_path_pose_id_regex, } ### compounds From 0dcca3ce8f70fca0dfba5456dab9170d2b53a621 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 11:11:40 +0000 Subject: [PATCH 079/163] postgres images #245 --- images/postgres/Dockerfile | 119 +++++++++ images/postgres/docker-entrypoint.sh | 382 +++++++++++++++++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 images/postgres/Dockerfile create mode 100644 images/postgres/docker-entrypoint.sh diff --git a/images/postgres/Dockerfile b/images/postgres/Dockerfile new file mode 100644 index 0000000..42f5e41 --- /dev/null +++ b/images/postgres/Dockerfile @@ -0,0 +1,119 @@ +# Single Dockerfile: builder stage builds RDKit; final stage is Debian Trixie runtime +ARG PG_MAJOR=18 +ARG RDKIT_VERSION=Release_2025_09_1 + +######################################## +# Stage: builder (build RDKit) +######################################## +FROM debian:trixie AS builder + +ARG PG_MAJOR +ARG RDKIT_VERSION +ENV DEBIAN_FRONTEND=noninteractive +ENV CMAKE_PREFIX_PATH=/usr/local + +# Install build deps and PGDG repo so postgresql-18 is available during builder +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr && \ + CODENAME="$(lsb_release -cs)"; \ + wget -qO /usr/share/keyrings/pgdg.gpg https://www.postgresql.org/media/keys/ACCC4CF8.asc; \ + echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt ${CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates curl wget python3 python3-pip \ + postgresql-${PG_MAJOR} postgresql-server-dev-${PG_MAJOR} \ + libeigen3-dev libcairo2-dev libfreetype6-dev libpng-dev libjpeg-dev \ + zlib1g-dev pkg-config libboost-all-dev && \ + rm -rf /var/lib/apt/lists/* + +# Fetch RDKit and build +WORKDIR /opt +RUN git clone --depth 1 --branch "${RDKIT_VERSION}" https://github.com/rdkit/rdkit.git /opt/rdkit + +WORKDIR /opt/rdkit_build +RUN set -eux; \ + PG_INC="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --includedir)"; \ + PG_INC_SRV="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --includedir-server)"; \ + PG_LIB="$(/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config --libdir)"; \ + cmake -S /opt/rdkit -B /opt/rdkit_build \ + -DCMAKE_BUILD_TYPE=Release \ + -DRDK_BUILD_PGSQL=ON \ + -DRDK_BUILD_PYTHON_WRAPPERS=OFF \ + -DRDK_BUILD_INCHI_SUPPORT=ON \ + -DRDK_BUILD_AVALON_SUPPORT=ON \ + -DRDK_BUILD_CAIRO_SUPPORT=ON \ + -DRDK_PGSQL_STATIC=ON \ + -DPostgreSQL_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPostgreSQL_INCLUDE_DIR="${PG_INC}" \ + -DPostgreSQL_TYPE_INCLUDE_DIR="${PG_INC_SRV}" \ + -DPostgreSQL_LIBRARY_DIR="${PG_LIB}"; \ + cmake --build /opt/rdkit_build -j"$(nproc)"; \ + cmake --install /opt/rdkit_build --strip + +# Preserve artifacts in canonical places for copying into final +RUN mkdir -p /usr/lib/postgresql/${PG_MAJOR}/lib /usr/share/postgresql/${PG_MAJOR}/extension /usr/local/lib && \ + cp -a /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ 2>/dev/null || true && \ + cp -a /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ 2>/dev/null || true && \ + cp -a /usr/lib/x86_64-linux-gnu/libboost_*.so* /usr/local/lib/ 2>/dev/null || true + +######################################## +# Stage: final (runtime on Debian Trixie) +######################################## +FROM debian:trixie AS final + +ARG PG_MAJOR +ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:${PATH}" +ENV PGDATA=/var/lib/postgresql/data + +# Add PGDG repo in final stage so postgresql-18 packages are available +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr && \ + CODENAME="$(lsb_release -cs)"; \ + wget -qO /usr/share/keyrings/pgdg.gpg https://www.postgresql.org/media/keys/ACCC4CF8.asc; \ + echo "deb [signed-by=/usr/share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt ${CODENAME}-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates wget gnupg2 lsb-release dirmngr gettext procps \ + libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g \ + postgresql-${PG_MAJOR} postgresql-server-dev-${PG_MAJOR} && \ + rm -rf /var/lib/apt/lists/* + +# Copy artifacts from the local builder stage (NOT from a deleted external image) +COPY --from=builder /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ +COPY --from=builder /usr/local/lib/ /usr/local/lib/ + +# change UID/GID and fix ownership (optional) +RUN set -eux; \ + OLD_UID="$(id -u postgres)"; OLD_GID="$(id -g postgres)"; \ + usermod -u 999 postgres || true; groupmod -g 999 postgres || true; \ + find / -path /proc -prune -o -user "${OLD_UID}" -exec chown -h 999 {} + 2>/dev/null || true; \ + find / -path /proc -prune -o -group "${OLD_GID}" -exec chgrp -h 999 {} + 2>/dev/null || true + +# Make common PG tools available in /usr/bin +RUN ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/initdb /usr/bin/initdb || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/pg_isready /usr/bin/pg_isready || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/pg_ctl /usr/bin/pg_ctl || true && \ + ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/psql /usr/bin/psql || true + +# Vendor entrypoint (place official script at files/docker-entrypoint.sh) +COPY files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Prepare dirs and permissions +RUN mkdir -p /docker-entrypoint-initdb.d /var/lib/postgresql && chown -R postgres:postgres /var/lib/postgresql /docker-entrypoint-initdb.d /usr/share/postgresql/${PG_MAJOR}/extension + +RUN ldconfig + +EXPOSE 5432 +VOLUME ["/var/lib/postgresql/data"] + +USER postgres +WORKDIR /var/lib/postgresql + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["postgres", "-c", "listen_addresses=*", "-D", "/var/lib/postgresql/data"] diff --git a/images/postgres/docker-entrypoint.sh b/images/postgres/docker-entrypoint.sh new file mode 100644 index 0000000..c3432be --- /dev/null +++ b/images/postgres/docker-entrypoint.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +set -Eeo pipefail +# TODO swap to -Eeuo pipefail above (after handling all potentially-unset variables) + +# usage: file_env VAR [DEFAULT] +# ie: file_env 'XYZ_DB_PASSWORD' 'example' +# (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of +# "$XYZ_DB_PASSWORD" from a file, especially for Docker's secrets feature) +file_env() { + local var="$1" + local fileVar="${var}_FILE" + local def="${2:-}" + if [ "${!var:-}" ] && [ "${!fileVar:-}" ]; then + printf >&2 'error: both %s and %s are set (but are exclusive)\n' "$var" "$fileVar" + exit 1 + fi + local val="$def" + if [ "${!var:-}" ]; then + val="${!var}" + elif [ "${!fileVar:-}" ]; then + val="$(< "${!fileVar}")" + fi + export "$var"="$val" + unset "$fileVar" +} + +# check to see if this file is being run or sourced from another script +_is_sourced() { + # https://unix.stackexchange.com/a/215279 + [ "${#FUNCNAME[@]}" -ge 2 ] \ + && [ "${FUNCNAME[0]}" = '_is_sourced' ] \ + && [ "${FUNCNAME[1]}" = 'source' ] +} + +# used to create initial postgres directories and if run as root, ensure ownership to the "postgres" user +docker_create_db_directories() { + local user; user="$(id -u)" + + mkdir -p "$PGDATA" + # ignore failure since there are cases where we can't chmod (and PostgreSQL might fail later anyhow - it's picky about permissions of this directory) + chmod 00700 "$PGDATA" || : + + # ignore failure since it will be fine when using the image provided directory; see also https://github.com/docker-library/postgres/pull/289 + mkdir -p /var/run/postgresql || : + chmod 03775 /var/run/postgresql || : + + # Create the transaction log directory before initdb is run so the directory is owned by the correct user + if [ -n "${POSTGRES_INITDB_WALDIR:-}" ]; then + mkdir -p "$POSTGRES_INITDB_WALDIR" + if [ "$user" = '0' ]; then + find "$POSTGRES_INITDB_WALDIR" \! -user postgres -exec chown postgres '{}' + + fi + chmod 700 "$POSTGRES_INITDB_WALDIR" + fi + + # allow the container to be started with `--user` + if [ "$user" = '0' ]; then + find "$PGDATA" \! -user postgres -exec chown postgres '{}' + + find /var/run/postgresql \! -user postgres -exec chown postgres '{}' + + fi +} + +# initialize empty PGDATA directory with new database via 'initdb' +# arguments to `initdb` can be passed via POSTGRES_INITDB_ARGS or as arguments to this function +# `initdb` automatically creates the "postgres", "template0", and "template1" dbnames +# this is also where the database user is created, specified by `POSTGRES_USER` env +docker_init_database_dir() { + # "initdb" is particular about the current user existing in "/etc/passwd", so we use "nss_wrapper" to fake that if necessary + # see https://github.com/docker-library/postgres/pull/253, https://github.com/docker-library/postgres/issues/359, https://cwrap.org/nss_wrapper.html + local uid; uid="$(id -u)" + if ! getent passwd "$uid" &> /dev/null; then + # see if we can find a suitable "libnss_wrapper.so" (https://salsa.debian.org/sssd-team/nss-wrapper/-/commit/b9925a653a54e24d09d9b498a2d913729f7abb15) + local wrapper + for wrapper in {/usr,}/lib{/*,}/libnss_wrapper.so; do + if [ -s "$wrapper" ]; then + NSS_WRAPPER_PASSWD="$(mktemp)" + NSS_WRAPPER_GROUP="$(mktemp)" + export LD_PRELOAD="$wrapper" NSS_WRAPPER_PASSWD NSS_WRAPPER_GROUP + local gid; gid="$(id -g)" + printf 'postgres:x:%s:%s:PostgreSQL:%s:/bin/false\n' "$uid" "$gid" "$PGDATA" > "$NSS_WRAPPER_PASSWD" + printf 'postgres:x:%s:\n' "$gid" > "$NSS_WRAPPER_GROUP" + break + fi + done + fi + + if [ -n "${POSTGRES_INITDB_WALDIR:-}" ]; then + set -- --waldir "$POSTGRES_INITDB_WALDIR" "$@" + fi + + # --pwfile refuses to handle a properly-empty file (hence the "\n"): https://github.com/docker-library/postgres/issues/1025 + eval 'initdb --username="$POSTGRES_USER" --pwfile=<(printf "%s\n" "$POSTGRES_PASSWORD") '"$POSTGRES_INITDB_ARGS"' "$@"' + + # unset/cleanup "nss_wrapper" bits + if [[ "${LD_PRELOAD:-}" == */libnss_wrapper.so ]]; then + rm -f "$NSS_WRAPPER_PASSWD" "$NSS_WRAPPER_GROUP" + unset LD_PRELOAD NSS_WRAPPER_PASSWD NSS_WRAPPER_GROUP + fi +} + +# print large warning if POSTGRES_PASSWORD is long +# error if both POSTGRES_PASSWORD is empty and POSTGRES_HOST_AUTH_METHOD is not 'trust' +# print large warning if POSTGRES_HOST_AUTH_METHOD is set to 'trust' +# assumes database is not set up, ie: [ -z "$DATABASE_ALREADY_EXISTS" ] +docker_verify_minimum_env() { + if [ -z "$POSTGRES_PASSWORD" ] && [ 'trust' != "$POSTGRES_HOST_AUTH_METHOD" ]; then + # The - option suppresses leading tabs but *not* spaces. :) + cat >&2 <<-'EOE' + Error: Database is uninitialized and superuser password is not specified. + You must specify POSTGRES_PASSWORD to a non-empty value for the + superuser. For example, "-e POSTGRES_PASSWORD=password" on "docker run". + + You may also use "POSTGRES_HOST_AUTH_METHOD=trust" to allow all + connections without a password. This is *not* recommended. + + See PostgreSQL documentation about "trust": + https://www.postgresql.org/docs/current/auth-trust.html + EOE + exit 1 + fi + if [ 'trust' = "$POSTGRES_HOST_AUTH_METHOD" ]; then + cat >&2 <<-'EOWARN' + ******************************************************************************** + WARNING: POSTGRES_HOST_AUTH_METHOD has been set to "trust". This will allow + anyone with access to the Postgres port to access your database without + a password, even if POSTGRES_PASSWORD is set. See PostgreSQL + documentation about "trust": + https://www.postgresql.org/docs/current/auth-trust.html + In Docker's default configuration, this is effectively any other + container on the same system. + + It is not recommended to use POSTGRES_HOST_AUTH_METHOD=trust. Replace + it with "-e POSTGRES_PASSWORD=password" instead to set a password in + "docker run". + ******************************************************************************** + EOWARN + fi +} +# similar to the above, but errors if there are any "old" databases detected (usually due to upgrades without pg_upgrade) +docker_error_old_databases() { + if [ -n "${OLD_DATABASES[0]:-}" ]; then + cat >&2 <<-EOE + Error: in 18+, these Docker images are configured to store database data in a + format which is compatible with "pg_ctlcluster" (specifically, using + major-version-specific directory names). This better reflects how + PostgreSQL itself works, and how upgrades are to be performed. + + See also https://github.com/docker-library/postgres/pull/1259 + + Counter to that, there appears to be PostgreSQL data in: + ${OLD_DATABASES[*]} + + This is usually the result of upgrading the Docker image without + upgrading the underlying database using "pg_upgrade" (which requires both + versions). + + The suggested container configuration for 18+ is to place a single mount + at /var/lib/postgresql which will then place PostgreSQL data in a + subdirectory, allowing usage of "pg_upgrade --link" without mount point + boundary issues. + + See https://github.com/docker-library/postgres/issues/37 for a (long) + discussion around this process, and suggestions for how to do so. + EOE + exit 1 + fi +} + +# usage: docker_process_init_files [file [file [...]]] +# ie: docker_process_init_files /always-initdb.d/* +# process initializer files, based on file extensions and permissions +docker_process_init_files() { + # psql here for backwards compatibility "${psql[@]}" + psql=( docker_process_sql ) + + printf '\n' + local f + for f; do + case "$f" in + *.sh) + # https://github.com/docker-library/postgres/issues/450#issuecomment-393167936 + # https://github.com/docker-library/postgres/pull/452 + if [ -x "$f" ]; then + printf '%s: running %s\n' "$0" "$f" + "$f" + else + printf '%s: sourcing %s\n' "$0" "$f" + . "$f" + fi + ;; + *.sql) printf '%s: running %s\n' "$0" "$f"; docker_process_sql -f "$f"; printf '\n' ;; + *.sql.gz) printf '%s: running %s\n' "$0" "$f"; gunzip -c "$f" | docker_process_sql; printf '\n' ;; + *.sql.xz) printf '%s: running %s\n' "$0" "$f"; xzcat "$f" | docker_process_sql; printf '\n' ;; + *.sql.zst) printf '%s: running %s\n' "$0" "$f"; zstd -dc "$f" | docker_process_sql; printf '\n' ;; + *) printf '%s: ignoring %s\n' "$0" "$f" ;; + esac + printf '\n' + done +} + +# Execute sql script, passed via stdin (or -f flag of pqsl) +# usage: docker_process_sql [psql-cli-args] +# ie: docker_process_sql --dbname=mydb <<<'INSERT ...' +# ie: docker_process_sql -f my-file.sql +# ie: docker_process_sql > "$PGDATA/pg_hba.conf" +} + +# start socket-only postgresql server for setting up or running scripts +# all arguments will be passed along as arguments to `postgres` (via pg_ctl) +docker_temp_server_start() { + if [ "$1" = 'postgres' ]; then + shift + fi + + # internal start of server in order to allow setup using psql client + # does not listen on external TCP/IP and waits until start finishes + set -- "$@" -c listen_addresses='' -p "${PGPORT:-5432}" + + # unset NOTIFY_SOCKET so the temporary server doesn't prematurely notify + # any process supervisor. + NOTIFY_SOCKET= \ + PGUSER="${PGUSER:-$POSTGRES_USER}" \ + pg_ctl -D "$PGDATA" \ + -o "$(printf '%q ' "$@")" \ + -w start +} + +# stop postgresql server after done setting up user and running scripts +docker_temp_server_stop() { + PGUSER="${PGUSER:-postgres}" \ + pg_ctl -D "$PGDATA" -m fast -w stop +} + +# check arguments for an option that would cause postgres to stop +# return true if there is one +_pg_want_help() { + local arg + for arg; do + case "$arg" in + # postgres --help | grep 'then exit' + # leaving out -C on purpose since it always fails and is unhelpful: + # postgres: could not access the server configuration file "/var/lib/postgresql/data/postgresql.conf": No such file or directory + -'?'|--help|--describe-config|-V|--version) + return 0 + ;; + esac + done + return 1 +} + +_main() { + # if first arg looks like a flag, assume we want to run postgres server + if [ "${1:0:1}" = '-' ]; then + set -- postgres "$@" + fi + + if [ "$1" = 'postgres' ] && ! _pg_want_help "$@"; then + docker_setup_env + # setup data directories and permissions (when run as root) + docker_create_db_directories + if [ "$(id -u)" = '0' ]; then + # then restart script as postgres user + exec gosu postgres "$BASH_SOURCE" "$@" + fi + + # only run initialization on an empty data directory + if [ -z "$DATABASE_ALREADY_EXISTS" ]; then + docker_verify_minimum_env + docker_error_old_databases + + # check dir permissions to reduce likelihood of half-initialized database + ls /docker-entrypoint-initdb.d/ > /dev/null + + docker_init_database_dir + pg_setup_hba_conf "$@" + + # PGPASSWORD is required for psql when authentication is required for 'local' connections via pg_hba.conf and is otherwise harmless + # e.g. when '--auth=md5' or '--auth-local=md5' is used in POSTGRES_INITDB_ARGS + export PGPASSWORD="${PGPASSWORD:-$POSTGRES_PASSWORD}" + docker_temp_server_start "$@" + + docker_setup_db + docker_process_init_files /docker-entrypoint-initdb.d/* + + docker_temp_server_stop + unset PGPASSWORD + + cat <<-'EOM' + + PostgreSQL init process complete; ready for start up. + + EOM + else + cat <<-'EOM' + + PostgreSQL Database directory appears to contain a database; Skipping initialization + + EOM + fi + fi + + exec "$@" +} + +if ! _is_sourced; then + _main "$@" +fi From 99cb030ca30f70144ef4790fb662d31894819609 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 11:17:14 +0000 Subject: [PATCH 080/163] backup: return destination --- hippo/db.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hippo/db.py b/hippo/db.py index 7181ba7..364c56e 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -5457,7 +5457,7 @@ def backup( source: Path | str, destination: Path | str | None = None, pages: int = 10_000, -) -> None: +) -> "Path": """Create a backup of the database""" from .tools import dt_hash @@ -5483,3 +5483,5 @@ def progress(status, remaining, total): src.backup(dst, pages=pages, progress=progress) dst.close() + + return destination From 66f3bfd56cbe66b1316778b88fd5065a2776cb83 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Fri, 12 Dec 2025 11:29:47 +0000 Subject: [PATCH 081/163] close database when complete #245 --- hippo/postgres.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index f30c00a..9ea5943 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -536,7 +536,7 @@ def migrate_sqlite( interactions: bool = True, subsites: bool = True, quotes: bool = True, - batch_size: int = 5_000, + batch_size: int = 10_000, tag_compound_id_regex: list[tuple[str, str]] | None = None, # pose_path_compound_id_regex: list[tuple[str, str]] | None = None, # pose_path_pose_id_regex: list[tuple[str, str]] | None = None, @@ -586,7 +586,7 @@ def migrate_sqlite( if not tag_compound_id_regex: tag_compound_id_regex = [ - (r"^C([0-9]+).*$", "C{new_compound_id}"), + (r"^C([0-9]+)", "C{new_compound_id}"), ] mrich.var("tag_compound_id_regex", tag_compound_id_regex) @@ -768,11 +768,15 @@ def migrate_sqlite( dump_json(migration_data, json_file_name) dump_xlsx(migration_data, xlsx_file_name) + source.db.close() + raise dump_json(migration_data, json_file_name) dump_xlsx(migration_data, xlsx_file_name) + source.db.close() + mrich.success( "Migration staged. Please review and db.commit() or db.rollback() the changes" ) From 92e2d4128a1b629a6a8a81cbe684d6430123fbee Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:34:08 +0000 Subject: [PATCH 082/163] add port --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 18deb2b..8ec066b 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Initialise database Run in foreground ```bash -/opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18 +/opt/homebrew/opt/postgresql@18/bin/postgres -D /opt/homebrew/var/postgresql@18 -p 5432 ``` Install psycopg From 53a79fad1a92ea61f45b31ff4f4fc6fde152ca1a Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:34:32 +0000 Subject: [PATCH 083/163] more postgres support #245 --- hippo/animal.py | 195 ++++++++++++++----- hippo/compound.py | 24 ++- hippo/cset.py | 124 +++++++----- hippo/db.py | 440 +++++++++++++++++++++++++++++++----------- hippo/iset.py | 42 ++-- hippo/migration.py | 42 +++- hippo/plotting.py | 12 +- hippo/pose.py | 13 +- hippo/pset.py | 43 +++-- hippo/reaction.py | 12 +- hippo/recipe.py | 28 +-- hippo/rgen.py | 12 +- hippo/rset.py | 41 ++-- hippo/tags.py | 36 +++- tests/config.py | 31 +-- tests/test_feature.py | 3 + 16 files changed, 755 insertions(+), 343 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 6c8dab4..1f14f27 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1339,10 +1339,18 @@ def add_syndirella_elabs( i for i in elab_df[key].unique() if i != scaffold_id ] - sql = """ - INSERT OR IGNORE INTO scaffold(scaffold_base, scaffold_superstructure) - VALUES(?1, ?2) - """ + match self.db.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO scaffold(scaffold_base, scaffold_superstructure) + VALUES(?1, ?2) + """ + case "psycopg": + sql = """ + INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ self.db.executemany(sql, [(scaffold_id, i) for i in superstructure_ids]) self.db.commit() @@ -1413,17 +1421,32 @@ def add_syndirella_elabs( mrich.debug(f"Registering {len(payload)} poses...") - sql = """ - INSERT OR IGNORE INTO pose( - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_energy_score, - pose_distance_score - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6) - """ + match self.db.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO pose( + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_energy_score, + pose_distance_score + ) + VALUES(?1, ?2, ?3, ?4, ?5, ?6) + """ + case "psycopg": + sql = """ + INSERT INTO hippo.pose( + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_energy_score, + pose_distance_score + ) + VALUES(%s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING; + """ n_before = self.num_poses self.db.executemany(sql, payload) @@ -1450,10 +1473,18 @@ def add_syndirella_elabs( for inspiration in inspirations.ids: payload.add((inspiration, pose_id)) - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ + match self.db.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) + VALUES(?1, ?2) + """ + case "psycopg": + sql = """ + INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) + VALUES(?%s1, %s) + ON CONFLICT DO NOTHING; + """ self.db.executemany(sql, list(payload)) self.db.commit() @@ -1994,11 +2025,20 @@ def add_soakdb_compounds( if update_aliases: - sql = """ - UPDATE OR IGNORE compound - SET compound_alias = :compound_alias - WHERE compound_inchikey = :compound_inchikey; - """ + match self.db.engine: + case "sqlite3": + sql = """ + UPDATE OR IGNORE compound + SET compound_alias = :compound_alias + WHERE compound_inchikey = :compound_inchikey; + """ + case "psycopg": + sql = """ + UPDATE hippo.compound + SET compound_alias = %(compound_alias)s + WHERE compound_inchikey = %(compound_inchikey)s + ON CONFLICT DO NOTHING; + """ mrich.debug("Updating aliases...") self.db.executemany(sql, alias_dicts) @@ -2024,15 +2064,23 @@ def add_soakdb_compounds( c_id = inchikey_id_lookup[inchikey] metadata_lookup[c_id]["SoakDB count"] = len(df[df[smiles_col] == old_s]) - sql = """ - UPDATE compound - SET compound_metadata = ?2 - WHERE compound_id = ?1; - """ + match self.db.engine: + case "sqlite3": + sql = """ + UPDATE compound + SET compound_metadata = ? + WHERE compound_id = ?; + """ + case "psycopg": + sql = """ + UPDATE hippo.compound + SET compound_metadata = %s + WHERE compound_id = %s; + """ mrich.debug("Updating metadata...") self.db.executemany( - sql, [(i, dumps(m)) for i, m in metadata_lookup.items()] + sql, [(dumps(m), i) for i, m in metadata_lookup.items()] ) self.db.commit() @@ -2270,13 +2318,27 @@ def register_reaction( reactant_ids = set(v.id if isinstance(v, Compound) else v for v in reactants) - pairs = self.db.execute( - f"""SELECT reactant_reaction, reactant_compound + match self.db.engine: + case "sqlite3": + sql = """ + SELECT reactant_reaction, reactant_compound FROM reactant INNER JOIN reaction ON reactant.reactant_reaction = reaction.reaction_id WHERE reaction_type="{type}" - AND reaction_product = {product}""" - ).fetchall() + AND reaction_product = {product} + """ + case "psycopg": + sql = """ + SELECT reactant_reaction, reactant_compound + FROM hippo.reactant AS reactant INNER JOIN hippo.reaction AS reaction + ON reactant.reactant_reaction = reaction.reaction_id + WHERE reaction_type="{type}" + AND reaction_product = {product} + """ + + sql.format(type=type, product=product) + + pairs = self.db.execute(sql).fetchall() if pairs: @@ -2372,11 +2434,20 @@ def register_reactions( return None # insert reaction records - sql = """ - INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(?1, ?2, 1) - RETURNING reaction_id - """ + + match self.db.engine: + case "sqlite3": + sql = """ + INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) + VALUES(?1, ?2, 1) + RETURNING reaction_id + """ + case "psycopg": + sql = """ + INSERT INTO hippo.reaction(reaction_type, reaction_product, reaction_product_yield) + VALUES(%s, %s, 1) + RETURNING reaction_id + """ payload = list(non_duplicates.keys()) @@ -2385,10 +2456,19 @@ def register_reactions( self.db.commit() # insert reactant records - sql = """ - INSERT OR IGNORE INTO reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(1.0, ?1, ?2) - """ + + match self.db.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO reactant(reactant_amount, reactant_reaction, reactant_compound) + VALUES(1.0, ?1, ?2) + """ + case "psycopg": + sql = """ + INSERT INTO hippo.reactant(reactant_amount, reactant_reaction, reactant_compound) + VALUES(1.0, %s, %s) + ON CONFLICT DO NOTHING; + """ payload = [] for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( @@ -2404,15 +2484,18 @@ def register_reactions( # delete orphaned reactions - sql = """ - SELECT reaction_id FROM reaction + sql = f""" + SELECT reaction_id FROM {self.db.SQL_SCHEMA_PREFIX}reaction LEFT JOIN reactant ON reaction_id = reactant_reaction WHERE reactant_compound IS NULL """ records = self.db.execute(sql).fetchall() orphaned_str_ids = str(tuple(r for r, in records)).replace(",)", ")") - self.db.execute(f"DELETE FROM reaction WHERE reaction_id IN {orphaned_str_ids}") + + self.db.execute( + f"DELETE FROM {self.db.SQL_SCHEMA_PREFIX}reaction WHERE reaction_id IN {orphaned_str_ids}" + ) if diff: mrich.success(f"Inserted {diff} new reactions") @@ -2765,9 +2848,25 @@ def quote_compounds( else: inchikeys = self.compounds.inchikeys + quote_fields = [ + "quote_id", + "quote_smiles", + "quote_amount", + "quote_supplier", + "quote_catalogue", + "quote_entry", + "quote_lead_time", + "quote_price", + "quote_currency", + "quote_purity", + "quote_date", + "quote_compound", + ] + sql = f""" - SELECT quote_id, quote_smiles, quote_amount, quote_supplier, quote_catalogue, quote_entry, quote_lead_time, quote_price, quote_currency, quote_purity, quote_date, quote_compound FROM quote - INNER JOIN compound ON quote_compound = compound_id + SELECT {', '.join(quote_fields)} + FROM {self.db.SQL_SCHEMA_PREFIX}quote + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON quote_compound = compound_id WHERE compound_inchikey IN {tuple(inchikeys)} """ diff --git a/hippo/compound.py b/hippo/compound.py index e42b1af..bbe1d72 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -1009,13 +1009,23 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe 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 - """ + match self.db.engine: + case "sqlite3": + 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 + """ + case "psycopg": + sql = """ + SELECT pose_id, inspiration_original FROM hippo.compound + INNER JOIN hippo.scaffold ON compound_id = scaffold_base + INNER JOIN hippo.pose ON compound_id = pose_compound + INNER JOIN hippo.inspiration ON pose_id = inspiration_derivative + WHERE compound_id = %(compound_id)s + """ with mrich.spinner(f"Querying inspirations for {self}"): records = self.db.execute(sql, dict(compound_id=self.id)).fetchall() diff --git a/hippo/cset.py b/hippo/cset.py index af3cece..ae4acdc 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -129,9 +129,15 @@ def tags(self) -> set[str]: def reactants(self) -> "CompoundSet": """Returns a :class:`.CompoundSet` of all compounds that are used as a reactants""" # ids = self.db.select(table='reactant', query='DISTINCT reactant_compound', multiple=True) - ids = self.db.execute( - "SELECT reactant_compound FROM reactant LEFT JOIN reaction ON reactant.reactant_compound = reaction.reaction_product WHERE reaction.reaction_product IS NULL" - ).fetchall() + + sql = f""" + SELECT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction + ON reactant_compound = reaction_product + WHERE reaction_product IS NULL + """ + + ids = self.db.execute(sql).fetchall() ids = [q for q, in ids] from .cset import CompoundSet @@ -142,9 +148,16 @@ def reactants(self) -> "CompoundSet": @property def products(self) -> "CompoundSet": """Returns a :class:`.CompoundSet` of all compounds that are a product of a reaction but not a reactant""" - ids = self.db.execute( - "SELECT reaction_product FROM reaction LEFT JOIN reactant ON reaction.reaction_product = reactant.reactant_compound WHERE reactant.reactant_compound IS NULL" - ).fetchall() + + sql = f""" + SELECT reaction_product + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + ON reaction_product = reactant_compound + WHERE reactant_compound IS NULL + """ + + ids = self.db.execute(sql).fetchall() ids = [q for q, in ids] from .cset import CompoundSet @@ -155,9 +168,15 @@ def products(self) -> "CompoundSet": @property def intermediates(self) -> "CompoundSet": """Returns a :class:`.CompoundSet` of all compounds that are products and reactants""" - ids = self.db.execute( - "SELECT DISTINCT reaction_product FROM reaction INNER JOIN reactant ON reaction.reaction_product = reactant.reactant_compound" - ).fetchall() + + sql = f""" + SELECT DISTINCT reaction_product + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + ON reaction_product = reactant_compound + """ + + ids = self.db.execute(sql).fetchall() ids = [q for q, in ids] from .cset import CompoundSet @@ -421,9 +440,13 @@ def write_smiles_csv(self, file: str) -> None: """ from pandas import DataFrame - records = self.db.execute( - """SELECT compound_id, compound_smiles FROM compound ORDER BY compound_id""" - ).fetchall() + sql = f""" + SELECT compound_id, compound_smiles + FROM {self.db.SQL_SCHEMA_PREFIX}compound + ORDER BY compound_id + """ + + records = self.db.execute(sql).fetchall() data = [dict(id=id, smiles=smiles) for id, smiles in records] @@ -819,22 +842,23 @@ def num_atoms_added(self) -> list[int]: """ - query = self.db.execute( - f""" + sql = f""" WITH nums AS ( - SELECT A.compound_id AS comp_id, - mol_num_hvyatms(A.compound_mol) - mol_num_hvyatms(B.compound_mol) AS diff - FROM compound A, compound B + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} ) - SELECT compound_id, diff FROM compound + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound LEFT JOIN nums ON comp_id = compound_id WHERE compound_id IN {self.str_ids} """ - ).fetchall() + + query = self.db.execute(sql).fetchall() lookup = {k: v for k, v in query} @@ -847,23 +871,23 @@ def avg_num_atoms_added(self) -> float: :returns: average number of atoms added values for compounds which have a scaffold """ - - (avg,) = self.db.execute( - f""" + sql = f""" WITH nums AS ( - SELECT A.compound_id AS comp_id, - mol_num_hvyatms(A.compound_mol) - mol_num_hvyatms(B.compound_mol) AS diff - FROM compound A, compound B + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} ) - SELECT AVG(diff) FROM compound + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound INNER JOIN nums ON comp_id = compound_id WHERE compound_id IN {self.str_ids} """ - ).fetchone() + + (avg,) = self.db.execute().fetchone() return avg @@ -882,7 +906,7 @@ def elaboration_balance(self) -> float: """Measure of how evenly elaborations are distributed across scaffolds in this set""" sql = f""" - SELECT COUNT(1) FROM scaffold + SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} GROUP BY scaffold_base """ @@ -907,7 +931,7 @@ def num_scaffolds_elaborated(self) -> int: (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_base) FROM scaffold + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() @@ -928,7 +952,7 @@ def scaffold_ids(self) -> list[int]: """Return a list of :class:`.Compound` ID's for scaffolds of this set""" scaffold_ids = self.db.execute( f""" - SELECT DISTINCT scaffold_base FROM scaffold + SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchall() @@ -939,7 +963,7 @@ def num_scaffolds(self) -> int: """Return a count of scaffolds of this set""" (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_base) FROM scaffold + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() @@ -970,7 +994,7 @@ def num_elabs(self) -> int: """Return a count of elaborations of this set""" (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_superstructure) FROM scaffold + SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_base IN {self.str_ids} """ ).fetchone() @@ -1003,7 +1027,7 @@ def id_num_poses_dict(self) -> dict[int, int]: """Get a dictionary mapping compound ids to the number of poses""" sql = f""" - SELECT pose_compound, COUNT(1) FROM pose + SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_compound IN {self.str_ids} GROUP BY pose_compound """ @@ -1182,10 +1206,10 @@ def get_risk_diversity(self, debug: bool = False) -> float: f""" WITH nums AS ( SELECT scaffold_base AS base, scaffold_superstructure AS elab, - mol_num_hvyatms(c2.compound_mol) - mol_num_hvyatms(c1.compound_mol) AS diff - FROM scaffold - INNER JOIN compound AS c1 ON scaffold_base = c1.compound_id - INNER JOIN compound AS c2 ON scaffold_superstructure = c2.compound_id + {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(c1.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id WHERE scaffold_superstructure IN {self.str_ids} ), @@ -1269,7 +1293,7 @@ def summary(self, return_df: bool = False) -> None: sql = f""" SELECT tag_name, COUNT(DISTINCT tag_compound) - FROM tag + FROM {self.db.SQL_SCHEMA_PREFIX}tag WHERE tag_compound IN {self.str_ids} GROUP BY tag_name ORDER BY tag_name @@ -1287,8 +1311,8 @@ def summary(self, return_df: bool = False) -> None: sql = f""" SELECT tag_name, COUNT(DISTINCT tag_pose) - FROM tag - INNER JOIN pose + FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON pose_id = tag_pose WHERE pose_compound IN {self.str_ids} GROUP BY tag_name @@ -1303,8 +1327,8 @@ def summary(self, return_df: bool = False) -> None: # compounds with poses sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM tag - INNER JOIN pose + SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON tag_pose = pose_id WHERE pose_compound IN {self.str_ids} GROUP BY tag_name @@ -1486,7 +1510,7 @@ def tag_summary(self) -> "pd.DataFrame": sql = f""" SELECT tag_name, COUNT(DISTINCT tag_compound) - FROM tag + FROM {self.db.SQL_SCHEMA_PREFIX}tag WHERE tag_compound IN {self.str_ids} GROUP BY tag_name ORDER BY tag_name; @@ -1568,8 +1592,10 @@ def get_routes( if permitted_reactions is not None: sql = f""" - SELECT route_id, route_product, component_ref FROM route - INNER JOIN component ON route_id = component_route + SELECT route_id, route_product, component_ref + FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component + ON route_id = component_route WHERE route_product IN {self.str_ids} AND component_type = 1 """ @@ -1612,7 +1638,7 @@ def get_routes( else: sql = f""" - SELECT route_id FROM route + SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route WHERE route_product IN {self.str_ids} """ @@ -1721,7 +1747,7 @@ def get_df( sql = f""" SELECT {query} - FROM compound + FROM {self.db.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {self.str_ids} """ @@ -2865,12 +2891,12 @@ def set_amounts( pairs = self.db.execute( f""" WITH matching_quotes AS ( - SELECT quote_id, quote_compound, MIN(quote_price) FROM quote + SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {self.str_compound_ids} AND quote_amount >= {amount} GROUP BY quote_compound ) - SELECT compound_id, quote_id FROM compound + SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound LEFT JOIN matching_quotes ON quote_compound = compound_id WHERE compound_id IN {self.str_compound_ids} """ diff --git a/hippo/db.py b/hippo/db.py index 364c56e..e2ce5d2 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -601,7 +601,9 @@ def execute( mrich.error(strip_sql(sql)) raise - def executemany(self, sql, payload, *, retry: float | None = 1) -> None: + def executemany( + self, sql, payload, *, retry: float | None = 1, batch_size: int = None + ) -> None: """Execute arbitrary SQL :param sql: SQL query @@ -615,15 +617,28 @@ def executemany(self, sql, payload, *, retry: float | None = 1) -> None: return executemany(self.path, sql, payload) + if batch_size and batch_size < len(payload): + + from itertools import batched + + batches = list(batched(payload, batch_size)) + + n = len(batches) + + for i, batch in enumerate(mrich.track(batches, prefix="batch execution")): + mrich.set_progress_field("i", i) + mrich.set_progress_field("n", n) + + self.executemany(sql, batch, batch_size=None, retry=retry) + + return + try: return self.cursor.executemany(sql, payload) except sqlite3.OperationalError as e: if "database is locked" in str(e) and retry: - with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." - ): - time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") + mrich.print("[debug]SQLite Database was locked, waiting...") + time.sleep(retry) return self.executemany(sql=sql, payload=payload, retry=retry) else: raise @@ -1305,9 +1320,9 @@ def insert_inspiration( derivative, int ), "Must pass an integer ID or Pose object (derivative)" - sql = """ - INSERT INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}inspiration(inspiration_original, inspiration_derivative) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -1363,9 +1378,9 @@ def insert_scaffold( # mrich.warning(f"Skipped self-referential scaffold assignment (C{scaffold})") return None - sql = """ - INSERT INTO scaffold(scaffold_base, scaffold_superstructure) - VALUES(?1, ?2) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -1411,9 +1426,9 @@ def insert_reaction( # assert isinstance(product, Compound), f'incompatible {product=}' assert isinstance(type, str), f"incompatible {type=}" - sql = """ - INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(?1, ?2, ?3) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}reaction(reaction_type, reaction_product, reaction_product_yield) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -1456,7 +1471,7 @@ def insert_reactant( sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(?1, ?2, ?3) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -1542,22 +1557,78 @@ def insert_quote( else: date_str = "date()" - sql = f""" - INSERT or REPLACE INTO {self.SQL_SCHEMA_PREFIX}quote( - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_compound, - quote_date - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, {date_str}); - """ + match self.engine: + case "sqlite3": + sql = f""" + INSERT OR REPLACE INTO quote( + quote_smiles, + quote_amount, + quote_supplier, + quote_catalogue, + quote_entry, + quote_lead_time, + quote_price, + quote_currency, + quote_purity, + quote_compound, + quote_date + ) + VALUES( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + {date_str} + ); + """ + case "psycopg": + sql = f""" + INSERT OR REPLACE INTO quote( + quote_smiles, + quote_amount, + quote_supplier, + quote_catalogue, + quote_entry, + quote_lead_time, + quote_price, + quote_currency, + quote_purity, + quote_compound, + quote_date + ) + VALUES( + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + {date_str} + ) + ON CONFLICT + DO UPDATE + quote_smiles = EXCLUDED.quote_smiles, + quote_amount = EXCLUDED.quote_amount, + quote_supplier = EXCLUDED.quote_supplier, + quote_catalogue = EXCLUDED.quote_catalogue, + quote_entry = EXCLUDED.quote_entry, + quote_lead_time = EXCLUDED.quote_lead_time, + quote_price = EXCLUDED.quote_price, + quote_currency = EXCLUDED.quote_currency, + quote_purity = EXCLUDED.quote_purity, + quote_compound = EXCLUDED.quote_compound, + quote_date = EXCLUDED.quote_date; + """ try: self.execute( @@ -1843,9 +1914,9 @@ def insert_route( """ - sql = """ - INSERT INTO route(route_product) - VALUES(?1) + sql = f""" + INSERT INTO {self.SQL_SCHEMA_PREFIX}route(route_product) + VALUES({self.SQL_STRING_PLACEHOLDER}) """ product_id = int(product_id) @@ -1889,11 +1960,20 @@ def insert_component( """ - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}component(component_route, component_type, component_ref, component_amount) - VALUES(:component_route, :component_type, :component_ref, :component_amount) - {self.sql_return_id_str('component')} - """ + match self.engine: + case "sqlite3": + + sql = """ + INSERT INTO component(component_route, component_type, component_ref, component_amount) + VALUES(:component_route, :component_type, :component_ref, :component_amount) + """ + + case "psycopg": + + sql = """ + INSERT INTO hippo.component(component_route, component_type, component_ref, component_amount) + VALUES(%(component_route)s, %(component_type)s, %(component_ref)s, %(component_amount)s) + """ route = int(route) ref = int(ref) @@ -2036,7 +2116,18 @@ def insert_interaction( interaction_angle, interaction_energy ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + VALUES( + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER} + ); """ try: @@ -2088,7 +2179,7 @@ def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite(subsite_target, subsite_name) - VALUES(?1, ?2) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -2148,7 +2239,7 @@ def insert_subsite_tag( sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(?1, ?2) + VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) """ try: @@ -2641,11 +2732,22 @@ def update_legacy_routes(self) -> None: # set values - sql = f""" - UPDATE {self.SQL_SCHEMA_PREFIX}component - SET component_amount = :component_amount - WHERE component_type = :component_type; - """ + match self.engine: + case "sqlite3": + + sql = """ + UPDATE component + SET component_amount = :component_amount + WHERE component_type = :component_type; + """ + + case "psycopg": + + sql = """ + UPDATE hippo.component + SET component_amount = %(component_amount)s + WHERE component_type = %(component_type)s; + """ self.execute(sql, dict(component_amount=None, component_type=1)) self.execute(sql, dict(component_amount=1.0, component_type=2)) @@ -2675,7 +2777,7 @@ def update_compound_pattern_bfp_table(self): """Update the compound pattern BFP table""" self.execute( f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}compound_pattern_bfp + INSERT INTO compound_pattern_bfp SELECT c.compound_id, c.compound_pattern_bfp FROM {self.SQL_SCHEMA_PREFIX}compound AS c LEFT JOIN compound_pattern_bfp as fp ON c.compound_id = fp.compound_id @@ -2745,7 +2847,7 @@ def reinitialise_molecules(self): sql = f""" UPDATE {self.SQL_SCHEMA_PREFIX}compound - SET compound_mol = mol_from_smiles(compound_smiles); + SET compound_mol = {self.SQL_SCHEMA_PREFIX}mol_from_smiles(compound_smiles); """ with mrich.loading("Reinitialising compounds..."): @@ -2761,11 +2863,19 @@ def fix_incorrect_pose_compound_assignments(self): count = self.count_where(table="pose", key="mol", value="NOT null") - sql = f""" - SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) - FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_mol IS NOT null - """ + match self.engine: + case "sqlite3": + sql = """ + SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) + FROM pose + WHERE pose_mol IS NOT null + """ + case "psycopg": + sql = """ + SELECT pose_id, pose_compound, hippo.mol_to_smiles(hippo.mol_from_pkl(pose_mol)) + FROM hippo.pose + WHERE pose_mol IS NOT null + """ c = self.execute(sql) @@ -2784,16 +2894,16 @@ def fix_incorrect_pose_compound_assignments(self): continue if comp_id != pose_compound: - fix.add((pose_id, comp_id)) + fix.add((comp_id, pose_id)) fix_count += 1 mrich.set_progress_field("#fix", fix_count) mrich.var("#fix", len(fix)) - sql = """ - UPDATE pose - SET pose_compound = ?2 - WHERE pose_id = ?1 + sql = f""" + UPDATE {self.SQL_SCHEMA_PREFIX}pose + SET pose_compound = {self.SQL_STRING_PLACEHOLDER} + WHERE pose_id = {self.SQL_STRING_PLACEHOLDER} """ self.executemany(sql, list(fix)) @@ -2863,15 +2973,32 @@ def register_compounds( else: - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}compound(compound_inchikey, compound_smiles, compound_mol) - VALUES(?1, ?2, mol_from_smiles(?2)) - """ + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol) + VALUES(?1, ?2, mol_from_smiles(?2)) + """ - if debug: - mrich.debug("Inserting...") + if debug: + mrich.debug("Inserting...") - self.executemany(sql, values) + self.executemany(sql, values) + + case "psycopg": + sql = """ + INSERT INTO hippo.compound(compound_inchikey, compound_smiles, compound_mol) + VALUES( + %(inchikey)s, + %(smiles)s, + hippo.mol_from_smiles(%(smiles)s) + ) + ON CONFLICT DO NOTHING; + """ + + self.executemany( + sql, [dict(inchikey=i, smiles=s) for i, s in values] + ) if self.auto_compute_bfps: self.update_compound_pattern_bfp_table() @@ -2907,22 +3034,67 @@ def register_poses(self, dicts: list[dict]) -> set[int]: ### POSES - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, - pose_metadata - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - """ + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO pose( + pose_inchikey, + pose_smiles, + pose_alias, + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_energy_score, + pose_distance_score, + pose_metadata + ) + VALUES( + :inchikey, + :smiles, + :alias, + :reference, + :path, + :compound, + :target, + :mol, + :energy_score, + :distance_score, + :metadata + ) + """ + + case "psycopg": + sql = """ + INSERT INTO hippo.pose( + pose_inchikey, + pose_smiles, + pose_alias, + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_energy_score, + pose_distance_score, + pose_metadata + ) + VALUES( + %(inchikey)s, + %(smiles)s, + %(alias)s, + %(reference)s, + %(path)s, + %(compound)s, + %(target)s, + %(mol)s, + %(energy_score)s, + %(distance_score)s, + %(metadata)s + ) + ON CONFLICT DO NOTHING; + """ values = [] for i, d in enumerate(dicts): @@ -2937,18 +3109,18 @@ def register_poses(self, dicts: list[dict]) -> set[int]: try: values.append( - ( - str(d["inchikey"]), - str(d["smiles"]), - alias, - reference_id, - str(d["path"]), - int(d["compound_id"]), - int(d["target_id"]), - d["mol"].ToBinary(), - float(d["energy_score"]), - float(d["distance_score"]), - dumps(d["metadata"]), + dict( + inchikey=str(d["inchikey"]), + smiles=str(d["smiles"]), + alias=alias, + reference=reference_id, + path=str(d["path"]), + compound=int(d["compound_id"]), + target=int(d["target_id"]), + mol=d["mol"].ToBinary(), + energy_score=float(d["energy_score"]), + distance_score=float(d["distance_score"]), + metadata=dumps(d["metadata"]), ) ) except KeyError as e: @@ -2975,10 +3147,19 @@ def register_poses(self, dicts: list[dict]) -> set[int]: for inspiration_id in d["inspiration_ids"]: values.append((inspiration_id, derivative_id)) - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) + VALUES(?1, ?2) + """ + + case "psycopg": + sql = """ + INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) + VALUES(%s, %s) + ON CONFLICT DO NOTHING; + """ self.executemany(sql, values) self.commit() @@ -2993,6 +3174,9 @@ def calculate_all_scaffolds(self) -> None: mrich.var("#compounds", self.count("compound")) mrich.var("#scaffold defs", n_before) + if not self.engine == "sqlite3": + raise NotImplementedError + sql = """ SELECT compound_id, compound_mol, compound_pattern_bfp FROM compound @@ -3003,14 +3187,14 @@ def calculate_all_scaffolds(self) -> None: self.commit() - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}scaffold - SELECT ?1, c.compound_id - FROM {self.SQL_SCHEMA_PREFIX}compound AS c, compound_pattern_bfp AS fp - WHERE c.compound_id = fp.compound_id - AND c.compound_id <> ?1 - AND mol_is_substruct(c.compound_mol, ?2) - AND fp.compound_id MATCH rdtree_subset(?3) + sql = """ + INSERT OR IGNORE INTO scaffold + SELECT ?1, c.compound_id + FROM compound AS c, compound_pattern_bfp AS fp + WHERE c.compound_id = fp.compound_id + AND c.compound_id <> ?1 + AND mol_is_substruct(c.compound_mol, ?2) + AND fp.compound_id MATCH rdtree_subset(?3) """ with mrich.loading("Calculating scaffolds..."): @@ -3159,11 +3343,22 @@ def calculate_all_murcko_scaffolds( mrich.var("#murcko scaffold relations", len(pairs)) + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) + VALUES (?,?) + """ + + case "psycopg": + sql = """ + INSERT INTO hippo.scaffold (scaffold_base, scaffold_superstructure) + VALUES (%s, %s) + ON CONFLICT DO NOTHING; + """ + self.executemany( - f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}scaffold (scaffold_base, scaffold_superstructure) - VALUES (?,?) - """, + sql, pairs, ) @@ -3206,12 +3401,21 @@ def calculate_all_murcko_scaffolds( def set_derivative_subsites(self, commit: bool = True) -> None: """Propagate all subsite assignments from inspirations to their derivatives""" - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}subsite_tag(subsite_tag_ref, subsite_tag_pose) - SELECT subsite_tag_ref, inspiration_derivative FROM {self.SQL_SCHEMA_PREFIX}subsite_tag - INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration - ON subsite_tag_pose = inspiration_original - """ + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) + SELECT subsite_tag_ref, inspiration_derivative FROM subsite_tag + INNER JOIN inspiration ON subsite_tag_pose = inspiration_original + """ + + case "psycopg": + sql = """ + INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) + SELECT subsite_tag_ref, inspiration_derivative FROM hippo.subsite_tag + INNER JOIN hippo.inspiration ON subsite_tag_pose = inspiration_original + ON CONFLICT DO NOTHING; + """ self.execute(sql) @@ -4329,7 +4533,7 @@ def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set] sql = f""" SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM {self.SQL_SCHEMA_PREFIX}interaction - INNER JOIN feature ON interaction_feature = feature_id + INNER JOIN {self.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id WHERE interaction_pose IN {pset.str_ids} """ diff --git a/hippo/iset.py b/hippo/iset.py index 906427c..7e34119 100644 --- a/hippo/iset.py +++ b/hippo/iset.py @@ -203,8 +203,8 @@ def from_residue( target = target.id sql = f""" - SELECT interaction_id FROM {self.table} - INNER JOIN feature + SELECT interaction_id FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id WHERE feature_target = {target} AND feature_residue_number = {residue_number} @@ -299,8 +299,8 @@ def residue_number_chain_pairs(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature + SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} """ @@ -312,8 +312,8 @@ def avg_num_residues_per_pose(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature + SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} """ @@ -335,7 +335,7 @@ def avg_num_interactions_per_pose(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT interaction_pose FROM {self.table} + SELECT interaction_pose FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} """ @@ -356,8 +356,8 @@ def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature + SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} """ @@ -379,8 +379,8 @@ def type_residue_number_chain_triples(self) -> list[tuple]: """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.table} - INNER JOIN feature + SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} """ @@ -393,7 +393,7 @@ def num_features(self) -> int: (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT interaction_feature) FROM {self.table} + SELECT COUNT(DISTINCT interaction_feature) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} """ ).fetchone() @@ -408,7 +408,7 @@ def avg_num_interactions_per_feature(self) -> float: f""" WITH counts AS ( - SELECT interaction_feature, COUNT(1) AS count FROM {self.table} + SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature ) @@ -425,7 +425,7 @@ def per_feature_count_hirsch(self) -> float: counts = self.db.execute( f""" - SELECT interaction_feature, COUNT(1) AS count FROM interaction + SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature """ @@ -507,7 +507,7 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "Hydrogen Bond" GROUP BY interaction_atom_ids @@ -521,7 +521,7 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "π-stacking" GROUP BY interaction_feature @@ -539,7 +539,7 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "π-cation" GROUP BY interaction_atom_ids @@ -554,7 +554,7 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "Electrostatic" GROUP BY interaction_atom_ids @@ -569,7 +569,7 @@ def resolve( sql = f""" SELECT interaction_id - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "Sulfur-Sulfur" """ @@ -582,7 +582,7 @@ def resolve( sql = f""" SELECT interaction_id, interaction_distance - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {self.str_ids} AND interaction_type = "Hydrophobic" """ @@ -691,7 +691,7 @@ def resolve( sql = f""" SELECT interaction_id, MIN(interaction_distance) - FROM {table} + FROM {self.db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_id IN {hydrophobic_keeper_iset.str_ids} GROUP BY interaction_feature """ diff --git a/hippo/migration.py b/hippo/migration.py index 7d545a1..fac73d7 100644 --- a/hippo/migration.py +++ b/hippo/migration.py @@ -22,6 +22,9 @@ def migrate_compounds( mrich.var("source: #compounds", len(compound_records)) + if not compound_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.compound( @@ -79,6 +82,9 @@ def migrate_scaffolds( multiple=True, ) + if not scaffold_records: + return migration_data + # map to new IDs scaffold_records = [ ( @@ -119,6 +125,9 @@ def migrate_targets( table="target", query="target_id, target_name", multiple=True ) + if not target_records: + return migration_data + # do the insertion for i, name in target_records: destination.insert_target(name=name, warn_duplicate=False) @@ -171,6 +180,9 @@ def migrate_poses( table="pose", query=", ".join(pose_fields), multiple=True ) + if not pose_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.pose( @@ -279,6 +291,9 @@ def migrate_pose_references( multiple=True, ) + if not reference_records: + return migration_data + # map to new IDs reference_dicts = [ dict( @@ -322,6 +337,9 @@ def migrate_inspirations( multiple=True, ) + if not inspiration_records: + return migration_data + # map to new IDs inspiration_dicts = [ dict( @@ -372,6 +390,9 @@ def migrate_tags( tag_names = sorted([t for t, in tag_names]) + if not tag_names: + return migration_data + # rename tags based on regex tag_name_map = {} @@ -471,6 +492,9 @@ def migrate_reactions_and_reactants( ) mrich.var("source: #reactions", len(source_reaction_dicts)) + if not source_reaction_dicts: + return migration_data + # get destination reaction data destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(destination) mrich.var("destination: #reactions", len(destination_reaction_dicts)) @@ -610,6 +634,9 @@ def migrate_features( mrich.var("source: #features", len(feature_records)) + if not feature_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.feature( @@ -742,6 +769,9 @@ def migrate_interactions( mrich.var("source: #interactions", len(interaction_records)) + if not interaction_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.interaction( @@ -880,6 +910,9 @@ def migrate_subsites( mrich.var("source: #subsites", len(subsite_records)) + if not subsite_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.subsite( @@ -1025,6 +1058,9 @@ def migrate_quotes( mrich.var("source: #quotes", len(quote_records)) + if not quote_records: + return migration_data + # insertion query sql = """ INSERT INTO hippo.quote( @@ -1060,7 +1096,7 @@ def migrate_quotes( quote_dicts = [ dict( smiles=smiles, - amount=amount, + amount=round(amount, 3), supplier=supplier, catalogue=catalogue, entry=entry, @@ -1093,7 +1129,7 @@ def migrate_quotes( # map to the destination records quote_map = { - (amount, supplier, catalogue, entry): i + (round(amount, 3), supplier, catalogue, entry): i for ( i, smiles, @@ -1115,7 +1151,7 @@ def migrate_quotes( } quote_id_map = { - i: quote_map[(amount, supplier, catalogue, entry)] + i: quote_map[(round(amount, 3), supplier, catalogue, entry)] for ( i, smiles, diff --git a/hippo/plotting.py b/hippo/plotting.py index 2f8139c..a74f490 100644 --- a/hippo/plotting.py +++ b/hippo/plotting.py @@ -1556,9 +1556,9 @@ def plot_compound_price( if style == "scatter": - sql = """ + sql = f""" SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM quote + FROM {animal.db.SQL_SCHEMA_PREFIX}quote INNER JOIN compound ON quote.quote_compound = compound.compound_id INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound WHERE quote_amount >= {min_amount} @@ -1611,11 +1611,11 @@ def plot_compound_price( if style == "scatter": - sql = """ + sql = f""" SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM quote - INNER JOIN compound ON quote.quote_compound = compound.compound_id - INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound + FROM {animal.db.SQL_SCHEMA_PREFIX}quote + INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}compound ON quote.quote_compound = compound.compound_id + INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}reactant ON quote.quote_compound = reactant.reactant_compound WHERE quote_amount >= {min_amount} AND quote_compound IN {str_ids} GROUP BY quote_compound diff --git a/hippo/pose.py b/hippo/pose.py index a5cc170..873761c 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -493,14 +493,15 @@ def num_atoms_added_wrt_inspirations(self) -> int | None: sql = f""" WITH inspirations AS ( - SELECT SUM(mol_num_hvyatms(compound_mol)) AS sum, inspiration_derivative FROM inspiration - INNER JOIN pose ON inspiration_original = pose_id - INNER JOIN compound ON pose_compound = compound_id + SELECT SUM({self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol)) AS sum, inspiration_derivative + FROM {self.db.SQL_SCHEMA_PREFIX}inspiration + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON inspiration_original = pose_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON pose_compound = compound_id WHERE inspiration_derivative = {self.id} ) - SELECT mol_num_hvyatms(compound_mol) - sum FROM inspirations - INNER JOIN pose ON inspiration_derivative = pose_id - INNER JOIN compound ON compound_id = pose_compound + SELECT {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol) - sum FROM inspirations + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON inspiration_derivative = pose_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON compound_id = pose_compound """ result = self.db.execute(sql).fetchone() diff --git a/hippo/pset.py b/hippo/pset.py index e7f18fb..54032bb 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -775,7 +775,7 @@ def inspiration_sets(self) -> list[set[int]]: """Return a list of unique sets of inspiration :class:`.Pose` IDs""" sql = f""" - SELECT inspiration_derivative, inspiration_original FROM inspiration + SELECT inspiration_derivative, inspiration_original FROM {self.db.SQL_SCHEMA_PREFIX}inspiration WHERE inspiration_derivative IN {self.str_ids} """ @@ -894,8 +894,10 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: from itertools import combinations sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM interaction - INNER JOIN feature ON interaction_feature = feature_id + SELECT DISTINCT interaction_pose, feature_id, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON interaction_feature = feature_id WHERE interaction_pose IN {self.str_ids} """ @@ -943,8 +945,10 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": # get interaction records sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type FROM interaction - INNER JOIN feature ON interaction_feature = feature_id + SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON interaction_feature = feature_id WHERE interaction_pose IN {self.str_ids} """ @@ -1056,7 +1060,8 @@ def subsite_balance(self) -> float: from numpy import std sql = f""" - SELECT COUNT(DISTINCT subsite_tag_ref) FROM subsite_tag + SELECT COUNT(DISTINCT subsite_tag_ref) + FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag WHERE subsite_tag_pose IN {self.str_ids} GROUP BY subsite_tag_pose """ @@ -1072,7 +1077,8 @@ def subsite_ids(self) -> set[int]: """Return a list of subsite id's of member poses""" sql = f""" - SELECT DISTINCT subsite_tag_ref FROM subsite_tag + SELECT DISTINCT subsite_tag_ref + FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag WHERE subsite_tag_pose IN {self.str_ids} """ @@ -1092,7 +1098,8 @@ def avg_energy_score(self) -> float: from numpy import mean sql = f""" - SELECT pose_energy_score FROM pose + SELECT pose_energy_score + FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} """ @@ -1106,7 +1113,8 @@ def avg_distance_score(self) -> float: from numpy import mean sql = f""" - SELECT pose_distance_score FROM pose + SELECT pose_distance_score + FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} """ @@ -1321,7 +1329,7 @@ def get_df( sql = f""" SELECT {query} - FROM pose + FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} """ @@ -1634,7 +1642,8 @@ def get_best_placed_poses_per_compound(self): """Choose the best placed pose (best distance_score) grouped by compound""" sql = f""" - SELECT pose_id, MIN(pose_distance_score) FROM pose + SELECT pose_id, MIN(pose_distance_score) + FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} GROUP BY pose_compound """ @@ -1678,7 +1687,7 @@ def filter( return PoseSet(self.db, ids) sql = f""" - SELECT pose_id FROM pose + SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} AND pose_{key} {operator} {value} """ @@ -1808,10 +1817,10 @@ def calculate_inspiration_scores( tuples = df[f"mocassin_{score_type}({alpha},{beta})"].items() - sql = """UPDATE pose SET pose_inspiration_score = ?2 WHERE pose_id = ?1""" + sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" mrich.debug("Updating pose_inspiration_score values") - self.db.executemany(sql, tuples) + self.db.executemany(sql, [(b, a) for a, b in tuples]) self.db.commit() return df @@ -2752,8 +2761,8 @@ def subsite_summary(self) -> "pd.DataFrame": from pandas import DataFrame sql = f""" - SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM subsite - INNER JOIN subsite_tag + SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag ON subsite_id = subsite_tag_ref WHERE subsite_tag_pose IN {self.str_ids} GROUP BY subsite_name @@ -2814,7 +2823,7 @@ def _delete(self, *, force: bool = False) -> None: self.db.execute( f""" - UPDATE pose + UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_reference = NULL WHERE pose_id IN {str_ids} """ diff --git a/hippo/reaction.py b/hippo/reaction.py index 04bc1eb..60a42b4 100644 --- a/hippo/reaction.py +++ b/hippo/reaction.py @@ -284,9 +284,9 @@ def check_reactant_availability( triples = self.db.execute( f""" - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM reactant - LEFT JOIN quote ON quote_compound = reactant_compound - LEFT JOIN reaction ON reaction_product = reactant_compound + SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}quote ON quote_compound = reactant_compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound WHERE reactant_reaction = {self.id} GROUP BY reactant_compound """ @@ -298,12 +298,12 @@ def check_reactant_availability( f""" WITH filtered_quotes AS ( - SELECT * FROM quote + SELECT * FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_supplier = "{supplier}" ) - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM reactant + SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant LEFT JOIN filtered_quotes ON quote_compound = reactant_compound - LEFT JOIN reaction ON reaction_product = reactant_compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound WHERE reactant_reaction = {self.id} GROUP BY reactant_compound """ diff --git a/hippo/recipe.py b/hippo/recipe.py index 63cfc18..89b33c3 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -318,8 +318,8 @@ def from_reactions( # raise NotImplementedError ids = reactions.db.execute( f""" - SELECT DISTINCT compound_id FROM compound - LEFT JOIN reactant ON compound_id = reactant_compound + SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound WHERE reactant_compound IS NULL AND compound_id IN {products.str_ids} """ @@ -1483,8 +1483,8 @@ def write_reactant_csv( route_ids = self.get_routes(return_ids=True) sql = f""" - SELECT component_ref, route_product FROM component - INNER JOIN route ON route_id = component_route + SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route WHERE component_type = 2 AND component_ref IN {self.reactants.compounds.str_ids} AND component_route IN {str(tuple(route_ids)).replace(',)',')')} @@ -1496,20 +1496,20 @@ def write_reactant_csv( sql = f""" WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id FROM component + SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component WHERE component_type = 2 AND component_ref IN {self.reactants.compounds.str_ids} ), reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM component - INNER JOIN reaction ON component_ref = reaction_id + SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id WHERE component_type = 1 AND component_ref IN {self.reactions.str_ids} ) - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM reactants - INNER JOIN reactions ON reactants.route_id = reactions.route_id + SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id """ reaction_lookup = {} for reactant_id, reaction_id, reaction_type in self.db.execute(sql): @@ -2597,8 +2597,8 @@ def product_ids(self) -> list[int]: def reactant_ids(self) -> list[int]: """Get the :class:`.Compound` ID's of the reactants""" sql = f""" - SELECT DISTINCT component_ref FROM component - INNER JOIN route ON component_route = route_id + SELECT DISTINCT component_ref FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON component_route = route_id WHERE component_type = 2 AND route_id IN {self.str_ids} """ @@ -2711,7 +2711,7 @@ def prune_unavailable(self, suppliers: list[str]): CASE WHEN quote_supplier IN {suppliers_str} THEN 1 END) AS [count_valid] - FROM quote + FROM {self.db.SQL_SCHEMA_PREFIX}quote GROUP BY quote_compound ), @@ -2722,8 +2722,8 @@ def prune_unavailable(self, suppliers: list[str]): WHEN count_valid = 0 THEN 1 WHEN count_valid IS NULL THEN 1 END) - AS [count_unavailable] FROM route - INNER JOIN component ON component_route = route_id + AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id LEFT JOIN possible_reactants ON quote_compound = component_ref WHERE component_type = 2 GROUP BY route_id diff --git a/hippo/rgen.py b/hippo/rgen.py index d0d700a..a2ca948 100644 --- a/hippo/rgen.py +++ b/hippo/rgen.py @@ -210,7 +210,7 @@ def get_route_pool(self, mini_test=False): sql = f""" WITH possible_reactants AS ( SELECT quote_compound, COUNT(CASE WHEN quote_supplier IN {self.suppliers_str} THEN 1 END) AS [count_valid] - FROM quote + FROM {self.db.SQL_SCHEMA_PREFIX}quote GROUP BY quote_compound ), @@ -221,14 +221,14 @@ def get_route_pool(self, mini_test=False): WHEN count_valid = 0 THEN 1 WHEN count_valid IS NULL THEN 1 END) - AS [count_unavailable] FROM route - INNER JOIN component ON component_route = route_id + AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id LEFT JOIN possible_reactants ON quote_compound = component_ref WHERE component_type = 2 GROUP BY route_id ) - SELECT route_id FROM route_reactants + SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route_reactants WHERE count_unavailable = 0 """ @@ -585,7 +585,7 @@ def get_compound_pool( sql = f""" SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM quote + FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_amount >= {self.amount} GROUP BY quote_compound """ @@ -621,7 +621,7 @@ def get_compound_pool( sql = f""" SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM quote + FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_amount >= {self.amount} AND quote_compound IN {compounds.str_ids} GROUP BY quote_compound diff --git a/hippo/rset.py b/hippo/rset.py index a2f8cb2..6dd3f8e 100644 --- a/hippo/rset.py +++ b/hippo/rset.py @@ -137,7 +137,12 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame if not smiles and not mols: - sql = "SELECT reaction_id, reaction_type, reaction_product, reactant_compound FROM reaction INNER JOIN reactant ON reaction.reaction_id = reactant.reactant_reaction" + sql = f""" + SELECT reaction_id, reaction_type, reaction_product, reactant_compound + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + ON reaction.reaction_id = reactant.reactant_reaction + """ triples = self.db.execute(sql).fetchall() @@ -151,17 +156,17 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame else: - sql = """ + sql = f""" SELECT {query} - FROM reaction + FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN reactant + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON reaction.reaction_id = reactant.reactant_reaction - INNER JOIN compound c_r + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound c_r ON c_r.compound_id = reactant.reactant_compound - INNER JOIN compound c_p + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound c_p ON c_p.compound_id = reaction.reaction_product """ @@ -229,16 +234,16 @@ def set_product_yields( assert product_yield <= 1.0 sql = f""" - UPDATE reaction - SET reaction_product_yield = :reaction_product_yield - WHERE reaction_type = :reaction_type; + UPDATE {self.db.SQL_SCHEMA_PREFIX}reaction + SET reaction_product_yield = {self.db.SQL_STRING_PLACEHOLDER} + WHERE reaction_type = {self.db.SQL_STRING_PLACEHOLDER} """ self.db.execute( sql, - dict( - reaction_product_yield=product_yield, - reaction_type=type, + ( + product_yield, + type, ), ) @@ -468,8 +473,8 @@ def products(self) -> "CompoundSet": intermediates = self.intermediates product_ids = self.db.execute( f""" - SELECT compound_id FROM compound - INNER JOIN reaction ON compound_id = reaction_product + SELECT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON compound_id = reaction_product WHERE reaction_id IN {self.str_ids} AND compound_id NOT IN {intermediates.str_ids} """ @@ -485,9 +490,9 @@ def intermediates(self) -> "CompoundSet": from .cset import CompoundSet sql = f""" - SELECT DISTINCT compound_id FROM compound - INNER JOIN reaction ON compound_id = reaction_product - INNER JOIN reactant ON compound_id = reactant_compound + SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON compound_id = reaction_product + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound WHERE reactant_reaction IN {self.str_ids} """ intermediate_ids = self.db.execute(sql).fetchall() @@ -502,7 +507,7 @@ def reactants(self) -> "CompoundSet": from .cset import CompoundSet sql = f""" - SELECT DISTINCT reactant_compound FROM reactant + SELECT DISTINCT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant WHERE reactant_reaction IN {self.str_ids} """ reactant_ids = self.db.execute(sql).fetchall() diff --git a/hippo/tags.py b/hippo/tags.py index af818e3..07720a3 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -97,13 +97,22 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": def rename(self, old: str, new: str) -> None: """Rename all instances of a tag across the database""" - sql = """ - UPDATE OR IGNORE tag - SET tag_name = ?2 - WHERE tag_name = ?1; - """ - - self.db.execute(sql, (str(old), str(new))) + match self.db.engine: + case "sqlite3": + sql = """ + UPDATE OR IGNORE tag + SET tag_name = ? + WHERE tag_name = ?; + """ + case "psycopg": + sql = """ + UPDATE hippo.tag + SET tag_name = %s + WHERE tag_name = %s + ON CONFLICT DO NOTHING; + """ + + self.db.execute(sql, (str(new), str(old))) self.delete(old) @@ -196,7 +205,12 @@ def _remove_tag_from_db( :param tag: tag to delete """ - sql = f'DELETE FROM tag WHERE tag_name="{tag}" AND tag_{self.parent.table} = {self.parent.id}' + sql = f""" + DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_name="{tag}" + AND tag_{self.parent.table} = {self.parent.id} + """ + self.db.execute(sql) def _clear_tags_from_db( @@ -208,7 +222,11 @@ def _clear_tags_from_db( :param tag: tag to delete """ - sql = f"DELETE FROM tag WHERE tag_{self.parent.table} = {self.parent.id}" + sql = f""" + DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_{self.parent.table} = {self.parent.id} + """ + self.db.execute(sql) def _add_tag_to_db( diff --git a/tests/config.py b/tests/config.py index 5465753..7ebf721 100644 --- a/tests/config.py +++ b/tests/config.py @@ -6,11 +6,11 @@ ## CONFIGURE CLEANUP CLEANUP_FILES = [ - f"{TARGET}.tar.gz", + # f"{TARGET}.tar.gz", ] CLEANUP_DIRS = [ - TARGET, + # TARGET, ] ## CONFIGURE DATABASE @@ -36,20 +36,21 @@ # local testing -DB = dict( - username="postgres", - password="hippo", - host="localhost", - port=5432, -) +# DB = dict( +# username="postgres", +# password="hippo", +# host="localhost", +# port=5432, +# ) # DLS deployment -# from os import environ +from os import environ -# DB = dict( -# username=environ["HIPPO_POSTGRES_USERNAME"], -# password=environ["HIPPO_POSTGRES_PASSWORD"], -# host="localhost", -# port=5555, -# ) +DB = dict( + username=environ["HIPPO_POSTGRES_USERNAME"], + password=environ["HIPPO_POSTGRES_PASSWORD"], + host="localhost", + port=5555, + dbname="postgres", +) diff --git a/tests/test_feature.py b/tests/test_feature.py index 7929526..9df33ca 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -18,6 +18,9 @@ def test_properties(): import hippo animal = hippo.HIPPO("test", DB) + + animal.db.print_table("feature") + feature = animal.F1 for prop in NOT_NULL_PROPERTIES: From 5e49ba8a01340529510c9b08fed9d9f1868947dc Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:49:08 +0000 Subject: [PATCH 084/163] fix tests --- hippo/db.py | 24 +++++++++++++++++++----- tests/config.py | 31 +++++++++++-------------------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index e2ce5d2..09ff315 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -596,9 +596,8 @@ def execute( else: raise except Exception as e: - from .tools import strip_sql - - mrich.error(strip_sql(sql)) + # from .tools import strip_sql + # mrich.error(strip_sql(sql)) raise def executemany( @@ -2971,6 +2970,8 @@ def register_compounds( ) """ + self.executemany(sql, values) + else: match self.engine: @@ -3309,8 +3310,21 @@ def calculate_all_murcko_scaffolds( multiple=True, ) + match self.engine: + case "sqlite3": + sql = """ + INSERT OR IGNORE INTO tag(tag_name, tag_compound) + VALUES (?,?) + """ + case "psycopg": + sql = """ + INSERT INTO hippo.tag(tag_name, tag_compound) + VALUES (%s,%s) + ON CONFLICT DO NOTHING; + """ + self.executemany( - """INSERT OR IGNORE INTO tag (tag_name, tag_compound) VALUES (?,?)""", + sql, [("MurckoScaffold", i) for i, in murcko_ids], ) @@ -3325,7 +3339,7 @@ def calculate_all_murcko_scaffolds( ) self.executemany( - """INSERT OR IGNORE INTO tag (tag_name, tag_compound) VALUES (?,?)""", + sql, [("GenericMurckoScaffold", i) for i, in generic_ids], ) diff --git a/tests/config.py b/tests/config.py index 7ebf721..354c526 100644 --- a/tests/config.py +++ b/tests/config.py @@ -6,11 +6,11 @@ ## CONFIGURE CLEANUP CLEANUP_FILES = [ - # f"{TARGET}.tar.gz", + f"{TARGET}.tar.gz", ] CLEANUP_DIRS = [ - # TARGET, + TARGET, ] ## CONFIGURE DATABASE @@ -26,31 +26,22 @@ ### SQLITE -# DB = "db_test.sqlite" +DB = "db_test.sqlite" -# CLEANUP_FILES.append(DB) +CLEANUP_FILES.append(DB) ### POSTGRES -SCAFFOLDS = False +# SCAFFOLDS = False # local testing +# from os import environ + # DB = dict( -# username="postgres", -# password="hippo", +# username=environ["HIPPO_POSTGRES_USERNAME"], +# password=environ["HIPPO_POSTGRES_PASSWORD"], # host="localhost", -# port=5432, +# port=5555, +# dbname="postgres", # ) - -# DLS deployment - -from os import environ - -DB = dict( - username=environ["HIPPO_POSTGRES_USERNAME"], - password=environ["HIPPO_POSTGRES_PASSWORD"], - host="localhost", - port=5555, - dbname="postgres", -) From 10e3e164778673af7de2aa3babadd16251cb4ae4 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:51:44 +0000 Subject: [PATCH 085/163] update readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8ec066b..9fac168 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ Install psycopg pip install psycopg[binary] ``` +See `images/postgres` for a container including the [RDKit cartridge](https://rdkit.org/docs/Cartridge.html) + ### Connecting to a remote deployment Check port availability: @@ -171,4 +173,8 @@ To connect to a specific database with `psql` psql -h localhost -U USER -p 5432 -n DATABASE ``` +### Running tests + +To run the unit tests, uncomment and configure `tests/config.py` to the desired postgres deployment. N.B. currently not all tests will succeed. + From 20df8b3ef440a53d61171ba1bab4edc0d0f30b7a Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:52:53 +0000 Subject: [PATCH 086/163] update docstring --- hippo/postgres.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hippo/postgres.py b/hippo/postgres.py index 9ea5943..fc03c03 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -519,9 +519,7 @@ def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: ### BULK CALCULATIONS def calculate_all_scaffolds(self) -> None: - raise NotImplementedError - - def calculate_all_murcko_scaffolds(self) -> None: + """Placeholder for calculate_all_scaffolds""" raise NotImplementedError ### MIGRATIONS From 902f1991271acacf71298055571f9420582a5fb4 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:53:09 +0000 Subject: [PATCH 087/163] remove postgres catch --- hippo/db.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 09ff315..94eacae 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -3175,9 +3175,6 @@ def calculate_all_scaffolds(self) -> None: mrich.var("#compounds", self.count("compound")) mrich.var("#scaffold defs", n_before) - if not self.engine == "sqlite3": - raise NotImplementedError - sql = """ SELECT compound_id, compound_mol, compound_pattern_bfp FROM compound From c34433921ba4c36f9613520cc8006b1cd3a86be0 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:56:00 +0000 Subject: [PATCH 088/163] docs update --- docs/source/api_reference.rst | 1 - docs/source/fff.rst | 54 ----------------------------------- docs/source/fragalysis.rst | 11 ------- docs/source/index.rst | 18 ++++++------ docs/source/rgen.rst | 26 ----------------- 5 files changed, 9 insertions(+), 101 deletions(-) delete mode 100644 docs/source/fff.rst delete mode 100644 docs/source/fragalysis.rst delete mode 100644 docs/source/rgen.rst diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst index a5671ea..38e1909 100644 --- a/docs/source/api_reference.rst +++ b/docs/source/api_reference.rst @@ -17,6 +17,5 @@ API Reference sampling metadata plotting - fragalysis db web diff --git a/docs/source/fff.rst b/docs/source/fff.rst deleted file mode 100644 index 01abd85..0000000 --- a/docs/source/fff.rst +++ /dev/null @@ -1,54 +0,0 @@ - -==================================================== -Running a Fast-Forward Fragments campaign with HIPPO -==================================================== - -Premise... - -Resources... - -Target setup -============ - -Fragment Merging -================ - -Place Merges -============ - -Curate scaffolds -================ - -Review Chemistry -================ - -Run Syndirella -============== - -Load Elaborations -================= - -Quote reactants -=============== - -Generate routes -=============== - -Create RandomRecipeGenerator -============================ - -Generate recipes -================ - -Profile interactions -==================== - -Score recipes -============= - -Optimise recipes -================ - -Web output -========== - diff --git a/docs/source/fragalysis.rst b/docs/source/fragalysis.rst deleted file mode 100644 index 3494978..0000000 --- a/docs/source/fragalysis.rst +++ /dev/null @@ -1,11 +0,0 @@ -Fragalysis -========== - -To use these functions import them like this - -:: - - from hippo.fragalysis import download_target - -.. automodule:: hippo.fragalysis - :members: diff --git a/docs/source/index.rst b/docs/source/index.rst index 8a147a6..b750675 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -7,7 +7,7 @@ HIPPO Documentation =================== -*Hit Interaction Profiling for Procurement Optimisation* (HIPPO) is a chemical database and python toolkit to expedite fragment-based drug discovery. +*Hit Interaction Profiling for Progression Optimisation* (HIPPO) is a chemical database and python toolkit to expedite fragment-based drug discovery. Installation ============ @@ -37,30 +37,30 @@ Getting started HIPPO uses an sqlite database with several inter-connected tables and Python-class representations thereof, the core concepts are explained in :doc:`definitions`. Once familiar you can try :doc:`getting_started`. +.. note:: + + HIPPO is built primarily as a Python API to be used in interactive :doc:`notebooks` such as in JupyterLab, but where higher performance is needed several tasks are accessible via a :doc:`cli` which can be used in SLURM jobs. .. toctree:: :maxdepth: 1 :caption: Documentation Pages - Home - Definitions, units, and data types Getting started Adding data - Interfacing with Syndirella - - Running an FFF campaign + Interfacing with Syndirella and merging algorithms - Preparing files for Fragalysis upload + Example Notebooks Windows installation - - Random recipe generation + + Command-Line Interface API Reference + Core concepts ============= diff --git a/docs/source/rgen.rst b/docs/source/rgen.rst deleted file mode 100644 index 83da58c..0000000 --- a/docs/source/rgen.rst +++ /dev/null @@ -1,26 +0,0 @@ - -======================== -Random Recipe Generation -======================== - -1. Start with a recipe - -:: - - recipe = hippo.Recipe.from_json() - -2. Create the generator object - -:: - - gen = hippo.RandomRecipeGenerator() - -hippo.RandomRecipeGenerator.get_route_pool() pseudocode: - -:: - - route_ids = SELECT route_id FROM route WHERE route_product in RandomRecipeGenerator.product_pool - routes = [db.get_route(id=route_id) from route_id, in route_ids] - return RouteSet(db, routes) - -3. \ No newline at end of file From b3dc9ca5cbd8648b1f28f772baa79d5f80eade4e Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 10:59:37 +0000 Subject: [PATCH 089/163] update API docs --- docs/source/db.rst | 5 ++++- hippo/postgres.py | 8 +++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/source/db.rst b/docs/source/db.rst index 791e0fa..9bf0500 100644 --- a/docs/source/db.rst +++ b/docs/source/db.rst @@ -1,7 +1,7 @@ Database ========= -HIPPO stores data in a SQLite Database file that structures information across multiple cross-referenced tables. For general use it is not necessary to understand the database schema in detail. +HIPPO stores data in a database file that structures information across multiple cross-referenced tables. For general use it is not necessary to understand the database schema in detail. .. image:: ../images/db_architecture-01.png :width: 900 @@ -9,3 +9,6 @@ HIPPO stores data in a SQLite Database file that structures information across m .. autoclass:: hippo.db.Database :members: + +.. autoclass:: hippo.postgres.PostgresDatabase + :members: diff --git a/hippo/postgres.py b/hippo/postgres.py index fc03c03..b1ba28a 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -320,7 +320,7 @@ def execute( debug: bool = False, time: bool = False, ): - """Execute arbitrary SQL with retry if database is locked.""" + """Execute arbitrary SQL""" if debug: mrich.debug(sql) @@ -355,7 +355,9 @@ def executemany( time: bool = False, batch_size: int = None, ): - """Execute arbitrary SQL with retry if database is locked.""" + """Execute arbitrary SQL + + :param batch_size: optional batch size for the execution""" returning = "RETURNING" in sql @@ -417,7 +419,7 @@ def executemany( return records def rollback(self) -> None: - """rollback (not relevant for sqlite)""" + """rollback the staged changes. not relevant for sqlite""" self.connection.rollback() self.connection.execute("SET client_encoding TO 'UTF8'") From fdd43f01f44afb9491d1c5059d1aba092f9d6bd2 Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Mon, 15 Dec 2025 11:50:12 +0000 Subject: [PATCH 090/163] update quotes on bulk insertion #245 --- hippo/db.py | 50 ++++++++++++++++++++++++---------------------- hippo/migration.py | 15 +++++++++++++- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index dd9f242..cb73c73 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1587,8 +1587,8 @@ def insert_quote( ); """ case "psycopg": - sql = f""" - INSERT OR REPLACE INTO quote( + sql = """ + INSERT OR REPLACE INTO hippo.quote( quote_smiles, quote_amount, quote_supplier, @@ -1602,32 +1602,34 @@ def insert_quote( quote_date ) VALUES( - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, {date_str} ) ON CONFLICT DO UPDATE - quote_smiles = EXCLUDED.quote_smiles, - quote_amount = EXCLUDED.quote_amount, - quote_supplier = EXCLUDED.quote_supplier, - quote_catalogue = EXCLUDED.quote_catalogue, - quote_entry = EXCLUDED.quote_entry, - quote_lead_time = EXCLUDED.quote_lead_time, - quote_price = EXCLUDED.quote_price, - quote_currency = EXCLUDED.quote_currency, - quote_purity = EXCLUDED.quote_purity, - quote_compound = EXCLUDED.quote_compound, - quote_date = EXCLUDED.quote_date; - """ + hippo.quote.quote_smiles = EXCLUDED.quote_smiles, + hippo.quote.quote_amount = EXCLUDED.quote_amount, + hippo.quote.quote_supplier = EXCLUDED.quote_supplier, + hippo.quote.quote_catalogue = EXCLUDED.quote_catalogue, + hippo.quote.quote_entry = EXCLUDED.quote_entry, + hippo.quote.quote_lead_time = EXCLUDED.quote_lead_time, + hippo.quote.quote_price = EXCLUDED.quote_price, + hippo.quote.quote_currency = EXCLUDED.quote_currency, + hippo.quote.quote_purity = EXCLUDED.quote_purity, + hippo.quote.quote_compound = EXCLUDED.quote_compound, + hippo.quote.quote_date = EXCLUDED.quote_date; + """.format( + date_str=date_str + ) try: self.execute( diff --git a/hippo/migration.py b/hippo/migration.py index fac73d7..a634048 100644 --- a/hippo/migration.py +++ b/hippo/migration.py @@ -1089,7 +1089,20 @@ def migrate_quotes( %(date)s, %(compound)s ) - ON CONFLICT DO NOTHING; + ON CONFLICT ON CONSTRAINT UC_quote + DO UPDATE SET + quote_smiles = EXCLUDED.quote_smiles, + quote_amount = EXCLUDED.quote_amount, + quote_supplier = EXCLUDED.quote_supplier, + quote_catalogue = EXCLUDED.quote_catalogue, + quote_entry = EXCLUDED.quote_entry, + quote_lead_time = EXCLUDED.quote_lead_time, + quote_price = EXCLUDED.quote_price, + quote_currency = EXCLUDED.quote_currency, + quote_purity = EXCLUDED.quote_purity, + quote_date = EXCLUDED.quote_date, + quote_compound = EXCLUDED.quote_compound + WHERE hippo.quote.quote_date < EXCLUDED.quote_date; """ # format the data From 9a11dfd1a039b650d9e1f9cd299df0b0a1b0d73f Mon Sep 17 00:00:00 2001 From: Max Winokan Date: Thu, 18 Dec 2025 11:42:53 +0000 Subject: [PATCH 091/163] Pose.calculate_interactions: in_memory_db #248 --- hippo/animal.py | 2 +- hippo/pose.py | 37 ++++++++++++++++++++++++------------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index 5387780..c5a7fde 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -824,7 +824,7 @@ def load_sdf( try: pose_id = int(insp) - inspirations.append(pose_id) + inspiration_list.append(pose_id) except ValueError: if ( diff --git a/hippo/pose.py b/hippo/pose.py index 873761c..4c2c3b6 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -884,6 +884,7 @@ def calculate_interactions( debug: bool = False, commit: bool = True, mutation_warnings: bool = True, + in_memory_db: bool = True, delete_temp_table: bool = True, ) -> None: """Enumerate all valid interactions between this ligand and the protein @@ -895,6 +896,7 @@ def calculate_interactions( :param debug: Increase verbosity for debugging :param commit: commit the changes to the database (Default value = True) :param mutation_warnings: warn when there has been a mutation in the protein (Default value = True) + :param in_memory_db: use an in-memory sqlite database when resolving interactions, faster but may break IPyWidgets (Default value = True) :param delete_temp_table: delete the temporary interaction table created during interaction resolution (Default value = True) """ @@ -942,19 +944,24 @@ def angle_between(v1, v2): ### IN-MEMORY DB - from .db import Database + if in_memory_db: - temp_db = Database( - ":memory:", - animal=None, - create_blank=False, - check_legacy=False, - create_indexes=False, - debug=False, - ) + from .db import Database + + temp_db = Database( + ":memory:", + animal=None, + create_blank=False, + check_legacy=False, + create_indexes=False, + debug=False, + ) - temp_db.create_table_interaction(debug=False) - temp_db.commit() + temp_db.create_table_interaction(debug=False) + temp_db.commit() + + else: + temp_db = db ### create temporary table @@ -1217,12 +1224,16 @@ def angle_between(v1, v2): table="interaction", key="pose", value=self.id, commit=commit ) - self.db.copy_temp_interactions(source_db=temp_db) + if in_memory_db: + self.db.copy_temp_interactions(source_db=temp_db) + else: + self.db.copy_temp_interactions() + self.set_has_fingerprint(True, commit=commit) ### delete temporary table - if delete_temp_table: + if in_memory_db and delete_temp_table: temp_db.close(debug=False) elif debug: From 828c39281a1557f79c7406e6d6cfe22cfdd66b01 Mon Sep 17 00:00:00 2001 From: Jochem Nelen Date: Thu, 22 Jan 2026 12:08:35 +0000 Subject: [PATCH 092/163] Download Fragalysis data when running pytest --- tests/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/config.py b/tests/config.py index 354c526..7f95b7b 100644 --- a/tests/config.py +++ b/tests/config.py @@ -18,7 +18,7 @@ ## CONFIGURE TESTS CLEANUP = True -DOWNLOAD = False +DOWNLOAD = True SETUP = True ADD_HITS = True SCAFFOLDS = True From bf4985cb6b0ec29fa260acec3ff526abf82f0bde Mon Sep 17 00:00:00 2001 From: Jochem Nelen Date: Thu, 22 Jan 2026 12:25:23 +0000 Subject: [PATCH 093/163] Implemented fix to ensure the correct ref_pdbs are exported, as suggested by @laurenreid1 as discussed in issue #249 --- hippo/animal.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index c5a7fde..d2edfd5 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -848,14 +848,14 @@ def load_sdf( ref_str = row.get(reference_col) if ref_str: try: - reference = int(ref_str) + row_reference = int(ref_str) except ValueError: - reference = inspiration_map[ref_str] + row_reference = inspiration_map[ref_str] else: - reference = None + row_reference = None elif isinstance(reference, Pose): - reference = reference.id + row_reference = reference.id # metadata metadata = {} @@ -906,7 +906,7 @@ def load_sdf( path=pose_path, metadata=metadata, inspiration_ids=inspiration_list, - reference_id=reference, + reference_id=row_reference, mol=mol, inchikey=inchikey, smiles=smiles, From ab74317f4995ac22b8e0ea5a66e93c80d643698c Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 27 Feb 2026 14:46:39 +0000 Subject: [PATCH 094/163] stashing --- Dockerfile | 71 +++++++++++++++++++ docker-compose.yaml | 69 ++++++++++++++++++ images/postgres/Dockerfile | 3 +- .../01-extensions.sql | 1 + 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml create mode 100644 pg_setup/docker-entrypoint-initdb.d/01-extensions.sql diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f017a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +FROM quay.io/jupyter/minimal-notebook:2025-04-14 +LABEL authors="Max Winokan" + +# Upgrade pip and install JupyterLab +# RUN pip install --upgrade pip && pip install hippo-db syndirella typer neo4j black gemmi +# RUN pip install --upgrade pip + + + +# RUN mamba create -y -n dev310 python=3.10 +# ENV CONDA_DEFAULT_ENV=dev310 +# ENV PATH=/opt/conda/envs/dev310/bin:$PATH + + +# RUN mamba create -y -n devenv python=3.12 +# ENV CONDA_DEFAULT_ENV=devenv +# ENV PATH=/opt/conda/envs/devenv/bin:$PATH + +RUN apt install sqlite3 + +# HIPPO dev branch +# WORKDIR "/home/code" +# RUN git clone https://github.com/mwinokan/HIPPO +WORKDIR "/home/code/HIPPO" +# RUN git checkout dev && pip install -e . --no-deps +COPY . ./ + + + +# RUN mamba install --yes -n devenv \ +RUN mamba install --yes \ + # -f syndirella_and_hippo.yaml \ + # --file requirements_syndirella_and_hippo.txt && \ + chemicalite=2024.05.1 pdbfixer && \ + mamba clean --all -f -y && \ + fix-permissions "${CONDA_DIR}" && \ + fix-permissions "/home/${NB_USER}" + + +# RUN /opt/conda/envs/devenv/bin/python -m pip install hippo-db syndirella typer neo4j gemmi mrich mpytools +# RUN /opt/conda/envs/devenv/bin/python -m pip uninstall -y hippo-db + +RUN python -m pip install hippo-db syndirella typer neo4j gemmi mrich mpytools +RUN python -m pip uninstall -y hippo-db + +# RUN pip install -r requirements_syndirella_and_hippo_fixed.txt +# RUN mamba install --yes --file requirements_syndirella_and_hippo.txt +# RUN conda install --yes --file requirements_syndirella_and_hippo.txt + + +# EXPOSE 8888 + +# patch rich +RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" +# RUN /opt/conda/envs/devenv/bin/python -c "import mrich; mrich.patch_rich_jupyter_margins()" + +# notebooks +USER 0 +# RUN mkdir "/home/code" && chown ${NB_USER} "/home/code" && \ +# sudo apt update && sudo apt install screen -y +RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y +USER ${NB_USER} + +# WORKDIR "/home/${NB_USER}" +WORKDIR "/home/code/HIPPO" + + +# WORKDIR /code + + +# CMD ["./docker-entrypoint.sh"] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..da51ebd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,69 @@ +--- +# Dev environment for local HIPPO development. +# Needs two containers, backend with HIPPO and dependencies installed, and database + +# database container is built from images/postgres/Dockerfile: +# sudo docker build --no-cache -t hippo-pg:latest . + +# Backend from ./Dockerfile +# sudo docker build --no-cache . -t hippo-backend:latest + +# Then bring the containers up with: +# sudo docker-compose up + +# To clear the system run +# sudo docker compose down + +# To also clear the datbase volume +# sudo docker compose down -v + + +services: + + database: + # this is what I built for myself + image: hippo-pg + image: pgvectorimage + # container_name: hippo_pg_db + volumes: + - postgres_data:/var/lib/postgresql/data + - type: bind + source: ./pg_setup/docker-entrypoint-initdb.d + target: /docker-entrypoint-initdb.d + + environment: + POSTGRES_PASSWORD: hippo + POSTGRES_DB: hippo + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + # healthcheck: + # test: pg_isready -U postgres -d frag + # interval: 10s + # timeout: 2s + # retries: 5 + # start_period: 10s + + backend: + image: hippo-backend + container_name: hippo_backend + build: + context: . + dockerfile: Dockerfile + # command: /bin/bash /code/launch-stack.sh + volumes: + - .:/code/ + env_file: + - .env + environment: + POSTGRESQL_USER: postgres + + ports: + - "8888:8888" + # depends_on: + # database: + # condition: service_healthy + + +volumes: + postgres_data: \ No newline at end of file diff --git a/images/postgres/Dockerfile b/images/postgres/Dockerfile index 42f5e41..21c60b0 100644 --- a/images/postgres/Dockerfile +++ b/images/postgres/Dockerfile @@ -101,7 +101,8 @@ RUN ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/initdb /usr/bin/initdb || true && ln -sf /usr/lib/postgresql/${PG_MAJOR}/bin/psql /usr/bin/psql || true # Vendor entrypoint (place official script at files/docker-entrypoint.sh) -COPY files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +# COPY files/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Prepare dirs and permissions diff --git a/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql b/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql new file mode 100644 index 0000000..e884453 --- /dev/null +++ b/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql @@ -0,0 +1 @@ +CREATE EXTENSION IF NOT EXISTS rdkit; From 66733a3219d9519f8ecfba41f7f9fec760939223 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 3 Mar 2026 08:14:14 +0000 Subject: [PATCH 095/163] fix: load_sdf pose-compound mapping issue (#2033) --- Dockerfile | 30 ++++++++++++++++++++++++++++++ docker-compose.yaml | 28 ++++++++++++++++++++++++++++ hippo/animal.py | 33 +++++++++++++++++++++++++++++++-- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..574b9a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM quay.io/jupyter/minimal-notebook:2025-04-14 +LABEL authors="Max Winokan" + +# Ported from Max's Dockerfile to support local development + +RUN apt install sqlite3 + +WORKDIR "/home/code/HIPPO" +COPY . ./ + +RUN mamba install --yes \ + chemicalite=2024.05.1 pdbfixer && \ + mamba clean --all -f -y && \ + fix-permissions "${CONDA_DIR}" && \ + fix-permissions "/home/${NB_USER}" + + +RUN python -m pip install syndirella typer neo4j gemmi mrich mpytools + + +# patch rich +RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" + +# notebooks +USER 0 +RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y +USER ${NB_USER} + +WORKDIR "/home/code/HIPPO" + diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..862f5cd --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,28 @@ +--- +# Dev environment for local HIPPO development. + +# Backend built from ./Dockerfile +# sudo docker build --no-cache . -t hippo-backend:latest + +# Bring the container up with: +# sudo docker-compose up + +# To clear the system run +# sudo docker compose down + + +services: + + backend: + image: hippo-backend + container_name: hippo_backend + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/home/code/HIPPO + env_file: + - .env + + ports: + - "8888:8888" \ No newline at end of file diff --git a/hippo/animal.py b/hippo/animal.py index d2edfd5..adfd181 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -18,7 +18,12 @@ from .pset import PoseTable, PoseSet from .rset import ReactionTable, ReactionSet from .cset import CompoundTable, IngredientSet, CompoundSet -from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError +from .tools import ( + flat_inchikey, + inchikey_from_smiles, + sanitise_smiles, + SanitisationError, +) class HIPPO: @@ -749,7 +754,31 @@ def load_sdf( mrich.debug("#smiles", len(smiles)) mrich.debug("Registering compounds...") pairs = self.register_compounds(smiles=smiles, sanitisation_verbosity=False) - smiles_lookup = {s1: i for s1, (i, s2) in zip(smiles, pairs)} + + # fix for 2033, replace smiles_lookup generation procedure + # smiles_lookup = {s1: i for s1, (i, s2) in zip(smiles, pairs)} + # duplicated sanitation in register_compounds + smiles_lookup = {} + for s in smiles: + try: + new_smiles = sanitise_smiles( + s, + sanitisation_failed="error", + radical="warning", + verbosity=True, + ) + except SanitisationError as e: + mrich.error(f"Could not sanitise {s=}") + mrich.error(str(e)) + continue + except AssertionError: + mrich.error(f"Could not sanitise {s=}") + continue + + # smiles must now be sanitised and should not throw error + # in flat_inchikey method + smiles_lookup[s] = flat_inchikey(new_smiles) + inchi_lookup = self.db.get_compound_inchikey_id_dict( inchikeys=smiles_lookup.values() ) From 42930fa5869a9bab59d54168b17adb7aad759a78 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 3 Mar 2026 08:19:57 +0000 Subject: [PATCH 096/163] fix: wrong use of string formatting (#2026) --- hippo/animal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/animal.py b/hippo/animal.py index d2edfd5..5bb08f8 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -2336,7 +2336,7 @@ def register_reaction( AND reaction_product = {product} """ - sql.format(type=type, product=product) + sql = sql.format(type=type, product=product) pairs = self.db.execute(sql).fetchall() From 9378917ae8c8e0e122352abea4ab8ed8ecc118b6 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 3 Mar 2026 14:11:04 +0000 Subject: [PATCH 097/163] fix: python-sqlite3 data type mismatch sqlite3's python wrapper doesn't know how to handle numpy data types, add explicit casts --- hippo/animal.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hippo/animal.py b/hippo/animal.py index d2edfd5..c2899c2 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1352,7 +1352,10 @@ def add_syndirella_elabs( ON CONFLICT DO NOTHING; """ - self.db.executemany(sql, [(scaffold_id, i) for i in superstructure_ids]) + self.db.executemany( + sql, + [(int(scaffold_id), int(i)) for i in superstructure_ids], + ) self.db.commit() # filter poses From deec14750bccb8d658e8bca8005571ff9fa5b9db Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Tue, 3 Mar 2026 15:58:01 +0000 Subject: [PATCH 098/163] ci: Release modifications --- .github/workflows/python-publish.yml | 39 ---------------------------- .github/workflows/release.yml | 32 +++++++++++++++++++++++ pyproject.toml | 5 +--- 3 files changed, 33 insertions(+), 43 deletions(-) delete mode 100644 .github/workflows/python-publish.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml deleted file mode 100644 index bdaab28..0000000 --- a/.github/workflows/python-publish.yml +++ /dev/null @@ -1,39 +0,0 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package - -on: - release: - types: [published] - -permissions: - contents: read - -jobs: - deploy: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..25e822f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +--- +name: Release + +on: + release: + types: + - published + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install dependencies + run: | + pip install --upgrade pip + pip install build + - name: Build + run: python -m build + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_APIKEY }} diff --git a/pyproject.toml b/pyproject.toml index cadfc09..acc41f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "hippo-db" +name = "xchem-hippo" version = "0.3.38" authors = [ { name = "Max Winokan", email = "max@winokan.com" }, @@ -34,9 +34,6 @@ dependencies = [ "apsw", "python-louvain", ] -[project.urls] -"Homepage" = "https://hippo.winokan.com" -"Bug Tracker" = "https://github.com/mwinokan/HIPPO/issues" [tool.hatch.build] include = [ "hippo/*.py", From f34b196a30fd403802507d82e70a6ac4b33c64b3 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Tue, 3 Mar 2026 16:19:43 +0000 Subject: [PATCH 099/163] ci: More work on CI --- .github/workflows/black.yml | 12 ---------- .github/workflows/lint.yml | 21 ++++++++++++++++++ .github/workflows/pytest.yml | 42 ----------------------------------- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 30 +++++++++++++++++++++++++ README.md | 6 ++--- 6 files changed, 54 insertions(+), 59 deletions(-) delete mode 100644 .github/workflows/black.yml create mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/pytest.yml create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 93ebb22..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: psf/black@stable - with: - src: "./hippo" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..9b18dca --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,21 @@ +--- +name: Lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Configure pre-commit + run: | + pip install --upgrade pip + pip install pre-commit==4.5.1 + - name: Repeat pre-commit + run: pre-commit run --all-files diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml deleted file mode 100644 index efb488c..0000000 --- a/.github/workflows/pytest.yml +++ /dev/null @@ -1,42 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python application - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -permissions: - contents: read - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.10 - uses: actions/setup-python@v3 - with: - python-version: "3.10" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install rdkit==2023.9.1 molparse>=0.0.13 tqdm jupyterlab pycule chardet pandas - conda install -c conda-forge -y chemicalite - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - # - name: Lint with flake8 - # run: | - # # stop the build if there are Python syntax errors or undefined names - # flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - # flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - #pytest - cd tests - python test_A71EV2A.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25e822f..3ef49a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.13' + python-version: '3.10' - name: Install dependencies run: | pip install --upgrade pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e911457 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +--- +name: Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + btestd: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install rdkit==2023.9.1 molparse>=0.0.13 tqdm jupyterlab pycule chardet pandas + conda install -c conda-forge -y chemicalite + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Test + run: | + #pytest + cd tests + python test_A71EV2A.py diff --git a/README.md b/README.md index ecd62d6..4048628 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ -HIPPO -===== +# XChem HIPPO > 🦛 Hit Interaction Profiling for Progression Optimisation @@ -9,7 +8,6 @@ HIPPO is in active development and feedback is appreciated. Please see the [documentation](https://hippo-docs.winokan.com) to get started - ![GitHub Tag](https://img.shields.io/github/v/tag/mwinokan/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fhippo-db%2F) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/python-publish.yml?label=publish&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fpython-publish.yml) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/black.yml?label=lint&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fblack.yml) @@ -40,7 +38,7 @@ Or by running the full suite of tests (see Developer information) ## More Information
- + Repository structure ### Branches From d5c636709af12a0d51b927641c25c9d8fadbc147 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Tue, 3 Mar 2026 17:01:22 +0000 Subject: [PATCH 100/163] ci: Disable test jobn (it doesn't work as written) --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e911457..508d7c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ permissions: contents: read jobs: - btestd: + .test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e35b455..22a2a86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,4 @@ +--- repos: # Using this mirror lets us use mypyc-compiled black, which is about 2x faster - repo: https://github.com/psf/black-pre-commit-mirror From 1bb256ebefc0367484dd4f112da4e26b3dcecbb1 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 09:52:55 +0000 Subject: [PATCH 101/163] ci: Better publishing --- .github/workflows/{lint.yml => lint.yaml} | 0 .github/workflows/release.yaml | 56 +++++++++++++++++++++++ .github/workflows/release.yml | 32 ------------- .github/workflows/{test.yml => test.yaml} | 0 4 files changed, 56 insertions(+), 32 deletions(-) rename .github/workflows/{lint.yml => lint.yaml} (100%) create mode 100644 .github/workflows/release.yaml delete mode 100644 .github/workflows/release.yml rename .github/workflows/{test.yml => test.yaml} (100%) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yaml similarity index 100% rename from .github/workflows/lint.yml rename to .github/workflows/lint.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..cf7c300 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,56 @@ +--- +# The standard xchem Python package release process. +# Run on 'Release' and published to PyPI as a 'trusted' publisher. +# +# See https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/ +# See https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/ +name: Release + +on: + release: + types: + - published + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install pypa/build + run: | + python3 -m pip install --upgrade pip + python3 -m pip install build --user + - name: Build + run: python3 -m build + - name: Store the distribution + uses: actions/upload-artifact@v5 + with: + name: python-package-distribution + path: dist/ + + publish: + name: Publish to PyPI + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/xchem-hippo + permissions: + id-token: write + steps: + - name: Download distribution + uses: actions/download-artifact@v6 + with: + name: python-package-distribution + path: dist/ + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 3ef49a8..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -name: Release - -on: - release: - types: - - published - -permissions: - contents: read - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.10' - - name: Install dependencies - run: | - pip install --upgrade pip - pip install build - - name: Build - run: python -m build - - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_APIKEY }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yaml similarity index 100% rename from .github/workflows/test.yml rename to .github/workflows/test.yaml From 11dfc563f17535238c590356592be2992456b2f8 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 10:17:02 +0000 Subject: [PATCH 102/163] build: Remove version from pyproject.toml --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index acc41f8..c40bf88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,6 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "xchem-hippo" -version = "0.3.38" authors = [ { name = "Max Winokan", email = "max@winokan.com" }, ] From 1c617ae5d5c1ccd0329173f95cf3ffa7c41d6971 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:33:08 +0000 Subject: [PATCH 103/163] ci: Use of uv --- .github/workflows/release.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cf7c300..7eb60e8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -24,12 +24,14 @@ jobs: uses: actions/setup-python@v6 with: python-version: '3.x' - - name: Install pypa/build + - name: Install build package run: | python3 -m pip install --upgrade pip - python3 -m pip install build --user + python3 -m pip install uv==0.10.6 --user - name: Build - run: python3 -m build + run: | + uv version ${{ github.ref_name }} + uv build - name: Store the distribution uses: actions/upload-artifact@v5 with: From 6c6fa32b83a88b05469973fa986dc5a57cb14536 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:33:22 +0000 Subject: [PATCH 104/163] ci: Restore version --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index c40bf88..4f713ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,6 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "xchem-hippo" +version = "0.0.0" authors = [ { name = "Max Winokan", email = "max@winokan.com" }, ] From e422ae4292acafb81d16f6071212699966d83969 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:40:51 +0000 Subject: [PATCH 105/163] ci: Better lint and test scheduling --- .github/workflows/lint.yaml | 6 +++++- .github/workflows/test.yaml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 9b18dca..4054fed 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,7 +1,11 @@ --- name: Lint -on: [push, pull_request] +on: + push: + tags-ignore: + - '*' + pull_request: jobs: lint: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 508d7c4..b200758 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,14 +3,18 @@ name: Test on: push: + tags-ignore: + - '*' pull_request: permissions: contents: read jobs: - .test: + test: runs-on: ubuntu-latest + # Test is broken - fix and remove this condition + if: false steps: - uses: actions/checkout@v6 - name: Set up Python From d76f4b7d47c97557a9f2da48664646e3758d680f Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:42:49 +0000 Subject: [PATCH 106/163] ci: Include branches --- .github/workflows/lint.yaml | 2 ++ .github/workflows/test.yaml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4054fed..5b97787 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -3,6 +3,8 @@ name: Lint on: push: + branches: + - '*' tags-ignore: - '*' pull_request: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b200758..6f096c4 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -3,6 +3,8 @@ name: Test on: push: + branches: + - '*' tags-ignore: - '*' pull_request: From 204ad2e10e13b5f328668f22a45bef2ac3454257 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:55:16 +0000 Subject: [PATCH 107/163] docs: README tweak --- README.md | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4048628..2fa7597 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ - + # XChem HIPPO -> 🦛 Hit Interaction Profiling for Progression Optimisation +> HIPPO: 🦛 Hit Interaction Profiling for Progression Optimisation HIPPO is in active development and feedback is appreciated. Please see the [documentation](https://hippo-docs.winokan.com) to get started -![GitHub Tag](https://img.shields.io/github/v/tag/mwinokan/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fhippo-db%2F) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/python-publish.yml?label=publish&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fpython-publish.yml) -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mwinokan/HIPPO/black.yml?label=lint&link=https%3A%2F%2Fgithub.com%2Fmwinokan%2FHIPPO%2Factions%2Fworkflows%2Fblack.yml) -[![Documentation Status](https://readthedocs.org/projects/hippo-db/badge/?version=latest)](https://hippo-docs.winokan.com/en/latest/?badge=latest) -![GitHub last commit](https://img.shields.io/github/last-commit/mwinokan/hippo) -![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/mwinokan/hippo) +![GitHub Tag](https://img.shields.io/github/v/tag/xchem/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fxchem-hippo%2F) +![Release](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/release.yaml?label=publish&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%2Frelease.yaml) +![Lint](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/lint.yaml?label=lint&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%lint.yaml) +![Test](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/test.yaml?label=lint&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%test.yaml) + [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) ## Installation -HIPPO is pip-installable, but use of a `conda` environment is recommended for the rdkit and chemicalite dependencies: +HIPPO is pip-installable, but use of a `conda` environment is recommended for the +rdkit and chemicalite dependencies: ``` pip install --upgrade hippo-db @@ -43,10 +43,10 @@ Or by running the full suite of tests (see Developer information) ### Branches -- [HIPPO/main](https://github.com/mwinokan/HIPPO/tree/main): latest stable version -- [HIPPO/dev](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's development branch -- [HIPPO/postgres](https://github.com/mwinokan/HIPPO/tree/dev): @mwinokan's PostgreSQL development branch -- [HIPPO/django_lean](https://github.com/mwinokan/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app +- [HIPPO/main](https://github.com/xchem/HIPPO/tree/main): latest stable version +- [HIPPO/dev](https://github.com/xchem/HIPPO/tree/dev): Dvelopment branch +- [HIPPO/postgres](https://github.com/xchem/HIPPO/tree/dev): PostgreSQL development branch +- [HIPPO/django_lean](https://github.com/xchem/HIPPO/tree/django_lean): An experimental branch implementing HIPPO as a Django web-app
@@ -67,11 +67,17 @@ pip install -e . ### Releases -HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as `hippo-db` via Github [releases](https://github.com/mwinokan/HIPPO/releases) off the `main` branch using the [python-publish](https://github.com/mwinokan/HIPPO/actions/workflows/python-publish.yml) workflow. +HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as +`xchem-hippo` via a Github Action off the using the +[release](https://github.com/xchem/HIPPO/actions/workflows/release.yaml) workflow. ### Code style -HIPPO is linted using [black](https://pypi.org/project/black/) and commits are automatically linted using the [black](https://github.com/mwinokan/HIPPO/actions/workflows/black.yml) workflow. The use of [pre-commit](https://pre-commit.com/) is encouraged for local development to automatically run the linting at git commit time: +HIPPO is linted using [black](https://pypi.org/project/black/) and commits are +automatically linted using the +[lint](https://github.com/xchem/HIPPO/actions/workflows/lint.yaml) workflow. +The use of [pre-commit](https://pre-commit.com/) is encouraged for local development +to automatically run the linting at git commit time: ``` pip install pre-commit @@ -80,7 +86,9 @@ pre-commit install ### Documentation -Documentation is automatically built off the [HIPPO/main](https://github.com/mwinokan/HIPPO/tree/main) branch using readthedocs. For local building using sphinx: +Documentation is automatically built off the +[HIPPO/main](https://github.com/xchem/HIPPO/tree/main) branch using readthedocs. +For local building using sphinx: ``` cd docs From 42825a0dcdd462129ee888259346e3178599d5d4 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 11:56:49 +0000 Subject: [PATCH 108/163] docs: Fix test badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2fa7597..6719251 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Please see the [documentation](https://hippo-docs.winokan.com) to get started ![GitHub Tag](https://img.shields.io/github/v/tag/xchem/hippo?include_prereleases&label=PyPI&link=https%3A%2F%2Fpypi.org%2Fproject%2Fxchem-hippo%2F) ![Release](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/release.yaml?label=publish&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%2Frelease.yaml) ![Lint](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/lint.yaml?label=lint&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%lint.yaml) -![Test](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/test.yaml?label=lint&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%test.yaml) +![Test](https://img.shields.io/github/actions/workflow/status/xchem/HIPPO/test.yaml?label=test&link=https%3A%2F%2Fgithub.com%2Fxchem%2FHIPPO%2Factions%2Fworkflows%test.yaml) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) From 1f64b619bd9633539ea55abff5e453d190a43048 Mon Sep 17 00:00:00 2001 From: "a.b.christie" Date: Wed, 4 Mar 2026 12:14:47 +0000 Subject: [PATCH 109/163] docs: Note about releases --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 6719251..1c3ffa9 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ HIPPO is automatically released to [PyPI](https://pypi.org/project/hippo-db/) as `xchem-hippo` via a Github Action off the using the [release](https://github.com/xchem/HIPPO/actions/workflows/release.yaml) workflow. +When you want to make an official release go to the [Releases](https://github.com/xchem/HIPPO/releases) page +and then click the **Draft a new release** button. Remember to familiarise yourself +with the xchem release process on the trunk-based-development Wiki +[Creating releases](https://github.com/xchem/trunk-based-development/wiki/Creating-releases) +page. + ### Code style HIPPO is linted using [black](https://pypi.org/project/black/) and commits are From cfa142f140aab68d8895723fce4025cd304e5959 Mon Sep 17 00:00:00 2001 From: Cedric Vallee Date: Wed, 4 Mar 2026 15:42:24 +0000 Subject: [PATCH 110/163] Change deprecated/remove sqlite3.version to sqlite3.sqlite_version --- hippo/db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/db.py b/hippo/db.py index cb73c73..a25787b 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -537,7 +537,7 @@ def connect(self, debug: bool = True) -> None: conn = sqlite3.connect(self.path) if debug: - mrich.debug(f"{sqlite3.version=}") + mrich.debug(f"{sqlite3.sqlite_version=}") conn.enable_load_extension(True) conn.load_extension("chemicalite") From 8fafd2c4158396e2ff428895c7425f2ea0680452 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 4 Mar 2026 16:23:29 +0000 Subject: [PATCH 111/163] fix: postgres dev env working When I say working I mean environment comes up and there seems to be communication with the database. Commands to write data to db seem to fail. --- Dockerfile | 8 +++----- docker-compose.yaml | 26 ++++++++++++-------------- hippo/postgres.py | 4 ++-- images/postgres/Dockerfile | 10 ++++++---- images/postgres/docker-entrypoint.sh | 6 +++--- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8f017a7..2890f95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,8 +16,6 @@ LABEL authors="Max Winokan" # ENV CONDA_DEFAULT_ENV=devenv # ENV PATH=/opt/conda/envs/devenv/bin:$PATH -RUN apt install sqlite3 - # HIPPO dev branch # WORKDIR "/home/code" # RUN git clone https://github.com/mwinokan/HIPPO @@ -37,11 +35,11 @@ RUN mamba install --yes \ fix-permissions "/home/${NB_USER}" -# RUN /opt/conda/envs/devenv/bin/python -m pip install hippo-db syndirella typer neo4j gemmi mrich mpytools +# RUN /opt/conda/envs/devenv/bin/python -m pip install hippo-db syndirella typer neo4j gemmi mrich mpytools # RUN /opt/conda/envs/devenv/bin/python -m pip uninstall -y hippo-db -RUN python -m pip install hippo-db syndirella typer neo4j gemmi mrich mpytools -RUN python -m pip uninstall -y hippo-db +RUN python -m pip install syndirella typer neo4j gemmi mrich mpytools psycopg[binary] molparse rdkit +RUN pip install rdkit --upgrade # RUN pip install -r requirements_syndirella_and_hippo_fixed.txt # RUN mamba install --yes --file requirements_syndirella_and_hippo.txt diff --git a/docker-compose.yaml b/docker-compose.yaml index da51ebd..5201a4a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -23,8 +23,8 @@ services: database: # this is what I built for myself image: hippo-pg - image: pgvectorimage - # container_name: hippo_pg_db + # image: pgvectorimage + container_name: hippo_pg_db volumes: - postgres_data:/var/lib/postgresql/data - type: bind @@ -34,15 +34,14 @@ services: environment: POSTGRES_PASSWORD: hippo POSTGRES_DB: hippo - PGDATA: /var/lib/postgresql/data/pgdata ports: - "5432:5432" - # healthcheck: - # test: pg_isready -U postgres -d frag - # interval: 10s - # timeout: 2s - # retries: 5 - # start_period: 10s + healthcheck: + test: pg_isready -U postgres -d hippo + interval: 10s + timeout: 2s + retries: 5 + start_period: 10s backend: image: hippo-backend @@ -50,9 +49,8 @@ services: build: context: . dockerfile: Dockerfile - # command: /bin/bash /code/launch-stack.sh volumes: - - .:/code/ + - .:/home/code/HIPPO env_file: - .env environment: @@ -60,9 +58,9 @@ services: ports: - "8888:8888" - # depends_on: - # database: - # condition: service_healthy + depends_on: + database: + condition: service_healthy volumes: diff --git a/hippo/postgres.py b/hippo/postgres.py index fc03c03..c373e71 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -50,7 +50,7 @@ class PostgresDatabase(Database): compound_alias TEXT, compound_smiles TEXT, compound_base INTEGER, - compound_mol hippo.MOL, + compound_mol MOL, compound_pattern_bfp bit(2048), compound_morgan_bfp bit(2048), compound_metadata TEXT, @@ -70,7 +70,7 @@ class PostgresDatabase(Database): pose_path TEXT, pose_compound INTEGER, pose_target INTEGER, - pose_mol hippo.MOL, + pose_mol MOL, pose_fingerprint INTEGER, pose_energy_score REAL, pose_distance_score REAL, diff --git a/images/postgres/Dockerfile b/images/postgres/Dockerfile index 21c60b0..35ebc42 100644 --- a/images/postgres/Dockerfile +++ b/images/postgres/Dockerfile @@ -78,7 +78,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ apt-get update && \ apt-get install -y --no-install-recommends \ ca-certificates wget gnupg2 lsb-release dirmngr gettext procps \ - libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g \ + libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g gosu \ postgresql-${PG_MAJOR} postgresql-server-dev-${PG_MAJOR} && \ rm -rf /var/lib/apt/lists/* @@ -106,14 +106,16 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN chmod +x /usr/local/bin/docker-entrypoint.sh # Prepare dirs and permissions -RUN mkdir -p /docker-entrypoint-initdb.d /var/lib/postgresql && chown -R postgres:postgres /var/lib/postgresql /docker-entrypoint-initdb.d /usr/share/postgresql/${PG_MAJOR}/extension +RUN mkdir -p /docker-entrypoint-initdb.d /var/lib/postgresql/data && chown -R postgres:postgres /var/lib/postgresql /docker-entrypoint-initdb.d /usr/share/postgresql/${PG_MAJOR}/extension RUN ldconfig EXPOSE 5432 -VOLUME ["/var/lib/postgresql/data"] +# adding this in compose file instead +# VOLUME ["/var/lib/postgresql/data"] -USER postgres +# entrypoint script needs root user to init db, then drops to postgres +# USER postgres WORKDIR /var/lib/postgresql ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/images/postgres/docker-entrypoint.sh b/images/postgres/docker-entrypoint.sh index c3432be..b49bcc4 100644 --- a/images/postgres/docker-entrypoint.sh +++ b/images/postgres/docker-entrypoint.sh @@ -324,14 +324,15 @@ _pg_want_help() { } _main() { - # if first arg looks like a flag, assume we want to run postgres server - if [ "${1:0:1}" = '-' ]; then + # if first arg looks like a flag, assume we want to run postgres server + if [ "${1:0:1}" = '-' ]; then set -- postgres "$@" fi if [ "$1" = 'postgres' ] && ! _pg_want_help "$@"; then docker_setup_env # setup data directories and permissions (when run as root) + # why is this here?? I'm creating them in dockerfile docker_create_db_directories if [ "$(id -u)" = '0' ]; then # then restart script as postgres user @@ -373,7 +374,6 @@ _main() { EOM fi fi - exec "$@" } From 1ad3982a77de5257cf7986ada35326782df79966 Mon Sep 17 00:00:00 2001 From: Cedric Vallee Date: Wed, 4 Mar 2026 17:47:54 +0000 Subject: [PATCH 112/163] add_hits() now accept .sdf that doesn't match Fragalysis pattern --- hippo/animal.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index cc940c6..a50ef09 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -320,7 +320,7 @@ def __str__(self) -> str: """name""" return self.name - subdirs = list(aligned_directory.glob("*[0-9][0-9][0-9][0-9]*")) + subdirs = list(aligned_directory.glob("*")) SUBDIR_PATTERN_FRAGALYSIS = re.compile(r"^.*\d{4}[a-z]$") SUBDIR_PATTERN_XCA = re.compile(r"^.*-.\d{4}$") @@ -387,12 +387,13 @@ def __str__(self) -> str: from .fragalysis import parse_observation_longcode - sdf_pattern = re.compile(r"^.*\d{4}[a-z].sdf$") + fragalysis_pattern = re.compile(r"^.*\d{4}[a-z].sdf$") + pdbid_pattern = re.compile(r"^[A-Za-z0-9]{4}-[a-z].sdf$") observations = {} for path in list( - sorted(aligned_directory.glob(f"*[0-9][0-9][0-9][0-9][a-z]")) + sorted(aligned_directory.glob(f"*")) ): name = path.name @@ -413,8 +414,17 @@ def __str__(self) -> str: sdf_name = sdf_path.name + if "_ligand" in sdf_name: # Quick fix, _ligand.sdf are exactly the same as .sdf in aligned_directory. + continue + # fragalysis SDF - if sdf_pattern.match(sdf_name): + if fragalysis_pattern.match(sdf_name): + sdfs.append(sdf_path) + # fragalysis SDF from PDB id + elif pdbid_pattern.match(sdf_name): + sdfs.append(sdf_path) + else: + mrich.warning(sdf_name, "doesn't not follow neither Fragalysis nor PDB ID patterns") sdfs.append(sdf_path) if not sdfs: From 207f4bbf5d52cd6caa78ecb221260031227479be Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 5 Mar 2026 10:28:36 +0000 Subject: [PATCH 113/163] feat: local postgres development environment Functional sytem Dockerfile, DB image Dockerfile and docker compose file combo --- docker-compose.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 664feeb..9ad60f4 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,9 +30,6 @@ services: target: /docker-entrypoint-initdb.d env_file: - .env - # environment: - # POSTGRES_PASSWORD: hippo - # POSTGRES_DB: hippo ports: - "5432:5432" healthcheck: @@ -53,8 +50,6 @@ services: - .:/home/code/HIPPO env_file: - .env - # environment: - # POSTGRESQL_USER: postgres ports: - "8888:8888" From b6c9e3bc30aa882c664b335548ae68c86a7fb4df Mon Sep 17 00:00:00 2001 From: Cedric Vallee Date: Thu, 5 Mar 2026 15:31:18 +0000 Subject: [PATCH 114/163] Fixing thread issue for interactive() in Jupyter Notebook --- hippo/animal.py | 13 ++++++++----- hippo/db.py | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/hippo/animal.py b/hippo/animal.py index a50ef09..a20fe67 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -392,9 +392,7 @@ def __str__(self) -> str: observations = {} - for path in list( - sorted(aligned_directory.glob(f"*")) - ): + for path in list(sorted(aligned_directory.glob(f"*"))): name = path.name @@ -414,7 +412,9 @@ def __str__(self) -> str: sdf_name = sdf_path.name - if "_ligand" in sdf_name: # Quick fix, _ligand.sdf are exactly the same as .sdf in aligned_directory. + if ( + "_ligand" in sdf_name + ): # Quick fix, _ligand.sdf are exactly the same as .sdf in aligned_directory. continue # fragalysis SDF @@ -424,7 +424,10 @@ def __str__(self) -> str: elif pdbid_pattern.match(sdf_name): sdfs.append(sdf_path) else: - mrich.warning(sdf_name, "doesn't not follow neither Fragalysis nor PDB ID patterns") + mrich.warning( + sdf_name, + "doesn't not follow neither Fragalysis nor PDB ID patterns", + ) sdfs.append(sdf_path) if not sdfs: diff --git a/hippo/db.py b/hippo/db.py index a25787b..b1aaba6 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -534,7 +534,7 @@ def connect(self, debug: bool = True) -> None: conn = None try: - conn = sqlite3.connect(self.path) + conn = sqlite3.connect(self.path, check_same_thread=False) if debug: mrich.debug(f"{sqlite3.sqlite_version=}") From 9def8d4ca7580bd385134c4e5c906269e3fe8ad8 Mon Sep 17 00:00:00 2001 From: Cedric Vallee Date: Thu, 5 Mar 2026 16:07:50 +0000 Subject: [PATCH 115/163] Making sure check_same_thread=False is safe to use --- hippo/db.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hippo/db.py b/hippo/db.py index b1aaba6..96ccda6 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -534,7 +534,10 @@ def connect(self, debug: bool = True) -> None: conn = None try: - conn = sqlite3.connect(self.path, check_same_thread=False) + if sqlite3.threadsafety == 3: # Serialized, safe to use multithreading + conn = sqlite3.connect(self.path, check_same_thread=False) + else: + conn = sqlite3.connect(self.path) if debug: mrich.debug(f"{sqlite3.sqlite_version=}") From 16fb4609227041beda184f826220c807e24c83fb Mon Sep 17 00:00:00 2001 From: Cedric Vallee Date: Thu, 5 Mar 2026 16:18:16 +0000 Subject: [PATCH 116/163] Making sure check_same_thread=False is safe to use (linted) --- hippo/db.py | 114 ++++++++++++++++++---------------------------------- 1 file changed, 38 insertions(+), 76 deletions(-) diff --git a/hippo/db.py b/hippo/db.py index 96ccda6..66b32e9 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -534,7 +534,7 @@ def connect(self, debug: bool = True) -> None: conn = None try: - if sqlite3.threadsafety == 3: # Serialized, safe to use multithreading + if sqlite3.threadsafety == 3: # Serialized, safe to use multithreading conn = sqlite3.connect(self.path, check_same_thread=False) else: conn = sqlite3.connect(self.path) @@ -1630,9 +1630,7 @@ def insert_quote( hippo.quote.quote_purity = EXCLUDED.quote_purity, hippo.quote.quote_compound = EXCLUDED.quote_compound, hippo.quote.quote_date = EXCLUDED.quote_date; - """.format( - date_str=date_str - ) + """.format(date_str=date_str) try: self.execute( @@ -2715,8 +2713,7 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: :returns: ID of the last inserted :class:`.Interaction` """ - cursor = self.execute( - f""" + cursor = self.execute(f""" INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}temp_interaction( interaction_feature, interaction_pose, @@ -2741,8 +2738,7 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: interaction_energy FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose = {pose_id} - """ - ) + """) return cursor.lastrowid @@ -2754,13 +2750,11 @@ def migrate_legacy_scaffolds(self) -> int: mrich.debug("HIPPO.Database.migrate_legacy_scaffolds()") - cursor = self.execute( - f""" + cursor = self.execute(f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) SELECT compound_base, compound_id FROM compound WHERE compound_base IS NOT NULL - """ - ) + """) self.commit() @@ -2823,15 +2817,13 @@ def update_legacy_pose_inspiration_score(self) -> None: def update_compound_pattern_bfp_table(self): """Update the compound pattern BFP table""" - self.execute( - f""" + self.execute(f""" INSERT INTO compound_pattern_bfp SELECT c.compound_id, c.compound_pattern_bfp FROM {self.SQL_SCHEMA_PREFIX}compound AS c LEFT JOIN compound_pattern_bfp as fp ON c.compound_id = fp.compound_id WHERE fp.compound_id IS NULL - """ - ) + """) ### BULK CLEANUP @@ -3524,13 +3516,11 @@ def set_subsites_from_metadata_field( VALUES(?, ?) """ case "psycopg": - sql = strip_sql( - """ + sql = strip_sql(""" INSERT INTO hippo.subsite(subsite_target, subsite_name) VALUES(%s, %s) ON CONFLICT DO NOTHING; - """ - ) + """) self.executemany(sql, sorted(list(subsites))) @@ -3549,13 +3539,11 @@ def set_subsites_from_metadata_field( VALUES(?, ?) """ case "psycopg": - sql = strip_sql( - """ + sql = strip_sql(""" INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) VALUES(%s, %s) ON CONFLICT DO NOTHING; - """ - ) + """) subsite_tags = [ (subsite_lookup[(t, name)], pose_id) for t, name, pose_id in subsite_tags @@ -4460,18 +4448,14 @@ def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute( - f""" + records = self.execute(f""" SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() + AND pose_id IN {pset.str_ids}""").fetchall() else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() + records = self.execute("""SELECT pose_id, pose_alias FROM pose + WHERE pose_alias IS NOT NULL""").fetchall() d = {} for pose_id, pose_alias in records: @@ -4483,11 +4467,9 @@ def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, s """Get a dictionary mapping :class:`.Pose` aliases to paths""" if pset: - records = self.execute( - f""" + records = self.execute(f""" SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {pset.str_ids}""" - ).fetchall() + WHERE pose_id IN {pset.str_ids}""").fetchall() else: records = self.execute( @@ -4504,18 +4486,14 @@ def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute( - f""" + records = self.execute(f""" SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() + AND pose_id IN {pset.str_ids}""").fetchall() else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() + records = self.execute("""SELECT pose_id, pose_alias FROM pose + WHERE pose_alias IS NOT NULL""").fetchall() d = {} for pose_id, pose_alias in records: @@ -4527,19 +4505,15 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute( - f""" + records = self.execute(f""" SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() + AND pose_id IN {pset.str_ids}""").fetchall() else: - records = self.execute( - f""" + records = self.execute(f""" SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_path IS NOT NULL""" - ).fetchall() + WHERE pose_path IS NOT NULL""").fetchall() d = {} for pose_id, pose_path in records: @@ -4757,14 +4731,12 @@ def get_reaction_map_from_products( str_ids = str(tuple(product_ids)).replace(",)", ")") - records = self.execute( - f""" + records = self.execute(f""" SELECT reaction_type, reaction_product, reaction_id, reactant_compound FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_id = reactant_reaction WHERE reaction_product IN {str_ids} - """ - ).fetchall() + """).fetchall() mapping = {} for reaction_type, reaction_product, reaction_id, reactant_compound in records: @@ -4795,8 +4767,7 @@ def get_possible_reaction_ids( compound_ids_str = str(tuple(compound_ids)).replace(",)", ")") - result = self.execute( - f""" + result = self.execute(f""" WITH possible_reactants AS ( SELECT reactant_reaction, CASE @@ -4816,8 +4787,7 @@ def get_possible_reaction_ids( SELECT reactant_reaction FROM possible_reactions WHERE count_null = 0 - """ - ).fetchall() + """).fetchall() return [q for q, in result] @@ -4898,14 +4868,12 @@ def get_unsolved_reaction_tree( product_ids = reactant_ids # all intermediates - ids = self.execute( - f""" + ids = self.execute(f""" SELECT DISTINCT reaction_product FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_product = reactant_compound - """ - ).fetchall() + """).fetchall() ids = [q for q, in ids] intermediates = CompoundSet(self, ids) @@ -4945,8 +4913,7 @@ def get_reaction_price_estimate( # sum lowest unit price for each reactant - (price,) = self.execute( - f""" + (price,) = self.execute(f""" WITH unit_prices AS ( SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price @@ -4955,8 +4922,7 @@ def get_reaction_price_estimate( GROUP BY quote_compound ) SELECT SUM(unit_price) FROM unit_prices - """ - ).fetchone() + """).fetchone() return price @@ -5411,13 +5377,11 @@ def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: """ - pairs = self.execute( - f""" + pairs = self.execute(f""" SELECT {table}_id, {table}_metadata FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_metadata LIKE '%"{key}": "%' - """ - ).fetchall() + """).fetchall() from json import loads return dict( @@ -5702,13 +5666,11 @@ def column_names(self, table: str) -> list[str]: def index_names(self) -> list[str]: """Get the index names""" - cursor = self.execute( - """ + cursor = self.execute(""" SELECT name FROM sqlite_master WHERE type = 'index'; - """ - ) + """) return [n for n, in cursor] From 3b69f61fc3889cb41216f33cab830983798d7fba Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 10 Mar 2026 15:17:16 +0000 Subject: [PATCH 117/163] feat: designdb docker image and correspoinding compose file In images/xchem-designdb --- docker-compose.yaml | 72 +- hippo/db.py | 112 +- images/xchem-designdb/.dockerignore | 29 + images/xchem-designdb/.gitignore | 42 + images/xchem-designdb/01_schema.sql | 1047 ++++++++++++++++++ images/xchem-designdb/01_schema_OLD.sql | 468 ++++++++ images/xchem-designdb/Dockerfile | 127 +++ images/xchem-designdb/docker-compose.yml | 42 + images/xchem-designdb/env.template | 22 + images/xchem-designdb/init-db/01_schema.sql | 1080 +++++++++++++++++++ 10 files changed, 2991 insertions(+), 50 deletions(-) create mode 100644 images/xchem-designdb/.dockerignore create mode 100644 images/xchem-designdb/.gitignore create mode 100644 images/xchem-designdb/01_schema.sql create mode 100644 images/xchem-designdb/01_schema_OLD.sql create mode 100644 images/xchem-designdb/Dockerfile create mode 100644 images/xchem-designdb/docker-compose.yml create mode 100644 images/xchem-designdb/env.template create mode 100644 images/xchem-designdb/init-db/01_schema.sql diff --git a/docker-compose.yaml b/docker-compose.yaml index 9ad60f4..e177e37 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,24 +20,63 @@ services: + # database: + # image: hippo-pg + # container_name: hippo_pg_db + # volumes: + # - postgres_data:/var/lib/postgresql/data + # - type: bind + # source: ./pg_setup/docker-entrypoint-initdb.d + # target: /docker-entrypoint-initdb.d + # env_file: + # - .env + # ports: + # - "5432:5432" + # healthcheck: + # test: pg_isready -U postgres -d hippo + # interval: 10s + # timeout: 2s + # retries: 5 + # start_period: 10s + database: - image: hippo-pg - container_name: hippo_pg_db + # build: + # context: . + # dockerfile: Dockerfile + # args: + # RDKIT_VERSION: ${RDKIT_VERSION:-Release_2025_09_5} + # BOOST_VERSION: ${BOOST_VERSION:-1.90.0} + # BOOST_VER_US: ${BOOST_VER_US:-1_90_0} + # PG_MAJOR: ${PG_MAJOR:-18} + # PG_BASE: ${PG_BASE:-postgres:18.3-bookworm} + # image: xchem_designdb:latest + # container_name: xchem_designdb + image: xchem-hippo-pg + container_name: hippo_pg_db + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" volumes: - - postgres_data:/var/lib/postgresql/data - - type: bind - source: ./pg_setup/docker-entrypoint-initdb.d - target: /docker-entrypoint-initdb.d + # - ${POSTGRES_DATA_PATH}:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data env_file: - - .env - ports: - - "5432:5432" + - .env + environment: + POSTGRES_DB: ${DB_NAME:-designdb} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_INITDB_ARGS: ${POSTGRES_INITDB_ARGS:---auth-host=scram-sha-256 --auth-local=trust} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + POSTGRES_HOST_AUTH_METHOD: ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256} + shm_size: 8g healthcheck: - test: pg_isready -U postgres -d hippo + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\" -h localhost"] interval: 10s - timeout: 2s + timeout: 5s retries: 5 - start_period: 10s + start_period: 90s + networks: + - app_network backend: @@ -50,13 +89,20 @@ services: - .:/home/code/HIPPO env_file: - .env - ports: - "8888:8888" + networks: + - app_network depends_on: database: condition: service_healthy +networks: + app_network: + name: xchem_designdb_network + driver: bridge + + volumes: postgres_data: diff --git a/hippo/db.py b/hippo/db.py index 66b32e9..1a28b87 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1630,7 +1630,9 @@ def insert_quote( hippo.quote.quote_purity = EXCLUDED.quote_purity, hippo.quote.quote_compound = EXCLUDED.quote_compound, hippo.quote.quote_date = EXCLUDED.quote_date; - """.format(date_str=date_str) + """.format( + date_str=date_str + ) try: self.execute( @@ -2713,7 +2715,8 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: :returns: ID of the last inserted :class:`.Interaction` """ - cursor = self.execute(f""" + cursor = self.execute( + f""" INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}temp_interaction( interaction_feature, interaction_pose, @@ -2738,7 +2741,8 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: interaction_energy FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose = {pose_id} - """) + """ + ) return cursor.lastrowid @@ -2750,11 +2754,13 @@ def migrate_legacy_scaffolds(self) -> int: mrich.debug("HIPPO.Database.migrate_legacy_scaffolds()") - cursor = self.execute(f""" + cursor = self.execute( + f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) SELECT compound_base, compound_id FROM compound WHERE compound_base IS NOT NULL - """) + """ + ) self.commit() @@ -2817,13 +2823,15 @@ def update_legacy_pose_inspiration_score(self) -> None: def update_compound_pattern_bfp_table(self): """Update the compound pattern BFP table""" - self.execute(f""" + self.execute( + f""" INSERT INTO compound_pattern_bfp SELECT c.compound_id, c.compound_pattern_bfp FROM {self.SQL_SCHEMA_PREFIX}compound AS c LEFT JOIN compound_pattern_bfp as fp ON c.compound_id = fp.compound_id WHERE fp.compound_id IS NULL - """) + """ + ) ### BULK CLEANUP @@ -3516,11 +3524,13 @@ def set_subsites_from_metadata_field( VALUES(?, ?) """ case "psycopg": - sql = strip_sql(""" + sql = strip_sql( + """ INSERT INTO hippo.subsite(subsite_target, subsite_name) VALUES(%s, %s) ON CONFLICT DO NOTHING; - """) + """ + ) self.executemany(sql, sorted(list(subsites))) @@ -3539,11 +3549,13 @@ def set_subsites_from_metadata_field( VALUES(?, ?) """ case "psycopg": - sql = strip_sql(""" + sql = strip_sql( + """ INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) VALUES(%s, %s) ON CONFLICT DO NOTHING; - """) + """ + ) subsite_tags = [ (subsite_lookup[(t, name)], pose_id) for t, name, pose_id in subsite_tags @@ -4448,14 +4460,18 @@ def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute(f""" + records = self.execute( + f""" SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""").fetchall() + AND pose_id IN {pset.str_ids}""" + ).fetchall() else: - records = self.execute("""SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""").fetchall() + records = self.execute( + """SELECT pose_id, pose_alias FROM pose + WHERE pose_alias IS NOT NULL""" + ).fetchall() d = {} for pose_id, pose_alias in records: @@ -4467,9 +4483,11 @@ def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, s """Get a dictionary mapping :class:`.Pose` aliases to paths""" if pset: - records = self.execute(f""" + records = self.execute( + f""" SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {pset.str_ids}""").fetchall() + WHERE pose_id IN {pset.str_ids}""" + ).fetchall() else: records = self.execute( @@ -4486,14 +4504,18 @@ def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute(f""" + records = self.execute( + f""" SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""").fetchall() + AND pose_id IN {pset.str_ids}""" + ).fetchall() else: - records = self.execute("""SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""").fetchall() + records = self.execute( + """SELECT pose_id, pose_alias FROM pose + WHERE pose_alias IS NOT NULL""" + ).fetchall() d = {} for pose_id, pose_alias in records: @@ -4505,15 +4527,19 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: - records = self.execute(f""" + records = self.execute( + f""" SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL - AND pose_id IN {pset.str_ids}""").fetchall() + AND pose_id IN {pset.str_ids}""" + ).fetchall() else: - records = self.execute(f""" + records = self.execute( + f""" SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_path IS NOT NULL""").fetchall() + WHERE pose_path IS NOT NULL""" + ).fetchall() d = {} for pose_id, pose_path in records: @@ -4731,12 +4757,14 @@ def get_reaction_map_from_products( str_ids = str(tuple(product_ids)).replace(",)", ")") - records = self.execute(f""" + records = self.execute( + f""" SELECT reaction_type, reaction_product, reaction_id, reactant_compound FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_id = reactant_reaction WHERE reaction_product IN {str_ids} - """).fetchall() + """ + ).fetchall() mapping = {} for reaction_type, reaction_product, reaction_id, reactant_compound in records: @@ -4767,7 +4795,8 @@ def get_possible_reaction_ids( compound_ids_str = str(tuple(compound_ids)).replace(",)", ")") - result = self.execute(f""" + result = self.execute( + f""" WITH possible_reactants AS ( SELECT reactant_reaction, CASE @@ -4787,7 +4816,8 @@ def get_possible_reaction_ids( SELECT reactant_reaction FROM possible_reactions WHERE count_null = 0 - """).fetchall() + """ + ).fetchall() return [q for q, in result] @@ -4868,12 +4898,14 @@ def get_unsolved_reaction_tree( product_ids = reactant_ids # all intermediates - ids = self.execute(f""" + ids = self.execute( + f""" SELECT DISTINCT reaction_product FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_product = reactant_compound - """).fetchall() + """ + ).fetchall() ids = [q for q, in ids] intermediates = CompoundSet(self, ids) @@ -4913,7 +4945,8 @@ def get_reaction_price_estimate( # sum lowest unit price for each reactant - (price,) = self.execute(f""" + (price,) = self.execute( + f""" WITH unit_prices AS ( SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price @@ -4922,7 +4955,8 @@ def get_reaction_price_estimate( GROUP BY quote_compound ) SELECT SUM(unit_price) FROM unit_prices - """).fetchone() + """ + ).fetchone() return price @@ -5377,11 +5411,13 @@ def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: """ - pairs = self.execute(f""" + pairs = self.execute( + f""" SELECT {table}_id, {table}_metadata FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_metadata LIKE '%"{key}": "%' - """).fetchall() + """ + ).fetchall() from json import loads return dict( @@ -5666,11 +5702,13 @@ def column_names(self, table: str) -> list[str]: def index_names(self) -> list[str]: """Get the index names""" - cursor = self.execute(""" + cursor = self.execute( + """ SELECT name FROM sqlite_master WHERE type = 'index'; - """) + """ + ) return [n for n, in cursor] diff --git a/images/xchem-designdb/.dockerignore b/images/xchem-designdb/.dockerignore new file mode 100644 index 0000000..e96f3c5 --- /dev/null +++ b/images/xchem-designdb/.dockerignore @@ -0,0 +1,29 @@ +# ========================================================= +# .dockerignore +# ========================================================= + +# Environment and secrets +.env +.env.* +*.env + +# Logs +*.log +*.log.* + +# Git +.git/ +.gitignore + +# IDE and OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +._* + +# Temporary files +*.tmp +*.temp +*.bak +*.backup diff --git a/images/xchem-designdb/.gitignore b/images/xchem-designdb/.gitignore new file mode 100644 index 0000000..d1e8b90 --- /dev/null +++ b/images/xchem-designdb/.gitignore @@ -0,0 +1,42 @@ +# ========================================================= +# Database Service .gitignore +# ========================================================= + +# Environment variables +.env +.env.local +.env.production +.env.development + +# Docker volumes and data +docker-data/ +postgres-data/ +data/ +*.db +*.sqlite + +# Logs +*.log +logs/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# Temporary files +*.tmp +*.temp +*.bak +*.backup diff --git a/images/xchem-designdb/01_schema.sql b/images/xchem-designdb/01_schema.sql new file mode 100644 index 0000000..8384231 --- /dev/null +++ b/images/xchem-designdb/01_schema.sql @@ -0,0 +1,1047 @@ +-- ========================================================= +-- designdb Database Schema +-- ========================================================= + +-- ========================================================= +DROP SCHEMA IF EXISTS designdb CASCADE; +-- ========================================================= + +-- ========================================================= +-- PREREQUISITES & EXTENSIONS +-- ========================================================= + +CREATE SCHEMA IF NOT EXISTS designdb; +CREATE SCHEMA IF NOT EXISTS rdkit; + +CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; + +SET search_path TO designdb, rdkit, public; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; + +-- ========================================================= +-- TABLES (ordered by FK dependencies) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS designdb.targets ( + id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis + external_target_id BIGINT, -- ID of this target in the external database (Scarab link) + target_name TEXT, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_target UNIQUE (target_name) +); + +CREATE TABLE IF NOT EXISTS designdb.compounds ( + id BIGSERIAL PRIMARY KEY, + compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_alias TEXT, -- Maybe insert by the codebase. + compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code + compound_mol rdkit.mol, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Originally, maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_metadata TEXT, -- currently Null + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) +); + +CREATE TABLE IF NOT EXISTS designdb.subsites ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase/notebook + subsite_name TEXT NOT NULL, -- Insert by codebase/notebook + subsite_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite UNIQUE (target_id, subsite_name) +); + +CREATE TABLE IF NOT EXISTS designdb.poses ( + id BIGSERIAL PRIMARY KEY, + pose_inchikey TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). Originally, nserted by codebase when registering poses? Might be done from pose.mol? + pose_alias TEXT, + pose_smiles TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. + pose_reference INTEGER, + pose_path TEXT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, + pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge + pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. + --pose_energy_score REAL, -- LR - this may become redundant once the scores table is implemented + --pose_distance_score REAL, -- LR - this may become redundant once the scores table is implemented + --pose_inspiration_score REAL, -- LR - this may become redundant once the scores table is implemented + pose_metadata TEXT, + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_alias UNIQUE (pose_alias), + CONSTRAINT uc_pose_path UNIQUE (pose_path) +); + +CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( + id BIGSERIAL PRIMARY KEY, + subsite_id BIGINT NOT NULL REFERENCES designdb.subsites (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite_tag UNIQUE (subsite_id, pose_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_methods ( + id BIGSERIAL PRIMARY KEY, + pose_method_name TEXT, + pose_method_description TEXT, + pose_method_version TEXT, + pose_method_organization TEXT, + pose_method_link TEXT, + pose_method_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_methods ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_method_id BIGINT NOT NULL REFERENCES designdb.pose_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_tags ( + id BIGSERIAL PRIMARY KEY, + pose_tag_name TEXT, + pose_tag_description TEXT, + pose_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_tags ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_tag_id BIGINT NOT NULL REFERENCES designdb.pose_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_tag_id) +); + +CREATE TABLE IF NOT EXISTS designdb.inspirations ( + id BIGSERIAL PRIMARY KEY, + original_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + derivative_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_inspiration UNIQUE (original_pose_id, derivative_pose_id) +); + +CREATE TABLE IF NOT EXISTS designdb.features ( + id BIGSERIAL PRIMARY KEY, + feature_family TEXT, -- Insert by codebase + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase + feature_chain_name TEXT, -- Insert by codebase + feature_residue_name TEXT, -- Insert by codebase + feature_residue_number INTEGER, -- Insert by codebase + feature_atom_name TEXT, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_feature UNIQUE (feature_family, target_id, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_name) +); + +CREATE TABLE IF NOT EXISTS designdb.interactions ( + id BIGSERIAL PRIMARY KEY, + feature_id BIGINT NOT NULL REFERENCES designdb.features (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + interaction_type TEXT NOT NULL, -- Insert by codebase + interaction_family TEXT NOT NULL, -- Insert by codebase + interaction_atom_id TEXT NOT NULL, -- Insert by codebase + interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_distance REAL NOT NULL, -- Insert by codebase + interaction_angle REAL, -- Insert by codebase + interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_interaction UNIQUE (feature_id, pose_id, interaction_type, interaction_family, interaction_atom_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.compound_tags ( + id BIGSERIAL PRIMARY KEY, + compound_tag_name TEXT, + compound_tag_description TEXT, + compound_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_compound_tags ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + compound_tag_id BIGINT NOT NULL REFERENCES designdb.compound_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, compound_tag_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods ( + id BIGSERIAL PRIMARY KEY, + enum_name TEXT, + enum_description TEXT, + enum_version TEXT, + enum_organization TEXT, + enum_link TEXT, + enum_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_enumeration_methods ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + enumeration_method_id BIGINT NOT NULL REFERENCES designdb.enumeration_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, enumeration_method_id) +); + +-- New table +-- score JSONB: one key per method (use scoring_method.method_name as key). +CREATE TABLE IF NOT EXISTS designdb.scores ( + id BIGSERIAL PRIMARY KEY, + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, + compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, + score JSONB, -- {"vina": {"score": -7.2, "version": "1.0"}, "gnina": {"score": 0.85, "version": "2.1"}} + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( + id BIGSERIAL PRIMARY KEY, + method_name TEXT, + method_description TEXT, + method_version TEXT, + method_organization TEXT, + method_link TEXT, + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reactions ( + id BIGSERIAL PRIMARY KEY, + reaction_type TEXT, -- Insert by codebase/notebook, Synderilla + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla + reaction_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reactants ( + id BIGSERIAL PRIMARY KEY, + reactant_amount REAL, -- Insert by codebase/notebook, Synderilla + reaction_id BIGINT NOT NULL REFERENCES designdb.reactions (id) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.quotes ( + id BIGSERIAL PRIMARY KEY, + quote_smiles TEXT, + quote_amount REAL, + quote_supplier TEXT, + quote_catalogue TEXT, -- Catalogue (there are null values, plus BB, Full stock etc.) + quote_entry TEXT, -- This the catalogue number (supplier id) + quote_lead_time INTEGER, -- Days, weeks? + quote_price REAL, + quote_currency TEXT, + quote_purity REAL, -- Not percentage (e.g. 0.99) + quote_date TEXT, + compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, --Quote compound originally, mapped with compound_id + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) +); + +CREATE TABLE IF NOT EXISTS designdb.scaffolds ( + id BIGSERIAL PRIMARY KEY, + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + superstructure_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scaffold UNIQUE (base_compound_id, superstructure_compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.routes ( + id BIGSERIAL PRIMARY KEY, + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.components ( + id BIGSERIAL PRIMARY KEY, + route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_ref INTEGER, -- Insert by codebase/notebook, Synderilla + component_amount REAL, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_component UNIQUE (route_id, component_ref, component_type) +); + +-- Replaced table with individual tag tables +-- CREATE TABLE IF NOT EXISTS designdb.tags ( +-- id BIGSERIAL PRIMARY KEY, +-- tag_name TEXT, -- Insert by codebase +-- tag_description TEXT, -- New column +-- note TEXT, -- New column +-- -- compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- -- pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- created_on TIMESTAMPTZ DEFAULT now(), +-- updated_on TIMESTAMPTZ DEFAULT now() +-- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, compound_id), +-- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, pose_id) +-- ); + + +-- ========================================================= +-- AUDIT TABLES +-- ========================================================= + +-- Event audit for quotes (tracks INSERT/UPDATE/DELETE for data load change tracking) +CREATE TABLE IF NOT EXISTS designdb.quotes_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, -- 'I'|'U'|'D' + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_tags +CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for compound_tags +CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_methods +CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for enumeration_methods +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for scoring_methods +CREATE TABLE IF NOT EXISTS designdb.scoring_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL, + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.targets(target_name); +CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.targets(created_on); + +CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_methods(method_name); +CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_methods(enum_name); +CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_methods(pose_method_name); +CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); +CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); + +CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); +CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.features(created_on); + +CREATE INDEX IF NOT EXISTS idx_route_product_compound_id ON designdb.routes(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.routes(created_on); + +CREATE INDEX IF NOT EXISTS idx_reaction_product_compound_id ON designdb.reactions(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_compound_id ON designdb.poses(compound_id); +CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); +CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.poses(pose_path); +CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); + +CREATE INDEX IF NOT EXISTS idx_scores_pose_id ON designdb.scores(pose_id); +CREATE INDEX IF NOT EXISTS idx_scores_compound_id ON designdb.scores(compound_id); +CREATE INDEX IF NOT EXISTS idx_scores_pose_id_compound_id ON designdb.scores(pose_id, compound_id); +CREATE INDEX IF NOT EXISTS idx_scores_created ON designdb.scores(created_on); +CREATE INDEX IF NOT EXISTS idx_scores_score_gin ON designdb.scores USING GIN (score); + +CREATE INDEX IF NOT EXISTS idx_subsite_target_id ON designdb.subsites(target_id); +CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsites(created_on); + +CREATE INDEX IF NOT EXISTS idx_component_route_id ON designdb.components(route_id); +CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.components(created_on); + +CREATE INDEX IF NOT EXISTS idx_inspiration_original_pose_id ON designdb.inspirations(original_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_derivative_pose_id ON designdb.inspirations(derivative_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspirations(created_on); + +CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(feature_id); +CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); +CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_quote_compound_id ON designdb.quotes(compound_id); +CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quotes(created_on); + +-- ========================================================= +-- AUDIT INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id ON designdb.quotes_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_operation ON designdb.quotes_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_at ON designdb.quotes_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_by ON designdb.quotes_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_old_gin ON designdb.quotes_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_new_gin ON designdb.quotes_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id_changed ON designdb.quotes_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_changed_at ON designdb.pose_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_old_gin ON designdb.pose_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_new_gin ON designdb.pose_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id_changed ON designdb.pose_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id ON designdb.compound_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_operation ON designdb.compound_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_changed_at ON designdb.compound_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_old_gin ON designdb.compound_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_new_gin ON designdb.compound_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id_changed ON designdb.compound_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id ON designdb.pose_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_operation ON designdb.pose_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_changed_at ON designdb.pose_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_old_gin ON designdb.pose_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_new_gin ON designdb.pose_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id_changed ON designdb.pose_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id ON designdb.enumeration_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_operation ON designdb.enumeration_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_changed_at ON designdb.enumeration_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_old_gin ON designdb.enumeration_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_new_gin ON designdb.enumeration_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id_changed ON designdb.enumeration_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id ON designdb.scoring_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_operation ON designdb.scoring_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_changed_at ON designdb.scoring_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_old_gin ON designdb.scoring_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_new_gin ON designdb.scoring_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id_changed ON designdb.scoring_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_reactant_reaction_id ON designdb.reactants(reaction_id); +CREATE INDEX IF NOT EXISTS idx_reactant_compound_id ON designdb.reactants(compound_id); +CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactants(created_on); + +CREATE INDEX IF NOT EXISTS idx_scaffold_base_compound_id ON designdb.scaffolds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure_compound_id ON designdb.scaffolds(superstructure_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffolds(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_tag_subsite_id ON designdb.subsite_tags(subsite_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose_id ON designdb.subsite_tags(pose_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tags(created_on); + +-- Removed due to replaced tables +-- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tags(tag_compound); +-- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tags(tag_pose); +-- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tags(created_on); +CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_pose_method_id ON designdb.has_pose_methods(pose_method_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_created ON designdb.has_pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_id ON designdb.has_pose_tags(pose_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_enumeration_method_id ON designdb.has_enumeration_methods(enumeration_method_id); +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); + +-- ========================================================= +-- MATERIALIZED VIEWS +-- ========================================================= +-- designdb.scores_per_pose_pivoted_mv: score_id, pose_id, compound_id + one column per +-- (method_name, method_version) from scoring_methods, filled from scores.score JSONB. Dymanically re-generated from scores table when new method added + +-- ========================================================= +-- VIEWS +-- ========================================================= + +-- Shows quote updates captured via designdb.quotes_event_audit +CREATE OR REPLACE VIEW designdb.quotes_price_changes_v AS +SELECT + a.id AS quote_id, + (NULLIF(COALESCE(a.new_values->>'compound_id', a.old_values->>'compound_id'), ''))::BIGINT AS compound_id, + COALESCE(a.new_values->>'quote_smiles', a.old_values->>'quote_smiles') AS quote_smiles, + (NULLIF(COALESCE(a.new_values->>'quote_amount', a.old_values->>'quote_amount'), ''))::DOUBLE PRECISION AS quote_amount, + COALESCE(a.new_values->>'quote_supplier', a.old_values->>'quote_supplier') AS quote_supplier, + COALESCE(a.new_values->>'quote_catalogue', a.old_values->>'quote_catalogue') AS quote_catalogue, + COALESCE(a.new_values->>'quote_entry', a.old_values->>'quote_entry') AS quote_entry, + (NULLIF(a.old_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_old, + (NULLIF(a.new_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_new, + COALESCE(a.new_values->>'quote_currency', a.old_values->>'quote_currency') AS quote_currency, + (NULLIF(COALESCE(a.new_values->>'quote_purity', a.old_values->>'quote_purity'), ''))::DOUBLE PRECISION AS quote_purity, + a.changed_at +FROM designdb.quotes_event_audit a +WHERE a.operation = 'U'; + +-- Pose tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +SELECT + a.id AS pose_tag_id, + a.old_values->>'pose_tag_name' AS pose_tag_name_old, + a.new_values->>'pose_tag_name' AS pose_tag_name_new, + a.old_values->>'pose_tag_description' AS pose_tag_description_old, + a.new_values->>'pose_tag_description' AS pose_tag_description_new, + a.old_values->>'pose_tag_note' AS pose_tag_note_old, + a.new_values->>'pose_tag_note' AS pose_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.pose_tags_event_audit a +WHERE a.operation = 'U'; + +-- Compound tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +SELECT + a.id AS compound_tag_id, + a.old_values->>'compound_tag_name' AS compound_tag_name_old, + a.new_values->>'compound_tag_name' AS compound_tag_name_new, + a.old_values->>'compound_tag_description' AS compound_tag_description_old, + a.new_values->>'compound_tag_description' AS compound_tag_description_new, + a.old_values->>'compound_tag_note' AS compound_tag_note_old, + a.new_values->>'compound_tag_note' AS compound_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.compound_tags_event_audit a +WHERE a.operation = 'U'; + +-- Pose methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +SELECT + a.id AS pose_method_id, + a.old_values->>'pose_method_name' AS pose_method_name_old, + a.new_values->>'pose_method_name' AS pose_method_name_new, + a.old_values->>'pose_method_description' AS pose_method_description_old, + a.new_values->>'pose_method_description' AS pose_method_description_new, + a.old_values->>'pose_method_version' AS pose_method_version_old, + a.new_values->>'pose_method_version' AS pose_method_version_new, + a.changed_by, + a.changed_at +FROM designdb.pose_methods_event_audit a +WHERE a.operation = 'U'; + +-- Enumeration methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +SELECT + a.id AS enumeration_method_id, + a.old_values->>'enum_name' AS enum_name_old, + a.new_values->>'enum_name' AS enum_name_new, + a.old_values->>'enum_description' AS enum_description_old, + a.new_values->>'enum_description' AS enum_description_new, + a.old_values->>'enum_version' AS enum_version_old, + a.new_values->>'enum_version' AS enum_version_new, + a.changed_by, + a.changed_at +FROM designdb.enumeration_methods_event_audit a +WHERE a.operation = 'U'; + +-- Scoring methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +SELECT + a.id AS scoring_method_id, + a.old_values->>'method_name' AS method_name_old, + a.new_values->>'method_name' AS method_name_new, + a.old_values->>'method_description' AS method_description_old, + a.new_values->>'method_description' AS method_description_new, + a.old_values->>'method_version' AS method_version_old, + a.new_values->>'method_version' AS method_version_new, + a.changed_by, + a.changed_at +FROM designdb.scoring_methods_event_audit a +WHERE a.operation = 'U'; + +-- ========================================================= +-- FUNCTIONS +-- ========================================================= + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND WRAPPERS +-- ========================================================= +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey (used by compound, pose, and quote triggers). + +CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol + LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND TRIGGER +-- ========================================================= +-- Input: compound_smiles (inserted by application). Populates compound_mol and compound_inchikey. + +CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; +BEGIN + IF NEW.compound_smiles IS NOT NULL THEN + BEGIN + v_mol := designdb.mol_from_smiles(NEW.compound_smiles); + IF v_mol IS NOT NULL THEN + NEW.compound_mol := v_mol; + NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_compound_cartridge_from_smiles ON designdb.compounds; +CREATE TRIGGER trg_populate_compound_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF compound_smiles ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – POSE TRIGGER +-- ========================================================= +-- Input: pose_mol (inserted by application). Populates pose_inchikey and pose_smiles. + +CREATE OR REPLACE FUNCTION designdb.populate_pose_cartridge_from_mol() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.pose_mol IS NOT NULL THEN + BEGIN + NEW.pose_smiles := designdb.mol_to_smiles(NEW.pose_mol); + NEW.pose_inchikey := designdb.mol_to_inchikey(NEW.pose_mol); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_pose_cartridge_from_mol ON designdb.poses; +CREATE TRIGGER trg_populate_pose_cartridge_from_mol + BEFORE INSERT OR UPDATE OF pose_mol ON designdb.poses + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_pose_cartridge_from_mol(); + +-- ========================================================= +-- (Re)creates materialized view designdb.scores_per_pose_pivoted_mv with columns from scoring_method. +-- Columns: score_id, pose_id, compound_id, then one JSONB column per (method_name, method_version). +-- Value in column: if score is numeric then JSONB number, if text then JSONB string (so numeric stays numeric, text stays text). +CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + select_qry text; + col text; + method_rec record; + score_txt text; + value_expr text; + numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +BEGIN + select_qry := 'SELECT s.id AS score_id, s.pose_id, s.compound_id'; + FOR method_rec IN + SELECT m.method_name, m.method_version + FROM designdb.scoring_methods m + ORDER BY m.id + LOOP + col := regexp_replace( + trim(method_rec.method_name) || '_' || coalesce( + replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), + '' + ), + '[^a-zA-Z0-9_]', '_', 'g' + ); + IF col <> '' AND col <> '_' THEN + col := quote_ident(col); + score_txt := '(s.score->' || quote_literal(trim(method_rec.method_name)) || '->>' || quote_literal('score') || ')'; + value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) + || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; + select_qry := select_qry || ', (CASE WHEN s.score ? ' || quote_literal(trim(method_rec.method_name)) + || ' AND (s.score->' || quote_literal(trim(method_rec.method_name)) || '->>' || quote_literal('version') + || ') IS NOT DISTINCT FROM ' || quote_nullable(method_rec.method_version) + || ' THEN ' || value_expr || ' END) AS ' || col; + END IF; + END LOOP; + select_qry := select_qry || ' FROM designdb.scores s WHERE s.score IS NOT NULL'; + EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; + EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM designdb.create_scores_per_pose_pivoted_mv(); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ========================================================= +-- AUDIT FUNCTIONS +-- ========================================================= + +-- Event audit trigger: records INSERT/UPDATE/DELETE to an audit table with JSONB old/new values. +-- TG_ARGV[0]=audit_table, [1]=pk_column, [2]=excluded_columns (comma-sep), [3]=binary_columns (comma-sep, hashed as sha256:hex). +CREATE OR REPLACE FUNCTION designdb.event_audit_trigger() +RETURNS trigger AS +$$ +DECLARE + v_audit_table TEXT := TG_ARGV[0]; + v_pk_col TEXT := TG_ARGV[1]; + v_excluded TEXT := COALESCE(TG_ARGV[2], ''); + v_bincols TEXT := COALESCE(TG_ARGV[3], ''); + v_excluded_arr TEXT[]; + v_bincols_arr TEXT[]; + v_changed_by TEXT := COALESCE(current_setting('app.current_user', true), current_user); + v_old_json JSONB; + v_new_json JSONB; + v_record_pk TEXT; + v_bin_hash TEXT; + v_col TEXT; +BEGIN + IF v_excluded = '' THEN + v_excluded_arr := ARRAY[]::text[]; + ELSE + v_excluded_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_excluded, ',') AS x); + END IF; + + IF v_bincols = '' THEN + v_bincols_arr := ARRAY[]::text[]; + ELSE + v_bincols_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_bincols, ',') AS x); + END IF; + + IF TG_OP = 'INSERT' THEN + v_old_json := NULL; + v_new_json := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + v_old_json := to_jsonb(OLD); + v_new_json := NULL; + ELSE + IF to_jsonb(OLD) IS NOT DISTINCT FROM to_jsonb(NEW) THEN + RETURN NEW; + END IF; + v_old_json := to_jsonb(OLD); + v_new_json := to_jsonb(NEW); + END IF; + + FOREACH v_col IN ARRAY v_excluded_arr LOOP + IF v_old_json IS NOT NULL THEN v_old_json := v_old_json - v_col; END IF; + IF v_new_json IS NOT NULL THEN v_new_json := v_new_json - v_col; END IF; + END LOOP; + + FOREACH v_col IN ARRAY v_bincols_arr LOOP + IF v_old_json IS NOT NULL AND v_old_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING OLD INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_old_json := jsonb_set(v_old_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_old_json := v_old_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_old_json := v_old_json - v_col; + END; + END IF; + IF v_new_json IS NOT NULL AND v_new_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING NEW INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_new_json := jsonb_set(v_new_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_new_json := v_new_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_new_json := v_new_json - v_col; + END; + END IF; + END LOOP; + + IF TG_OP = 'DELETE' THEN + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING OLD INTO v_record_pk; + ELSE + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING NEW INTO v_record_pk; + END IF; + + EXECUTE format('INSERT INTO %s (%s, operation, old_values, new_values, changed_by, changed_at) VALUES ($1,$2,$3,$4,$5,NOW())', v_audit_table, v_pk_col) + USING v_record_pk::BIGINT, TG_OP::CHAR, v_old_json, v_new_json, v_changed_by; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql VOLATILE; + +-- ========================================================= +-- TRIGGERS (updated_on) +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scores_updated_on ON designdb.scores; +CREATE TRIGGER trg_scores_updated_on BEFORE UPDATE ON designdb.scores FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scores_refresh_pivoted_mv ON designdb.scores; +CREATE TRIGGER trg_scores_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scores + FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quotes; +CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quotes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- ========================================================= +-- AUDIT TRIGGERS +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_quotes_event_audit ON designdb.quotes; +CREATE TRIGGER trg_quotes_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.quotes + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.quotes_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.compound_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +CREATE TRIGGER trg_pose_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.enumeration_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.scoring_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Removed due to replaced tables +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods +SELECT designdb.create_scores_per_pose_pivoted_mv(); diff --git a/images/xchem-designdb/01_schema_OLD.sql b/images/xchem-designdb/01_schema_OLD.sql new file mode 100644 index 0000000..d296c7d --- /dev/null +++ b/images/xchem-designdb/01_schema_OLD.sql @@ -0,0 +1,468 @@ +-- ========================================================= +-- designdb Database Schema +-- ========================================================= + +-- ========================================================= +-- PREREQUISITES & EXTENSIONS +-- ========================================================= + +DROP SCHEMA IF EXISTS designdb CASCADE; +CREATE SCHEMA IF NOT EXISTS designdb; +CREATE SCHEMA IF NOT EXISTS rdkit; + +CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; + +SET search_path TO designdb, rdkit, public; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; + +-- ========================================================= +-- TABLES (ordered by FK dependencies) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS designdb.target ( + target_pk BIGSERIAL PRIMARY KEY, --Must be a link to Scarab protein production target + target_name TEXT, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_target UNIQUE (target_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.scoring_method ( + method_pk BIGSERIAL PRIMARY KEY, + method_name TEXT, + method_description TEXT, + method_version TEXT, + method_organization TEXT, + method_link TEXT, + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.enumeration_method ( + enum_pk BIGSERIAL PRIMARY KEY, + enum_name TEXT, + enum_description TEXT, + enum_version TEXT, + enum_organization TEXT, + enum_link TEXT, + enum_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.pose_method ( + pose_method_pk BIGSERIAL PRIMARY KEY, + pose_method_name TEXT, + pose_method_description TEXT, + pose_method_version TEXT, + pose_method_organization TEXT, + pose_method_link TEXT, + pose_method_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.compound ( + compound_pk BIGSERIAL PRIMARY KEY, + compound_inchikey TEXT, -- Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_alias TEXT, -- Maybe insert by the codebase. + compound_smiles TEXT, -- Inseret by the codebase. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + compound_base BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Not populated by code + compound_mol rdkit.mol, -- Maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently its broken + compound_morgan_bfp bit(2048), -- Postgresartridge can't calc this. Msut be insert by codebase, but currently its broken + compound_metadata TEXT, -- currently Null + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) +); + +CREATE TABLE IF NOT EXISTS designdb.feature ( + feature_pk BIGSERIAL PRIMARY KEY, + feature_family TEXT, -- Insert by codebase + feature_target BIGINT REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, -- Insert by codebase + feature_chain_name TEXT, -- Insert by codebase + feature_residue_name TEXT, -- Insert by codebase + feature_residue_number INTEGER, -- Insert by codebase + feature_atom_names TEXT, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_feature UNIQUE (feature_family, feature_target, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_names) +); + +CREATE TABLE IF NOT EXISTS designdb.route ( + route_pk BIGSERIAL PRIMARY KEY, + route_product BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reaction ( + reaction_pk BIGSERIAL PRIMARY KEY, + reaction_type TEXT, -- Insert by codebase/notebook, Synderilla + reaction_product BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla + reaction_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.pose ( + pose_pk BIGSERIAL PRIMARY KEY, + pose_inchikey TEXT, -- Insert by codebase when registering poses? Might be done from pose.mol? + pose_alias TEXT, + pose_smiles TEXT, -- LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. + pose_reference INTEGER, + pose_path TEXT, + pose_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, + pose_target BIGINT REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, + pose_mol rdkit.mol, -- Insert by the codebase and /or Chemicalite/Postgres RDkit cartridge + pose_fingerprint INTEGER, + --pose_energy_score REAL, -- LR - this may become redundant once the scores table is implemented + --pose_distance_score REAL, -- LR - this may become redundant once the scores table is implemented + --pose_inspiration_score REAL, -- LR - this may become redundant once the scores table is implemented + pose_metadata TEXT, + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_alias UNIQUE (pose_alias), + CONSTRAINT uc_pose_path UNIQUE (pose_path) +); + +CREATE TABLE IF NOT EXISTS designdb.scores ( + score_pk BIGSERIAL PRIMARY KEY, + pose_pk BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, + compound_pk BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, + score JSONB, -- method_name -> {"score": number, "version": text} + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.subsite ( + subsite_pk BIGSERIAL PRIMARY KEY, + subsite_target BIGINT NOT NULL REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook + subsite_name TEXT NOT NULL, -- Insert by codebase/notebook + subsite_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite UNIQUE (subsite_target, subsite_name) +); + +CREATE TABLE IF NOT EXISTS designdb.component ( + component_pk BIGSERIAL PRIMARY KEY, + component_route BIGINT REFERENCES designdb.route (route_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_ref INTEGER, -- Insert by codebase/notebook, Synderilla + component_amount REAL, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_component UNIQUE (component_route, component_ref, component_type) +); + +CREATE TABLE IF NOT EXISTS designdb.inspiration ( + inspiration_pk BIGSERIAL PRIMARY KEY, + inspiration_original BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase + inspiration_derivative BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_inspiration UNIQUE (inspiration_original, inspiration_derivative) +); + +CREATE TABLE IF NOT EXISTS designdb.interaction ( + interaction_pk BIGSERIAL PRIMARY KEY, + interaction_feature BIGINT NOT NULL REFERENCES designdb.feature (feature_pk) ON DELETE RESTRICT, -- Insert by codebase + interaction_pose BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, -- Insert by codebase + interaction_type TEXT NOT NULL, -- Insert by codebase + interaction_family TEXT NOT NULL, -- Insert by codebase + interaction_atom_ids TEXT NOT NULL, -- Insert by codebase + interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_distance REAL NOT NULL, -- Insert by codebase + interaction_angle REAL, -- Insert by codebase + interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_interaction UNIQUE (interaction_feature, interaction_pose, interaction_type, interaction_family, interaction_atom_ids) +); + +CREATE TABLE IF NOT EXISTS designdb.quote ( + quote_pk BIGSERIAL PRIMARY KEY, + quote_smiles TEXT, -- From compound + quote_mol rdkit.mol, -- New column, should be generated by cartridge + quote_amount REAL, + quote_supplier TEXT, + quote_catalogue TEXT, + quote_entry TEXT, + quote_lead_time INTEGER, + quote_price REAL, + quote_currency TEXT, + quote_purity REAL, + quote_date TEXT, + quote_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) +); + +CREATE TABLE IF NOT EXISTS designdb.reactant ( + reactant_pk BIGSERIAL PRIMARY KEY, + reactant_amount REAL, -- Insert by codebase/notebook, Synderilla + reactant_reaction BIGINT REFERENCES designdb.reaction (reaction_pk) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla + reactant_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_reactant UNIQUE (reactant_reaction, reactant_compound) +); + +CREATE TABLE IF NOT EXISTS designdb.scaffold ( + scaffold_pk BIGSERIAL PRIMARY KEY, + scaffold_base BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase + scaffold_superstructure BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scaffold UNIQUE (scaffold_base, scaffold_superstructure) +); + +CREATE TABLE IF NOT EXISTS designdb.subsite_tag ( + subsite_tag_pk BIGSERIAL PRIMARY KEY, + subsite_tag_ref BIGINT NOT NULL REFERENCES designdb.subsite (subsite_pk) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_pose BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite_tag UNIQUE (subsite_tag_ref, subsite_tag_pose) +); + +-- CREATE TABLE IF NOT EXISTS designdb.tag ( +-- tag_pk BIGSERIAL PRIMARY KEY, +-- tag_name TEXT, -- Insert by codebase +-- tag_description TEXT, -- New column +-- note TEXT, -- New column +-- -- tag_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- -- tag_pose BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- created_on TIMESTAMPTZ DEFAULT now(), +-- updated_on TIMESTAMPTZ DEFAULT now() +-- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, tag_compound), +-- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, tag_pose) +-- ); + +CREATE TABLE IF NOT EXISTS designdb.pose_tag ( + pose_tag_pk BIGSERIAL PRIMARY KEY, + pose_tag_name TEXT, + pose_tag_description TEXT, + pose_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.compound_tag ( + compound_tag_pk BIGSERIAL PRIMARY KEY, + compound_tag_name TEXT, + compound_tag_description TEXT, + compound_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +-- ========================================================= +-- New tables supporting tagging + +CREATE TABLE IF NOT EXISTS designdb.has_pose_tag ( + has_pose_tag_pk BIGSERIAL PRIMARY KEY, + pose_pk BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE CASCADE, + pose_tag_pk BIGINT NOT NULL REFERENCES designdb.pose_tag (pose_tag_pk) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_has_pose_tag UNIQUE (pose_pk, pose_tag_pk) +); + +CREATE TABLE IF NOT EXISTS designdb.has_compound_tag ( + has_compound_tag_pk BIGSERIAL PRIMARY KEY, + compound_pk BIGINT NOT NULL REFERENCES designdb.compound (compound_pk) ON DELETE CASCADE, + compound_tag_pk BIGINT NOT NULL REFERENCES designdb.compound_tag (compound_tag_pk) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_has_compound_tag UNIQUE (compound_pk, compound_tag_pk) +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.target(target_name); +CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.target(created_on); + +CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_method(method_name); +CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_method(created_on); + +CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_method(enum_name); +CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_method(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_method(pose_method_name); +CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_method(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_base ON designdb.compound(compound_base); +CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compound(compound_inchikey); +CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compound(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compound(created_on); + +CREATE INDEX IF NOT EXISTS idx_feature_target ON designdb.feature(feature_target); +CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.feature(created_on); + +CREATE INDEX IF NOT EXISTS idx_route_product ON designdb.route(route_product); +CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.route(created_on); + +CREATE INDEX IF NOT EXISTS idx_reaction_product ON designdb.reaction(reaction_product); +CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reaction(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_compound ON designdb.pose(pose_compound); +CREATE INDEX IF NOT EXISTS idx_pose_target ON designdb.pose(pose_target); +CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.pose(pose_path); +CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.pose(created_on); + +CREATE INDEX IF NOT EXISTS idx_scores_pose_pk ON designdb.scores(pose_pk); +CREATE INDEX IF NOT EXISTS idx_scores_compound_pk ON designdb.scores(compound_pk); +CREATE INDEX IF NOT EXISTS idx_scores_created ON designdb.scores(created_on); +CREATE INDEX IF NOT EXISTS idx_scores_score_gin ON designdb.scores USING GIN (score); + +CREATE INDEX IF NOT EXISTS idx_subsite_target ON designdb.subsite(subsite_target); +CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsite(created_on); + +CREATE INDEX IF NOT EXISTS idx_component_route ON designdb.component(component_route); +CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.component(created_on); + +CREATE INDEX IF NOT EXISTS idx_inspiration_original ON designdb.inspiration(inspiration_original); +CREATE INDEX IF NOT EXISTS idx_inspiration_derivative ON designdb.inspiration(inspiration_derivative); +CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspiration(created_on); + +CREATE INDEX IF NOT EXISTS idx_interaction_feature ON designdb.interaction(interaction_feature); +CREATE INDEX IF NOT EXISTS idx_interaction_pose ON designdb.interaction(interaction_pose); +CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interaction(created_on); + +CREATE INDEX IF NOT EXISTS idx_quote_compound ON designdb.quote(quote_compound); +CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quote(created_on); + +CREATE INDEX IF NOT EXISTS idx_reactant_reaction ON designdb.reactant(reactant_reaction); +CREATE INDEX IF NOT EXISTS idx_reactant_compound ON designdb.reactant(reactant_compound); +CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactant(created_on); + +CREATE INDEX IF NOT EXISTS idx_scaffold_base ON designdb.scaffold(scaffold_base); +CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure ON designdb.scaffold(scaffold_superstructure); +CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffold(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_tag_ref ON designdb.subsite_tag(subsite_tag_ref); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose ON designdb.subsite_tag(subsite_tag_pose); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tag(created_on); + +-- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tag(tag_compound); +-- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tag(tag_pose); +-- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tag(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tag(created_on); +CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tag(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_pk ON designdb.has_pose_tag(pose_pk); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_pk ON designdb.has_pose_tag(pose_tag_pk); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tag(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_pk ON designdb.has_compound_tag(compound_pk); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_pk ON designdb.has_compound_tag(compound_tag_pk); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tag(created_on); + +-- ========================================================= +-- FUNCTIONS +-- ========================================================= + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ========================================================= +-- TRIGGERS (updated_on) +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.target; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.target FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_method; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_method; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_method; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compound; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compound FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.feature; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.feature FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.route; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.route FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reaction; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reaction FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.pose; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.pose FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scores_updated_on ON designdb.scores; +CREATE TRIGGER trg_scores_updated_on BEFORE UPDATE ON designdb.scores FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsite; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsite FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.component; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.component FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspiration; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspiration FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interaction; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interaction FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quote; +CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quote FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactant; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactant FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffold; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffold FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tag; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tag; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tag; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tag; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tag; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tag; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); diff --git a/images/xchem-designdb/Dockerfile b/images/xchem-designdb/Dockerfile new file mode 100644 index 0000000..1b72437 --- /dev/null +++ b/images/xchem-designdb/Dockerfile @@ -0,0 +1,127 @@ +# Dockerfile — RDKit + Postgres cartridge build (Bookworm / Postgres 18) +# CVE mitigation: pinned base 18.3-bookworm; gosu rebuilt with Go >= 1.24.8 to fix golang stdlib CVEs; apt-get upgrade in runtime. + +ARG RDKIT_VERSION=Release_2025_09_5 +ARG BOOST_VERSION=1.90.0 +ARG BOOST_VER_US=1_90_0 +ARG PG_MAJOR=18 +ARG PG_BASE=postgres:18.3-bookworm +ARG GOLANG_VERSION=1.24.13-alpine +ARG GOSU_VERSION=1.19 + +# ------------------------------------------------------------------- +# Stage 1: builder image (uses upstream postgres dev headers) +# ------------------------------------------------------------------- +FROM ${PG_BASE} AS builder + +ARG RDKIT_VERSION +ARG BOOST_VERSION +ARG BOOST_VER_US +ARG PG_MAJOR + +ENV DEBIAN_FRONTEND=noninteractive +ENV CMAKE_PREFIX_PATH=/usr/local +ENV PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:${PATH}" + +# Install build deps (no python3-pip; RDKit Python wrappers are OFF) +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential cmake git ca-certificates curl python3 \ + postgresql-server-dev-${PG_MAJOR} \ + libeigen3-dev libcairo2-dev libfreetype6-dev libpng-dev libjpeg-dev \ + zlib1g-dev pkg-config && \ + rm -rf /var/lib/apt/lists/* + +# Build Boost from source (ensures ABI compatibility) +WORKDIR /opt +RUN set -eux; \ + BOOST_URL="https://archives.boost.io/release/${BOOST_VERSION}/source/boost_${BOOST_VER_US}.tar.gz"; \ + echo "Downloading Boost from: ${BOOST_URL}"; \ + curl -fSL --retry 5 --retry-connrefused -o boost.tar.gz "${BOOST_URL}"; \ + tar -xzf boost.tar.gz; \ + cd "boost_${BOOST_VER_US}"; \ + ./bootstrap.sh --prefix=/usr/local; \ + ./b2 -j"$(nproc)" install + +# pg_config in PATH and as PG_CONFIG (RDKit CMake may probe both) +ENV PG_CONFIG="/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config" +RUN ln -sf "$PG_CONFIG" /usr/bin/pg_config && \ + mkdir -p /usr/lib/bin && ln -sf "$PG_CONFIG" /usr/lib/bin/pg_config + +# Fetch RDKit source +RUN git clone --depth 1 --branch "${RDKIT_VERSION}" https://github.com/rdkit/rdkit.git /opt/rdkit + +# Configure and build RDKit with PostgreSQL cartridge +WORKDIR /opt/rdkit_build +RUN set -eux; \ + PG_INC="$(pg_config --includedir)"; \ + PG_INC_SRV="$(pg_config --includedir-server)"; \ + PG_LIB="$(pg_config --libdir)"; \ + cmake -S /opt/rdkit -B /opt/rdkit_build \ + -DCMAKE_BUILD_TYPE=Release \ + -DRDK_BUILD_PYTHON_WRAPPERS=OFF \ + -DRDK_BUILD_PGSQL=ON \ + -DRDK_BUILD_INCHI_SUPPORT=ON \ + -DRDK_BUILD_AVALON_SUPPORT=ON \ + -DRDK_BUILD_CAIRO_SUPPORT=ON \ + -DRDK_PGSQL_STATIC=ON \ + -DPostgreSQL_CONFIG=pg_config \ + -DPG_CONFIG=/usr/lib/postgresql/${PG_MAJOR}/bin/pg_config \ + -DPostgreSQL_INCLUDE_DIR="${PG_INC}" \ + -DPostgreSQL_TYPE_INCLUDE_DIR="${PG_INC_SRV}" \ + -DPostgreSQL_LIBRARY_DIR="${PG_LIB}" && \ + cmake --build /opt/rdkit_build -j"$(nproc)" && \ + cmake --install /opt/rdkit_build --strip + +# ------------------------------------------------------------------- +# Stage 2: gosu builder (fixes golang stdlib CVEs in postgres base image) +# ------------------------------------------------------------------- +# The official postgres image includes gosu (used by docker-entrypoint.sh for +# privilege switching) built with an older Go stdlib, causing ~60 CVEs including +# CVE-2025-68121 (Critical). Upstream fix: https://github.com/docker-library/postgres/pull/1323 +# We rebuild gosu with Go (stdlib >= 1.24.8 fixes the CVEs) and replace the binary. +# REMOVAL: Remove this stage when the official postgres image ships gosu built +# with Go >= 1.24.8 (check https://github.com/docker-library/postgres/pull/1323). +FROM golang:${GOLANG_VERSION} AS gosu-builder +ARG GOSU_VERSION=1.19 +RUN apk add --no-cache git ca-certificates && \ + git clone --depth 1 --branch "${GOSU_VERSION}" https://github.com/tianon/gosu.git /gosu && \ + cd /gosu && go build -o gosu . + +# ------------------------------------------------------------------- +# Stage 3: final runtime image +# ------------------------------------------------------------------- +FROM ${PG_BASE} + +ARG PG_MAJOR + +ENV DEBIAN_FRONTEND=noninteractive + +# Replace gosu with CVE-free build (see gosu-builder stage above) +COPY --from=gosu-builder /gosu/gosu /usr/local/bin/gosu +RUN gosu --version + +# Runtime libraries required by rdkit and cairo/freetype. Latest Debian patches to fix CVE +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ + libcairo2 libfreetype6 libpng16-16 libjpeg62-turbo zlib1g && \ + rm -rf /var/lib/apt/lists/* + +# Copy RDKit cartridge artifacts from builder +COPY --from=builder /usr/lib/postgresql/${PG_MAJOR}/lib/rdkit.so /usr/lib/postgresql/${PG_MAJOR}/lib/ +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/extension/rdkit* /usr/share/postgresql/${PG_MAJOR}/extension/ + +# Copy Boost runtime libs from builder +COPY --from=builder /usr/local/lib/libboost_*.so* /usr/local/lib/ + +# Update linker cache so Postgres can load rdkit.so +RUN ldconfig + +# Copy init-db scripts (run on first start) +COPY init-db/ /docker-entrypoint-initdb.d/ + +# Healthcheck (uses POSTGRES_USER from env when set, e.g. by compose) +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ + CMD pg_isready -U "$${POSTGRES_USER:-postgres}" || exit 1 + +EXPOSE 5432 + +# The base postgres image will handle the entrypoint diff --git a/images/xchem-designdb/docker-compose.yml b/images/xchem-designdb/docker-compose.yml new file mode 100644 index 0000000..ffd5ad1 --- /dev/null +++ b/images/xchem-designdb/docker-compose.yml @@ -0,0 +1,42 @@ +# Postgres + RDKit cartridge (designdb). Config via .env. +# Rebuild with: docker compose build --pull to refresh base image and reduce CVEs. + +services: + database: + build: + context: . + dockerfile: Dockerfile + args: + RDKIT_VERSION: ${RDKIT_VERSION:-Release_2025_09_5} + BOOST_VERSION: ${BOOST_VERSION:-1.90.0} + BOOST_VER_US: ${BOOST_VER_US:-1_90_0} + PG_MAJOR: ${PG_MAJOR:-18} + PG_BASE: ${PG_BASE:-postgres:18.3-bookworm} + image: xchem_designdb:latest + container_name: xchem_designdb + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - ${POSTGRES_DATA_PATH}:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${DB_NAME:-designdb} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_INITDB_ARGS: ${POSTGRES_INITDB_ARGS:---auth-host=scram-sha-256 --auth-local=trust} + PGDATA: ${PGDATA:-/var/lib/postgresql/data} + POSTGRES_HOST_AUTH_METHOD: ${POSTGRES_HOST_AUTH_METHOD:-scram-sha-256} + shm_size: 8g + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\" -h localhost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 90s + networks: + - app_network + +networks: + app_network: + name: xchem_designdb_network + driver: bridge diff --git a/images/xchem-designdb/env.template b/images/xchem-designdb/env.template new file mode 100644 index 0000000..3eb0d5d --- /dev/null +++ b/images/xchem-designdb/env.template @@ -0,0 +1,22 @@ +# ========================================================= +# DATABASE CONFIGURATION +# ========================================================= +DB_NAME=CHANGE_ME +DB_USER=CHANGE_ME +DB_PASSWORD=CHANGE_ME + +# PostgreSQL +POSTGRES_PORT=5432 +POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust +PGDATA=/var/lib/postgresql/data +POSTGRES_HOST_AUTH_METHOD=scram-sha-256 + +# Volume path for persistent data +POSTGRES_DATA_PATH=CHANGE_ME + +# Build versions (RDKit / Postgres versions) +RDKIT_VERSION=Release_2025_09_5 +BOOST_VERSION=1.90.0 +BOOST_VER_US=1_90_0 +PG_MAJOR=18 +PG_BASE=postgres:18.2-bookworm \ No newline at end of file diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql new file mode 100644 index 0000000..101ca6e --- /dev/null +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -0,0 +1,1080 @@ +-- ========================================================= +-- designdb Database Schema +-- ========================================================= + +-- ========================================================= +DROP SCHEMA IF EXISTS designdb CASCADE; +-- ========================================================= + +-- ========================================================= +-- PREREQUISITES & EXTENSIONS +-- ========================================================= + +CREATE SCHEMA IF NOT EXISTS designdb; +CREATE SCHEMA IF NOT EXISTS rdkit; + +CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; + +SET search_path TO designdb, rdkit, public; + +REVOKE CREATE ON SCHEMA public FROM PUBLIC; + +-- ========================================================= +-- TABLES (ordered by FK dependencies) +-- ========================================================= + +CREATE TABLE IF NOT EXISTS designdb.targets ( + id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis + external_target_id BIGINT, -- ID of this target in the external database (Scarab link) + target_name TEXT NOT NULL, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_target UNIQUE (target_name) +); + +CREATE TABLE IF NOT EXISTS designdb.compounds ( + id BIGSERIAL PRIMARY KEY, + compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_alias TEXT, -- Maybe insert by the codebase. + compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code + compound_mol rdkit.mol, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Originally, maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_metadata TEXT, -- currently Null + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) +); + +CREATE TABLE IF NOT EXISTS designdb.subsites ( + id BIGSERIAL PRIMARY KEY, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase/notebook + subsite_name TEXT NOT NULL, -- Insert by codebase/notebook + subsite_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite UNIQUE (target_id, subsite_name) +); + +CREATE TABLE IF NOT EXISTS designdb.poses ( + id BIGSERIAL PRIMARY KEY, + pose_inchikey TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). Originally, nserted by codebase when registering poses? Might be done from pose.mol? + pose_alias TEXT, + pose_smiles TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. + pose_reference INTEGER, + pose_path TEXT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, + pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge + pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. + --pose_energy_score REAL, -- LR - redundant; use designdb.score_values + --pose_distance_score REAL, -- LR - redundant; use designdb.score_values + --pose_inspiration_score REAL, -- LR - redundant; use designdb.score_values + pose_metadata TEXT, + note TEXT, -- New column + rdkit_version TEXT, --Can be done by RDkit cartridge + inchi_version TEXT, -- Must be done by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() + -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed + -- CONSTRAINT uc_pose_path UNIQUE (pose_path) -- Removed +); + +CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( + id BIGSERIAL PRIMARY KEY, + subsite_id BIGINT NOT NULL REFERENCES designdb.subsites (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + subsite_tag_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_subsite_tag UNIQUE (subsite_id, pose_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_methods ( + id BIGSERIAL PRIMARY KEY, + pose_method_name TEXT, + pose_method_description TEXT, + pose_method_version TEXT, + pose_method_organization TEXT, + pose_method_link TEXT, + pose_method_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_method UNIQUE NULLS NOT DISTINCT (pose_method_name, pose_method_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_methods ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_method_id BIGINT NOT NULL REFERENCES designdb.pose_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.pose_tags ( + id BIGSERIAL PRIMARY KEY, + pose_tag_name TEXT NOT NULL, + pose_tag_description TEXT, + pose_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_tag_name UNIQUE (pose_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_pose_tags ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE CASCADE, + pose_tag_id BIGINT NOT NULL REFERENCES designdb.pose_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (pose_id, pose_tag_id) +); + +CREATE TABLE IF NOT EXISTS designdb.inspirations ( + id BIGSERIAL PRIMARY KEY, + original_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + derivative_pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_inspiration UNIQUE (original_pose_id, derivative_pose_id) +); + +CREATE TABLE IF NOT EXISTS designdb.features ( + id BIGSERIAL PRIMARY KEY, + feature_family TEXT, -- Insert by codebase + target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, -- Insert by codebase + feature_chain_name TEXT, -- Insert by codebase + feature_residue_name TEXT, -- Insert by codebase + feature_residue_number INTEGER, -- Insert by codebase + feature_atom_name TEXT, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_feature UNIQUE (feature_family, target_id, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_name) +); + +CREATE TABLE IF NOT EXISTS designdb.interactions ( + id BIGSERIAL PRIMARY KEY, + feature_id BIGINT NOT NULL REFERENCES designdb.features (id) ON DELETE RESTRICT, -- Insert by codebase + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, -- Insert by codebase + interaction_type TEXT NOT NULL, -- Insert by codebase + interaction_family TEXT NOT NULL, -- Insert by codebase + interaction_atom_id TEXT NOT NULL, -- Insert by codebase + interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + interaction_distance REAL NOT NULL, -- Insert by codebase + interaction_angle REAL, -- Insert by codebase + interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_interaction UNIQUE (feature_id, pose_id, interaction_type, interaction_family, interaction_atom_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.compound_tags ( + id BIGSERIAL PRIMARY KEY, + compound_tag_name TEXT NOT NULL, + compound_tag_description TEXT, + compound_tag_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_tag_name UNIQUE (compound_tag_name) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_compound_tags ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + compound_tag_id BIGINT NOT NULL REFERENCES designdb.compound_tags (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, compound_tag_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods ( + id BIGSERIAL PRIMARY KEY, + enum_name TEXT, + enum_description TEXT, + enum_version TEXT, + enum_organization TEXT, + enum_link TEXT, + enum_note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_enumeration_method UNIQUE NULLS NOT DISTINCT (enum_name, enum_version) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.has_enumeration_methods ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + enumeration_method_id BIGINT NOT NULL REFERENCES designdb.enumeration_methods (id) ON DELETE CASCADE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (compound_id, enumeration_method_id) +); + +-- New table +CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( + id BIGSERIAL PRIMARY KEY, + method_name TEXT, + method_description TEXT, + method_version TEXT, + method_organization TEXT, + method_link TEXT, + note TEXT, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scoring_method UNIQUE NULLS NOT DISTINCT (method_name, method_version) +); + +-- One row per (pose_id, compound_id, scoring_method_id). score JSONB stores {"score": value} (numeric or text). +CREATE TABLE IF NOT EXISTS designdb.score_values ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + scoring_method_id BIGINT NOT NULL REFERENCES designdb.scoring_methods (id) ON DELETE RESTRICT, + score JSONB NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT pk_score_values PRIMARY KEY (pose_id, compound_id, scoring_method_id) +); + +CREATE TABLE IF NOT EXISTS designdb.reactions ( + id BIGSERIAL PRIMARY KEY, + reaction_type TEXT, -- Insert by codebase/notebook, Synderilla + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla + reaction_metadata TEXT, -- Not populated by code + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.reactants ( + id BIGSERIAL PRIMARY KEY, + reactant_amount REAL, -- Insert by codebase/notebook, Synderilla + reaction_id BIGINT NOT NULL REFERENCES designdb.reactions (id) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.quotes ( + id BIGSERIAL PRIMARY KEY, + quote_smiles TEXT, + quote_amount REAL, + quote_supplier TEXT, + quote_catalogue TEXT, -- Catalogue (there are null values, plus BB, Full stock etc.) + quote_entry TEXT, -- This the catalogue number (supplier id) + quote_lead_time INTEGER, -- Days, weeks? + quote_price REAL, + quote_currency TEXT, + quote_purity REAL, -- Not percentage (e.g. 0.99) + quote_date TEXT, + compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, --Quote compound originally, mapped with compound_id + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) +); + +CREATE TABLE IF NOT EXISTS designdb.scaffolds ( + id BIGSERIAL PRIMARY KEY, + base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + superstructure_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scaffold UNIQUE (base_compound_id, superstructure_compound_id) +); + +CREATE TABLE IF NOT EXISTS designdb.routes ( + id BIGSERIAL PRIMARY KEY, + product_compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS designdb.components ( + id BIGSERIAL PRIMARY KEY, + route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_ref INTEGER, -- Insert by codebase/notebook, Synderilla + component_amount REAL, -- Insert by codebase/notebook, Synderilla + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_component UNIQUE (route_id, component_ref, component_type) +); + +-- Replaced table with individual tag tables +-- CREATE TABLE IF NOT EXISTS designdb.tags ( +-- id BIGSERIAL PRIMARY KEY, +-- tag_name TEXT, -- Insert by codebase +-- tag_description TEXT, -- New column +-- note TEXT, -- New column +-- -- compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- -- pose_id BIGINT REFERENCES designdb.poses (id) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed +-- created_on TIMESTAMPTZ DEFAULT now(), +-- updated_on TIMESTAMPTZ DEFAULT now() +-- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, compound_id), +-- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, pose_id) +-- ); + + +-- ========================================================= +-- AUDIT TABLES +-- ========================================================= + +-- Event audit for quotes (tracks INSERT/UPDATE/DELETE for data load change tracking) +CREATE TABLE IF NOT EXISTS designdb.quotes_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_tags +CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for compound_tags +CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for pose_methods +CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for enumeration_methods +CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for scoring_methods +CREATE TABLE IF NOT EXISTS designdb.scoring_methods_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- ========================================================= +-- INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.targets(target_name); +CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.targets(created_on); + +CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_methods(method_name); +CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_methods(enum_name); +CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_methods(pose_method_name); +CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); +CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); + +CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); +CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.features(created_on); + +CREATE INDEX IF NOT EXISTS idx_route_product_compound_id ON designdb.routes(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.routes(created_on); + +CREATE INDEX IF NOT EXISTS idx_reaction_product_compound_id ON designdb.reactions(product_compound_id); +CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_compound_id ON designdb.poses(compound_id); +CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); +CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.poses(pose_path); +CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); + +CREATE INDEX IF NOT EXISTS idx_score_values_pose_id ON designdb.score_values(pose_id); +CREATE INDEX IF NOT EXISTS idx_score_values_compound_id ON designdb.score_values(compound_id); +CREATE INDEX IF NOT EXISTS idx_score_values_scoring_method_id ON designdb.score_values(scoring_method_id); +CREATE INDEX IF NOT EXISTS idx_score_values_created ON designdb.score_values(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_target_id ON designdb.subsites(target_id); +CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsites(created_on); + +CREATE INDEX IF NOT EXISTS idx_component_route_id ON designdb.components(route_id); +CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.components(created_on); + +CREATE INDEX IF NOT EXISTS idx_inspiration_original_pose_id ON designdb.inspirations(original_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_derivative_pose_id ON designdb.inspirations(derivative_pose_id); +CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspirations(created_on); + +CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(feature_id); +CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); +CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); + +CREATE INDEX IF NOT EXISTS idx_quote_compound_id ON designdb.quotes(compound_id); +CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quotes(created_on); + +-- ========================================================= +-- AUDIT INDEXES +-- ========================================================= + +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id ON designdb.quotes_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_operation ON designdb.quotes_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_at ON designdb.quotes_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_by ON designdb.quotes_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_old_gin ON designdb.quotes_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_new_gin ON designdb.quotes_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id_changed ON designdb.quotes_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_changed_at ON designdb.pose_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_old_gin ON designdb.pose_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_new_gin ON designdb.pose_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id_changed ON designdb.pose_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id ON designdb.compound_tags_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_operation ON designdb.compound_tags_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_changed_at ON designdb.compound_tags_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_old_gin ON designdb.compound_tags_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_new_gin ON designdb.compound_tags_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_compound_tags_event_audit_id_changed ON designdb.compound_tags_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id ON designdb.pose_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_operation ON designdb.pose_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_changed_at ON designdb.pose_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_old_gin ON designdb.pose_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_new_gin ON designdb.pose_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_pose_methods_event_audit_id_changed ON designdb.pose_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id ON designdb.enumeration_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_operation ON designdb.enumeration_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_changed_at ON designdb.enumeration_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_old_gin ON designdb.enumeration_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_new_gin ON designdb.enumeration_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_enumeration_methods_event_audit_id_changed ON designdb.enumeration_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id ON designdb.scoring_methods_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_operation ON designdb.scoring_methods_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_changed_at ON designdb.scoring_methods_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_old_gin ON designdb.scoring_methods_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_new_gin ON designdb.scoring_methods_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_scoring_methods_event_audit_id_changed ON designdb.scoring_methods_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_reactant_reaction_id ON designdb.reactants(reaction_id); +CREATE INDEX IF NOT EXISTS idx_reactant_compound_id ON designdb.reactants(compound_id); +CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactants(created_on); + +CREATE INDEX IF NOT EXISTS idx_scaffold_base_compound_id ON designdb.scaffolds(base_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure_compound_id ON designdb.scaffolds(superstructure_compound_id); +CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffolds(created_on); + +CREATE INDEX IF NOT EXISTS idx_subsite_tag_subsite_id ON designdb.subsite_tags(subsite_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose_id ON designdb.subsite_tags(pose_id); +CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tags(created_on); + +-- Removed due to replaced tables +-- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tags(tag_compound); +-- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tags(tag_pose); +-- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tags(created_on); +CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_pose_method_id ON designdb.has_pose_methods(pose_method_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_methods_created ON designdb.has_pose_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_id ON designdb.has_pose_tags(pose_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tags(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_enumeration_method_id ON designdb.has_enumeration_methods(enumeration_method_id); +CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_enumeration_methods(created_on); + +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); +CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); + +-- ========================================================= +-- MATERIALIZED VIEWS +-- ========================================================= +-- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. + +-- ========================================================= +-- VIEWS +-- ========================================================= + +-- Shows quote updates captured via designdb.quotes_event_audit +CREATE OR REPLACE VIEW designdb.quotes_price_changes_v AS +SELECT + a.id AS quote_id, + (NULLIF(COALESCE(a.new_values->>'compound_id', a.old_values->>'compound_id'), ''))::BIGINT AS compound_id, + COALESCE(a.new_values->>'quote_smiles', a.old_values->>'quote_smiles') AS quote_smiles, + (NULLIF(COALESCE(a.new_values->>'quote_amount', a.old_values->>'quote_amount'), ''))::DOUBLE PRECISION AS quote_amount, + COALESCE(a.new_values->>'quote_supplier', a.old_values->>'quote_supplier') AS quote_supplier, + COALESCE(a.new_values->>'quote_catalogue', a.old_values->>'quote_catalogue') AS quote_catalogue, + COALESCE(a.new_values->>'quote_entry', a.old_values->>'quote_entry') AS quote_entry, + (NULLIF(a.old_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_old, + (NULLIF(a.new_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_new, + COALESCE(a.new_values->>'quote_currency', a.old_values->>'quote_currency') AS quote_currency, + (NULLIF(COALESCE(a.new_values->>'quote_purity', a.old_values->>'quote_purity'), ''))::DOUBLE PRECISION AS quote_purity, + a.changed_at +FROM designdb.quotes_event_audit a +WHERE a.operation = 'U'; + +-- Pose tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +SELECT + a.id AS pose_tag_id, + a.old_values->>'pose_tag_name' AS pose_tag_name_old, + a.new_values->>'pose_tag_name' AS pose_tag_name_new, + a.old_values->>'pose_tag_description' AS pose_tag_description_old, + a.new_values->>'pose_tag_description' AS pose_tag_description_new, + a.old_values->>'pose_tag_note' AS pose_tag_note_old, + a.new_values->>'pose_tag_note' AS pose_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.pose_tags_event_audit a +WHERE a.operation = 'U'; + +-- Compound tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +SELECT + a.id AS compound_tag_id, + a.old_values->>'compound_tag_name' AS compound_tag_name_old, + a.new_values->>'compound_tag_name' AS compound_tag_name_new, + a.old_values->>'compound_tag_description' AS compound_tag_description_old, + a.new_values->>'compound_tag_description' AS compound_tag_description_new, + a.old_values->>'compound_tag_note' AS compound_tag_note_old, + a.new_values->>'compound_tag_note' AS compound_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.compound_tags_event_audit a +WHERE a.operation = 'U'; + +-- Pose methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +SELECT + a.id AS pose_method_id, + a.old_values->>'pose_method_name' AS pose_method_name_old, + a.new_values->>'pose_method_name' AS pose_method_name_new, + a.old_values->>'pose_method_description' AS pose_method_description_old, + a.new_values->>'pose_method_description' AS pose_method_description_new, + a.old_values->>'pose_method_version' AS pose_method_version_old, + a.new_values->>'pose_method_version' AS pose_method_version_new, + a.changed_by, + a.changed_at +FROM designdb.pose_methods_event_audit a +WHERE a.operation = 'U'; + +-- Enumeration methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +SELECT + a.id AS enumeration_method_id, + a.old_values->>'enum_name' AS enum_name_old, + a.new_values->>'enum_name' AS enum_name_new, + a.old_values->>'enum_description' AS enum_description_old, + a.new_values->>'enum_description' AS enum_description_new, + a.old_values->>'enum_version' AS enum_version_old, + a.new_values->>'enum_version' AS enum_version_new, + a.changed_by, + a.changed_at +FROM designdb.enumeration_methods_event_audit a +WHERE a.operation = 'U'; + +-- Scoring methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +SELECT + a.id AS scoring_method_id, + a.old_values->>'method_name' AS method_name_old, + a.new_values->>'method_name' AS method_name_new, + a.old_values->>'method_description' AS method_description_old, + a.new_values->>'method_description' AS method_description_new, + a.old_values->>'method_version' AS method_version_old, + a.new_values->>'method_version' AS method_version_new, + a.changed_by, + a.changed_at +FROM designdb.scoring_methods_event_audit a +WHERE a.operation = 'U'; + +-- ========================================================= +-- FUNCTIONS +-- ========================================================= + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND WRAPPERS +-- ========================================================= +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey (used by compound, pose, and quote triggers). + +CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol + LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; + +-- ========================================================= +-- RDKIT CARTRIDGE – COMPOUND TRIGGER +-- ========================================================= +-- Input: compound_smiles (inserted by application). Populates compound_mol and compound_inchikey. + +CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; +BEGIN + IF NEW.compound_smiles IS NOT NULL THEN + BEGIN + v_mol := designdb.mol_from_smiles(NEW.compound_smiles); + IF v_mol IS NOT NULL THEN + NEW.compound_mol := v_mol; + NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); + END IF; + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_compound_cartridge_from_smiles ON designdb.compounds; +CREATE TRIGGER trg_populate_compound_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF compound_smiles ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); + +-- ========================================================= +-- RDKIT CARTRIDGE – POSE TRIGGER +-- ========================================================= +-- Input: pose_mol (inserted by application). Populates pose_inchikey and pose_smiles. + +CREATE OR REPLACE FUNCTION designdb.populate_pose_cartridge_from_mol() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.pose_mol IS NOT NULL THEN + BEGIN + NEW.pose_smiles := designdb.mol_to_smiles(NEW.pose_mol); + NEW.pose_inchikey := designdb.mol_to_inchikey(NEW.pose_mol); + EXCEPTION WHEN OTHERS THEN + NULL; + END; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_pose_cartridge_from_mol ON designdb.poses; +CREATE TRIGGER trg_populate_pose_cartridge_from_mol + BEFORE INSERT OR UPDATE OF pose_mol ON designdb.poses + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_pose_cartridge_from_mol(); + +-- ========================================================= +-- SCORE_VALUES – ENFORCE compound_id MATCHES pose's compound_id +-- ========================================================= +CREATE OR REPLACE FUNCTION designdb.check_score_values_compound_matches_pose() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_pose_compound_id BIGINT; +BEGIN + SELECT compound_id INTO v_pose_compound_id + FROM designdb.poses + WHERE id = NEW.pose_id; + IF v_pose_compound_id IS NULL THEN + RAISE EXCEPTION 'pose_id % does not exist', NEW.pose_id; + END IF; + IF NEW.compound_id IS DISTINCT FROM v_pose_compound_id THEN + RAISE EXCEPTION 'score_values.compound_id (%) must match poses.compound_id (%) for pose_id %', + NEW.compound_id, v_pose_compound_id, NEW.pose_id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_check_score_values_compound_matches_pose ON designdb.score_values; +CREATE TRIGGER trg_check_score_values_compound_matches_pose + BEFORE INSERT OR UPDATE OF pose_id, compound_id ON designdb.score_values + FOR EACH ROW + EXECUTE FUNCTION designdb.check_score_values_compound_matches_pose(); + +-- ========================================================= +-- (Re)creates materialized view designdb.scores_per_pose_pivoted_mv with columns from scoring_method. +-- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). +-- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). +-- Value in column: if score is numeric then JSONB number, if text then JSONB string. +CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + select_qry text; + col text; + method_rec record; + score_txt text; + value_expr text; + numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +BEGIN + select_qry := 'SELECT sv.pose_id, sv.compound_id'; + FOR method_rec IN + SELECT m.id, m.method_name, m.method_version + FROM designdb.scoring_methods m + ORDER BY m.id + LOOP + col := regexp_replace( + trim(method_rec.method_name) || '_' || coalesce( + replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), + '' + ), + '[^a-zA-Z0-9_]', '_', 'g' + ) || '_m' || method_rec.id; + IF col <> '' AND col <> '_' THEN + col := quote_ident(col); + score_txt := '(sv.score->>' || quote_literal('score') || ')'; + value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) + || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; + select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id + || ' THEN ' || value_expr || ' END) AS ' || col; + END IF; + END LOOP; + select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; + EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; + EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; + EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM designdb.create_scores_per_pose_pivoted_mv(); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ========================================================= +-- AUDIT FUNCTIONS +-- ========================================================= + +-- Event audit trigger: records INSERT/UPDATE/DELETE to an audit table with JSONB old/new values. +-- TG_ARGV[0]=audit_table, [1]=pk_column, [2]=excluded_columns (comma-sep), [3]=binary_columns (comma-sep, hashed as sha256:hex). +CREATE OR REPLACE FUNCTION designdb.event_audit_trigger() +RETURNS trigger AS +$$ +DECLARE + v_audit_table TEXT := TG_ARGV[0]; + v_pk_col TEXT := TG_ARGV[1]; + v_excluded TEXT := COALESCE(TG_ARGV[2], ''); + v_bincols TEXT := COALESCE(TG_ARGV[3], ''); + v_excluded_arr TEXT[]; + v_bincols_arr TEXT[]; + v_changed_by TEXT := COALESCE(current_setting('app.current_user', true), current_user); + v_old_json JSONB; + v_new_json JSONB; + v_record_pk TEXT; + v_bin_hash TEXT; + v_col TEXT; +BEGIN + IF v_excluded = '' THEN + v_excluded_arr := ARRAY[]::text[]; + ELSE + v_excluded_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_excluded, ',') AS x); + END IF; + + IF v_bincols = '' THEN + v_bincols_arr := ARRAY[]::text[]; + ELSE + v_bincols_arr := ARRAY(SELECT trim(x) FROM regexp_split_to_table(v_bincols, ',') AS x); + END IF; + + IF TG_OP = 'INSERT' THEN + v_old_json := NULL; + v_new_json := to_jsonb(NEW); + ELSIF TG_OP = 'DELETE' THEN + v_old_json := to_jsonb(OLD); + v_new_json := NULL; + ELSE + IF to_jsonb(OLD) IS NOT DISTINCT FROM to_jsonb(NEW) THEN + RETURN NEW; + END IF; + v_old_json := to_jsonb(OLD); + v_new_json := to_jsonb(NEW); + END IF; + + FOREACH v_col IN ARRAY v_excluded_arr LOOP + IF v_old_json IS NOT NULL THEN v_old_json := v_old_json - v_col; END IF; + IF v_new_json IS NOT NULL THEN v_new_json := v_new_json - v_col; END IF; + END LOOP; + + FOREACH v_col IN ARRAY v_bincols_arr LOOP + IF v_old_json IS NOT NULL AND v_old_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING OLD INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_old_json := jsonb_set(v_old_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_old_json := v_old_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_old_json := v_old_json - v_col; + END; + END IF; + IF v_new_json IS NOT NULL AND v_new_json ? v_col THEN + BEGIN + EXECUTE format('SELECT CASE WHEN ($1).%I IS NULL THEN NULL ELSE encode(digest(($1).%I::bytea, ''sha256''), ''hex'') END', v_col, v_col) + USING NEW INTO v_bin_hash; + IF v_bin_hash IS NOT NULL THEN + v_new_json := jsonb_set(v_new_json, ARRAY[v_col], to_jsonb('sha256:' || v_bin_hash)); + ELSE + v_new_json := v_new_json - v_col; + END IF; + EXCEPTION WHEN others THEN + v_new_json := v_new_json - v_col; + END; + END IF; + END LOOP; + + IF TG_OP = 'DELETE' THEN + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING OLD INTO v_record_pk; + ELSE + EXECUTE format('SELECT ($1).%I::text', v_pk_col) USING NEW INTO v_record_pk; + END IF; + + EXECUTE format('INSERT INTO %s (%s, operation, old_values, new_values, changed_by, changed_at) VALUES ($1,$2,$3,$4,$5,NOW())', v_audit_table, v_pk_col) + USING v_record_pk::BIGINT, TG_OP::CHAR, v_old_json, v_new_json, v_changed_by; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql VOLATILE; + +-- ========================================================= +-- TRIGGERS (updated_on) +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +CREATE TRIGGER trg_score_values_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values + FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quotes; +CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quotes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- ========================================================= +-- AUDIT TRIGGERS +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_quotes_event_audit ON designdb.quotes; +CREATE TRIGGER trg_quotes_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.quotes + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.quotes_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.compound_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +CREATE TRIGGER trg_pose_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.enumeration_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.scoring_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Removed due to replaced tables +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods +SELECT designdb.create_scores_per_pose_pivoted_mv(); From 45a6a23beef82179805db4888a226635744c35e0 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 10 Mar 2026 15:19:17 +0000 Subject: [PATCH 118/163] fix: deleted unnecessary pg_setup, handled by the new image --- pg_setup/docker-entrypoint-initdb.d/01-extensions.sql | 1 - 1 file changed, 1 deletion(-) delete mode 100644 pg_setup/docker-entrypoint-initdb.d/01-extensions.sql diff --git a/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql b/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql deleted file mode 100644 index e884453..0000000 --- a/pg_setup/docker-entrypoint-initdb.d/01-extensions.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS rdkit; From 43071ab122e4993530d148669aadbc4b7acaa4e7 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 10 Mar 2026 15:22:03 +0000 Subject: [PATCH 119/163] fix: clean up compose file --- docker-compose.yaml | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index e177e37..369f26b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,44 +20,13 @@ services: - # database: - # image: hippo-pg - # container_name: hippo_pg_db - # volumes: - # - postgres_data:/var/lib/postgresql/data - # - type: bind - # source: ./pg_setup/docker-entrypoint-initdb.d - # target: /docker-entrypoint-initdb.d - # env_file: - # - .env - # ports: - # - "5432:5432" - # healthcheck: - # test: pg_isready -U postgres -d hippo - # interval: 10s - # timeout: 2s - # retries: 5 - # start_period: 10s - database: - # build: - # context: . - # dockerfile: Dockerfile - # args: - # RDKIT_VERSION: ${RDKIT_VERSION:-Release_2025_09_5} - # BOOST_VERSION: ${BOOST_VERSION:-1.90.0} - # BOOST_VER_US: ${BOOST_VER_US:-1_90_0} - # PG_MAJOR: ${PG_MAJOR:-18} - # PG_BASE: ${PG_BASE:-postgres:18.3-bookworm} - # image: xchem_designdb:latest - # container_name: xchem_designdb - image: xchem-hippo-pg - container_name: hippo_pg_db + image: xchem_designdb:latest + container_name: xchem_designdb restart: unless-stopped ports: - "${POSTGRES_PORT:-5432}:5432" volumes: - # - ${POSTGRES_DATA_PATH}:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data env_file: - .env From 86d0ff33d277f4159c32e193bca82cddea8da269 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 11 Mar 2026 14:58:15 +0000 Subject: [PATCH 120/163] feat: new dependency management toolchain Added uv for dependencies and various linters for static analysis Updated Dockerfile to acommodate pinned dependency version NB! Commited with --no-verify, lots of errors, not going to fix before refactor. --- .dockerignore | 40 + .pre-commit-config.yaml | 50 +- Dockerfile | 27 +- Makefile | 44 + README.md | 2 +- docker-compose.yaml | 14 +- docs/requirements.txt | 1 - docs/source/add_data.rst | 1 - docs/source/animal.rst | 1 - docs/source/compounds.rst | 1 - docs/source/conf.py | 52 +- docs/source/definitions.rst | 8 +- docs/source/getting_started.rst | 9 +- docs/source/index.rst | 20 +- docs/source/poses.rst | 2 +- docs/source/quoting.rst | 1 - docs/source/recipes.rst | 1 - docs/source/sampling.rst | 1 - docs/source/syndirella.rst | 2 +- docs/source/windows.rst | 2 +- hippo/__init__.py | 10 +- hippo/__main__.py | 124 +- hippo/animal.py | 1135 +++--- hippo/apsw.py | 2 +- hippo/chem.py | 278 +- hippo/compound.py | 346 +- hippo/cset.py | 999 +++-- hippo/db.py | 1911 +++++---- hippo/feature.py | 24 +- hippo/fragalysis.py | 47 +- hippo/interaction.py | 42 +- hippo/iset.py | 162 +- hippo/metadata.py | 4 +- hippo/migration.py | 455 ++- hippo/pca.py | 7 +- hippo/plotting.py | 859 ++-- hippo/pose.py | 555 ++- hippo/postgres.py | 258 +- hippo/price.py | 65 +- hippo/prolif.py | 193 +- hippo/pset.py | 1000 +++-- hippo/pyvis.py | 55 +- hippo/quote.py | 44 +- hippo/reaction.py | 120 +- hippo/recipe.py | 1084 +++--- hippo/rgen.py | 326 +- hippo/rset.py | 187 +- hippo/scoring.py | 366 +- hippo/subsite.py | 40 +- hippo/syndirella.py | 13 +- hippo/tags.py | 52 +- hippo/target.py | 42 +- hippo/tools.py | 97 +- hippo/web.py | 521 ++- hippo/xca.py | 5 +- images/postgres/docker-entrypoint.sh | 0 images/xchem-designdb/01_schema.sql | 2 +- images/xchem-designdb/01_schema_OLD.sql | 4 +- images/xchem-designdb/env.template | 2 +- images/xchem-designdb/init-db/01_schema.sql | 4 +- pyproject.toml | 115 +- tests/config.py | 10 +- tests/test_00_cleanup.py | 2 +- tests/test_01_fragalysis_download.py | 7 +- tests/test_02_setup_animal.py | 5 +- tests/test_03_add_hits.py | 8 +- tests/test_04_interactions.py | 4 +- tests/test_05_scaffolds.py | 7 +- tests/test_06_subsites.py | 6 +- tests/test_compound.py | 66 +- tests/test_feature.py | 22 +- tests/test_interaction.py | 44 +- tests/test_pose.py | 84 +- tests/test_subsite.py | 22 +- tests/test_tags.py | 10 +- tests/test_target.py | 16 +- uv.lock | 3871 +++++++++++++++++++ 77 files changed, 9847 insertions(+), 6171 deletions(-) create mode 100644 .dockerignore create mode 100644 Makefile mode change 100644 => 100755 images/postgres/docker-entrypoint.sh create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb632dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# ========================================================= +# .dockerignore +# ========================================================= + +# Environment and secrets +.env +.env.* +*.env + +# Logs +*.log +*.log.* + +# Git +.git/ +.gitignore + +# IDE and OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +._* +*~ +\#*# +\#* + + +# Temporary files +*.tmp +*.temp +*.bak +*.backup + +# Virtual environment +.venv + +#Python cache files +__pycache__ + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22a2a86..897e11d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,44 @@ --- + repos: - # Using this mirror lets us use mypyc-compiled black, which is about 2x faster - - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.1.0 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - - id: black - # It is recommended to specify the latest version of Python - # supported by your project here, or alternatively use - # pre-commit's default_language_version, see - # https://pre-commit.com/#top_level-default_language_version - language_version: python3 + - id: check-yaml + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: detect-private-key + - id: end-of-file-fixer + - id: trailing-whitespace + args: + - --markdown-linebreak-ext=md + + + # project tools using uv local environment + - repo: local + hooks: + + - id: ruff + name: ruff lint + entry: uv run ruff check --fix + language: system + types: [python] + + - id: ruff-format + name: ruff format + entry: uv run ruff format + language: system + types: [python] + + - id: mypy + name: mypy + entry: uv run mypy + language: system + pass_filenames: false + + - id: ty-check + name: ty check + entry: uv run ty check + language: system + pass_filenames: false diff --git a/Dockerfile b/Dockerfile index 81e8ff1..46e834b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,46 @@ FROM quay.io/jupyter/minimal-notebook:2025-04-14 LABEL authors="Max Winokan" + # Ported from Max's Dockerfile to support local development +# combines conda and pip environments. There's a conflict somewhere +# which I haven't resolved (TODO) between something in conda and pip +# envs, so cannot use a single env + +# this is what I'm stuck on. Project depends on numpy v1.23 (why?) and +# I can't go over 3.12 +ARG PYTHON_VERSION=3.12 + + WORKDIR "/home/code/HIPPO" COPY . ./ +# need this from conda. See comment in pyproject.toml, possibly can get rid of this RUN mamba install --yes \ - chemicalite=2024.05.1 pdbfixer && \ + chemicalite=2024.05.1 && \ mamba clean --all -f -y && \ fix-permissions "${CONDA_DIR}" && \ fix-permissions "/home/${NB_USER}" -RUN python -m pip install syndirella typer neo4j gemmi \ - mrich mpytools psycopg[binary] molparse rdkit +# install package dependencies into different virtual env +COPY uv.lock pyproject.toml ./ +RUN python -m pip install --upgrade pip && python -m pip install uv + +# install all dependencies into active environment without updating lockfile +RUN python -m uv sync --frozen --quiet --active -# Too old rdkit in base container -RUN pip install rdkit --upgrade +# now add venv python to path so conda python can find it +ENV PATH="/home/code/HIPPO/.venv/bin:$PATH" +ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages:$PYTHONPATH" # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" + # notebooks USER 0 RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f0038ab --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +PYTHON = uv run python + +SRC = hippo +TESTS = tests + +.PHONY: help install lint format typecheck check test ci build clean + +help: + @echo "Available commands:" + @echo " make install Install dependencies" + @echo " make lint Run ruff lint" + @echo " make format Run ruff format" + @echo " make typecheck Run mypy" + @echo " make check Run all checks" + @echo " make test Run tests" + @echo " make ci Simulate CI run" + @echo " make build Build package" + @echo " make clean Remove build artifacts" + +install: + uv sync --frozen + +lint: + uv run pre-commit run ruff --all-files + +format: + uv run pre-commit run ruff-format --all-files + +typecheck: + uv run pre-commit run mypy --all-files + +check: + uv run pre-commit run --all-files + +test: + uv run pytest + +ci: check test + +build: + uv run python -m build + +clean: + rm -rf build dist *.egg-info .pytest_cache .mypy_cache .ruff_cache diff --git a/README.md b/README.md index efda6af..6c94740 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ pytest N.B. the numbered tests, e.g. `test_00_cleanup.py` need to run in sequential order to set up the database. Other tests can run in arbitrary order thereafter. The tests will fail if https://fragalysis.diamond.ac.uk can not provide the protein target's data, as specified in tests/config.py. - +
Postgres specific instructions diff --git a/docker-compose.yaml b/docker-compose.yaml index 369f26b..4d2a22a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,11 +2,11 @@ # Dev environment for local HIPPO development. # Needs two containers, backend with HIPPO and dependencies installed, and database -# database container is built from images/postgres/Dockerfile: -# sudo docker build --no-cache -t hippo-pg:latest . +# database container is built from images/xchem-designdb/Dockerfile: +# sudo docker build --no-cache -t xchem_designdb:latest . # Backend from ./Dockerfile -# sudo docker build --no-cache . -t hippo-backend:latest +# sudo docker build --no-cache . -t hippo_backend:latest # Then bring the containers up with: # sudo docker-compose up @@ -27,9 +27,9 @@ services: ports: - "${POSTGRES_PORT:-5432}:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data env_file: - - .env + - .env environment: POSTGRES_DB: ${DB_NAME:-designdb} POSTGRES_USER: ${DB_USER} @@ -49,7 +49,7 @@ services: backend: - image: hippo-backend + image: hippo_backend:latest container_name: hippo_backend build: context: . @@ -61,7 +61,7 @@ services: ports: - "8888:8888" networks: - - app_network + - app_network depends_on: database: condition: service_healthy diff --git a/docs/requirements.txt b/docs/requirements.txt index dcb6a32..a535849 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -9,4 +9,3 @@ pandas tqdm pycule sphinxcontrib-prettyspecialmethods - diff --git a/docs/source/add_data.rst b/docs/source/add_data.rst index 9b7f1d5..6f5d980 100644 --- a/docs/source/add_data.rst +++ b/docs/source/add_data.rst @@ -140,4 +140,3 @@ To add a reaction: The above will register an amidation reaction combining compounds **a** and **b** into **c**. See also the API reference :meth:`.HIPPO.register_reaction`. - diff --git a/docs/source/animal.rst b/docs/source/animal.rst index 276c805..f0d5095 100644 --- a/docs/source/animal.rst +++ b/docs/source/animal.rst @@ -4,4 +4,3 @@ HIPPO "animal" object .. autoclass:: hippo.animal.HIPPO :members: - \ No newline at end of file diff --git a/docs/source/compounds.rst b/docs/source/compounds.rst index 12bebb1..e8912bc 100644 --- a/docs/source/compounds.rst +++ b/docs/source/compounds.rst @@ -31,4 +31,3 @@ IngredientSet: Set of Ingredients .. autoclass:: hippo.cset.IngredientSet :members: - diff --git a/docs/source/conf.py b/docs/source/conf.py index a5a44d0..a07e8e2 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -6,56 +6,56 @@ import os import sys -sys.path.insert(0, os.path.abspath("../../")) +sys.path.insert(0, os.path.abspath('../../')) # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = "HIPPO" -copyright = "2024, Max Winokan" -author = "Max Winokan" +project = 'HIPPO' +copyright = '2024, Max Winokan' +author = 'Max Winokan' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.doctest", - "sphinx.ext.extlinks", - "sphinx.ext.mathjax", - "sphinx.ext.viewcode", - "sphinx.ext.napoleon", - "sphinx.ext.intersphinx", - "sphinxcontrib.prettyspecialmethods", + 'sphinx.ext.autodoc', + 'sphinx.ext.doctest', + 'sphinx.ext.extlinks', + 'sphinx.ext.mathjax', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', + 'sphinx.ext.intersphinx', + 'sphinxcontrib.prettyspecialmethods', ] -templates_path = ["_templates"] +templates_path = ['_templates'] exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] +html_theme = 'sphinx_rtd_theme' +html_static_path = ['_static'] -html_logo = "../../logos/hippo_logo_tightcrop.png" -html_favicon = "../../logos/hippo_assets-02.gif" +html_logo = '../../logos/hippo_logo_tightcrop.png' +html_favicon = '../../logos/hippo_assets-02.gif' html_theme_options = { - "navigation_depth": -1, - "logo_only": True, - "prev_next_buttons_location": "both", + 'navigation_depth': -1, + 'logo_only': True, + 'prev_next_buttons_location': 'both', } -html_css_files = ["css/custom.css"] +html_css_files = ['css/custom.css'] -source_suffix = ".rst" -master_doc = "index" +source_suffix = '.rst' +master_doc = 'index' # project = 'ASE' # copyright = f'{datetime.date.today().year}, ASE-developers' # templates_path = ['templates'] -exclude_patterns = ["build"] +exclude_patterns = ['build'] # default_role = 'math' # pygments_style = 'sphinx' -autoclass_content = "both" -modindex_common_prefix = ["hippo."] +autoclass_content = 'both' +modindex_common_prefix = ['hippo.'] diff --git a/docs/source/definitions.rst b/docs/source/definitions.rst index 3a2d4dc..1e65b4a 100644 --- a/docs/source/definitions.rst +++ b/docs/source/definitions.rst @@ -18,7 +18,7 @@ Compound A :class:`.Compound` represents a ligand/small molecule with stereochemistry removed and no atomic coordinates. I.e. it represents the flattened chemical structure. It's default name is always an InChiKey. :class:`.Compound` objects can have an :attr:`.Compound.alias` which is a custom name which will supercede the InChiKey when representing the compound. :class:`.Compound` objects also have a shorthand prefixed with ``C``, for example: ``C1`` which refers to the compound with database id 7273. -:: +:: c1 = animal.register_compound(smiles="OCc1ccc2c(c1)CCO2") print(c1) @@ -36,7 +36,7 @@ Scaffolds / Elaborations Scaffold / superstructure relationships can also be encoded for :class:`.Compound` objects. Namely, the :attr:`.Compound.scaffolds` property can be used to access other :class:`.Compound` objects that have been labelled as scaffolds/substructures, and :attr:`.Compound.elabs` is used to access the inverse relationship. -:: +:: c2 = animal.register_compound(smiles="OCc1ccc2c(c1F)CCO2") c2.add_scaffold(scaffold=c1) @@ -57,7 +57,7 @@ Pose A :class:`.Pose` is a particular conformer of a :class:`.Compound` within a protein environment. A pose will have its own (stereochemical) smiles string, and must have a path to a coordinate file. This file can either be a ``.mol`` molecule file or a ``.pdb`` file of the protein-ligand complex. -:: +:: p1 = c1.poses[0] print(p1) @@ -134,7 +134,7 @@ The :class:`.Interaction` class can be used to store protein-ligand interactions .. seealso:: :doc:`interactions` API reference page - + Units ===== diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index 46f2c93..11c6f2a 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -63,12 +63,12 @@ You can select a subset of compounds using slices, tuples, or lists: Additionally you can get compounds by their tag: :: - + hits = animal.compounds(tag='hits') .. See also the :doc:`tools for structure-based searching` -Equivalent methods exist for animal.poses (returns a :class:`PoseTable`), animal.reactions (returns a :class:`.ReactionTable`), animal.interactions (returns a :class:`.InteractionTable`), and animal.tags returns a :class:`TagTable`). See also the :doc:`api_reference` pages. +Equivalent methods exist for animal.poses (returns a :class:`PoseTable`), animal.reactions (returns a :class:`.ReactionTable`), animal.interactions (returns a :class:`.InteractionTable`), and animal.tags returns a :class:`TagTable`). See also the :doc:`api_reference` pages. Inspecting a compound and its poses ----------------------------------- @@ -85,7 +85,7 @@ Once you have a compound you can access database properties using its properties c.mol # rdkit.Chem.Mol c.tags # assigned tags c.metadata # metadata dictionary - + c.draw() # draw the molecule (and its scaffold) You can access a compounds poses, which have similar functionality @@ -102,7 +102,7 @@ You can access a compounds poses, which have similar functionality p.mol # rdkit.Chem.Mol p.tags # assigned tags p.metadata # metadata dictionary - + c.draw() # draw the molecule pose (3d) See also the API reference for :doc:`compounds ` and :doc:`poses `. @@ -173,4 +173,3 @@ This will create an HTML file you can open in your browser: This method of writing to an HTML file works for all the above figures. See also :func:`.plotting.plot_pose_interactions`. - diff --git a/docs/source/index.rst b/docs/source/index.rst index b750675..5591b41 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,14 +12,14 @@ HIPPO Documentation Installation ============ -On Mac OS and Linux it is recommended to install from PyPI using Conda/Miniconda. +On Mac OS and Linux it is recommended to install from PyPI using Conda/Miniconda. Chemicalite is not supported on Windows, but there is a workaround described in the :doc:`windows`. The `hippo` python module can be obtained from PyPI: :: - + $ pip install --upgrade hippo-db You will also need `chemicalite` which is an extension to SQLite for cheminformatics: @@ -28,7 +28,7 @@ You will also need `chemicalite` which is an extension to SQLite for cheminforma $ conda install -c conda-forge chemicalite=2024.05.1 -N.B. Compatibility between rdkit and chemicalite versions is quite strict, and database files created with a certain version pair may not be interoperable with others. +N.B. Compatibility between rdkit and chemicalite versions is quite strict, and database files created with a certain version pair may not be interoperable with others. See also :ref:`installation-snippets`. @@ -52,15 +52,15 @@ HIPPO uses an sqlite database with several inter-connected tables and Python-cla Adding data Interfacing with Syndirella and merging algorithms - + Example Notebooks Windows installation - + Command-Line Interface API Reference - + Core concepts ============= @@ -70,7 +70,7 @@ HIPPO uses an sqlite database with several inter-connected tables (see :doc:`db` 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.scaffold` 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`). +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.scaffold` 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`). Pose ---- @@ -92,7 +92,7 @@ Installation Snippets If the above fails in your existing software environments, try this: :: - + mamba create --name py312 python=3.12 mamba activate py312 pip install hippo-db syndirella typer neo4j black gemmi @@ -105,7 +105,7 @@ Additionally, this Dockerfile can be used to create a container with a Jupyter N FROM quay.io/jupyter/minimal-notebook:2025-04-14 LABEL authors="Max Winokan" - + # Upgrade pip and install JupyterLab RUN pip install --upgrade pip && pip install hippo-db syndirella typer neo4j black gemmi @@ -144,4 +144,4 @@ Additionally, this Dockerfile can be used to create a container with a Jupyter N .. Structure-based searching .. Inserting synthetic pathways -.. Quoting with Pycule \ No newline at end of file +.. Quoting with Pycule diff --git a/docs/source/poses.rst b/docs/source/poses.rst index 85d9e46..c523d0a 100644 --- a/docs/source/poses.rst +++ b/docs/source/poses.rst @@ -8,4 +8,4 @@ Poses :members: .. autoclass:: hippo.pset.PoseSet - :members: \ No newline at end of file + :members: diff --git a/docs/source/quoting.rst b/docs/source/quoting.rst index 4a2e42e..22eaeaf 100644 --- a/docs/source/quoting.rst +++ b/docs/source/quoting.rst @@ -7,4 +7,3 @@ Quoting .. autoclass:: hippo.price.Price :members: - \ No newline at end of file diff --git a/docs/source/recipes.rst b/docs/source/recipes.rst index 2734a6d..aaaaa06 100644 --- a/docs/source/recipes.rst +++ b/docs/source/recipes.rst @@ -15,4 +15,3 @@ Recipes & Routes .. autoclass:: hippo.rgen.RandomSelectionGenerator :members: - diff --git a/docs/source/sampling.rst b/docs/source/sampling.rst index 5d19180..5ac113a 100644 --- a/docs/source/sampling.rst +++ b/docs/source/sampling.rst @@ -23,4 +23,3 @@ Scoring recipes .. autoclass:: hippo.scoring.CustomAttribute :members: - diff --git a/docs/source/syndirella.rst b/docs/source/syndirella.rst index 703c8bc..666ca87 100644 --- a/docs/source/syndirella.rst +++ b/docs/source/syndirella.rst @@ -97,7 +97,7 @@ Syndirella has been developed to produce a HIPPO-friendly output in the syntax ` mrich.error(file) mrich.error(e) continue - + animal.db.close() The above script can be submitted to the DLS / IRIS cluster as follows: diff --git a/docs/source/windows.rst b/docs/source/windows.rst index e942043..308908a 100644 --- a/docs/source/windows.rst +++ b/docs/source/windows.rst @@ -8,7 +8,7 @@ Since chemicalite is not available on the Windows platform, a workaround is need Setting up Docker ================= -Docker is the industry standard way to create and share application *containers*, which are standalone Linux emulations with customisable software environments. +Docker is the industry standard way to create and share application *containers*, which are standalone Linux emulations with customisable software environments. 1. Create a Docker_ account 2. Install `Docker Desktop`_ diff --git a/hippo/__init__.py b/hippo/__init__.py index 5b8b43d..a0e7ac8 100644 --- a/hippo/__init__.py +++ b/hippo/__init__.py @@ -10,7 +10,7 @@ """ -__version__ = "0.3.38" +__version__ = '0.3.38' from .animal import HIPPO from .compound import Compound, Ingredient @@ -20,13 +20,13 @@ from .metadata import MetaData from .pose import Pose from .price import Price -from .pset import PoseTable, PoseSet +from .pset import PoseSet, PoseTable from .quote import Quote from .reaction import Reaction from .recipe import Recipe, Route, RouteSet from .rgen import RandomRecipeGenerator -from .rset import ReactionTable, ReactionSet -from .tags import TagTable, TagSet +from .rset import ReactionSet, ReactionTable +from .scoring import CustomAttribute, Scorer +from .tags import TagSet, TagTable from .target import Target -from .scoring import Scorer, CustomAttribute from .web import ProjectPage diff --git a/hippo/__main__.py b/hippo/__main__.py index 6df65e2..476d616 100644 --- a/hippo/__main__.py +++ b/hippo/__main__.py @@ -2,7 +2,6 @@ import mrich from typer import Typer -from pathlib import Path app = Typer() @@ -11,12 +10,12 @@ def setup_animal( database: str, backup: bool = True, update_legacy: bool = False, -) -> "HIPPO": +) -> 'HIPPO': """Setup the :class:`.HIPPO` object and optionally perform a database backup""" from .animal import HIPPO - animal = HIPPO("CLI", database, update_legacy=update_legacy) + animal = HIPPO('CLI', database, update_legacy=update_legacy) if backup: animal.db.backup() return animal @@ -25,25 +24,25 @@ def setup_animal( @app.command() def backup(database: str): """Backup database file""" - mrich.h1("hippo.backup") + mrich.h1('hippo.backup') - mrich.h3("Params") - mrich.var("database", database) + mrich.h3('Params') + mrich.var('database', database) from hippo.db import backup backup(database) - mrich.success("Successfully backed up") + mrich.success('Successfully backed up') @app.command() def update_legacy(database: str, backup: bool = True): """Update legacy database format""" - mrich.h1("hippo.update_legacy") + mrich.h1('hippo.update_legacy') - mrich.h3("Params") - mrich.var("database", database) + mrich.h3('Params') + mrich.var('database', database) if backup: from hippo.db import backup @@ -51,7 +50,7 @@ def update_legacy(database: str, backup: bool = True): backup(database) animal = setup_animal(database, backup=False, update_legacy=True) - mrich.success("Successfully updated database format") + mrich.success('Successfully updated database format') @app.command() @@ -61,27 +60,27 @@ def calculate_scaffolds( ): """Calculate scaffold/superstructure relationships for all compounds""" - mrich.h1("hippo.calculate_scaffolds") + mrich.h1('hippo.calculate_scaffolds') - mrich.h3("Params") - mrich.var("database", database) - mrich.var("backup", backup) + mrich.h3('Params') + mrich.var('database', database) + mrich.var('backup', backup) - mrich.h3("Animal") + mrich.h3('Animal') animal = setup_animal(database=database, backup=backup) - mrich.h3("State Before") - mrich.var("scaffolds", animal.scaffolds) - mrich.var("elabs", animal.elabs) + mrich.h3('State Before') + mrich.var('scaffolds', animal.scaffolds) + mrich.var('elabs', animal.elabs) - mrich.h3("Calculation") + mrich.h3('Calculation') animal.db.calculate_all_scaffolds() - mrich.h3("State After") - mrich.var("scaffolds", animal.scaffolds) - mrich.var("elabs", animal.elabs) + mrich.h3('State After') + mrich.var('scaffolds', animal.scaffolds) + mrich.var('elabs', animal.elabs) - mrich.success("Completed") + mrich.success('Completed') @app.command() @@ -94,42 +93,40 @@ def calculate_interactions( ) -> None: """Calculate interactions for all poses""" - mrich.h1("hippo.calculate_interactions") + mrich.h1('hippo.calculate_interactions') - mrich.h3("Params") - mrich.var("database", database) - mrich.var("backup", backup) + mrich.h3('Params') + mrich.var('database', database) + mrich.var('backup', backup) - mrich.h3("Animal") + mrich.h3('Animal') animal = setup_animal(database=database, backup=backup) - mrich.h3("State Before") - mrich.var("#total poses", animal.num_poses) - mrich.var("#fingerprinted", animal.poses.num_fingerprinted) + mrich.h3('State Before') + mrich.var('#total poses', animal.num_poses) + mrich.var('#fingerprinted', animal.poses.num_fingerprinted) - mrich.h3("Calculation") + mrich.h3('Calculation') n_tasks = 1 if not force: pose_ids = animal.db.select_id_where( - table="pose", key="pose_fingerprint != 1", multiple=True + table='pose', key='pose_fingerprint != 1', multiple=True ) else: - pose_ids = animal.db.execte("SELECT pose_id FROM pose").fetchall() + pose_ids = animal.db.execte('SELECT pose_id FROM pose').fetchall() - pose_ids = [i for i, in pose_ids] + pose_ids = [i for (i,) in pose_ids] - mrich.var("#poses", len(pose_ids)) + mrich.var('#poses', len(pose_ids)) if n_tasks == 1: - poses = animal.poses[pose_ids] n = len(poses) for i, pose in mrich.track(enumerate(poses), total=n): - - mrich.set_progress_prefix(f"{i}/{n}") + mrich.set_progress_prefix(f'{i}/{n}') try: if prolif: @@ -139,21 +136,20 @@ def calculate_interactions( except Exception as e: mrich.error(e) - mrich.error("Could not fingerprint pose") + mrich.error('Could not fingerprint pose') continue else: - from joblib import Parallel, delayed poses = animal.db.get_poses(ids=pose_ids) if prolif: raise NotImplementedError( - "ProLIF fingerprint calculation does not support in-memory resolution" + 'ProLIF fingerprint calculation does not support in-memory resolution' ) - def calculate_interactions(pose: "Pose") -> None: + def calculate_interactions(pose: 'Pose') -> None: """Joblib wrapper for the calculation""" pose.calculate_interactions(force=force) @@ -163,10 +159,10 @@ def calculate_interactions(pose: "Pose") -> None: Parallel(verbose=100, n_jobs=n_tasks)(task for task in tasks) - mrich.h3("State After") - mrich.var("#fingerprinted", animal.poses.num_fingerprinted) + mrich.h3('State After') + mrich.var('#fingerprinted', animal.poses.num_fingerprinted) - mrich.success("Completed") + mrich.success('Completed') @app.command() @@ -175,13 +171,13 @@ def verify() -> None: import os - file_path = "_test.sqlite" + file_path = '_test.sqlite' try: animal = setup_animal(file_path, backup=False) - c = animal.register_compound(smiles="COc1ccc2sc(N)nc2c1") + c = animal.register_compound(smiles='COc1ccc2sc(N)nc2c1') c.mol - mrich.success("HIPPO/rdkit/chemicalite installations are compatible") + mrich.success('HIPPO/rdkit/chemicalite installations are compatible') except Exception as e: mrich.error(e) @@ -195,10 +191,10 @@ def tag_summary( ) -> None: """Print a table of statistics for all tags in the database""" - mrich.h1("hippo.tag_summary") + mrich.h1('hippo.tag_summary') - mrich.h3("Params") - mrich.var("database", database) + mrich.h3('Params') + mrich.var('database', database) animal = setup_animal(database=database, backup=False) animal.tags.summary() @@ -218,17 +214,17 @@ def add_hits( ): """Load hits from Fragalysis / XCA data package""" - mrich.h1("hippo.tag_summary") + mrich.h1('hippo.tag_summary') - mrich.h3("Params") - mrich.var("database", database) - mrich.var("target_name", target_name) - mrich.var("metadata_csv", metadata_csv) - mrich.var("aligned_directory", aligned_directory) - mrich.var("tags", tags) - mrich.var("skip", skip) - mrich.var("debug", debug) - mrich.var("load_pose_mols", load_pose_mols) + mrich.h3('Params') + mrich.var('database', database) + mrich.var('target_name', target_name) + mrich.var('metadata_csv', metadata_csv) + mrich.var('aligned_directory', aligned_directory) + mrich.var('tags', tags) + mrich.var('skip', skip) + mrich.var('debug', debug) + mrich.var('load_pose_mols', load_pose_mols) animal = setup_animal(database=database, backup=False) @@ -248,5 +244,5 @@ def main() -> None: app() -if __name__ == "__main__": +if __name__ == '__main__': main() diff --git a/hippo/animal.py b/hippo/animal.py index a20fe67..1639b52 100644 --- a/hippo/animal.py +++ b/hippo/animal.py @@ -1,28 +1,26 @@ """Main animal class for HIPPO""" +from pathlib import Path + import mcol import mrich -from mrich import print - -import numpy as np import pandas as pd -from pathlib import Path -from rdkit.Chem import Mol +from mrich import print +from .compound import Compound +from .cset import CompoundSet, CompoundTable, IngredientSet +from .iset import InteractionTable from .pose import Pose +from .pset import PoseSet, PoseTable +from .reaction import Reaction +from .rset import ReactionTable from .tags import TagTable from .target import Target -from .compound import Compound -from .reaction import Reaction -from .iset import InteractionTable -from .pset import PoseTable, PoseSet -from .rset import ReactionTable, ReactionSet -from .cset import CompoundTable, IngredientSet, CompoundSet from .tools import ( + SanitisationError, flat_inchikey, inchikey_from_smiles, sanitise_smiles, - SanitisationError, ) @@ -54,14 +52,13 @@ def __init__( ) -> None: """HIPPO initialisation""" - mrich.bold("Creating HIPPO animal") + mrich.bold('Creating HIPPO animal') self._name = name - mrich.var("name", name, color="arg") + mrich.var('name', name, color='arg') if isinstance(db, dict): - ### POSTGRES from .postgres import PostgresDatabase @@ -69,14 +66,13 @@ def __init__( self._db = PostgresDatabase(animal=self, **db) else: - ### INITIALISE SQLITE DATABASE from .db import Database db_path = Path(db) - mrich.var("db_path", db_path, color="file") + mrich.var('db_path', db_path, color='file') if copy_from: self._db = Database.copy_from( @@ -101,7 +97,7 @@ def __init__( self._scaffolds = None self._elabs = None - mrich.success("Initialised animal", f"[var_name]{self}") + mrich.success('Initialised animal', f'[var_name]{self}') ### PROPERTIES @@ -119,7 +115,7 @@ def db_path(self) -> str: return self.db.path @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns the Database object""" return self._db @@ -174,44 +170,44 @@ def num_tags(self) -> int: @property def targets(self) -> list[Target]: """Access Targets in the Database""" - target_ids = self.db.select(table="target", query="target_id", multiple=True) - return [self.db.get_target(id=q) for q, in target_ids] + target_ids = self.db.select(table='target', query='target_id', multiple=True) + return [self.db.get_target(id=q) for (q,) in target_ids] @property def reactants(self) -> CompoundSet: """Returns all compounds that are reactants for at least one :class:`.Reaction` (and not products of others)""" if ( self._reactants is None - or self._reactants["total_changes"] != self.db.total_changes + or self._reactants['total_changes'] != self.db.total_changes ): self._reactants = dict( set=self.compounds.reactants, total_changes=self.db.total_changes ) - return self._reactants["set"] + return self._reactants['set'] @property def products(self) -> CompoundSet: """Returns all compounds that are products of at least one :class:`.Reaction` (and not reactants of others)""" if ( self._products is None - or self._products["total_changes"] != self.db.total_changes + or self._products['total_changes'] != self.db.total_changes ): self._products = dict( set=self.compounds.products, total_changes=self.db.total_changes ) - return self._products["set"] + return self._products['set'] @property def intermediates(self) -> CompoundSet: """Returns all compounds that are products and reactants of :class:`.Reaction`""" if ( self._intermediates is None - or self._intermediates["total_changes"] != self.db.total_changes + or self._intermediates['total_changes'] != self.db.total_changes ): self._intermediates = dict( set=self.compounds.intermediates, total_changes=self.db.total_changes ) - return self._intermediates["set"] + return self._intermediates['set'] @property def num_reactants(self) -> int: @@ -231,23 +227,23 @@ def num_products(self) -> int: @property def elabs(self) -> CompoundSet: """Returns compounds that are an based on another""" - if self._elabs is None or self._elabs["total_changes"] != self.db.total_changes: + if self._elabs is None or self._elabs['total_changes'] != self.db.total_changes: self._elabs = dict( set=self.compounds.elabs, total_changes=self.db.total_changes ) - return self._elabs["set"] + return self._elabs['set'] @property def scaffolds(self) -> CompoundSet: """Returns compounds that are the basis for one or more elaborations""" if ( self._scaffolds is None - or self._scaffolds["total_changes"] != self.db.total_changes + or self._scaffolds['total_changes'] != self.db.total_changes ): self._scaffolds = dict( set=self.compounds.scaffolds, total_changes=self.db.total_changes ) - return self._scaffolds["set"] + return self._scaffolds['set'] @property def num_elabs(self) -> int: @@ -287,21 +283,23 @@ def add_hits( import re from enum import Enum + import molparse as mp from rdkit.Chem import PandasTools + from .tools import remove_other_ligands ### Process arguments - assert aligned_directory, "aligned_directory must be provided" + assert aligned_directory, 'aligned_directory must be provided' skip = skip or [] - tags = tags or ["hits"] + tags = tags or ['hits'] if not isinstance(aligned_directory, Path): aligned_directory = Path(aligned_directory) - mrich.var("aligned_directory", aligned_directory) + mrich.var('aligned_directory', aligned_directory) ### Register Target @@ -320,10 +318,10 @@ def __str__(self) -> str: """name""" return self.name - subdirs = list(aligned_directory.glob("*")) + subdirs = list(aligned_directory.glob('*')) - SUBDIR_PATTERN_FRAGALYSIS = re.compile(r"^.*\d{4}[a-z]$") - SUBDIR_PATTERN_XCA = re.compile(r"^.*-.\d{4}$") + SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') + SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') fragalysis_subdirs_present = any( SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs @@ -331,20 +329,19 @@ def __str__(self) -> str: xca_subdirs_present = any( SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs ) - assert ( - fragalysis_subdirs_present ^ xca_subdirs_present - ), "Unexpected mixed data format" + assert fragalysis_subdirs_present ^ xca_subdirs_present, ( + 'Unexpected mixed data format' + ) if fragalysis_subdirs_present: data_format = DataFormat.Fragalysis_v2 else: - - if any(list(subdir.glob("*_artefacts.pdb")) for subdir in subdirs): + if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): data_format = DataFormat.XChemAlign_v3 else: data_format = DataFormat.XChemAlign_v2 - mrich.var("data_format", data_format) + mrich.var('data_format', data_format) ### Counters @@ -355,8 +352,7 @@ def __str__(self) -> str: ### Read metadata if data_format is DataFormat.Fragalysis_v2: - - assert metadata_csv, "metadata.csv required" + assert metadata_csv, 'metadata.csv required' meta_df = pd.read_csv(metadata_csv) curated_tag_cols = [ @@ -364,36 +360,34 @@ def __str__(self) -> str: for c in meta_df.columns if c not in [ - "Code", - "Long code", - "Compound code", - "Smiles", - "Downloaded", - "Main status", - "GOOD count", - "MEDIOCRE count", - "BAD count", - "RefinementResolution", + 'Code', + 'Long code', + 'Compound code', + 'Smiles', + 'Downloaded', + 'Main status', + 'GOOD count', + 'MEDIOCRE count', + 'BAD count', + 'RefinementResolution', ] + GENERATED_TAG_COLS ] - mrich.var("curated_tag_cols", curated_tag_cols) + mrich.var('curated_tag_cols', curated_tag_cols) ### Parse subdirectories match data_format: case DataFormat.Fragalysis_v2: - from .fragalysis import parse_observation_longcode - fragalysis_pattern = re.compile(r"^.*\d{4}[a-z].sdf$") - pdbid_pattern = re.compile(r"^[A-Za-z0-9]{4}-[a-z].sdf$") + fragalysis_pattern = re.compile(r'^.*\d{4}[a-z].sdf$') + pdbid_pattern = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') observations = {} - for path in list(sorted(aligned_directory.glob(f"*"))): - + for path in list(sorted(aligned_directory.glob('*'))): name = path.name if name in skip: @@ -408,12 +402,11 @@ def __str__(self) -> str: sdfs = [] - for sdf_path in path.glob("*.sdf"): - + for sdf_path in path.glob('*.sdf'): sdf_name = sdf_path.name if ( - "_ligand" in sdf_name + '_ligand' in sdf_name ): # Quick fix, _ligand.sdf are exactly the same as .sdf in aligned_directory. continue @@ -431,29 +424,29 @@ def __str__(self) -> str: sdfs.append(sdf_path) if not sdfs: - mrich.error(name, "has no compatible SDFs", path) + mrich.error(name, 'has no compatible SDFs', path) continue elif len(sdfs) > 1: - mrich.warning(name, "has multiple compatible SDFs", sdfs) + mrich.warning(name, 'has multiple compatible SDFs', sdfs) - d["sdf"] = sdfs[0] + d['sdf'] = sdfs[0] ### PDBs pdbs = [ p - for p in path.glob("*.pdb") - if "_ligand" not in p.name - and "_apo" not in p.name - and "_hippo" not in p.name + for p in path.glob('*.pdb') + if '_ligand' not in p.name + and '_apo' not in p.name + and '_hippo' not in p.name ] if not len(pdbs) == 1: - mrich.error(name, "has invalid PDBs", pdbs) + mrich.error(name, 'has invalid PDBs', pdbs) continue - d["pdb"] = pdbs[0] + d['pdb'] = pdbs[0] observations[name] = d @@ -461,7 +454,6 @@ def __str__(self) -> str: print(d) case _: - from .xca import parse_observation_longcode observations = {} @@ -469,17 +461,16 @@ def __str__(self) -> str: match data_format: case DataFormat.XChemAlign_v2: sdf_pattern = re.compile( - r"^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$" + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' ) case DataFormat.XChemAlign_v3: sdf_pattern = re.compile( - r"^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$" + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' ) for path in list( - sorted(aligned_directory.glob(f"*[0-9][0-9][0-9][0-9]")) + sorted(aligned_directory.glob('*[0-9][0-9][0-9][0-9]')) ): - name = path.name if name in skip: @@ -489,20 +480,18 @@ def __str__(self) -> str: sdfs = [] - for sdf_path in sorted(path.glob("*.sdf")): - + for sdf_path in sorted(path.glob('*.sdf')): sdf_name = sdf_path.name if sdf_pattern.match(sdf_name): sdfs.append(sdf_path) if not sdfs: - mrich.error(name, "has no compatible SDFs", path) + mrich.error(name, 'has no compatible SDFs', path) continue for i, sdf in enumerate(sdfs): - - subname = name + chr(ord("a") + i) + subname = name + chr(ord('a') + i) d = dict( name=subname, @@ -510,37 +499,36 @@ def __str__(self) -> str: sdf=sdf, ) - pdb = path / sdf.name.replace("_ligand.sdf", ".pdb") + pdb = path / sdf.name.replace('_ligand.sdf', '.pdb') if not pdb.exists(): - mrich.error(name, "is missing PDB", pdb) + mrich.error(name, 'is missing PDB', pdb) continue - d["pdb"] = pdb + d['pdb'] = pdb observations[name] = d - mrich.var("#valid observations", len(observations)) + mrich.var('#valid observations', len(observations)) n_poses = self.num_poses for observation_dict in mrich.track( - observations.values(), prefix="Adding hits..." + observations.values(), prefix='Adding hits...' ): - - path = observation_dict["path"] - name = observation_dict["name"] - sdf = observation_dict["sdf"] - pdb = observation_dict["pdb"] + path = observation_dict['path'] + name = observation_dict['name'] + sdf = observation_dict['sdf'] + pdb = observation_dict['pdb'] if debug: - mrich.debug("Processing", path) + mrich.debug('Processing', path) count_directories_tried += 1 # load the SDF df = PandasTools.LoadSDF( - str(sdf), molColName="ROMol", idName="ID", strictParsing=True + str(sdf), molColName='ROMol', idName='ID', strictParsing=True ) # extract fields @@ -562,15 +550,15 @@ def __str__(self) -> str: sys = mp.parse(pdb, verbosity=0) # create the single ligand bound pdb - lig_residues = sys.residues["LIG"] + lig_residues = sys.residues['LIG'] if len(lig_residues) > 1 or any( r.contains_alternative_sites for r in lig_residues ): sys = remove_other_ligands( - sys, obs_dict["residue_number"], obs_dict["chain"] + sys, obs_dict['residue_number'], obs_dict['chain'] ) - sys.prune_alternative_sites("A", verbosity=0) - pose_path = str(pdb.resolve()).replace(".pdb", "_hippo.pdb") + sys.prune_alternative_sites('A', verbosity=0) + pose_path = str(pdb.resolve()).replace('.pdb', '_hippo.pdb') mp.write(pose_path, sys, shift_name=True, verbosity=debug) else: pose_path = str(pdb.resolve()) @@ -588,17 +576,16 @@ def __str__(self) -> str: ) if not compound_id: - inchikey = inchikey_from_smiles(smiles) compound = self.compounds[inchikey] if not compound: mrich.error( - "Compound exists in database but could not be found by inchikey" + 'Compound exists in database but could not be found by inchikey' ) - mrich.var("smiles", smiles) - mrich.var("inchikey", inchikey) - mrich.var("observation_shortname", name) + mrich.var('smiles', smiles) + mrich.var('inchikey', inchikey) + mrich.var('observation_shortname', name) raise Exception else: @@ -609,15 +596,14 @@ def __str__(self) -> str: match data_format: case DataFormat.Fragalysis_v2: - - meta_row = meta_df[meta_df["Code"] == name] + meta_row = meta_df[meta_df['Code'] == name] if not len(meta_row): assert longcode - meta_row = meta_df[meta_df["Long code"] == longcode] + meta_row = meta_df[meta_df['Long code'] == longcode] assert len(meta_row) - metadata = {"fragalysis_longcode": meta_row["Long code"].values[0]} + metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} for tag in GENERATED_TAG_COLS: if tag in meta_row.columns: @@ -630,7 +616,7 @@ def __str__(self) -> str: pose_tags.add(tag) case DataFormat.XChemAlign_v2: - metadata = {"xca_longcode": longcode} + metadata = {'xca_longcode': longcode} pose_tags = set(tags) pose = self.register_pose( @@ -640,19 +626,19 @@ def __str__(self) -> str: path=pose_path, tags=pose_tags, metadata=metadata, - duplicate_alias="skip", + duplicate_alias='skip', ) if load_pose_mols: try: pose.mol except Exception as e: - mrich.error("Could not load molecule", pose) + mrich.error('Could not load molecule', pose) mrich.error(e) - mrich.var("#directories parsed", count_directories_tried) - mrich.var("#compounds registered", count_compound_registered) - mrich.var("#poses registered", self.num_poses - n_poses) + mrich.var('#directories parsed', count_directories_tried) + mrich.var('#compounds registered', count_compound_registered) + mrich.var('#poses registered', self.num_poses - n_poses) def load_sdf( self, @@ -663,12 +649,12 @@ def load_sdf( inspirations: list[int] | PoseSet | None = None, compound_tags: None | list[str] = None, pose_tags: None | list[str] = None, - mol_col: str = "ROMol", - name_col: str | None = "ID", - inspiration_col: str | None = "ref_mols", - reference_col: str = "ref_pdb", - energy_score_col: str = "energy_score", - distance_score_col: str = "distance_score", + mol_col: str = 'ROMol', + name_col: str | None = 'ID', + inspiration_col: str | None = 'ref_mols', + reference_col: str = 'ref_pdb', + energy_score_col: str = 'energy_score', + distance_score_col: str = 'distance_score', inspiration_map: None | dict = None, convert_floats: bool = True, skip_equal_dict: dict | None = None, @@ -703,18 +689,17 @@ def load_sdf( skip_equal_dict = skip_equal_dict or {} skip_not_equal_dict = skip_not_equal_dict or {} - mrich.debug(f"{path=}") + mrich.debug(f'{path=}') compound_tags = compound_tags or [] pose_tags = pose_tags or [] - from rdkit.Chem import PandasTools, MolToMolFile, MolFromMolFile - from molparse.rdkit import mol_to_smiles, mol_to_pdb_block + from molparse.rdkit import mol_to_smiles from numpy import isnan from pandas import read_pickle - from tempfile import NamedTemporaryFile + from rdkit.Chem import PandasTools - if path.name.endswith(".sdf"): + if path.name.endswith('.sdf'): df = PandasTools.LoadSDF(str(path.resolve())) else: df = read_pickle(path) @@ -723,33 +708,33 @@ def load_sdf( target = self.register_target(target) - assert mol_col in df_columns, f"{mol_col=} not in {df_columns}" + assert mol_col in df_columns, f'{mol_col=} not in {df_columns}' if name_col: - assert name_col in df_columns, f"{name_col=} not in {df_columns}" + assert name_col in df_columns, f'{name_col=} not in {df_columns}' if inspiration_col and not inspirations: - assert ( - inspiration_col in df_columns - ), f"{inspiration_col=} not in {df_columns}" + assert inspiration_col in df_columns, ( + f'{inspiration_col=} not in {df_columns}' + ) if not reference and reference_col: - assert reference_col in df_columns, f"{reference_col=} not in {df_columns}" + assert reference_col in df_columns, f'{reference_col=} not in {df_columns}' - output_directory = str(path.name).removesuffix(".sdf") + output_directory = str(path.name).removesuffix('.sdf') output_directory = Path(output_directory) if not output_directory.exists: - mrich.writing(f"Creating output directory {output_directory}") - os.system(f"mkdir -p {output_directory}") + mrich.writing(f'Creating output directory {output_directory}') + os.system(f'mkdir -p {output_directory}') n_poses = self.num_poses n_comps = self.num_compounds ### FILTER DATAFRAME - mrich.var("SDF entries (pre-filter)", len(df)) + mrich.var('SDF entries (pre-filter)', len(df)) - df = df[df["ID"] != "ver_1.2"] + df = df[df['ID'] != 'ver_1.2'] for k, v in skip_equal_dict.items(): df = df[df[k] == v] @@ -757,15 +742,15 @@ def load_sdf( for k, v in skip_not_equal_dict.items(): df = df[df[k] != v] - mrich.var("SDF entries (post-filter)", len(df)) + mrich.var('SDF entries (post-filter)', len(df)) ### COMPOUND REGISTRATION - if "smiles" not in df.columns: - df["smiles"] = df[mol_col].apply(mol_to_smiles) - smiles = list(set(df["smiles"].values)) - mrich.debug("#smiles", len(smiles)) - mrich.debug("Registering compounds...") + if 'smiles' not in df.columns: + df['smiles'] = df[mol_col].apply(mol_to_smiles) + smiles = list(set(df['smiles'].values)) + mrich.debug('#smiles', len(smiles)) + mrich.debug('Registering compounds...') pairs = self.register_compounds(smiles=smiles, sanitisation_verbosity=False) # fix for 2033, replace smiles_lookup generation procedure @@ -776,16 +761,16 @@ def load_sdf( try: new_smiles = sanitise_smiles( s, - sanitisation_failed="error", - radical="warning", + sanitisation_failed='error', + radical='warning', verbosity=True, ) except SanitisationError as e: - mrich.error(f"Could not sanitise {s=}") + mrich.error(f'Could not sanitise {s=}') mrich.error(str(e)) continue except AssertionError: - mrich.error(f"Could not sanitise {s=}") + mrich.error(f'Could not sanitise {s=}') continue # smiles must now be sanitised and should not throw error @@ -796,14 +781,14 @@ def load_sdf( inchikeys=smiles_lookup.values() ) - df["inchikey"] = df["smiles"].apply(lambda x: smiles_lookup.get(x)) - df["compound_id"] = df["inchikey"].apply(lambda x: inchi_lookup.get(x)) - df["compound_id"] = df["compound_id"].fillna(0).astype(int) + df['inchikey'] = df['smiles'].apply(lambda x: smiles_lookup.get(x)) + df['compound_id'] = df['inchikey'].apply(lambda x: inchi_lookup.get(x)) + df['compound_id'] = df['compound_id'].fillna(0).astype(int) - if n := len(df[df["compound_id"].isna()]): - mrich.error(n, "invalid compound rows") + if n := len(df[df['compound_id'].isna()]): + mrich.error(n, 'invalid compound rows') - cset = self.compounds[set(i for i in df["compound_id"].values if i)] + cset = self.compounds[set(i for i in df['compound_id'].values if i)] for tag in compound_tags: cset.add_tag(tag) @@ -815,23 +800,22 @@ def load_sdf( # dicts: (alias, compound, target, path, metadata, inspirations, tags, reference,) data = [] - for i, row in mrich.track(df.iterrows(), prefix="Reading SDF rows..."): - + for i, row in mrich.track(df.iterrows(), prefix='Reading SDF rows...'): if name_col: - name = row[name_col].strip() or f"pose_{i}" + name = row[name_col].strip() or f'pose_{i}' alias = name else: - name = f"pose_{i}" + name = f'pose_{i}' alias = None mol = row[mol_col] - inchikey = row["inchikey"] - smiles = row["smiles"] - compound_id = row["compound_id"] + inchikey = row['inchikey'] + smiles = row['smiles'] + compound_id = row['compound_id'] if not compound_id: - mrich.error("Skipping invalid compound", i) + mrich.error('Skipping invalid compound', i) continue - pose_path = (output_directory / f"{name}.fake.mol").resolve() + pose_path = (output_directory / f'{name}.fake.mol').resolve() energy_score = float(row[energy_score_col]) distance_score = float(row[distance_score_col]) @@ -843,17 +827,16 @@ def load_sdf( inspiration_list = list(inspirations.ids) elif inspirations or inspiration_col: - if inspirations: insp_str = inspirations else: insp_str = row[inspiration_col] if isinstance(insp_str, str): - insp_str = insp_str.removeprefix("[") - insp_str = insp_str.removesuffix("]") - insp_str = insp_str.replace("'", "") - generator = insp_str.split(",") + insp_str = insp_str.removeprefix('[') + insp_str = insp_str.removesuffix(']') + insp_str = insp_str.replace("'", '') + generator = insp_str.split(',') elif isinstance(insp_str, float): generator = [] @@ -876,13 +859,13 @@ def load_sdf( pose_id = inspiration_map[insp] if pose_id: inspiration_list.append(pose_id) - elif hasattr(inspiration_map, "__call__"): + elif callable(inspiration_map): pose_id = inspiration_map(insp) if pose_id: inspiration_list.append(pose_id) else: mrich.error( - f"Could not find inspiration pose with alias={insp}" + f'Could not find inspiration pose with alias={insp}' ) continue @@ -902,18 +885,18 @@ def load_sdf( # metadata metadata = {} skip = { - "smiles", - "inchikey", - "compound_id", + 'smiles', + 'inchikey', + 'compound_id', inspiration_col, name_col, mol_col, energy_score_col, distance_score_col, - "target_id", - "reference_id", - "path", - "exports", + 'target_id', + 'reference_id', + 'path', + 'exports', } for col in df_columns: @@ -935,7 +918,7 @@ def load_sdf( if not (isinstance(value, str) or isinstance(value, float)): if i == 0: - mrich.warning(f"Skipping metadata from column={col}.") + mrich.warning(f'Skipping metadata from column={col}.') continue metadata[col] = value @@ -959,10 +942,10 @@ def load_sdf( ### ACTUALLY DO THE BULK INSERTION - mrich.debug("Registering poses...") + mrich.debug('Registering poses...') ids = self.db.register_poses(data) pset = self.poses[ids] - mrich.debug("Adding tags...") + mrich.debug('Adding tags...') for tag in pose_tags: pset.add_tag(tag) @@ -971,20 +954,20 @@ def load_sdf( else: f = mrich.warning - f(f"{n} new compounds from {path}") + f(f'{n} new compounds from {path}') if n := self.num_poses - n_poses: f = mrich.success else: f = mrich.warning - f(f"{n} new poses from {path}") + f(f'{n} new poses from {path}') def add_syndirella_scaffolds( self, output_directory: str | Path, *, - pattern: str = "*-*-?-scaffold-check/scaffold-*", + pattern: str = '*-*-?-scaffold-check/scaffold-*', tags: None | list[str] = None, target: int | str = 1, debug: bool = False, @@ -1006,43 +989,42 @@ def add_syndirella_scaffolds( n_poses = self.num_poses - mrich.warning("Not setting inspirations and references") + mrich.warning('Not setting inspirations and references') for subdir in mrich.track( - list(output_directory.glob(pattern)), prefix="Loading scaffolds..." + list(output_directory.glob(pattern)), prefix='Loading scaffolds...' ): - - inchikey = subdir.parent.name.replace("-scaffold-check", "") + inchikey = subdir.parent.name.replace('-scaffold-check', '') compound = self.compounds[inchikey] if debug: - mrich.var("subdir", subdir) - mrich.var("inchikey", inchikey) - mrich.var("compound", compound) + mrich.var('subdir', subdir) + mrich.var('inchikey', inchikey) + mrich.var('compound', compound) name = subdir.name - mol_file = subdir / f"{name}.minimised.mol" + mol_file = subdir / f'{name}.minimised.mol' if not mol_file.exists(): continue - json_file = subdir / f"{name}.minimised.json" + json_file = subdir / f'{name}.minimised.json' if not json_file.exists(): continue - metadata = json.load(open(json_file, "rt")) + metadata = json.load(open(json_file)) if debug: mrich.print(metadata) energy_score = ( - metadata["Energy"]["bound"]["total_score"] - - metadata["Energy"]["unbound"]["total_score"] + metadata['Energy']['bound']['total_score'] + - metadata['Energy']['unbound']['total_score'] ) - distance_score = metadata["mRMSD"] + distance_score = metadata['mRMSD'] - tags = tags or ["Syndirella scaffold"] + tags = tags or ['Syndirella scaffold'] self.register_pose( path=mol_file, @@ -1055,9 +1037,9 @@ def add_syndirella_scaffolds( n_poses = self.num_poses - n_poses if n_poses: - mrich.success(f"Added {n_poses} scaffold Poses") + mrich.success(f'Added {n_poses} scaffold Poses') else: - mrich.warning(f"Added {n_poses} scaffold Poses") + mrich.warning(f'Added {n_poses} scaffold Poses') def add_syndirella_elabs( self, @@ -1068,11 +1050,11 @@ def add_syndirella_elabs( reject_flags: list[str] | None = None, register_reactions: bool = True, dry_run: bool = False, - scaffold_route: "Route | None" = None, - scaffold_compound: "Compound | None" = None, + scaffold_route: 'Route | None' = None, + scaffold_compound: 'Compound | None' = None, pose_tags: list[str] | None = None, product_tags: list[str] | None = None, - ) -> "pd.DataFrame": + ) -> 'pd.DataFrame': """ Load Syndirella elaboration compounds and poses from a pickled DataFrame @@ -1090,14 +1072,12 @@ def add_syndirella_elabs( """ reject_flags = reject_flags or [ - "one_of_multiple_products", - "selectivity_issue_contains_reaction_atoms_of_both_reactants", + 'one_of_multiple_products', + 'selectivity_issue_contains_reaction_atoms_of_both_reactants', ] - pose_tags = pose_tags or ["syndirella_product", "syndirella_placed"] - product_tags = product_tags or ["syndirella_product"] - - from .syndirella import reactions_from_row + pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] + product_tags = product_tags or ['syndirella_product'] df_path = Path(df_path) mrich.h3(df_path.name) @@ -1107,12 +1087,12 @@ def add_syndirella_elabs( # work out number of reaction steps num_steps = max( - [int(s.split("_")[0]) for s in df.columns if "_product_smiles" in s] + [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] ) - mrich.var("num_steps", num_steps) + mrich.var('num_steps', num_steps) # add is_scaffold row - df["is_scaffold"] = df[f"{num_steps}_product_name"].str.contains("scaffold") + df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') ###### PREP ###### @@ -1122,45 +1102,45 @@ def add_syndirella_elabs( for step in range(num_steps): step += 1 - for flags in set(df[df[f"{step}_flag"].notna()][f"{step}_flag"].to_list()): + for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): for flag in flags: present_flags.add(flag) if present_flags: - mrich.warning("Flags in DataFrame:", present_flags) + mrich.warning('Flags in DataFrame:', present_flags) for flag in reject_flags: if flag in present_flags: for step in range(num_steps): step += 1 - matches = df[f"{step}_flag"].apply( + matches = df[f'{step}_flag'].apply( lambda x: flag in x if x is not None else False ) mrich.print( - "Filtering out", + 'Filtering out', len(df[matches]), - "rows from step", + 'rows from step', step, - "due to", + 'due to', flag, ) df = df[~matches] # poses - n_null_mol = len(df[df["path_to_mol"].isna()]) + n_null_mol = len(df[df['path_to_mol'].isna()]) if n_null_mol: - df = df[df["path_to_mol"].notna()] - mrich.var("#rows skipped due to null path_to_mol", n_null_mol) + df = df[df['path_to_mol'].notna()] + mrich.var('#rows skipped due to null path_to_mol', n_null_mol) if not len(df): - mrich.warning("No valid rows") + mrich.warning('No valid rows') return None # inspirations - inspiration_sets = set(tuple(sorted(i)) for i in df["regarded"]) + inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) if len(inspiration_sets) != 1: - mrich.error("Varying inspirations not supported") + mrich.error('Varying inspirations not supported') return df (inspiration_set,) = inspiration_sets @@ -1168,30 +1148,29 @@ def add_syndirella_elabs( assert len(inspirations) == len(inspiration_set) # reference - template_paths = set(df["template"].to_list()) - assert len(template_paths) == 1, "Multiple references not supported" + template_paths = set(df['template'].to_list()) + assert len(template_paths) == 1, 'Multiple references not supported' (template_path,) = template_paths template_path = Path(template_path) - mrich.var("template_path", template_path) - base_name = template_path.name.removesuffix(".pdb").removesuffix("_apo-desolv") + mrich.var('template_path', template_path) + base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') reference = self.poses[base_name] - assert reference, "Could not determine reference structure" - mrich.var("reference", reference) + assert reference, 'Could not determine reference structure' + mrich.var('reference', reference) target = reference.target # subset of rows - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] - mrich.var("#scaffold entries", len(scaffold_df)) - mrich.var("#elab entries", len(elab_df)) + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + mrich.var('#scaffold entries', len(scaffold_df)) + mrich.var('#elab entries', len(elab_df)) if not len(scaffold_df) and not scaffold_route and not scaffold_compound: - mrich.error("No valid scaffold rows") + mrich.error('No valid scaffold rows') return None elif scaffold_route: - ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE assert scaffold_route.num_reactions == 1 @@ -1202,42 +1181,41 @@ def add_syndirella_elabs( assert len(reaction.reactants) == 2 scaffold_dict = { - "scaffold_smiles": product.smiles, - "1_reaction": reaction.type, - "1_r1_smiles": reaction.reactants[0].smiles, - "1_r2_smiles": reaction.reactants[1].smiles, - "1_product_smiles": product.smiles, - "1_product_name": "scaffold", - "1_single_reactant_elab": False, - "1_num_atom_diff": 0, - "is_scaffold": True, + 'scaffold_smiles': product.smiles, + '1_reaction': reaction.type, + '1_r1_smiles': reaction.reactants[0].smiles, + '1_r2_smiles': reaction.reactants[1].smiles, + '1_product_smiles': product.smiles, + '1_product_name': 'scaffold', + '1_single_reactant_elab': False, + '1_num_atom_diff': 0, + 'is_scaffold': True, } scaffold_df = pd.DataFrame([scaffold_dict]) df = pd.concat([scaffold_df, df]) - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] elif scaffold_compound: - ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT scaffold_dict = { - "scaffold_smiles": scaffold_compound.smiles, - "is_scaffold": True, + 'scaffold_smiles': scaffold_compound.smiles, + 'is_scaffold': True, } scaffold_df = pd.DataFrame([scaffold_dict]) df = pd.concat([scaffold_df, df]) - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] if dry_run: - mrich.error("Not registering records (dry_run)") + mrich.error('Not registering records (dry_run)') return df ###### ELABS ###### @@ -1245,18 +1223,17 @@ def add_syndirella_elabs( # bulk register compounds smiles_cols = [ - c for c in df.columns if c.endswith("_smiles") and c != "scaffold_smiles" + c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' ] for smiles_col in smiles_cols: - - inchikey_col = smiles_col.replace("_smiles", "_inchikey") - compound_id_col = smiles_col.replace("_smiles", "_compound_id") + inchikey_col = smiles_col.replace('_smiles', '_inchikey') + compound_id_col = smiles_col.replace('_smiles', '_compound_id') unique_smiles = df[smiles_col].dropna().unique() mrich.debug( - f"Registering {len(unique_smiles)} compounds from column: {smiles_col}" + f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' ) values = self.register_compounds( @@ -1267,7 +1244,9 @@ def add_syndirella_elabs( orig_smiles_to_inchikey = { orig_smiles: inchikey - for orig_smiles, (inchikey, new_smiles) in zip(unique_smiles, values) + for orig_smiles, (inchikey, new_smiles) in zip( + unique_smiles, values, strict=False + ) } df[inchikey_col] = df[smiles_col].apply( @@ -1286,22 +1265,20 @@ def add_syndirella_elabs( if register_reactions: for step in range(num_steps): - step += 1 - mrich.debug(f"Registering reactions for step {step}") + mrich.debug(f'Registering reactions for step {step}') reaction_dicts = [] for reaction_name, r1_id, r2_id, product_id in df[ [ - f"{step}_reaction", - f"{step}_r1_compound_id", - f"{step}_r2_compound_id", - f"{step}_product_compound_id", + f'{step}_reaction', + f'{step}_r1_compound_id', + f'{step}_r2_compound_id', + f'{step}_product_compound_id', ] ].values: - # skip invalid rows if pd.isna(r1_id) or pd.isna(product_id): mrich.warning("Can't insert reactions for missing scaffold") @@ -1328,17 +1305,17 @@ def add_syndirella_elabs( ) reaction_ids = self.register_reactions( - types=[d["reaction_name"] for d in reaction_dicts], - product_ids=[d["product_id"] for d in reaction_dicts], - reactant_id_lists=[d["reactant_ids"] for d in reaction_dicts], + types=[d['reaction_name'] for d in reaction_dicts], + product_ids=[d['product_id'] for d in reaction_dicts], + reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], ) - scaffold_df = df[df["is_scaffold"]] - elab_df = df[~df["is_scaffold"]] + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] # tag product compounds: - product_ids = list(df[f"{num_steps}_product_compound_id"].dropna().unique()) + product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) products = self.compounds[product_ids] for tag in product_tags: products.add_tag(tag) @@ -1346,33 +1323,29 @@ def add_syndirella_elabs( # bulk register scaffold relationships for step in range(num_steps): - step += 1 - for role in ["r1", "r2", "product"]: + for role in ['r1', 'r2', 'product']: + key = f'{step}_{role}_compound_id' - key = f"{step}_{role}_compound_id" - - mrich.debug(f"Registering scaffold relatonships for {key}") - - if step == num_steps and role == "product" and scaffold_compound: + mrich.debug(f'Registering scaffold relatonships for {key}') + if step == num_steps and role == 'product' and scaffold_compound: scaffold_id = scaffold_compound.id else: - scaffold_ids = list(scaffold_df[key].dropna().unique()) if not scaffold_ids: mrich.warning( "Can't insert scaffold relationships due to missing", key, - "for all scaffold rows", + 'for all scaffold rows', ) continue if len(scaffold_ids) > 1: - mrich.error("Multiple scaffold row values in", key) + mrich.error('Multiple scaffold row values in', key) return scaffold_df scaffold_id = scaffold_ids[0] @@ -1382,12 +1355,12 @@ def add_syndirella_elabs( ] match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO scaffold(scaffold_base, scaffold_superstructure) VALUES(?1, ?2) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) VALUES(%s, %s) @@ -1407,34 +1380,34 @@ def add_syndirella_elabs( try: if require_intra_geometry_pass: mrich.var( - "#poses !intra_geometry_pass", - len(df[df["intra_geometry_pass"] == False]), + '#poses !intra_geometry_pass', + len(df[df['intra_geometry_pass'] == False]), ) - ok = ok[ok["intra_geometry_pass"] == True] + ok = ok[ok['intra_geometry_pass'] == True] if max_energy_score is not None: mrich.var( - f"#poses ∆∆G > {max_energy_score}", - len(df[df["∆∆G"] > max_energy_score]), + f'#poses ∆∆G > {max_energy_score}', + len(df[df['∆∆G'] > max_energy_score]), ) - ok = ok[ok["∆∆G"] <= max_energy_score] + ok = ok[ok['∆∆G'] <= max_energy_score] if max_distance_score is not None: mrich.var( - f"#poses comRMSD > {max_distance_score}", - len(df[df["comRMSD"] > max_energy_score]), + f'#poses comRMSD > {max_distance_score}', + len(df[df['comRMSD'] > max_energy_score]), ) - ok = ok[ok["comRMSD"] <= max_distance_score] + ok = ok[ok['comRMSD'] <= max_distance_score] except Exception as e: - mrich.error("Problem filtering dataframe") + mrich.error('Problem filtering dataframe') mrich.error(e) return df - mrich.var("#acceptable poses", len(ok)) + mrich.var('#acceptable poses', len(ok)) if not len(ok): - mrich.warning("No valid poses") + mrich.warning('No valid poses') return None # bulk register poses @@ -1442,32 +1415,31 @@ def add_syndirella_elabs( payload = [] for i, row in ok.iterrows(): - path = Path(row.path_to_mol).resolve() if not path.exists(): - mrich.warning("Skipping pose w/ non-exising file:", path) + mrich.warning('Skipping pose w/ non-exising file:', path) continue pose_tuple = ( int(reference.id), str(path), - int(row[f"{num_steps}_product_compound_id"]), + int(row[f'{num_steps}_product_compound_id']), int(target.id), - float(row["∆∆G"]), - float(row["comRMSD"]), + float(row['∆∆G']), + float(row['comRMSD']), ) payload.append(pose_tuple) if not payload: - mrich.warning("No valid poses") + mrich.warning('No valid poses') return None - mrich.debug(f"Registering {len(payload)} poses...") + mrich.debug(f'Registering {len(payload)} poses...') match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO pose( pose_reference, @@ -1479,7 +1451,7 @@ def add_syndirella_elabs( ) VALUES(?1, ?2, ?3, ?4, ?5, ?6) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.pose( pose_reference, @@ -1499,17 +1471,17 @@ def add_syndirella_elabs( diff = self.num_poses - n_before if diff: - mrich.success("Registered", diff, "new poses") + mrich.success('Registered', diff, 'new poses') else: - mrich.warning("Registered", diff, "new poses") + mrich.warning('Registered', diff, 'new poses') # query relevant poses (also previously registered) paths = [t[1] for t in payload] - str_ids = str(tuple(paths)).replace(",)", ")") + str_ids = str(tuple(paths)).replace(',)', ')') records = self.db.select_where( - table="pose", query="pose_id", key=f"pose_path IN {str_ids}", multiple=True + table='pose', query='pose_id', key=f'pose_path IN {str_ids}', multiple=True ) - pose_ids = [i for i, in records] + pose_ids = [i for (i,) in records] # bulk register inspirations @@ -1519,12 +1491,12 @@ def add_syndirella_elabs( payload.add((inspiration, pose_id)) match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) VALUES(?1, ?2) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) VALUES(?%s1, %s) @@ -1551,28 +1523,26 @@ def add_syndirella_routes( ) -> pd.DataFrame: """Add routes found from syndirella --just_retro query""" - from .recipe import Recipe - from .cset import IngredientSet - from .rset import ReactionSet from .chem import InvalidChemistryError, UnsupportedChemistryError + from .cset import IngredientSet + from .recipe import Recipe df = pd.read_pickle(pickle_path) for i, row in mrich.track(df.iterrows(), total=len(df)): - - mrich.set_progress_field("i", i) - mrich.set_progress_field("n", len(df)) + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) d = row.to_dict() - comp = self.compounds(smiles=d["smiles"]) + comp = self.compounds(smiles=d['smiles']) n_routes = 0 for key in d: - if not key.startswith("route"): + if not key.startswith('route'): continue - if not key.endswith("_names"): + if not key.endswith('_names'): continue v = d[key] @@ -1588,12 +1558,11 @@ def add_syndirella_routes( routes = [] for j in range(n_routes): - - route_str = f"route{j}" + route_str = f'route{j}' route = d[route_str] - if CAR_only and not d[route_str + "_CAR"]: + if CAR_only and not d[route_str + '_CAR']: continue reactions = ReactionSet(self.db) @@ -1603,15 +1572,14 @@ def add_syndirella_routes( try: for k, reaction in enumerate(route): + reaction_type = reaction['name'] - reaction_type = reaction["name"] - - product = self.compounds(smiles=reaction["productSmiles"]) + product = self.compounds(smiles=reaction['productSmiles']) mrich.print(i, j, k, reaction_type, product) rs = [] - for reactant_s in reaction["reactantSmiles"]: + for reactant_s in reaction['reactantSmiles']: reactant = self.register_compound(smiles=reactant_s) rs.append(reactant.id) @@ -1633,10 +1601,10 @@ def add_syndirella_routes( except InvalidChemistryError: continue except UnsupportedChemistryError: - mrich.warning("Skipping unsupported chemistry:", reaction_type) + mrich.warning('Skipping unsupported chemistry:', reaction_type) continue - except Exception as e: - mrich.error("Uncaught error with row", i, "route", j, "reaction", k) + except Exception: + mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) continue products.add(product.as_ingredient(amount=1)) @@ -1651,7 +1619,7 @@ def add_syndirella_routes( if register_routes: route_id = self.register_route(recipe=recipe) - mrich.success("registered route", route_id) + mrich.success('registered route', route_id) if pick_first: break @@ -1662,24 +1630,24 @@ def add_enamine_quote( self, path: str | Path, *, - orig_name_col: str = "Customer Code", + orig_name_col: str = 'Customer Code', # orig_name_col: str = 'Diamond ID (Molecule Name)', price_col: str | None = None, fixed_amount: float | None = None, fixed_lead_time: float | None = False, fixed_purity: float | None = False, - entry_col: str = "Catalog ID", - catalogue_col: str = "Collection", - smiles_col: str = "SMILES", - amount_col: str = "Amount, mg", - purity_col: str = "Purity, %", - lead_time_col: str | None = "Lead time", + entry_col: str = 'Catalog ID', + catalogue_col: str = 'Collection', + smiles_col: str = 'SMILES', + amount_col: str = 'Amount, mg', + purity_col: str = 'Purity, %', + lead_time_col: str | None = 'Lead time', stop_after: None | int = None, orig_name_is_hippo_id: bool = False, allow_no_catalogue_col: bool = False, delete_unavailable: bool = True, overwrite_existing_quotes: bool = False, - supplier_name: str = "Enamine", + supplier_name: str = 'Enamine', warn_nan_orig_name: bool = True, currency: str = None, dry_run: bool = False, @@ -1715,53 +1683,53 @@ def unexpected_column(key: str, value: str | float) -> str: if smiles_col not in df.columns: smiles_col = smiles_col.lower() - assert smiles_col in df.columns, unexpected_column("smiles_col", smiles_col) + assert smiles_col in df.columns, unexpected_column('smiles_col', smiles_col) if orig_name_col is not None: assert orig_name_col in df.columns, unexpected_column( - "orig_name_col", orig_name_col + 'orig_name_col', orig_name_col ) else: orig_name_is_hippo_id = False - assert entry_col in df.columns, unexpected_column("entry_col", entry_col) + assert entry_col in df.columns, unexpected_column('entry_col', entry_col) if fixed_purity is False: - assert purity_col in df.columns, unexpected_column("purity_col", purity_col) + assert purity_col in df.columns, unexpected_column('purity_col', purity_col) if fixed_amount is None: - assert amount_col in df.columns, unexpected_column("amount_col", amount_col) + assert amount_col in df.columns, unexpected_column('amount_col', amount_col) if fixed_lead_time is False and lead_time_col is not None: assert lead_time_col in df.columns, unexpected_column( - "lead_time_col", lead_time_col + 'lead_time_col', lead_time_col ) if not allow_no_catalogue_col: assert catalogue_col in df.columns, unexpected_column( - "catalogue_col", catalogue_col + 'catalogue_col', catalogue_col ) elif catalogue_col not in df.columns: catalogue_col = None assert ( - "Price, EUR" in df.columns - or "Price, USD" in df.columns + 'Price, EUR' in df.columns + or 'Price, USD' in df.columns or price_col in df.columns - ), unexpected_column("Price", "") + ), unexpected_column('Price', '') if price_col is None: - price_cols = [c for c in df.columns if c.startswith("Price")] + price_cols = [c for c in df.columns if c.startswith('Price')] assert len(price_cols) == 1 price_col = price_cols[0] - currency = currency or price_col.split(", ")[-1] + currency = currency or price_col.split(', ')[-1] ingredients = IngredientSet(self.db) if len(df) > 100: generator = mrich.track( - df.iterrows(), prefix="Loading quotes...", total=len(df) + df.iterrows(), prefix='Loading quotes...', total=len(df) ) else: generator = df.iterrows() @@ -1770,61 +1738,56 @@ def unexpected_column(key: str, value: str | float) -> str: smiles = row[smiles_col] if debug: - mrich.debug("smiles", smiles) + mrich.debug('smiles', smiles) if not isinstance(smiles, str): if debug: - mrich.debug("SKIPPING smiles!=str", smiles) + mrich.debug('SKIPPING smiles!=str', smiles) continue compound = self.register_compound(smiles=smiles) if orig_name_is_hippo_id: - if pd.isna(row[orig_name_col]): if warn_nan_orig_name: - mrich.warning(f"row {i} has NaN {orig_name_col}") + mrich.warning(f'row {i} has NaN {orig_name_col}') continue expected_id = int(row[orig_name_col]) if expected_id != compound.id: - mrich.error("Compound registration mismatch:") - mrich.var("expected_id", expected_id) - mrich.var("new_id", compound.id) - mrich.var("original_smiles", self.compounds[expected_id].smiles) - mrich.var("new_smiles", smiles) + mrich.error('Compound registration mismatch:') + mrich.var('expected_id', expected_id) + mrich.var('new_id', compound.id) + mrich.var('original_smiles', self.compounds[expected_id].smiles) + mrich.var('new_smiles', smiles) if catalogue_col and (catalogue := row[catalogue_col]) in [ - "No starting material", - "Out of stock", - "Unavailable", + 'No starting material', + 'Out of stock', + 'Unavailable', ]: - if not dry_run and delete_unavailable: - mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) self.db.delete_where( - table="quote", + table='quote', key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", ) continue if (price := row[price_col]) == 0.0: - if not dry_run and delete_unavailable: - mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) self.db.delete_where( - table="quote", + table='quote', key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", ) if debug: - mrich.debug("Skipping NULL price", compound, i) + mrich.debug('Skipping NULL price', compound, i) continue @@ -1841,8 +1804,8 @@ def unexpected_column(key: str, value: str | float) -> str: if fixed_lead_time is False: if not isinstance(row[lead_time_col], str): continue - if "week" in row[lead_time_col]: - lead_time = int(row[lead_time_col].split()[0].split("-")[-1]) * 5 + if 'week' in row[lead_time_col]: + lead_time = int(row[lead_time_col].split()[0].split('-')[-1]) * 5 else: raise NotImplementedError else: @@ -1865,26 +1828,26 @@ def unexpected_column(key: str, value: str | float) -> str: mrich.print(quote_data) if dry_run: - mrich.warning("Dry-run, stopping before any database modifications") + mrich.warning('Dry-run, stopping before any database modifications') return quote_data if overwrite_existing_quotes: self.db.delete_where( - table="quote", + table='quote', key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", ) q_id = self.db.insert_quote(**quote_data) if debug: - mrich.debug("inserted quote", q_id) + mrich.debug('inserted quote', q_id) ingredients.add( compound_id=compound.id, amount=amount, quoted_amount=amount, quote_id=q_id, - supplier="Enamine", + supplier='Enamine', max_lead_time=None, ) @@ -1906,14 +1869,14 @@ def add_mcule_quote( ### get lead time from suppliers sheet - sheet_name: str = "List of suppliers" + sheet_name: str = 'List of suppliers' df = pd.read_excel(path, sheet_name=sheet_name) - supplier_col = "Supplier" - lead_time_col = "Delivery time (working days)" + supplier_col = 'Supplier' + lead_time_col = 'Delivery time (working days)' - assert supplier_col in df.columns, "Unexpected Excel format (supplier_col)" - assert lead_time_col in df.columns, "Unexpected Excel format (lead_time_col)" + assert supplier_col in df.columns, 'Unexpected Excel format (supplier_col)' + assert lead_time_col in df.columns, 'Unexpected Excel format (lead_time_col)' lead_time_lookup = { row[supplier_col]: row[lead_time_col] for i, row in df.iterrows() @@ -1921,30 +1884,30 @@ def add_mcule_quote( ### parse individual compound quotes - sheet_name: str = "List of products" + sheet_name: str = 'List of products' df = pd.read_excel(path, sheet_name=sheet_name) # return df - smiles_col = "Quoted product SMILES" - entry_col = "Query Mcule ID" - purity_col = "Guaranteed purity (%)" - amount_col = "Quoted Amount (mg)" - catalogue_col = "Supplier" - lead_time_col = "Lead time" - price_col = "Product price (USD)" - currency = "USD" - - assert smiles_col in df.columns, "Unexpected Excel format (smiles_col)" - assert entry_col in df.columns, "Unexpected Excel format (entry_col)" - assert purity_col in df.columns, "Unexpected Excel format (purity_col)" - assert amount_col in df.columns, "Unexpected Excel format (amount_col)" - assert catalogue_col in df.columns, "Unexpected Excel format (catalogue_col)" - assert price_col in df.columns, "Unexpected Excel format (price_col)" + smiles_col = 'Quoted product SMILES' + entry_col = 'Query Mcule ID' + purity_col = 'Guaranteed purity (%)' + amount_col = 'Quoted Amount (mg)' + catalogue_col = 'Supplier' + lead_time_col = 'Lead time' + price_col = 'Product price (USD)' + currency = 'USD' + + assert smiles_col in df.columns, 'Unexpected Excel format (smiles_col)' + assert entry_col in df.columns, 'Unexpected Excel format (entry_col)' + assert purity_col in df.columns, 'Unexpected Excel format (purity_col)' + assert amount_col in df.columns, 'Unexpected Excel format (amount_col)' + assert catalogue_col in df.columns, 'Unexpected Excel format (catalogue_col)' + assert price_col in df.columns, 'Unexpected Excel format (price_col)' ingredients = IngredientSet(self.db) - for i, row in mrich.track(df.iterrows(), prefix="Loading quotes..."): + for i, row in mrich.track(df.iterrows(), prefix='Loading quotes...'): smiles = row[smiles_col] if not isinstance(smiles, str): @@ -1971,7 +1934,7 @@ def add_mcule_quote( quote_data = dict( compound=compound, - supplier="MCule", + supplier='MCule', catalogue=catalogue, entry=row[entry_col], amount=row[amount_col], @@ -1988,7 +1951,7 @@ def add_mcule_quote( compound_id=compound.id, amount=row[amount_col], quote_id=q_id, - supplier="MCule", + supplier='MCule', max_lead_time=None, ) @@ -1998,14 +1961,14 @@ def add_mcule_quote( def add_soakdb_compounds( self, - path: "str | Path", - smiles_col: str = "CompoundSMILES", - alias_col: str = "CompoundCode", + path: 'str | Path', + smiles_col: str = 'CompoundSMILES', + alias_col: str = 'CompoundCode', update_aliases: bool = True, soak_count_to_metadata: bool = True, sanitisation_verbosity: bool = False, stop_after: int | None = None, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Registers compounds with aliases and metadata from a SoakDB file :param path: Path to SoakDB CSV or SQLite file @@ -2016,10 +1979,10 @@ def add_soakdb_compounds( path = Path(path) - match ext := path.name.split(".")[-1]: - case "csv": + match ext := path.name.split('.')[-1]: + case 'csv': df = pd.read_csv(path) - case "sqlite": + case 'sqlite': raise NotImplementedError case _: print(ext) @@ -2027,13 +1990,12 @@ def add_soakdb_compounds( f"Could not determine file type from extension, use '.csv' or '.sqlite' {path}" ) - unique = df[df["CompoundSMILES"] != "-"].drop_duplicates( + unique = df[df['CompoundSMILES'] != '-'].drop_duplicates( subset=[smiles_col, alias_col] ) smiles_alias_tuples = [] for j, (i, row) in enumerate(unique.iterrows()): - smiles = row[smiles_col] alias = row[alias_col] @@ -2048,36 +2010,39 @@ def add_soakdb_compounds( if stop_after and j > stop_after: break - mrich.var("#unique compounds", len(smiles_alias_tuples)) + mrich.var('#unique compounds', len(smiles_alias_tuples)) old_smiles = [s for s, a in smiles_alias_tuples] - mrich.debug("Registering compounds...") + mrich.debug('Registering compounds...') inchikey_new_smiles_tuples = self.register_compounds( smiles=old_smiles, sanitisation_verbosity=sanitisation_verbosity ) inchikey_old_smiles_lookup = { inchikey: old_s - for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) + for old_s, (inchikey, new_s) in zip( + old_smiles, inchikey_new_smiles_tuples, strict=False + ) } alias_lookup = {s: a for s, a in smiles_alias_tuples} alias_dicts = [ dict(compound_inchikey=inchikey, compound_alias=alias_lookup[old_s]) - for old_s, (inchikey, new_s) in zip(old_smiles, inchikey_new_smiles_tuples) + for old_s, (inchikey, new_s) in zip( + old_smiles, inchikey_new_smiles_tuples, strict=False + ) ] if update_aliases: - match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ UPDATE OR IGNORE compound SET compound_alias = :compound_alias WHERE compound_inchikey = :compound_inchikey; """ - case "psycopg": + case 'psycopg': sql = """ UPDATE hippo.compound SET compound_alias = %(compound_alias)s @@ -2085,45 +2050,44 @@ def add_soakdb_compounds( ON CONFLICT DO NOTHING; """ - mrich.debug("Updating aliases...") + mrich.debug('Updating aliases...') self.db.executemany(sql, alias_dicts) self.db.commit() - inchikeys = [d["compound_inchikey"] for d in alias_dicts] + inchikeys = [d['compound_inchikey'] for d in alias_dicts] inchikey_id_lookup = self.db.get_compound_inchikey_id_dict(inchikeys) cset = self.compounds[ - [inchikey_id_lookup[d["compound_inchikey"]] for d in alias_dicts] + [inchikey_id_lookup[d['compound_inchikey']] for d in alias_dicts] ] - cset.add_tag("soaks") + cset.add_tag('soaks') - metadata_lookup = self.db.get_id_metadata_dict(table="compound", ids=cset.ids) + metadata_lookup = self.db.get_id_metadata_dict(table='compound', ids=cset.ids) if soak_count_to_metadata: - - mrich.debug("Getting soak counts...") + mrich.debug('Getting soak counts...') for inchikey in inchikeys: old_s = inchikey_old_smiles_lookup[inchikey] c_id = inchikey_id_lookup[inchikey] - metadata_lookup[c_id]["SoakDB count"] = len(df[df[smiles_col] == old_s]) + metadata_lookup[c_id]['SoakDB count'] = len(df[df[smiles_col] == old_s]) match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ UPDATE compound SET compound_metadata = ? WHERE compound_id = ?; """ - case "psycopg": + case 'psycopg': sql = """ UPDATE hippo.compound SET compound_metadata = %s WHERE compound_id = %s; """ - mrich.debug("Updating metadata...") + mrich.debug('Updating metadata...') self.db.executemany( sql, [(dumps(m), i) for i, m in metadata_lookup.items()] ) @@ -2146,7 +2110,7 @@ def register_compound( alias: str | None = None, return_duplicate: bool = False, register_scaffold_if_duplicate: bool = True, - radical: str = "warning", + radical: str = 'warning', debug: bool = False, ) -> Compound: """Use a smiles string to add a compound to the database. If it already exists return the compound @@ -2166,18 +2130,18 @@ def register_compound( """ assert smiles - assert isinstance(smiles, str), f"Non-string {smiles=}" + assert isinstance(smiles, str), f'Non-string {smiles=}' try: smiles = sanitise_smiles( - smiles, sanitisation_failed="error", radical=radical, verbosity=debug + smiles, sanitisation_failed='error', radical=radical, verbosity=debug ) except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') mrich.error(str(e)) return None except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') return None if scaffolds: @@ -2186,7 +2150,7 @@ def register_compound( inchikey = inchikey_from_smiles(smiles) if debug: - mrich.var("inchikey", inchikey) + mrich.var('inchikey', inchikey) compound_id = self.db.insert_compound( smiles=smiles, @@ -2201,7 +2165,7 @@ def register_compound( duplicate = not bool(compound_id) def _return( - compound: "Compound", + compound: 'Compound', duplicate: bool, return_compound: bool, return_duplicate: bool, @@ -2220,16 +2184,16 @@ def check_smiles(compound_id: int, smiles: str) -> None: """Check smiles""" assert compound_id db_smiles = self.db.select_where( - table="compound", query="compound_smiles", key="id", value=compound_id + table='compound', query='compound_smiles', key='id', value=compound_id ) (db_smiles,) = db_smiles if db_smiles != smiles: mrich.warning( - f"SMILES changed during compound registration: {smiles} --> {db_smiles}" + f'SMILES changed during compound registration: {smiles} --> {db_smiles}' ) def insert_scaffolds( - scaffolds: "list[Compound] | list[int]", compound_id: int + scaffolds: 'list[Compound] | list[int]', compound_id: int ) -> None: """Insert scaffolds""" scaffolds = [b for b in scaffolds if b is not None] or [] @@ -2267,7 +2231,6 @@ def insert_scaffolds( else: if not compound_id: - assert inchikey compound_id = self.db.get_compound_id(inchikey=inchikey) @@ -2283,7 +2246,7 @@ def register_compounds( self, *, smiles: list[str], - radical: str = "warning", + radical: str = 'warning', sanitisation_verbosity: bool = True, debug: bool = False, ) -> list[tuple[str, str]]: @@ -2295,7 +2258,7 @@ def register_compounds( """ if debug: - mrich.var("#smiles", len(smiles)) + mrich.var('#smiles', len(smiles)) n_before = self.num_compounds @@ -2309,9 +2272,9 @@ def register_compounds( diff = self.num_compounds - n_before if diff: - mrich.success(f"Inserted {diff} new compounds") + mrich.success(f'Inserted {diff} new compounds') else: - mrich.warning(f"Inserted {diff} new compounds") + mrich.warning(f'Inserted {diff} new compounds') return values @@ -2340,9 +2303,8 @@ def register_reaction( if check_chemistry: from .chem import ( - check_chemistry, InvalidChemistryError, - UnsupportedChemistryError, + check_chemistry, ) if not isinstance(product, Compound): @@ -2354,7 +2316,7 @@ def register_reaction( valid = check_chemistry(type, reactants, product) if not valid: - raise InvalidChemistryError(f"{type=}, {reactants.ids=}, {product.id=}") + raise InvalidChemistryError(f'{type=}, {reactants.ids=}, {product.id=}') ### CHECK FOR DUPLICATES @@ -2364,20 +2326,20 @@ def register_reaction( reactant_ids = set(v.id if isinstance(v, Compound) else v for v in reactants) match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ - SELECT reactant_reaction, reactant_compound - FROM reactant INNER JOIN reaction - ON reactant.reactant_reaction = reaction.reaction_id - WHERE reaction_type="{type}" + SELECT reactant_reaction, reactant_compound + FROM reactant INNER JOIN reaction + ON reactant.reactant_reaction = reaction.reaction_id + WHERE reaction_type="{type}" AND reaction_product = {product} """ - case "psycopg": + case 'psycopg': sql = """ - SELECT reactant_reaction, reactant_compound + SELECT reactant_reaction, reactant_compound FROM hippo.reactant AS reactant INNER JOIN hippo.reaction AS reaction - ON reactant.reactant_reaction = reaction.reaction_id - WHERE reaction_type="{type}" + ON reactant.reactant_reaction = reaction.reaction_id + WHERE reaction_type="{type}" AND reaction_product = {product} """ @@ -2386,7 +2348,6 @@ def register_reaction( pairs = self.db.execute(sql).fetchall() if pairs: - reax_dict = {} for reaction_id, reactant_id in pairs: if reaction_id not in reax_dict: @@ -2399,9 +2360,9 @@ def register_reaction( ### INSERT A NEW REACTION - assert ( - product_yield > 0 and product_yield <= 1.0 - ), f"{product_yield=} out of range (0,1)" + assert product_yield > 0 and product_yield <= 1.0, ( + f'{product_yield=} out of range (0,1)' + ) reaction_id = self.db.insert_reaction( type=type, product=product, commit=commit, product_yield=product_yield @@ -2451,9 +2412,8 @@ def register_reactions( existing_count = 0 for reaction_type, product_id, reactant_ids in zip( - types, product_ids, reactant_id_lists + types, product_ids, reactant_id_lists, strict=False ): - key = (reaction_type, product_id) reactant_ids = set(reactant_ids) @@ -2472,22 +2432,22 @@ def register_reactions( non_duplicates[key] = reactant_ids if existing_count: - mrich.warning("Skipped", existing_count, "existing reactions") + mrich.warning('Skipped', existing_count, 'existing reactions') if not non_duplicates: - mrich.warning("All reactions are duplicates") + mrich.warning('All reactions are duplicates') return None # insert reaction records match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) VALUES(?1, ?2, 1) RETURNING reaction_id """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.reaction(reaction_type, reaction_product, reaction_product_yield) VALUES(%s, %s, 1) @@ -2497,18 +2457,18 @@ def register_reactions( payload = list(non_duplicates.keys()) records = self.db.executemany(sql, payload) - reaction_ids = [r_id for r_id, in records] + reaction_ids = [r_id for (r_id,) in records] self.db.commit() # insert reactant records match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO reactant(reactant_amount, reactant_reaction, reactant_compound) VALUES(1.0, ?1, ?2) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.reactant(reactant_amount, reactant_reaction, reactant_compound) VALUES(1.0, %s, %s) @@ -2517,7 +2477,7 @@ def register_reactions( payload = [] for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( - reaction_ids, non_duplicates.items() + reaction_ids, non_duplicates.items(), strict=False ): for reactant_id in reactant_ids: payload.append((reaction_id, reactant_id)) @@ -2536,16 +2496,16 @@ def register_reactions( """ records = self.db.execute(sql).fetchall() - orphaned_str_ids = str(tuple(r for r, in records)).replace(",)", ")") + orphaned_str_ids = str(tuple(r for (r,) in records)).replace(',)', ')') self.db.execute( - f"DELETE FROM {self.db.SQL_SCHEMA_PREFIX}reaction WHERE reaction_id IN {orphaned_str_ids}" + f'DELETE FROM {self.db.SQL_SCHEMA_PREFIX}reaction WHERE reaction_id IN {orphaned_str_ids}' ) if diff: - mrich.success(f"Inserted {diff} new reactions") + mrich.success(f'Inserted {diff} new reactions') else: - mrich.warning(f"Inserted {diff} new reactions") + mrich.warning(f'Inserted {diff} new reactions') return reaction_ids @@ -2591,7 +2551,7 @@ def register_pose( check_RMSD: bool = False, RMSD_tolerance: float = 1.0, split_PDB: bool = False, - duplicate_alias: str = "modify", + duplicate_alias: str = 'modify', resolve_path: bool = True, load_mol: bool = False, ) -> Pose: @@ -2619,12 +2579,11 @@ def register_pose( :returns: The registered/existing :class:`.Pose` object or its ID (depending on ``return_pose``) """ - assert duplicate_alias in ["error", "modify", "skip"] + assert duplicate_alias in ['error', 'modify', 'skip'] from molparse import parse if split_PDB: - sys = parse(path, verbosity=False, alternative_site_warnings=False) lig_residues = [] @@ -2633,15 +2592,14 @@ def register_pose( lig_residues += res.split_by_site() if len(lig_residues) > 1: - assert not energy_score assert not distance_score - mrich.warning(f"Splitting ligands in PDB: {path}") + mrich.warning(f'Splitting ligands in PDB: {path}') results = [] for i, res in enumerate(lig_residues): - file = str(path).replace(".pdb", f"_hippo_{i}.pdb") + file = str(path).replace('.pdb', f'_hippo_{i}.pdb') split_sys = sys.protein_system @@ -2681,23 +2639,21 @@ def register_pose( compound_id = compound.id if check_RMSD: - # check if the compound has existing poses other_pose_ids = self.db.select_id_where( - table="pose", - key="compound", + table='pose', + key='compound', value=compound_id, - none="quiet", + none='quiet', multiple=True, ) if other_pose_ids: - other_poses = PoseSet(self.db, [i for i, in other_pose_ids]) + other_poses = PoseSet(self.db, [i for (i,) in other_pose_ids]) - from molparse.rdkit import draw_mols, draw_flat - from rdkit.Chem import MolFromMolFile - from numpy.linalg import norm from numpy import array + from numpy.linalg import norm + from rdkit.Chem import MolFromMolFile mol = MolFromMolFile(str(path.resolve())) @@ -2712,8 +2668,8 @@ def register_pose( symbols2 = [a.GetSymbol() for a in atoms2] positions2 = [c2.GetAtomPosition(i) for i, _ in enumerate(atoms2)] - for s1, p1 in zip(symbols1, positions1): - for s2, p2 in zip(symbols2, positions2): + for s1, p1 in zip(symbols1, positions1, strict=False): + for s2, p2 in zip(symbols2, positions2, strict=False): if s2 != s1: continue if norm(array(p2 - p1)) <= RMSD_tolerance: @@ -2724,7 +2680,7 @@ def register_pose( break else: # all atoms within tolerance --> too similar - mrich.warning(f"Found similar {pose=}") + mrich.warning(f'Found similar {pose=}') if return_pose: return pose else: @@ -2749,35 +2705,33 @@ def register_pose( # if no pose_id then there must be a duplicate if not pose_id: - # constraint failed if isinstance(path, Path): path = path.resolve() # try getting by path result = self.db.select_where( - table="pose", query="pose_id", key="path", value=str(path), none="quiet" + table='pose', query='pose_id', key='path', value=str(path), none='quiet' ) # try getting by alias if not result: result = self.db.select_where( - table="pose", query="pose_id", key="alias", value=alias + table='pose', query='pose_id', key='alias', value=alias ) - if result and duplicate_alias == "error": - raise Exception("could not register pose with existing alias") + if result and duplicate_alias == 'error': + raise Exception('could not register pose with existing alias') - elif result and duplicate_alias == "modify": + elif result and duplicate_alias == 'modify': + new_alias = alias + '_copy' - new_alias = alias + "_copy" + mrich.warning(f'Modifying alias={alias} --> {new_alias}') - mrich.warning(f"Modifying alias={alias} --> {new_alias}") - - pose_data["alias"] = new_alias + pose_data['alias'] = new_alias pose_id = self.db.insert_pose(**pose_data) - elif result and duplicate_alias == "skip": + elif result and duplicate_alias == 'skip': (pose_id,) = result else: @@ -2788,15 +2742,15 @@ def register_pose( assert pose_id, (result, pose_id) if not pose_id: - mrich.var("compound", compound) - mrich.var("inchikey", inchikey) - mrich.var("alias", alias) - mrich.var("target", target) - mrich.var("path", path) - mrich.var("reference", reference) - mrich.var("tags", tags) - mrich.debug(f"{metadata=}") - mrich.debug(f"{inspirations=}") + mrich.var('compound', compound) + mrich.var('inchikey', inchikey) + mrich.var('alias', alias) + mrich.var('target', target) + mrich.var('path', path) + mrich.var('reference', reference) + mrich.var('tags', tags) + mrich.debug(f'{metadata=}') + mrich.debug(f'{inspirations=}') raise Exception @@ -2814,7 +2768,7 @@ def register_pose( if overwrite_metadata: self.db.insert_metadata( - table="pose", id=pose_id, payload=metadata, commit=commit + table='pose', id=pose_id, payload=metadata, commit=commit ) inspirations = inspirations or [] @@ -2831,7 +2785,7 @@ def register_pose( def register_route( self, *, - recipe: "Recipe", + recipe: 'Recipe', commit: bool = True, ) -> int: """ @@ -2848,11 +2802,11 @@ def register_route( def quote_compounds( self, - ref_animal: "HIPPO", + ref_animal: 'HIPPO', compounds: CompoundSet | None = None, *, debug: bool = False, - ) -> "CompoundSet,CompoundSet": + ) -> 'CompoundSet,CompoundSet': """Transfer quotes from another reference :class:`.HIPPO` animal object (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from @@ -2866,37 +2820,36 @@ def quote_compounds( inchikeys = self.compounds.inchikeys quote_fields = [ - "quote_id", - "quote_smiles", - "quote_amount", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_lead_time", - "quote_price", - "quote_currency", - "quote_purity", - "quote_date", - "quote_compound", + 'quote_id', + 'quote_smiles', + 'quote_amount', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_lead_time', + 'quote_price', + 'quote_currency', + 'quote_purity', + 'quote_date', + 'quote_compound', ] sql = f""" - SELECT {', '.join(quote_fields)} + SELECT {', '.join(quote_fields)} FROM {self.db.SQL_SCHEMA_PREFIX}quote INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON quote_compound = compound_id WHERE compound_inchikey IN {tuple(inchikeys)} """ - with mrich.loading("Querying reference database..."): + with mrich.loading('Querying reference database...'): records = ref_animal.db.execute(sql).fetchall() quoted_compound_ids = set() - quote_count = self.db.count("quote") + quote_count = self.db.count('quote') for record in mrich.track( - records, total=len(records), prefix="Inserting quotes" + records, total=len(records), prefix='Inserting quotes' ): - ( quote_id, quote_smiles, @@ -2919,7 +2872,7 @@ def quote_compounds( continue if debug: - mrich.debug("Inserting quote for", compound) + mrich.debug('Inserting quote for', compound) try: self.db.insert_quote( @@ -2951,18 +2904,18 @@ def quote_compounds( else: unquoted_compounds = self.compounds[:] - quoted_compounds - mrich.var("#new quotes", self.db.count("quote") - quote_count) - mrich.var("#quoted_compounds", len(quoted_compounds)) - mrich.var("#unquoted_compounds", len(unquoted_compounds)) + mrich.var('#new quotes', self.db.count('quote') - quote_count) + mrich.var('#quoted_compounds', len(quoted_compounds)) + mrich.var('#unquoted_compounds', len(unquoted_compounds)) return quoted_compounds, unquoted_compounds def quote_reactants( self, - ref_animal: "HIPPO", + ref_animal: 'HIPPO', *, unquoted_only: bool = False, - supplier: str = "any", + supplier: str = 'any', debug: bool = False, ) -> None: """Get batch quotes for all reactants in the database @@ -2976,13 +2929,13 @@ def quote_reactants( else: compounds = self.reactants - mrich.var("#compounds", len(compounds)) + mrich.var('#compounds', len(compounds)) self.quote_compounds(ref_animal=ref_animal, compounds=compounds, debug=debug) def quote_intermediates( self, - ref_animal: "HIPPO", + ref_animal: 'HIPPO', ) -> None: """Get batch quotes for all reactants in the database @@ -2994,23 +2947,23 @@ def quote_intermediates( ### PLOTTING - def plot_tag_statistics(self, *args, **kwargs) -> "plotly.graph_objects.Figure": + def plot_tag_statistics(self, *args, **kwargs) -> 'plotly.graph_objects.Figure': """Plot an overview of the number of compounds and poses for each tag, see :func:`hippo.plotting.plot_tag_statistics`""" if not self.num_tags: - mrich.error("No tagged compounds or poses") + mrich.error('No tagged compounds or poses') return from .plotting import plot_tag_statistics return plot_tag_statistics(self, *args, **kwargs) - def plot_compound_property(self, prop, **kwargs) -> "plotly.graph_objects.Figure": + def plot_compound_property(self, prop, **kwargs) -> 'plotly.graph_objects.Figure': """Plot an arbitrary compound property across the whole dataset, see :func:`hippo.plotting.plot_compound_property`""" from .plotting import plot_compound_property return plot_compound_property(self, prop, **kwargs) - def plot_pose_property(self, prop, **kwargs) -> "plotly.graph_objects.Figure": + def plot_pose_property(self, prop, **kwargs) -> 'plotly.graph_objects.Figure': """Plot an arbitrary pose property across the whole dataset, see :func:`hippo.plotting.plot_pose_property`""" from .plotting import plot_pose_property @@ -3018,7 +2971,7 @@ def plot_pose_property(self, prop, **kwargs) -> "plotly.graph_objects.Figure": def plot_interaction_punchcard( self, poses=None, subtitle=None, opacity=1.0, **kwargs - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_interaction_punchcard`""" from .plotting import plot_interaction_punchcard @@ -3028,7 +2981,7 @@ def plot_interaction_punchcard( def plot_interaction_punchcard_by_tags( self, tags: dict[str, str] | list[str], **kwargs - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot an interaction punchcard for a set of poses associated to given tags, see :func:`hippo.plotting.plot_interaction_punchcard_by_tags`""" from .plotting import plot_interaction_punchcard_by_tags @@ -3036,7 +2989,7 @@ def plot_interaction_punchcard_by_tags( def plot_residue_interactions( self, residue_number: int, poses: str | None = None, **kwargs - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_residue_interactions`""" from .plotting import plot_residue_interactions @@ -3046,7 +2999,7 @@ def plot_residue_interactions( def plot_compound_availability( self, compounds=None, **kwargs - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot a bar chart of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" from .plotting import plot_compound_availability @@ -3054,7 +3007,7 @@ def plot_compound_availability( def plot_compound_availability_venn( self, compounds, **kwargs - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot a venn diagram of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" from .plotting import plot_compound_availability_venn @@ -3065,9 +3018,9 @@ def plot_compound_price( min_amount, compounds=None, plot_lead_time=False, - style="histogram", + style='histogram', **kwargs, - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot a bar chart of minimum compound price for a given minimum amount, see :func:`hippo.plotting.plot_compound_price`""" from .plotting import plot_compound_price @@ -3075,15 +3028,15 @@ def plot_compound_price( self, min_amount=min_amount, compounds=compounds, style=style, **kwargs ) - def plot_reaction_funnel(self, **kwargs) -> "plotly.graph_objects.Figure": + def plot_reaction_funnel(self, **kwargs) -> 'plotly.graph_objects.Figure': """Plot a funnel chart of the reactants, intermediates, and products across the whole dataset, see :func:`hippo.plotting.plot_reaction_funnel`""" from .plotting import plot_reaction_funnel return plot_reaction_funnel(self, **kwargs) def plot_pose_interactions( - self, pose: "Pose", **kwargs - ) -> "plotly.graph_objects.Figure": + self, pose: 'Pose', **kwargs + ) -> 'plotly.graph_objects.Figure': """3d figure showing the interactions between a :class:`.Pose` and the protein. see :func:`hippo.plotting.plot_pose_interactions`""" from .plotting import plot_pose_interactions @@ -3091,15 +3044,15 @@ def plot_pose_interactions( def get_scaffold_network( self, - compounds: "CompoundSet | None" = None, - scaffolds: "CompoundSet | None" = None, + compounds: 'CompoundSet | None' = None, + scaffolds: 'CompoundSet | None' = None, notebook: bool = True, depth: int = 5, scaffold_tag: str | None = None, exclude_tag: str | None = None, physics: bool = True, arrows: bool = True, - ) -> "pyvis.network.Network": + ) -> 'pyvis.network.Network': """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" from .pyvis import get_scaffold_network @@ -3199,15 +3152,15 @@ def get_scaffold_network( def summary(self) -> None: """Print a text summary of this HIPPO""" mrich.header(self) - mrich.var("db_path", self.db_path) - mrich.var("#compounds", self.num_compounds) - mrich.var("#poses", self.num_poses) - mrich.var("#reactions", self.num_reactions) - mrich.var("#tags", self.num_tags) - mrich.var("tags", self.tags.unique) + mrich.var('db_path', self.db_path) + mrich.var('#compounds', self.num_compounds) + mrich.var('#poses', self.num_poses) + mrich.var('#reactions', self.num_reactions) + mrich.var('#tags', self.num_tags) + mrich.var('tags', self.tags.unique) # mrich.var('#products', len(self.products)) - def get_by_shorthand(self, key) -> "Compound | Pose | Reaction": + def get_by_shorthand(self, key) -> 'Compound | Pose | Reaction': """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID :param key: shortname of the object, e.g. C100 for :class:`.Compound` with id=100 @@ -3220,32 +3173,32 @@ def get_by_shorthand(self, key) -> "Compound | Pose | Reaction": prefix = key[0] index = key[1:] - if prefix not in "CPRTFIS": + if prefix not in 'CPRTFIS': raise AttributeError(f"'HIPPO' object has no attribute '{key}'") try: index = int(index) except ValueError: - mrich.error(f"Cannot convert {index} to integer") + mrich.error(f'Cannot convert {index} to integer') return None match key[0]: - case "C": + case 'C': return self.compounds[index] - case "P": + case 'P': return self.poses[index] - case "R": + case 'R': return self.reactions[index] - case "T": + case 'T': return self.db.get_target(id=index) - case "F": + case 'F': return self.db.get_feature(id=index) - case "I": + case 'I': return self.db.get_interaction(id=index) - case "S": + case 'S': return self.db.get_subsite(id=index) - mrich.error(f"Unsupported {prefix=}") + mrich.error(f'Unsupported {prefix=}') return None ### DUNDERS @@ -3256,11 +3209,11 @@ def __str__(self) -> str: def __repr__(self) -> str: """Returns a command line representation of this HIPPO""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.clear}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.clear}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __getitem__(self, key: str): """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID. See :meth:`.HIPPO.get_by_shorthand`""" @@ -3272,22 +3225,22 @@ def __getattr__(self, key: str): GENERATED_TAG_COLS = [ - "ConformerSites alias", - "CanonSites alias", - "CrystalformSites alias", - "Quatassemblies alias", - "Crystalforms alias", - "ConformerSites upload name", - "CanonSites upload name", - "CrystalformSites upload name", - "Quatassemblies upload name", - "Crystalforms upload name", - "ConformerSites short tag", - "CanonSites short tag", - "CrystalformSites short tag", - "Quatassemblies short tag", - "Crystalforms short tag", - "Centroid res", - "Experiment code", - "Pose", + 'ConformerSites alias', + 'CanonSites alias', + 'CrystalformSites alias', + 'Quatassemblies alias', + 'Crystalforms alias', + 'ConformerSites upload name', + 'CanonSites upload name', + 'CrystalformSites upload name', + 'Quatassemblies upload name', + 'Crystalforms upload name', + 'ConformerSites short tag', + 'CanonSites short tag', + 'CrystalformSites short tag', + 'Quatassemblies short tag', + 'Crystalforms short tag', + 'Centroid res', + 'Experiment code', + 'Pose', ] diff --git a/hippo/apsw.py b/hippo/apsw.py index 0917c22..8d8d6b8 100644 --- a/hippo/apsw.py +++ b/hippo/apsw.py @@ -3,7 +3,7 @@ import apsw -def executemany(path: "Path", sql: str, payload: list[tuple]): +def executemany(path: 'Path', sql: str, payload: list[tuple]): """Bulk execution with apsw""" connection = apsw.Connection(str(path.resolve())) result = list(connection.executemany(sql, payload)) diff --git a/hippo/chem.py b/hippo/chem.py index b60ae35..a0269e3 100644 --- a/hippo/chem.py +++ b/hippo/chem.py @@ -14,128 +14,128 @@ """ SUPPORTED_CHEMISTRY = { - "Amidation": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": 2}, + 'Amidation': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, }, }, - "Ester_amidation": { - "heavy_atoms_diff": ">=3", - "rings_diff": 0, - "atomtype": { - "removed": {"O": ">=1", "*": "*"}, + 'Ester_amidation': { + 'heavy_atoms_diff': '>=3', + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': '>=1', '*': '*'}, }, }, - "Williamson_ether_synthesis": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, # any halogen + 'Williamson_ether_synthesis': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen }, }, - "N-Boc_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 2, "C": 5, "H": 8}, + 'N-Boc_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 2, 'C': 5, 'H': 8}, }, }, - "TBS_alcohol_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 0, - "atomtype": { - "removed": {"C": 6, "Si": 1, "H": 14}, + 'TBS_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'C': 6, 'Si': 1, 'H': 14}, }, }, - "Sp3-sp2_Suzuki_coupling": { + 'Sp3-sp2_Suzuki_coupling': { # "heavy_atoms_diff": 10, - "heavy_atoms_diff": ">=4", - "rings_diff": ">=0", - "atomtype": { + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen - "removed": {"C": ">=0", "O": 2, "B": 1, "Ha": 1, "H": ">=2"}, # any halogen + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen }, }, - "Sp2-sp2_Suzuki_coupling": { - "heavy_atoms_diff": ">=4", - "rings_diff": ">=0", - "atomtype": { - "removed": {"C": ">=0", "O": 2, "B": 1, "Ha": 1, "H": ">=2"}, # any halogen + 'Sp2-sp2_Suzuki_coupling': { + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen }, }, - "Buchwald-Hartwig_amidation_with_amide-like_nucleophile": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, + 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, }, }, - "Buchwald-Hartwig_amination": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, + 'Buchwald-Hartwig_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, }, }, - "Nucleophilic_substitution_with_amine": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, + 'Nucleophilic_substitution_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, }, }, - "N-nucleophilic_aromatic_substitution": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"Ha": 1, "H": 1}, # any halogen + 'N-nucleophilic_aromatic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen }, }, - "Reductive_amination": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1}, # any halogen + 'Reductive_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1}, # any halogen }, }, - "Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": ">=1"}, + 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': '>=1'}, }, }, - "Steglich_esterification": { - "heavy_atoms_diff": 1, - "rings_diff": 0, - "atomtype": { - "removed": {"O": 1, "H": 2}, + 'Steglich_esterification': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, }, }, - "Benzyl_alcohol_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 1, - "atomtype": { - "removed": {"C": 7, "H": 6}, + 'Benzyl_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + 'atomtype': { + 'removed': {'C': 7, 'H': 6}, }, }, - "N-Bn_deprotection": { - "heavy_atoms_diff": 7, - "rings_diff": 1, + 'N-Bn_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, }, - "Formation_of_urea_from_two_amines": { - "heavy_atoms_diff": -2, - "rings_diff": 0, + 'Formation_of_urea_from_two_amines': { + 'heavy_atoms_diff': -2, + 'rings_diff': 0, }, - "Amide_Schotten-Baumann_with_amine": { - "heavy_atoms_diff": 1, - "rings_diff": 0, + 'Amide_Schotten-Baumann_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, }, - "Nucleophilic_substitution": { - "heavy_atoms_diff": 1, - "rings_diff": 0, + 'Nucleophilic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, }, } @@ -154,45 +154,44 @@ def check_reaction_types(types: list[str]) -> None: def check_chemistry( reaction_type: str, - reactants: "CompoundSet", - product: "Compound", + reactants: 'CompoundSet', + product: 'Compound', debug: bool = False, ) -> bool: """Check chemistry of given reaction""" if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.var('reactants', reactants.ids) + mrich.var('product', product) - mrich.var("reactants", reactants.ids) - mrich.var("product", product) - - raise UnsupportedChemistryError(f"Unsupported {reaction_type=}") + raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') assert reactants assert product CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] - if "heavy_atoms_diff" in CHEMISTRY: + if 'heavy_atoms_diff' in CHEMISTRY: check = check_count_diff( - "heavy_atoms", reaction_type, reactants, product, debug=debug + 'heavy_atoms', reaction_type, reactants, product, debug=debug ) if not check: return False - if "rings_diff" in CHEMISTRY: + if 'rings_diff' in CHEMISTRY: check = check_count_diff( - "rings", reaction_type, reactants, product, debug=debug + 'rings', reaction_type, reactants, product, debug=debug ) if not check: return False - if "atomtype" in CHEMISTRY: + if 'atomtype' in CHEMISTRY: check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) if not check: return False if debug: - mrich.success(f"{reaction_type}: All OK") + mrich.success(f'{reaction_type}: All OK') return True @@ -200,75 +199,73 @@ def check_chemistry( def check_count_diff( check_type: str, reaction_type: str, - reactants: "CompoundSet", - product: "Compound", + reactants: 'CompoundSet', + product: 'Compound', debug: bool = False, ): """Check integer difference""" # get target value - diff = SUPPORTED_CHEMISTRY[reaction_type][f"{check_type}_diff"] + diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] # get attribute name - attr = f"num_{check_type}" + attr = f'num_{check_type}' # get values reac_count = getattr(reactants, attr) prod_count = getattr(product, attr) if debug: - mrich.var(f"#{check_type} reactants", reac_count) + mrich.var(f'#{check_type} reactants', reac_count) if debug: - mrich.var(f"#{check_type} product", prod_count) + mrich.var(f'#{check_type} product', prod_count) # check against target value if isinstance(diff, str): - - assert diff.startswith(">="), diff + assert diff.startswith('>='), diff diff = int(diff[2:]) if reac_count - prod_count < diff: if debug: mrich.error( - f"{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL" + f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' ) return False elif debug: - mrich.success(f"{reaction_type}: #{check_type} OK") + mrich.success(f'{reaction_type}: #{check_type} OK') else: - if reac_count - diff != prod_count: if debug: - mrich.error(f"{reaction_type}: #{check_type} FAIL") + mrich.error(f'{reaction_type}: #{check_type} FAIL') return False elif debug: - mrich.success(f"{reaction_type}: #{check_type} OK") + mrich.success(f'{reaction_type}: #{check_type} OK') return True def check_atomtype_diff( reaction_type: str, - reactants: "CompoundSet", - product: "Compound", + reactants: 'CompoundSet', + product: 'Compound', debug: bool = False, ) -> bool: """check atomtypes""" - check_type = "atomtype" + check_type = 'atomtype' # get values reac = reactants.atomtype_dict prod = product.atomtype_dict if debug: - mrich.var("reactants.atomtype_dict", str(reac)) - mrich.var("product.atomtype_dict", str(prod)) + mrich.var('reactants.atomtype_dict', str(reac)) + mrich.var('product.atomtype_dict', str(prod)) - if "removed" in SUPPORTED_CHEMISTRY[reaction_type]["atomtype"]: + if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: removal = check_specific_atomtype_diff( reaction_type, prod, reac, removal=True, debug=debug ) @@ -276,7 +273,7 @@ def check_atomtype_diff( if not removal: return False - if "added" in SUPPORTED_CHEMISTRY[reaction_type]["atomtype"]: + if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: addition = check_specific_atomtype_diff( reaction_type, prod, reac, removal=False, debug=debug ) @@ -285,26 +282,26 @@ def check_atomtype_diff( return False if debug: - mrich.success(f"{reaction_type}: atomtypes OK") + mrich.success(f'{reaction_type}: atomtypes OK') return True def check_specific_atomtype_diff( reaction_type: str, - prod: "Compound", - reac: "Compound", + prod: 'Compound', + reac: 'Compound', removal: bool = False, debug: bool = False, ) -> bool: """check specific atomtype difference""" if removal: - add_str = "removed" + add_str = 'removed' else: - add_str = "added" + add_str = 'added' - add_dict = SUPPORTED_CHEMISTRY[reaction_type]["atomtype"][add_str] + add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] if not add_dict: return True @@ -313,15 +310,14 @@ def check_specific_atomtype_diff( mrich.var(add_str, str(add_dict)) for symbol, count in add_dict.items(): - - if symbol == "Ha": + if symbol == 'Ha': p_count = halogen_count(prod) r_count = halogen_count(reac) - elif symbol == "*": - assert count == "*", (symbol, count) + elif symbol == '*': + assert count == '*', (symbol, count) if debug: - mrich.debug("Allowing wildcard atomtype differences") + mrich.debug('Allowing wildcard atomtype differences') continue else: @@ -329,50 +325,48 @@ def check_specific_atomtype_diff( r_count = reac[symbol] if symbol in reac else 0 if isinstance(count, str): - - assert count.startswith(">="), (symbol, count) + assert count.startswith('>='), (symbol, count) count = int(count[2:]) if removal and r_count - p_count < count: if debug: mrich.error( - f"{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}" + f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' ) mrich.error( - f"{reaction_type}: atomtype removal {symbol} × {count} FAIL" + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' ) return False elif not removal and p_count - r_count < count: if debug: mrich.error( - f"{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}" + f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' ) mrich.error( - f"{reaction_type}: atomtype addition {symbol} × {count} FAIL" + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' ) return False else: - if removal and r_count - p_count != count: if debug: mrich.error( - f"{symbol}: {r_count=} - {p_count=} = {r_count - p_count}" + f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' ) mrich.error( - f"{reaction_type}: atomtype removal {symbol} × {count} FAIL" + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' ) return False elif not removal and p_count - r_count != count: if debug: mrich.error( - f"{symbol}: {p_count=} - {r_count=} = {p_count - r_count}" + f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' ) mrich.error( - f"{reaction_type}: atomtype addition {symbol} × {count} FAIL" + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' ) return False @@ -382,7 +376,7 @@ def check_specific_atomtype_diff( def halogen_count(atomtype_dict: dict[str, int]) -> int: """Count halogens""" count = 0 - symbols = ["F", "Cl", "Br", "I"] + symbols = ['F', 'Cl', 'Br', 'I'] for symbol in symbols: if symbol in atomtype_dict: count += atomtype_dict[symbol] diff --git a/hippo/compound.py b/hippo/compound.py index 6b16828..63e2fec 100644 --- a/hippo/compound.py +++ b/hippo/compound.py @@ -2,13 +2,11 @@ import mcol import mrich - from rdkit import Chem from .pose import Pose -from .tags import TagSet from .quote import Quote -from .target import Target +from .tags import TagSet class Compound: @@ -20,12 +18,12 @@ class Compound: """ - _table = "compound" + _table = 'compound' def __init__( self, - animal: "HIPPO", - db: "Database", + animal: 'HIPPO', + db: 'Database', id: int, inchikey: str, alias: str, @@ -109,7 +107,7 @@ 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 + 'num_heavy_atoms', self.id ) return self._num_heavy_atoms @@ -118,7 +116,7 @@ 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 + 'molecular_weight', self.id ) return self._molecular_weight @@ -127,7 +125,7 @@ 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 + 'num_rings', self.id ) return self._num_rings @@ -135,7 +133,7 @@ def num_rings(self) -> int: def formula(self) -> str: """Get the chemical formula""" if self._formula is None: - self._formula = self.db.get_compound_computed_property("formula", self.id) + self._formula = self.db.get_compound_computed_property('formula', self.id) return self._formula @property @@ -150,31 +148,31 @@ 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") + 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) + 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") + 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) + - self.db.get_compound_computed_property('num_heavy_atoms', b_id) for b_id in self.scaffolds.ids ] @property - def metadata(self) -> "MetaData": + 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) + self._metadata = self.db.get_metadata(table='compound', id=self.id) return self._metadata @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -186,7 +184,7 @@ def tags(self) -> TagSet: return self._tags @property - def poses(self) -> "PoseSet": + def poses(self) -> 'PoseSet': """Returns the compound's poses""" return self.get_poses() @@ -198,20 +196,20 @@ def best_placed_pose(self) -> 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) + 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) + 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) + return self.db.count_where(table='reactant', key='compound', value=self.id) @property - def scaffolds(self) -> "CompoundSet | None": + 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() @@ -220,7 +218,7 @@ def scaffolds(self) -> "CompoundSet | None": else: from .cset import CompoundSet - self._scaffolds = CompoundSet(self.db, ids, name=f"scaffolds of {self}") + self._scaffolds = CompoundSet(self.db, ids, name=f'scaffolds of {self}') self._total_changes = self.db.total_changes return self._scaffolds @@ -242,25 +240,25 @@ def elabs(self): else: from .cset import CompoundSet - self._elabs = CompoundSet(self.db, ids, name=f"elaborations of {self}") + 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": + def reactions(self) -> 'ReactionSet': """Returns the reactions resulting in this compound""" return self.get_reactions(none=False) @property - def reaction(self) -> "Reaction": + 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") + mrich.warning(f'{self} has no reactions') return None case 1: - mrich.warning(f"{self} has multiple reactions, returning first") + mrich.warning(f'{self} has multiple reactions, returning first') case _: pass @@ -276,12 +274,12 @@ 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", + query='1', + table='scaffold', + key='base', value=self.id, multiple=False, - none="quiet", + none='quiet', ) ) @@ -290,12 +288,12 @@ 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", + query='1', + table='scaffold', + key='superstructure', value=self.id, multiple=False, - none="quiet", + none='quiet', ) ) @@ -340,14 +338,13 @@ def add_stock( assert amount # search for existing in stock quotes - existing = self.get_quotes(supplier="Stock", df=False) + 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, @@ -360,16 +357,16 @@ def add_stock( delete.add(quote.id) - delete_str = str(tuple(delete)).replace(",)", ")") + delete_str = str(tuple(delete)).replace(',)', ')') - self.db.delete_where(table="quote", key=f"quote_id IN {delete_str}") + 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") + 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" + f'Did not remove {not_deleted} existing In-Stock Quotes with differing entry/purity/location' ) # insert the new quote @@ -378,7 +375,7 @@ def add_stock( price=0, lead_time=0, currency=None, - supplier="Stock", + supplier='Stock', catalogue=location, entry=entry, amount=amount, @@ -390,15 +387,15 @@ def add_stock( else: return quote_id - def get_tags(self) -> "TagSet": + def get_tags(self) -> 'TagSet': """Get the tags assigned to this compound""" tags = self.db.select_where( - query="tag_name", - table="tag", - key="compound", + query='tag_name', + table='tag', + key='compound', value=self.id, multiple=True, - none="quiet", + none='quiet', ) return TagSet(self, {t[0] for t in tags}, commit=False) @@ -415,10 +412,10 @@ def get_quotes( min_amount: float | None = None, supplier: str | None = None, max_lead_time: float | None = None, - none: str = "quiet", + none: str = 'quiet', pick_cheapest: bool = False, df: bool = False, - ) -> list["Quote"]: + ) -> list['Quote']: """Get all quotes associated to this compound :param min_amount: Only return quotes with amounts greater than this, defaults to ``None`` @@ -433,26 +430,26 @@ def get_quotes( if not supplier: quote_ids = self.db.select_where( - query="quote_id", - table="quote", - key="compound", + 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", + 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(",)",")")}', + query='quote_id', + table='quote', + key=f'quote_compound = {self.id} AND quote_supplier IN {str(tuple(supplier)).replace(",)", ")")}', multiple=True, none=none, ) @@ -470,7 +467,7 @@ def get_quotes( if not suitable_quotes: mrich.debug( - f"No quote available for C{self.id} with amount >= {min_amount} mg. Estimating price..." + f'No quote available for C{self.id} with amount >= {min_amount} mg. Estimating price...' ) quotes = [Quote.combination(min_amount, quotes)] @@ -483,16 +480,16 @@ def get_quotes( if df: from pandas import DataFrame - return DataFrame([q.dict for q in quotes]).drop(columns="compound") + return DataFrame([q.dict for q in quotes]).drop(columns='compound') return quotes def get_reactions( self, as_reactant: bool = False, - permitted_reactions: "ReactionSet" = None, - none: str = "error", - ) -> "ReactionSet": + 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`` @@ -504,24 +501,24 @@ def get_reactions( if as_reactant: reaction_ids = self.db.select_where( - query="reactant_reaction", - table="reactant", - key="compound", + 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", + query='reaction_id', + table='reaction', + key='product', value=self.id, multiple=True, none=none, ) - reaction_ids = [q for q, in reaction_ids] + reaction_ids = [q for (q,) in reaction_ids] if permitted_reactions: reaction_ids = [i for i in reaction_ids if i in permitted_reactions] @@ -529,17 +526,17 @@ def get_reactions( rset = ReactionSet(self.db, reaction_ids) if not permitted_reactions: - rset._name = f"reactions resulting in {str(self)}" + rset._name = f'reactions resulting in {str(self)}' return rset - def get_poses(self) -> "PoseSet": + def get_poses(self) -> 'PoseSet': """Get the associated :class:`.Pose` objects.""" pose_ids = self.db.select_where( - query="pose_id", - table="pose", - key="compound", + query='pose_id', + table='pose', + key='compound', value=self.id, multiple=True, none=False, @@ -563,7 +560,7 @@ def get_dict( scaffolds: bool = True, elabs: bool = True, tags: bool = True, - ) -> "dict": + ) -> 'dict': """Returns a dictionary representing this :class:`.Compound` :param mol: Include a ``rdkit.Chem.Mol object``, defaults to ``True`` @@ -579,18 +576,18 @@ def get_dict( """ serialisable_fields = [ - "id", - "smiles", + 'id', + 'smiles', ] if alias: - serialisable_fields.append("alias") + serialisable_fields.append('alias') if inchikey: - serialisable_fields.append("inchikey") + serialisable_fields.append('inchikey') if num_reactant: - serialisable_fields.append("num_reactant") + serialisable_fields.append('num_reactant') if num_reactions: - serialisable_fields.append("num_reactions") + serialisable_fields.append('num_reactions') data = {} for key in serialisable_fields: @@ -598,40 +595,38 @@ def get_dict( if mol: try: - data["mol"] = self.mol + data['mol'] = self.mol except InvalidMolError: - data["mol"] = None + data['mol'] = None if scaffolds: if self.scaffolds: - data["scaffolds"] = self.scaffolds.ids + data['scaffolds'] = self.scaffolds.ids else: - data["scaffolds"] = None + data['scaffolds'] = None if elabs: if self.elabs: - data["elabs"] = self.elabs.ids + data['elabs'] = self.elabs.ids else: - data["elabs"] = None + data['elabs'] = None if tags: - data["tags"] = self.tags + data['tags'] = self.tags if poses: - poses = self.poses if poses: - - data["poses"] = poses.ids - data["targets"] = poses.target_names + 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) + data[f'#poses {target.name}'] = len(t_poses) if metadata and (metadict := self.metadata): for key in metadict: @@ -651,8 +646,8 @@ def get_recipes( ): """Get :class:`.Recipe` objects that result in this compound. See :meth:`.Recipe.from_compounds`""" - from .recipe import Recipe from .cset import CompoundSet + from .recipe import Recipe return Recipe.from_compounds( CompoundSet(self.db, [self.id]), @@ -667,32 +662,32 @@ def get_recipes( 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", + table='scaffold', + query='scaffold_base', + key='superstructure', value=self.id, - none="quiet", + none='quiet', multiple=True, ) if not ids: return None - return [i for i, in ids] + return [i for (i,) in ids] 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", + table='scaffold', + query='scaffold_superstructure', + key='base', value=self.id, - none="quiet", + none='quiet', multiple=True, ) if not ids: return None - return [i for i, in ids] + return [i for (i,) in ids] - def add_scaffold(self, scaffold: "Compound | int", commit: bool = True) -> None: + def add_scaffold(self, scaffold: 'Compound | int', commit: bool = True) -> None: """ Add a scaffold :class:`.Compound` this molecule is derived from. @@ -701,7 +696,7 @@ def add_scaffold(self, scaffold: "Compound | int", commit: bool = True) -> None: """ if not isinstance(scaffold, int): - assert scaffold._table == "compound" + assert scaffold._table == 'compound' scaffold = scaffold.id self.db.insert_scaffold(scaffold=scaffold, superstructure=self.id) @@ -716,9 +711,9 @@ def set_alias(self, alias: str, commit=True) -> None: assert isinstance(alias, str) self._alias = alias self.db.update( - table="compound", + table='compound', id=self.id, - key="compound_alias", + key='compound_alias', value=alias, commit=commit, ) @@ -729,8 +724,8 @@ def as_ingredient( max_lead_time: float = None, supplier: str = None, get_quote: bool = True, - quote_none: str = "quiet", - ) -> "Ingredient": + 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`` @@ -773,16 +768,14 @@ def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None """ if scaffolds and (scaffolds := self.scaffolds): - from molparse.rdkit import draw_mcs data = {} for scaffold in scaffolds: - data[scaffold.smiles] = f"{scaffold} (scaffold)" + data[scaffold.smiles] = f'{scaffold} (scaffold)' data[self.smiles] = str(self) if len(data) > 1: - drawing = draw_mcs( data, align_substructure=align_substructure, @@ -793,7 +786,7 @@ def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None else: mrich.error( - f"Problem drawing {scaffold.id=} vs {self.id=}, self referential?" + f'Problem drawing {scaffold.id=} vs {self.id=}, self referential?' ) display(self.mol) @@ -804,7 +797,7 @@ def draw_elabs(self): """Draw elaborations""" from molparse.rdkit import draw_highlighted_mol - from rdkit.Chem import rdRGroupDecomposition, MolFromSmarts + from rdkit.Chem import MolFromSmarts, rdRGroupDecomposition elabs = self.elabs @@ -812,7 +805,7 @@ def draw_elabs(self): display(elabs) if not elabs: - mrich.error(self, "has no elaborations") + mrich.error(self, 'has no elaborations') return self.draw() # set RGD params @@ -833,9 +826,9 @@ def draw_elabs(self): rgroup_table = rgd.GetRGroupsAsColumns() # get the core and its attachment points - core = rgroup_table["Core"][0] + core = rgroup_table['Core'][0] attachment_points = set() - for rgroup in rgroup_table["Core"]: + 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()) @@ -888,31 +881,31 @@ def summary( 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('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('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) + mrich.var('#reactions (product)', self.num_reactions) + mrich.var('#reactions (reactant)', self.num_reactant) if tags: - mrich.var("tags", self.tags) + mrich.var('tags', self.tags) poses = self.poses - mrich.var("#poses", len(poses)) + mrich.var('#poses', len(poses)) if poses: - mrich.var("targets", poses.targets) + mrich.var('targets', poses.targets) if metadata: - mrich.var("metadata", str(self.metadata)) + mrich.var('metadata', str(self.metadata)) if draw: self.draw() @@ -924,7 +917,7 @@ def place( inspirations: list[Pose] | None = None, max_ddG: float = 0.0, max_RMSD: float = 2.0, - output_dir: str = "wictor_place", + output_dir: str = 'wictor_place', tags: list[str] = None, metadata: dict = None, overwrite: bool = False, @@ -942,9 +935,10 @@ def place( :param overwrite: Delete old poses, defaults to ``False`` """ - from fragmenstein import Monster, Wictor from pathlib import Path + from fragmenstein import Wictor + tags = tags or [] metadata = metadata or {} @@ -966,23 +960,23 @@ def place( victor.place(smiles, long_name=self.name) # metadata - metadata["ddG"] = ( - victor.energy_score["bound"]["total_score"] - - victor.energy_score["unbound"]["total_score"] + metadata['ddG'] = ( + victor.energy_score['bound']['total_score'] + - victor.energy_score['unbound']['total_score'] ) - metadata["RMSD"] = victor.mrmsd.mrmsd + metadata['RMSD'] = victor.mrmsd.mrmsd - if metadata["ddG"] > max_ddG: + if metadata['ddG'] > max_ddG: return None - if metadata["RMSD"] > max_RMSD: + 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", + path=Path(victor.work_path) / self.name / f'{self.name}.minimised.mol', inspirations=inspirations, reference=reference, tags=tags, @@ -992,14 +986,14 @@ def place( 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)") + 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}") + mrich.success(f'Successfully posed {self}') return pose - def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSet": + 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 @@ -1008,7 +1002,7 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe from .pset import PoseSet match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ SELECT pose_id, inspiration_original FROM compound INNER JOIN scaffold ON compound_id = scaffold_base @@ -1016,7 +1010,7 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe INNER JOIN inspiration ON pose_id = inspiration_derivative WHERE compound_id = :compound_id """ - case "psycopg": + case 'psycopg': sql = """ SELECT pose_id, inspiration_original FROM hippo.compound INNER JOIN hippo.scaffold ON compound_id = scaffold_base @@ -1025,20 +1019,20 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe WHERE compound_id = %(compound_id)s """ - with mrich.spinner(f"Querying inspirations for {self}"): + 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) + 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}") + mrich.debug(f'Inspirations derived from {derivatives.ids}') - inspirations._name = f"Inspirations for {self}" + inspirations._name = f'Inspirations for {self}' return inspirations @@ -1046,7 +1040,7 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe def __str__(self) -> str: """Unformatted string representation""" - return f"C{self.id}" + return f'C{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" @@ -1074,17 +1068,17 @@ class Ingredient: :class:`.Ingredient` objects should not be created directly. Instead use :meth:`.Compound.as_ingredient`. """ - _table = "ingredient" + _table = 'ingredient' def __init__( self, - db: "Database", - compound: "Compound | int", + db: 'Database', + compound: 'Compound | int', amount: float, - quote: "Quote | None", + quote: 'Quote | None', max_lead_time: float | None = None, supplier: str | None = None, - ) -> "Ingredient": + ) -> 'Ingredient': """Ingredient initialisation""" assert compound @@ -1101,7 +1095,6 @@ def __init__( self._compound_id = compound if isinstance(quote, Quote): - if id := quote.id: self._quote_id = quote.id self._quote = None @@ -1126,7 +1119,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns the parent :class:`.Database`""" return self._db @@ -1168,7 +1161,7 @@ def amount(self, a) -> None: min_amount=a, max_lead_time=self._max_lead_time, supplier=self._supplier, - none="quiet", + none='quiet', ) self._quote_id = quote_id @@ -1188,7 +1181,6 @@ 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) @@ -1198,7 +1190,7 @@ def quote(self) -> Quote: min_amount=self.amount, max_lead_time=self.max_lead_time, supplier=self.supplier, - none="quiet", + none='quiet', ) if not q: @@ -1212,7 +1204,7 @@ def quote(self) -> Quote: @property def compound_price_amount_str(self) -> str: """String representation including :class:`.Compound`, :class:`.Price`, and amount.""" - return f"{self} ({self.amount})" + return f'{self} ({self.amount})' @property def smiles(self) -> str: @@ -1220,7 +1212,7 @@ def smiles(self) -> str: return self.compound.smiles @property - def price(self) -> "Price | None": + def price(self) -> 'Price | None': """Returns the :class:`.Price` of the associated :class:`.Quote`""" if self.quote: return self.quote.price @@ -1250,7 +1242,7 @@ def get_cheapest_quote_id( min_amount: float | None = None, supplier: str | None = None, max_lead_time: float | None = None, - none: str = "quiet", + none: str = 'quiet', ) -> int | None: """ Query quotes associated to this ingredient, and return the cheapest @@ -1261,14 +1253,14 @@ def get_cheapest_quote_id( :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 "" + 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 "" + 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" + 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 + query='quote_id', table='quote', key=key_str, multiple=False, none=none ) if result: @@ -1278,7 +1270,7 @@ def get_cheapest_quote_id( else: return None - def get_quotes(self, **kwargs) -> list["Quote"]: + def get_quotes(self, **kwargs) -> list['Quote']: """Wrapper for :meth:`.Compound.get_quotes()`""" return self.compound.get_quotes(**kwargs) @@ -1286,15 +1278,15 @@ def get_quotes(self, **kwargs) -> list["Quote"]: def __str__(self) -> str: """Plain string representation""" - return f"{self.amount:.2f}mg of C{self._compound_id}" + return f'{self.amount:.2f}mg of C{self._compound_id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{str(self)}{mcol.unbold}{mcol.ununderline}" + 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)}" + return f'[bold underline]{str(self)}' def __eq__(self, other) -> bool: """Equality operator""" diff --git a/hippo/cset.py b/hippo/cset.py index ed4ef9a..df60da6 100644 --- a/hippo/cset.py +++ b/hippo/cset.py @@ -1,15 +1,14 @@ """Classes for working with sets of compounds""" +from collections.abc import Callable + import mcol import mrich +from numpy import int64, isnan, mean -import os -from typing import Callable -from numpy import int64, nan, isnan, mean, std - +from .compound import Compound, Ingredient from .db import Database from .recipe import Recipe -from .compound import Compound, Ingredient class CompoundTable: @@ -59,8 +58,8 @@ class CompoundTable: """ - _table = "compound" - _name = "all compounds" + _table = 'compound' + _name = 'all compounds' def __init__( self, @@ -85,8 +84,8 @@ def table(self) -> str: @property def names(self) -> list[str]: """Returns the names of child compounds""" - result = self.db.select(table=self.table, query="compound_name", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='compound_name', multiple=True) + return [q for (q,) in result] @property def name(self) -> None | str: @@ -96,92 +95,92 @@ def name(self) -> None | str: @property def ids(self) -> list[int]: """Returns the IDs of child compounds""" - result = self.db.select(table=self.table, query="compound_id", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='compound_id', multiple=True) + return [q for (q,) in result] @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property def inchikeys(self) -> list[str]: """Returns the inchikeys of all compounds""" result = self.db.select( - query="compound_inchikey", - table="compound", + query='compound_inchikey', + table='compound', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def tags(self) -> set[str]: """Returns the set of unique tags present in this compound set""" values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key="tag_compound IS NOT NULL", + table='tag', + query='DISTINCT tag_name', + key='tag_compound IS NOT NULL', multiple=True, ) - return set(v for v, in values) + return set(v for (v,) in values) @property - def reactants(self) -> "CompoundSet": + def reactants(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are used as a reactants""" # ids = self.db.select(table='reactant', query='DISTINCT reactant_compound', multiple=True) sql = f""" - SELECT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction - ON reactant_compound = reaction_product + SELECT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction + ON reactant_compound = reaction_product WHERE reaction_product IS NULL """ ids = self.db.execute(sql).fetchall() - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet cset = CompoundSet(self.db, ids) - cset._name = "all reactants" + cset._name = 'all reactants' return cset @property - def products(self) -> "CompoundSet": + def products(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are a product of a reaction but not a reactant""" sql = f""" - SELECT reaction_product - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON reaction_product = reactant_compound + SELECT reaction_product + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + ON reaction_product = reactant_compound WHERE reactant_compound IS NULL """ ids = self.db.execute(sql).fetchall() - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet cset = CompoundSet(self.db, ids) - cset._name = "all products" + cset._name = 'all products' return cset @property - def intermediates(self) -> "CompoundSet": + def intermediates(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are products and reactants""" sql = f""" - SELECT DISTINCT reaction_product - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + SELECT DISTINCT reaction_product + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON reaction_product = reactant_compound """ ids = self.db.execute(sql).fetchall() - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet cset = CompoundSet(self.db, ids) - cset._name = "all intermediates" + cset._name = 'all intermediates' return cset @property @@ -200,41 +199,41 @@ def num_products(self) -> int: return len(self.products) @property - def elabs(self) -> "CompoundSet": + def elabs(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" ids = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key="scaffold_superstructure IS NOT NULL", + query='scaffold_superstructure', + table='scaffold', + key='scaffold_superstructure IS NOT NULL', multiple=True, - none="quiet", + none='quiet', ) if not ids: return None - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet cset = CompoundSet(self.db, ids) - cset._name = "all elaborations" + cset._name = 'all elaborations' return cset @property - def scaffolds(self) -> "CompoundSet": + def scaffolds(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are the basis for a set of elaborations""" ids = self.db.select_where( - query="DISTINCT scaffold_base", - table="scaffold", - key="scaffold_base IS NOT NULL", + query='DISTINCT scaffold_base', + table='scaffold', + key='scaffold_base IS NOT NULL', multiple=True, - none="quiet", + none='quiet', ) - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet cset = CompoundSet(self.db, ids) - cset._name = "all scaffolds" + cset._name = 'all scaffolds' return cset @property @@ -253,7 +252,7 @@ def get_by_tag( self, tag: str, inverse: bool = False, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Get all child compounds with a certain tag :param tag: tag to filter by @@ -261,38 +260,36 @@ def get_by_tag( """ if not inverse: - values = self.db.select_where( - query="tag_compound", table="tag", key="name", value=tag, multiple=True + query='tag_compound', table='tag', key='name', value=tag, multiple=True ) else: - values = self.db.select_where( - query="tag_compound", table="tag", key="name", value=tag, multiple=True + query='tag_compound', table='tag', key='name', value=tag, multiple=True ) if not values: return self - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] values = self.db.select_where( - query="compound_id", - table="compound", - key=f"compound_id NOT IN {str(tuple(ids))}", + query='compound_id', + table='compound', + key=f'compound_id NOT IN {str(tuple(ids))}', multiple=True, ) if not values: return None - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] cset = self[ids] if inverse: - cset._name = f"compounds not tagged {tag}" + cset._name = f'compounds not tagged {tag}' else: - cset._name = f"compounds tagged {tag}" + cset._name = f'compounds tagged {tag}' return cset def get_by_metadata( @@ -307,16 +304,16 @@ def get_by_metadata( """ results = self.db.select( - query="compound_id, compound_metadata", table="compound", multiple=True + query='compound_id, compound_metadata', table='compound', multiple=True ) if value is None: ids = [i for i, d in results if d and f'"{key}":' in d] - name = f"compounds with {key} in metadata" + name = f'compounds with {key} in metadata' else: if isinstance(value, str): value = f'"{value}"' ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f"compounds with metadata[{key}] == {value}" + name = f'compounds with metadata[{key}] == {value}' cset = self[ids] cset._name = name @@ -325,24 +322,24 @@ def get_by_metadata( def get_by_metadata_substring_match( self, substring: str, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" assert substring assert isinstance(substring, str) compound_ids = self.db.select_where( - table="compound", - query="compound_id", + table='compound', + query='compound_id', key=f"""compound_metadata LIKE '%{substring}%'""", multiple=True, ) if not compound_ids: - mrich.error(f"No compounds with metadata substring: {substring}") + mrich.error(f'No compounds with metadata substring: {substring}') return None - compound_ids = [i for i, in compound_ids] + compound_ids = [i for (i,) in compound_ids] name = f"compounds with '{substring}' in metadata" @@ -354,7 +351,7 @@ def get_by_metadata_substring_match( def get_by_scaffold( self, scaffold: Compound | int, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Get all compounds that elaborate the given scaffold compound :param scaffold: :class:`.Compound` object or ID to search by @@ -362,35 +359,35 @@ def get_by_scaffold( """ if not isinstance(scaffold, int): - assert scaffold._table == "compound" + assert scaffold._table == 'compound' scaffold = scaffold.id values = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key="base", + query='scaffold_superstructure', + table='scaffold', + key='base', value=scaffold, multiple=True, ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] cset = self[ids] - cset._name = f"elaborations of C{scaffold}" + cset._name = f'elaborations of C{scaffold}' return cset - def get_by_smiles(self, smiles: str, **kwargs) -> "Compound | None": + def get_by_smiles(self, smiles: str, **kwargs) -> 'Compound | None': """Get a member compound by its smiles""" - from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError + from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles - assert isinstance(smiles, str), f"Non-string {smiles=}" + assert isinstance(smiles, str), f'Non-string {smiles=}' try: - smiles = sanitise_smiles(smiles, sanitisation_failed="error") + smiles = sanitise_smiles(smiles, sanitisation_failed='error') except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') mrich.error(str(e)) return None except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') return None return c inchikey = inchikey_from_smiles(smiles) @@ -398,15 +395,15 @@ def get_by_smiles(self, smiles: str, **kwargs) -> "Compound | None": def summary(self) -> None: """Print a summary of this compound set""" - mrich.header("CompoundTable()") - mrich.var("#compounds", len(self)) + mrich.header('CompoundTable()') + mrich.var('#compounds', len(self)) # mrich.var('#poses', self.num_poses) - mrich.var("tags", self.tags) - mrich.var("#scaffolds", self.num_scaffolds) - mrich.var("#elabs", self.num_elabs) - mrich.var("#reactants", self.num_reactants) - mrich.var("#intermediates", self.num_intermediates) - mrich.var("#products", self.num_products) + mrich.var('tags', self.tags) + mrich.var('#scaffolds', self.num_scaffolds) + mrich.var('#elabs', self.num_elabs) + mrich.var('#reactants', self.num_reactants) + mrich.var('#intermediates', self.num_intermediates) + mrich.var('#products', self.num_products) def draw(self) -> None: """2D grid of drawings of molecules in this set @@ -428,7 +425,7 @@ def interactive(self) -> None: """ self[self.ids].interactive() - def plot_tsnee(self, **kwargs) -> "go.Figure": + def plot_tsnee(self, **kwargs) -> 'go.Figure': """Plot a tanimoto similarity plot of these compounds. See :func:`hippo.plotting.plot_compound_tsnee`""" return self[:].plot_tsnee(**kwargs) @@ -441,8 +438,8 @@ def write_smiles_csv(self, file: str) -> None: from pandas import DataFrame sql = f""" - SELECT compound_id, compound_smiles - FROM {self.db.SQL_SCHEMA_PREFIX}compound + SELECT compound_id, compound_smiles + FROM {self.db.SQL_SCHEMA_PREFIX}compound ORDER BY compound_id """ @@ -465,7 +462,7 @@ def __call__( ids: list | set | None = None, sort: bool = True, **kwargs, - ) -> "CompoundSet | Compound | None": + ) -> 'CompoundSet | Compound | None': """Filter compounds by a given tag, scaffold, or it's SMILES string. See :meth:`.CompoundTable.get_by_tag` and :meth:`.CompoundTable.get_by_scaffold` :param tag: optional tag to filter by @@ -486,7 +483,7 @@ def __call__( elif ids: return CompoundSet(self.db, indices=list(ids), sort=sort) else: - mrich.error("Must provide one of tag, scaffold, or smiles arguments") + mrich.error('Must provide one of tag, scaffold, or smiles arguments') return None def __getitem__( @@ -503,10 +500,8 @@ def __getitem__( from pandas import Index, Series match key: - # case int(): case key if isinstance(key, int) or isinstance(key, int64): - if key == 0: return self.__getitem__(key=1) @@ -518,7 +513,7 @@ def __getitem__( return self.db.get_compound(id=key) case str(): - comp = self.db.get_compound(inchikey=key, none="quiet") + comp = self.db.get_compound(inchikey=key, none='quiet') if not comp: comp = self.db.get_compound(alias=key) return comp @@ -531,7 +526,6 @@ def __getitem__( or isinstance(key, Index) or isinstance(key, Series) ): - if isinstance(key, Index): assert key.nlevels == 1 @@ -562,7 +556,7 @@ def __getitem__( case _: mrich.error( - f"Unsupported type for CompoundTable.__getitem__(): {key=} {type(key)}" + f'Unsupported type for CompoundTable.__getitem__(): {key=} {type(key)}' ) return None @@ -571,21 +565,21 @@ def __str__(self) -> str: """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"C × {len(self)}" "}" + s += f'{{C × {len(self)}}}' return s def __repr__(self) -> str: """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """Total number of compounds""" @@ -650,7 +644,7 @@ class CompoundSet: """ - _table = "compound" + _table = 'compound' def __init__( self, @@ -681,7 +675,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Associated :class:`.Database` object""" return self._db @@ -709,96 +703,95 @@ def name(self) -> str | None: def names(self) -> list[str]: """Returns the aliases of compounds in this set""" result = self.db.select_where( - query="compound_alias", - table="compound", - key=f"compound_id in {self.str_ids}", + query='compound_alias', + table='compound', + key=f'compound_id in {self.str_ids}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def smiles(self) -> list[str]: """Returns the smiles of child compounds""" result = self.db.select_where( - query="compound_smiles", - table="compound", - key=f"compound_id in {self.str_ids}", + query='compound_smiles', + table='compound', + key=f'compound_id in {self.str_ids}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property - def mols(self) -> "list[Chem.Mol]": + def mols(self) -> 'list[Chem.Mol]': """Returns the molecules of child compounds""" from rdkit.Chem import Mol result = self.db.select_where( - query="mol_to_binary_mol(compound_mol)", - table="compound", - key=f"compound_id in {self.str_ids}", + query='mol_to_binary_mol(compound_mol)', + table='compound', + key=f'compound_id in {self.str_ids}', multiple=True, ) - return [Mol(q) for q, in result] + return [Mol(q) for (q,) in result] @property def inchikeys(self) -> list[str]: """Returns the inchikeys of compounds in this set""" result = self.db.select_where( - query="compound_inchikey", - table="compound", - key=f"compound_id in {self.str_ids}", + query='compound_inchikey', + table='compound', + key=f'compound_id in {self.str_ids}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def tags(self) -> set[str]: """Returns the set of unique tags present in this compound set""" values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key=f"tag_compound in {self.str_ids}", + table='tag', + query='DISTINCT tag_name', + key=f'tag_compound in {self.str_ids}', multiple=True, ) if not values: return set() - return set(v for v, in values) + return set(v for (v,) in values) @property def num_poses(self) -> int: """Count the poses associated to this set of compounds""" - from .pset import PoseSet - return self.db.count_where(table="pose", key=f"pose_compound in {self.str_ids}") + return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') @property - def poses(self) -> "PoseSet": + def poses(self) -> 'PoseSet': """Get the poses associated to this set of compounds""" from .pset import PoseSet ids = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound in {self.str_ids}", + query='pose_id', + table='pose', + key=f'pose_compound in {self.str_ids}', multiple=True, - none="warning", + none='warning', ) if not ids: return PoseSet(self.db, {}) - ids = [v for v, in ids] + ids = [v for (v,) in ids] return PoseSet(self.db, ids) @property - def best_placed_poses(self) -> "PoseSet": + def best_placed_poses(self) -> 'PoseSet': """Get the best placed pose for each compound in this set""" from .pset import PoseSet query = self.db.select_where( - table="pose", - query="pose_id, MIN(pose_distance_score)", - key=f"pose_compound in {self.str_ids} GROUP BY pose_compound", + table='pose', + query='pose_id, MIN(pose_distance_score)', + key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', multiple=True, ) ids = [i for i, s in query] @@ -807,7 +800,7 @@ def best_placed_poses(self) -> "PoseSet": @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property def num_heavy_atoms(self) -> int: @@ -829,7 +822,7 @@ def formula(self) -> str: @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, combine_atomtype_dicts + from molparse.atomtypes import combine_atomtype_dicts atomtype_dicts = [c.atomtype_dict for c in self] return combine_atomtype_dicts(atomtype_dicts) @@ -844,9 +837,9 @@ def num_atoms_added(self) -> list[int]: sql = f""" WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(B.compound_mol) AS diff + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} @@ -873,9 +866,9 @@ def avg_num_atoms_added(self) -> float: """ sql = f""" WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(B.compound_mol) AS diff + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} @@ -913,7 +906,7 @@ def elaboration_balance(self) -> float: counts = self.db.execute(sql).fetchall() - counts = [c for c, in counts] # + [0 for _ in range(len(self)-len(counts))] + counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] from hirsch import hirsch @@ -932,14 +925,14 @@ def num_scaffolds_elaborated(self) -> int: (count,) = self.db.execute( f""" SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} + WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() return count @property - def scaffolds(self) -> "CompoundSet": + def scaffolds(self) -> 'CompoundSet': """Get the scaffold compounds that have at least one elaboration in this set :returns: :class:`.CompoundSet` @@ -953,10 +946,10 @@ def scaffold_ids(self) -> list[int]: scaffold_ids = self.db.execute( f""" SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} + WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchall() - return [i for i, in scaffold_ids] + return [i for (i,) in scaffold_ids] @property def num_scaffolds(self) -> int: @@ -964,27 +957,27 @@ def num_scaffolds(self) -> int: (count,) = self.db.execute( f""" SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} + WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() return count @property - def elabs(self) -> "CompoundSet": + def elabs(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" ids = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key=f"scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}", + query='scaffold_superstructure', + table='scaffold', + key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) if not ids: return None - ids = [q for q, in ids] + ids = [q for (q,) in ids] from .cset import CompoundSet return CompoundSet(self.db, ids) @@ -995,13 +988,13 @@ def num_elabs(self) -> int: (count,) = self.db.execute( f""" SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_base IN {self.str_ids} + WHERE scaffold_base IN {self.str_ids} """ ).fetchone() return count @property - def elab_df(self) -> "pd.DataFrame": + def elab_df(self) -> 'pd.DataFrame': """Get a DataFrame summarising the elaborations in this CompoundSet""" from pandas import DataFrame @@ -1056,14 +1049,14 @@ def _db_changed(self) -> bool: def reaction_ids(self) -> list[int]: """Returns a list of :class:`.Reaction` IDs that result in members of this set""" records = self.db.select_where( - table="reaction", - query="reaction_id", - key=f"reaction_product IN {self.str_ids}", + table='reaction', + query='reaction_id', + key=f'reaction_product IN {self.str_ids}', multiple=True, ) if not records: return None - return [r for r, in records] + return [r for (r,) in records] ### FILTERING @@ -1071,25 +1064,25 @@ def get_by_tag( self, tag: str, inverse: bool = False, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Get all child compounds with a certain tag""" values = self.db.select_where( - query="tag_compound", - table="tag", + query='tag_compound', + table='tag', key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', multiple=True, ) if inverse: - matches = set(v for v, in values) + matches = set(v for (v,) in values) ids = [i for i in self.ids if i not in matches] else: - ids = [v for v, in values] + ids = [v for (v,) in values] return CompoundSet(self.db, ids) - def get_by_metadata(self, key: str, value: str | None = None) -> "CompoundSet": + def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient :param key: metadata key @@ -1097,9 +1090,9 @@ def get_by_metadata(self, key: str, value: str | None = None) -> "CompoundSet": """ results = self.db.select_where( - query="compound_id, compound_metadata", - table="compound", - key=f"compound_id IN {self.str_ids}", + query='compound_id, compound_metadata', + table='compound', + key=f'compound_id IN {self.str_ids}', multiple=True, ) if value is None: @@ -1113,24 +1106,24 @@ def get_by_metadata(self, key: str, value: str | None = None) -> "CompoundSet": def get_by_metadata_substring_match( self, substring: str, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" assert substring assert isinstance(substring, str) compound_ids = self.db.select_where( - table="compound", - query="compound_id", + table='compound', + query='compound_id', key=f"""compound_metadata LIKE '%{substring}%' AND compound_id IN {self.str_ids}""", multiple=True, ) if not compound_ids: - mrich.error(f"No compounds with metadata substring: {substring}") + mrich.error(f'No compounds with metadata substring: {substring}') return None - compound_ids = [i for i, in compound_ids] + compound_ids = [i for (i,) in compound_ids] name = f"compounds with '{substring}' in metadata" @@ -1142,8 +1135,8 @@ def get_by_metadata_substring_match( def get_by_scaffold( self, scaffold: Compound | int, - none: str = "error", - ) -> "CompoundSet": + none: str = 'error', + ) -> 'CompoundSet': """Get all compounds that elaborate the given scaffold compound :param scaffold: :class:`.Compound` object or ID to search by @@ -1151,17 +1144,17 @@ def get_by_scaffold( """ if not isinstance(scaffold, int): - assert scaffold._table == "compound" + assert scaffold._table == 'compound' scaffold = scaffold.id values = self.db.select_where( - query="scaffold_superstructure", - table="scaffold", - key=f"scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}", + query='scaffold_superstructure', + table='scaffold', + key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', multiple=True, none=none, ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] if not ids: return None @@ -1170,7 +1163,7 @@ def get_by_scaffold( def get_all_possible_reactants( self, debug: bool = False, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. :param debug: Increased verbosity for debugging (Default value = False) @@ -1184,7 +1177,7 @@ def get_all_possible_reactants( def get_all_possible_reactions( self, debug: bool = False, - ) -> "ReactionSet": + ) -> 'ReactionSet': """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. :param debug: Increased verbosity for debugging (Default value = False) @@ -1205,15 +1198,15 @@ def get_risk_diversity(self, debug: bool = False) -> float: variances = self.db.execute( f""" WITH nums AS ( - SELECT scaffold_base AS base, scaffold_superstructure AS elab, - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS["num_heavy_atoms"]}(c1.compound_mol) AS diff + SELECT scaffold_base AS base, scaffold_superstructure AS elab, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff FROM {self.db.SQL_SCHEMA_PREFIX}scaffold INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id WHERE scaffold_superstructure IN {self.str_ids} ), - means AS ( + means AS ( SELECT base, AVG(diff) AS mean FROM nums GROUP BY base ) @@ -1228,25 +1221,25 @@ def get_risk_diversity(self, debug: bool = False) -> float: if not variances: return None - variances = [v for v, in variances] + variances = [v for (v,) in variances] if debug: - mrich.debug(f"{variances=}") + mrich.debug(f'{variances=}') return mean(variances) def count_by_tag( self, tag: str, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Count all child compounds with a certain tag :param tag: tag to filter by """ (count,) = self.db.select_where( - query="COUNT(tag_compound)", - table="tag", + query='COUNT(tag_compound)', + table='tag', key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', multiple=False, ) @@ -1304,7 +1297,7 @@ def summary(self, return_df: bool = False) -> None: data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] df = DataFrame(data) - df = df.set_index("tag") + df = df.set_index('tag') # poses @@ -1322,7 +1315,7 @@ def summary(self, return_df: bool = False) -> None: cursor = self.db.execute(sql) for tag, count in cursor.fetchall(): - df.loc[tag, "num_poses"] = count + df.loc[tag, 'num_poses'] = count # compounds with poses @@ -1338,11 +1331,11 @@ def summary(self, return_df: bool = False) -> None: cursor = self.db.execute(sql) for tag, count in cursor.fetchall(): - df.loc[tag, "num_posed_compounds"] = count + df.loc[tag, 'num_posed_compounds'] = count - df.loc["TOTAL", "num_compounds"] = len(self) - df.loc["TOTAL", "num_poses"] = self.num_poses - df.loc["TOTAL", "num_posed_compounds"] = len(self.poses.compounds) + df.loc['TOTAL', 'num_compounds'] = len(self) + df.loc['TOTAL', 'num_poses'] = self.num_poses + df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) df = df.fillna(0) df = df.astype(int) @@ -1358,18 +1351,16 @@ def interactive( ) -> None: """Creates a ipywidget to interactively navigate this PoseSet.""" + from IPython.display import display from ipywidgets import ( - interactive, BoundedIntText, Checkbox, - interactive_output, - HBox, GridBox, Layout, VBox, + interactive, + interactive_output, ) - from IPython.display import display - from pprint import pprint if function: @@ -1386,40 +1377,39 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description=f"Comp (/{len(self)}):", + description=f'Comp (/{len(self)}):', disabled=False, ), ) else: - a = BoundedIntText( value=0, min=0, max=len(self) - 1, step=1, - description=f"Comp (/{len(self)}):", + description=f'Comp (/{len(self)}):', disabled=False, ) - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - d = Checkbox(description="2D", value=True) - e = Checkbox(description="Poses", value=False) - f = Checkbox(description="Reactions", value=False) - g = Checkbox(description="Tags", value=False) - h = Checkbox(description="Quotes", value=False) - i = Checkbox(description="Metadata", value=False) - j = Checkbox(description="Classify", value=False) + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='2D', value=True) + e = Checkbox(description='Poses', value=False) + f = Checkbox(description='Reactions', value=False) + g = Checkbox(description='Tags', value=False) + h = Checkbox(description='Quotes', value=False) + i = Checkbox(description='Metadata', value=False) + j = Checkbox(description='Classify', value=False) ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns="repeat(3, 100px)") + [b, c, d], layout=Layout(grid_template_columns='repeat(3, 100px)') ) ui2 = GridBox( - [e, f, g], layout=Layout(grid_template_columns="repeat(3, 100px)") + [e, f, g], layout=Layout(grid_template_columns='repeat(3, 100px)') ) ui3 = GridBox( - [h, i, j], layout=Layout(grid_template_columns="repeat(3, 100px)") + [h, i, j], layout=Layout(grid_template_columns='repeat(3, 100px)') ) ui = VBox([a, ui1, ui2, ui3]) @@ -1469,40 +1459,40 @@ def widget( r.draw() if tags: - mrich.title("Tags") + mrich.title('Tags') mrich.print(comp.tags) if quotes: - mrich.title("Quotes") + mrich.title('Quotes') display(comp.get_quotes(df=True)) if metadata: - mrich.title("Metadata:") + mrich.title('Metadata:') mrich.print(comp.metadata) if classify: - mrich.title("Classification:") + mrich.title('Classification:') comp.classify() out = interactive_output( widget, { - "i": a, - "name": b, - "summary": c, - "draw": d, - "poses": e, - "reactions": f, - "tags": g, - "quotes": h, - "metadata": i, - "classify": j, + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'poses': e, + 'reactions': f, + 'tags': g, + 'quotes': h, + 'metadata': i, + 'classify': j, }, ) display(ui, out) - def tag_summary(self) -> "pd.DataFrame": + def tag_summary(self) -> 'pd.DataFrame': """Print a summary table of tags with compound counts""" from pandas import DataFrame @@ -1521,7 +1511,7 @@ def tag_summary(self) -> "pd.DataFrame": data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] df = DataFrame(data) - df = df.set_index("tag") + df = df.set_index('tag') df = df.astype(int) @@ -1551,7 +1541,7 @@ def get_recipes( amount: float = 1, debug: bool = False, pick_cheapest: bool = False, - permitted_reactions: "ReactionSet | None" = None, + permitted_reactions: 'ReactionSet | None' = None, quoted_only: bool = False, supplier: None | str = None, **kwargs, @@ -1560,7 +1550,6 @@ def get_recipes( See :meth:`.Recipe.from_compounds` """ - from .recipe import Recipe return Recipe.from_compounds( self, @@ -1575,26 +1564,25 @@ def get_recipes( def get_routes( self, - permitted_reactions: "None | ReactionSet" = None, + permitted_reactions: 'None | ReactionSet' = None, return_ids: bool = False, debug: bool = True, - ) -> "RouteSet": + ) -> 'RouteSet': """Get a RoutSet to products in this set. :param permitted_reactions: optionally restrict reactions to those in this :class:`.ReactionSet` """ - if "route" not in self.db.table_names: - mrich.error("route table not in Database") + if 'route' not in self.db.table_names: + mrich.error('route table not in Database') raise NotImplementedError if permitted_reactions is not None: - sql = f""" - SELECT route_id, route_product, component_ref + SELECT route_id, route_product, component_ref FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON route_id = component_route WHERE route_product IN {self.str_ids} AND component_type = 1 @@ -1603,27 +1591,27 @@ def get_routes( permitted_reactions = set(permitted_reactions.ids) if debug: - mrich.debug("Querying database for routes") + mrich.debug('Querying database for routes') records = self.db.execute(sql).fetchall() if debug: - mrich.debug("Assembling route dictionary") + mrich.debug('Assembling route dictionary') routes = {} for route_id, route_product, reaction_id in records: if route_id not in routes: routes[route_id] = dict(product=route_product, reactions=set()) - assert routes[route_id]["product"] == route_product - routes[route_id]["reactions"].add(reaction_id) + assert routes[route_id]['product'] == route_product + routes[route_id]['reactions'].add(reaction_id) if debug: - mrich.debug("Checking availability") + mrich.debug('Checking availability') available_routes = set() for route_id, route_dict in routes.items(): - product = route_dict["product"] + product = route_dict['product'] assert product in self - reactions = route_dict["reactions"] + reactions = route_dict['reactions'] if all(r in permitted_reactions for r in reactions): available_routes.add(route_id) @@ -1632,37 +1620,36 @@ def get_routes( routes = [ self.db.get_route(id=route_id) - for route_id in mrich.track(available_routes, prefix="Getting routes") + for route_id in mrich.track(available_routes, prefix='Getting routes') ] else: - sql = f""" SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route WHERE route_product IN {self.str_ids} """ if debug: - mrich.debug("Querying database for routes") + mrich.debug('Querying database for routes') records = self.db.execute(sql).fetchall() if return_ids: - return [i for i, in records] + return [i for (i,) in records] routes = [ self.db.get_route(id=route_id) - for route_id, in mrich.track(records, prefix="Getting routes") + for (route_id,) in mrich.track(records, prefix='Getting routes') ] from .recipe import RouteSet return RouteSet(self.db, routes) - def copy(self) -> "CompoundSet": + def copy(self) -> 'CompoundSet': """Returns a copy of this set""" return CompoundSet(self.db, self.ids) - def shuffled(self) -> "CompoundSet": + def shuffled(self) -> 'CompoundSet': """Returns a randomised copy of this set""" copy = self.copy() copy.shuffle() @@ -1701,7 +1688,7 @@ def get_df( routes: bool = False, debug: bool = False, **kwargs, - ) -> "DataFrame": + ) -> 'DataFrame': """Get a DataFrame representation of this set :param smiles: include SMILES column (Default value = True) @@ -1721,29 +1708,30 @@ def get_df( """ from json import loads - from rdkit.Chem import Mol + from pandas import DataFrame + from rdkit.Chem import Mol data = [] - query = ["compound_id"] + query = ['compound_id'] if smiles: - query.append("compound_smiles") + query.append('compound_smiles') if inchikey: - query.append("compound_inchikey") + query.append('compound_inchikey') if alias: - query.append("compound_alias") + query.append('compound_alias') if mol: - query.append("mol_to_binary_mol(compound_mol)") + query.append('mol_to_binary_mol(compound_mol)') if metadata: - query.append("compound_metadata") + query.append('compound_metadata') - query = ", ".join(query) + query = ', '.join(query) sql = f""" SELECT {query} @@ -1752,7 +1740,7 @@ def get_df( """ if debug: - mrich.debug("querying...") + mrich.debug('querying...') records = self.db.execute(sql).fetchall() if debug: @@ -1761,25 +1749,23 @@ def get_df( generator = records for row in generator: - row = list(row) d = dict(id=row.pop(0)) if smiles: - d["smiles"] = row.pop(0) + d['smiles'] = row.pop(0) if inchikey: - d["inchikey"] = row.pop(0) + d['inchikey'] = row.pop(0) if alias: - d["alias"] = row.pop(0) + d['alias'] = row.pop(0) if mol: - d["mol"] = Mol(row.pop(0)) + d['mol'] = Mol(row.pop(0)) if metadata and (meta_str := row.pop(0)): - meta_dict = loads(meta_str) if expand_metadata: @@ -1787,7 +1773,7 @@ def get_df( d[k] = v else: - d["metadata"] = meta_dict + d['metadata'] = meta_dict data.append(d) @@ -1795,17 +1781,17 @@ def get_df( if poses or num_poses: if debug: - mrich.debug("adding pose column") + mrich.debug('adding pose column') lookup = self.db.get_compound_id_pose_ids_dict(self) if poses: - df["poses"] = df["id"].apply(lambda x: lookup.get(x, {})) + df['poses'] = df['id'].apply(lambda x: lookup.get(x, {})) if num_poses: - df["num_poses"] = df["id"].apply(lambda x: len(lookup.get(x, {}))) + df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, {}))) if num_reactant or num_reactions: if debug: - mrich.debug("adding reaction columns") + mrich.debug('adding reaction columns') tuples = self.db.get_reactant_product_tuples(self.ids, deduplicated=False) if num_reactant: @@ -1813,18 +1799,18 @@ def get_df( for r, p in tuples: lookup.setdefault(r, 0) lookup[r] += 1 - df["num_reactant"] = df["id"].apply(lambda x: lookup.get(x, 0)) + df['num_reactant'] = df['id'].apply(lambda x: lookup.get(x, 0)) if num_reactions: lookup = {} for r, p in tuples: lookup.setdefault(p, 0) lookup[p] += 1 - df["num_reactions"] = df["id"].apply(lambda x: lookup.get(x, 0)) + df['num_reactions'] = df['id'].apply(lambda x: lookup.get(x, 0)) if scaffolds or elabs: if debug: - mrich.debug("adding scaffold columns") + mrich.debug('adding scaffold columns') tuples = self.db.get_scaffold_tuples(self.ids) if scaffolds: @@ -1832,62 +1818,62 @@ def get_df( for b, e in tuples: lookup.setdefault(e, set()) lookup[e].add(b) - df["scaffolds"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) if elabs: lookup = {} for b, e in tuples: lookup.setdefault(b, set()) lookup[b].add(e) - df["elabs"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) if tags: if debug: - mrich.debug("adding tag column") + mrich.debug('adding tag column') lookup = self.db.get_compound_tag_dict() - df["tags"] = df["id"].apply(lambda x: lookup.get(x, {})) + df['tags'] = df['id'].apply(lambda x: lookup.get(x, {})) if routes: if debug: - mrich.debug("adding route column") + mrich.debug('adding route column') lookup = self.db.get_product_id_routes_dict() - df["routes"] = df["id"].apply(lambda x: lookup.get(x, {})) + df['routes'] = df['id'].apply(lambda x: lookup.get(x, {})) - df = df.set_index("id") + df = df.set_index('id') return df def get_quoted( self, *, - supplier: str = "any", - ) -> "CompoundSet": + supplier: str = 'any', + ) -> 'CompoundSet': """Get all member compounds that have a quote from given supplier :param supplier: supplier name (Default value = 'any') """ - if supplier == "any": - key = f"quote_compound IN {self.str_ids}" + if supplier == 'any': + key = f'quote_compound IN {self.str_ids}' else: key = f'quote_compound IN {self.str_ids} AND quote_supplier = "{supplier}"' ids = self.db.select_where( - table="quote", - query="DISTINCT quote_compound", + table='quote', + query='DISTINCT quote_compound', key=key, multiple=True, ) - ids = [i for i, in ids] + ids = [i for (i,) in ids] return CompoundSet(self.db, ids) def get_unquoted( self, *, - supplier: str = "any", - ) -> "CompoundSet": + supplier: str = 'any', + ) -> 'CompoundSet': """Get all member compounds that do not have a quote from given supplier :param supplier: supplier name (Default value = 'any') @@ -1915,11 +1901,11 @@ def write_smiles_csv( if tags: records = self.db.select_where( - table="tag", - query="tag_compound, tag_name", - key=f"tag_compound IN {self.str_ids}", + table='tag', + query='tag_compound, tag_name', + key=f'tag_compound IN {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) TAGS = {} if records: @@ -1930,8 +1916,8 @@ def write_smiles_csv( records = self.db.select_where( table=self.table, - query="compound_id, compound_smiles", - key=f"compound_id IN {self.str_ids}", + query='compound_id, compound_smiles', + key=f'compound_id IN {self.str_ids}', multiple=True, ) @@ -1939,14 +1925,13 @@ def write_smiles_csv( if tags: for d in data: - - tagset = TAGS.get(d["id"], set()) + tagset = TAGS.get(d['id'], set()) if split_tags: for tag in tagset: d[tag] = True else: - d["tags"] = tagset + d['tags'] = tagset df = DataFrame(data) mrich.writing(file) @@ -1956,8 +1941,8 @@ def write_postera_csv( self, file, *, - supplier: str = "Enamine", - prefix: str = "fragment", + supplier: str = 'Enamine', + prefix: str = 'fragment', ) -> None: """Write a CSV formatted for upload to Postera's Manifold @@ -1968,15 +1953,15 @@ def write_postera_csv( """ from datetime import date as dt + from pandas import DataFrame if prefix: - prefix = f"{prefix}_" + prefix = f'{prefix}_' data = [] - for c in mrich.track(self, prefix="Creating DataFrame"): - + for c in mrich.track(self, prefix='Creating DataFrame'): # get props smiles = c.smiles tags = c.tags @@ -1992,24 +1977,24 @@ def write_postera_csv( date = dt.today() # author - assert "author" in metadata, c - author = metadata["author"] + assert 'author' in metadata, c + author = metadata['author'] match len(poses): case 1: pose = poses[0] case 0: - mrich.warning(f"{c} has no poses") + mrich.warning(f'{c} has no poses') assert scaffold pose = scaffold.poses[0] case _: - mrich.warning(f"{c} has multiple poses") + mrich.warning(f'{c} has multiple poses') pose = poses[0] # extract inspirations inspirations = pose.inspirations - inspiration_names = ",".join(inspirations.names) - inspiration_smiles = ".".join(inspirations.smiles) + inspiration_names = ','.join(inspirations.names) + inspiration_smiles = '.'.join(inspirations.smiles) # quote info quotes = c.get_quotes(supplier=supplier) @@ -2020,23 +2005,23 @@ def write_postera_csv( catalog_lead_time = quote.lead_time # hippo string - hippo_str = f"compound={c.id}, pose={pose.id}" + hippo_str = f'compound={c.id}, pose={pose.id}' # create row data.append( { - "SMILES": smiles, - f"{prefix}HIPPO_IDs": hippo_str, - f"{prefix}method": method, - f"{prefix}export_date": date, - f"{prefix}author": author, - f"{prefix}inspiration_names": inspiration_names, - f"{prefix}inspiration_SMILES": inspiration_smiles, - f"{prefix}supplier": supplier, - f"{prefix}supplier_catalogue": quote.catalogue, - f"{prefix}supplier_ID": catalog_id, - f"{prefix}supplier_price": catalog_price, - f"{prefix}supplier_lead_time": catalog_lead_time, + 'SMILES': smiles, + f'{prefix}HIPPO_IDs': hippo_str, + f'{prefix}method': method, + f'{prefix}export_date': date, + f'{prefix}author': author, + f'{prefix}inspiration_names': inspiration_names, + f'{prefix}inspiration_SMILES': inspiration_smiles, + f'{prefix}supplier': supplier, + f'{prefix}supplier_catalogue': quote.catalogue, + f'{prefix}supplier_ID': catalog_id, + f'{prefix}supplier_price': catalog_price, + f'{prefix}supplier_lead_time': catalog_lead_time, } ) @@ -2049,14 +2034,14 @@ def write_postera_csv( def write_CAR_csv( self, - file: "str | Path", + file: 'str | Path', amount: float = 1, # in mg return_df: bool = False, # pick_cheapest: bool = False, quoted_only: bool = False, get_ingredient_quotes: bool = True, **kwargs, - ) -> "DataFrame | None": + ) -> 'DataFrame | None': """List of reactions for CAR Columns: @@ -2086,15 +2071,14 @@ def write_CAR_csv( """ from pathlib import Path + from pandas import DataFrame - from .recipe import Recipe file = str(Path(file).resolve()) rows = [] - for r_id in mrich.track(self.reaction_ids, prefix="Solving compound recipes"): - + for r_id in mrich.track(self.reaction_ids, prefix='Solving compound recipes'): reaction = self.db.get_reaction(id=r_id) recipes = Recipe.from_reaction( @@ -2107,39 +2091,37 @@ def write_CAR_csv( ) for sub_recipe in recipes: - product = sub_recipe.product row = { - "target-names": str(product.compound), - "no-steps": 0, - "concentration-required-mM": None, - "amount-required-uL": None, - "batch-tag": None, + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, } for i, reaction in enumerate(sub_recipe.reactions): - i = i + 1 - row["no-steps"] += 1 + row['no-steps'] += 1 match len(reaction.reactants): case 1: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = None + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None case 2: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = reaction.reactants[1].smiles + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles case _: raise NotImplementedError( - f"Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}" + f'Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}' ) - row[f"reaction-product-smiles-{i}"] = reaction.product.smiles - row[f"reaction-name-{i}"] = reaction.type - row[f"reaction-recipe-{i}"] = None - row[f"reaction-groupby-column-{i}"] = None + row[f'reaction-product-smiles-{i}'] = reaction.product.smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None # row[f'reaction-id-{i}'] = int(reaction.id) rows.append(row) @@ -2148,9 +2130,9 @@ def write_CAR_csv( df = df.convert_dtypes() - for n_steps in set(df["no-steps"]): - subset = df[df["no-steps"] == n_steps] - this_file = file.replace(".csv", f"_{n_steps}steps.csv") + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') mrich.writing(this_file) subset.to_csv(this_file, index=False) @@ -2175,7 +2157,7 @@ def add_tag( self.db.commit() - def plot_tsnee(self, **kwargs) -> "go.Figure": + def plot_tsnee(self, **kwargs) -> 'go.Figure': """Plot a tanimoto similarity plot of these compounds""" from .plotting import plot_compound_tsnee @@ -2185,13 +2167,13 @@ def as_ingredientset( self, amount: float | list[float] = 1, supplier: str | list | None = None, - ) -> "IngredientSet": + ) -> 'IngredientSet': """Get an :class:`.IngredientSet` for these compounds""" return IngredientSet.from_compounds( compounds=self, amount=amount, supplier=supplier ) - def split_by_scaffolds(self) -> "dict[CompoundSet, CompoundSet]": + def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': """Split this set into subsets clustered by scaffold compound""" cluster_dict = self.db.get_compound_cluster_dict(cset=self) @@ -2206,65 +2188,59 @@ def split_by_scaffolds(self) -> "dict[CompoundSet, CompoundSet]": def despaghettify( self, register_missing_routes: bool = True, - supplier="Enamine", - ) -> "CompoundSet": + supplier='Enamine', + ) -> 'CompoundSet': """Reduce this set to only compounds that elaborate a single reactant at a time. Requires routes to be present in the database.""" - from .recipe import RouteSet - if register_missing_routes: - mrich.debug("registering_missing_routes...") + mrich.debug('registering_missing_routes...') route_lookup = self.register_missing_routes( missing_only=True, supplier=supplier ) - mrich.debug("clustering by scaffold...") + mrich.debug('clustering by scaffold...') clustered = self.split_by_scaffolds() n = len(clustered) - mrich.var("#clusters", n) + mrich.var('#clusters', n) - mrich.debug("getting route lookup..." "") + mrich.debug('getting route lookup...') route_lookup = self.db.get_product_id_routes_dict() - mrich.debug("getting reactant lookup..." "") + mrich.debug('getting reactant lookup...') reactant_lookup = self.db.get_route_id_reactant_ids_dict() keep = set() for i, (cluster, elabs) in enumerate(clustered.items()): - for scaffold in cluster: - mrich.debug( - f"{i}/{n}", - "scaffold:", + f'{i}/{n}', + 'scaffold:', scaffold.id, - "#elabs:", + '#elabs:', len(elabs), - "#kept:", + '#kept:', len(keep), ) route_ids = route_lookup.get(scaffold.id) if not route_ids: - mrich.error(f"scaffold {scaffold} has no routes") + mrich.error(f'scaffold {scaffold} has no routes') continue elif len(route_ids) > 1: - mrich.warning(f"scaffold {scaffold} has multiple routes") + mrich.warning(f'scaffold {scaffold} has multiple routes') for route_id in route_ids: - scaffold_reactants = reactant_lookup[route_id] for elab in elabs: - route_ids = route_lookup.get(elab.id, set()) if len(route_ids) != 1: - mrich.error(f"elab {elab.id} has {route_ids=}") + mrich.error(f'elab {elab.id} has {route_ids=}') continue reactants = reactant_lookup[list(route_ids)[0]] @@ -2277,7 +2253,7 @@ def despaghettify( return CompoundSet(self.db, keep) def register_missing_routes( - self, missing_only: bool = True, supplier: str = "Enamine" + self, missing_only: bool = True, supplier: str = 'Enamine' ) -> None: """Calculate missing routes to compounds in this set""" @@ -2285,21 +2261,20 @@ def register_missing_routes( from .cset import CompoundSet records = self.db.select_where( - table="route", - key=f"route_product IN {self.str_ids}", - query="route_product", + table='route', + key=f'route_product IN {self.str_ids}', + query='route_product', multiple=True, ) - existing = set(i for i, in records) + existing = set(i for (i,) in records) missing = set(self.ids) - existing return CompoundSet(self.db, missing).register_missing_routes( missing_only=False, supplier=supplier ) - mrich.var("#compounds", len(self)) + mrich.var('#compounds', len(self)) for i, c in mrich.track(enumerate(self), total=len(self)): - try: reactions = c.reactions except Exception as e: @@ -2307,7 +2282,6 @@ def register_missing_routes( continue for reaction in reactions: - try: recipes = reaction.get_recipes(supplier=supplier) except Exception as e: @@ -2315,10 +2289,9 @@ def register_missing_routes( continue for recipe in recipes: - route = self.db.register_route(recipe=recipe) - mrich.print(f"registered {route=}") + mrich.print(f'registered {route=}') self.db.prune_duplicate_routes() @@ -2335,7 +2308,7 @@ def __iter__(self): def __getitem__( self, key: int | slice, - ) -> "Compound | CompoundSet": + ) -> 'Compound | CompoundSet': """Get compounds or subsets thereof from this set :param key: integer index or slice of indices @@ -2355,12 +2328,11 @@ def __getitem__( def __sub__( self, - other: "Compound | CompoundSet | IngredientSet", - ) -> "CompoundSet": + other: 'Compound | CompoundSet | IngredientSet', + ) -> 'CompoundSet': """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" match other: - case Compound(): ids = set(self.ids) - set([other.id]) return CompoundSet(self.db, ids) @@ -2371,7 +2343,7 @@ def __sub__( case IngredientSet(): mrich.warning( - "Subtracting IngredientSet from CompoundSet. Ignoring quote/amount data" + 'Subtracting IngredientSet from CompoundSet. Ignoring quote/amount data' ) ids = set(self.ids) - set([int(i) for i in other.compound_ids]) return CompoundSet(self.db, ids) @@ -2381,12 +2353,11 @@ def __sub__( def __add__( self, - other: "Compound | CompoundSet | IngredientSet | int", - ) -> "CompoundSet": + other: 'Compound | CompoundSet | IngredientSet | int', + ) -> 'CompoundSet': """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" match other: - case Compound(): ids = set(self.ids) ids.add(other.id) @@ -2408,11 +2379,10 @@ def __add__( case _: raise NotImplementedError - def __and__(self, other: "CompoundSet"): + def __and__(self, other: 'CompoundSet'): """AND set operation, returns only compounds in both sets""" match other: - case CompoundSet(): ids = set(self.ids) & set(other.ids) return CompoundSet(self.db, ids) @@ -2420,11 +2390,10 @@ def __and__(self, other: "CompoundSet"): case _: raise NotImplementedError - def __or__(self, other: "CompoundSet"): + def __or__(self, other: 'CompoundSet'): """OR set operation, returns union of both sets""" match other: - case CompoundSet(): ids = set(self.ids) | set(other.ids) return CompoundSet(self.db, ids) @@ -2432,11 +2401,10 @@ def __or__(self, other: "CompoundSet"): case _: raise NotImplementedError - def __xor__(self, other: "CompoundSet"): + def __xor__(self, other: 'CompoundSet'): """Exclusive OR set operation, returns all compounds in either set but not both""" match other: - case CompoundSet(): ids = set(self.ids) ^ set(other.ids) return CompoundSet(self.db, ids) @@ -2448,21 +2416,21 @@ def __str__(self) -> str: """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"C × {len(self)}" "}" + s += f'{{C × {len(self)}}}' return s def __repr__(self) -> str: """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __contains__(self, other: Compound | Ingredient | int): """Check if compound or ingredient is a member of this set""" @@ -2502,18 +2470,18 @@ class IngredientSet: """ _columns = [ - "compound_id", - "amount", - "quote_id", - "supplier", - "max_lead_time", - "quoted_amount", + 'compound_id', + 'amount', + 'quote_id', + 'supplier', + 'max_lead_time', + 'quoted_amount', ] def __init__( self, - db: "Database", - ingredients: "None | list[Ingredient]" = None, + db: 'Database', + ingredients: 'None | list[Ingredient]' = None, supplier: str | list | None = None, debug: bool = False, ) -> None: @@ -2536,7 +2504,7 @@ def __init__( self.add(ingredient) for col in self._columns: - assert col in self._data.columns, f"{col} not in df.columns" + assert col in self._data.columns, f'{col} not in df.columns' if debug: mrich.debug(self._data) @@ -2544,10 +2512,10 @@ def __init__( @classmethod def from_ingredient_df( cls, - db: "Database", - df: "DataFrame", + db: 'Database', + df: 'DataFrame', supplier: str | list | None = None, - ) -> "IngredientSet": + ) -> 'IngredientSet': """Create an :class:`.IngredientSet` from a DataFrame :param db: HIPPO Database @@ -2560,7 +2528,7 @@ def from_ingredient_df( for col in cls._columns: if col not in df.columns: - raise Exception(f"{col} not in df.columns") + raise Exception(f'{col} not in df.columns') df[col] = None self._db = db @@ -2572,11 +2540,11 @@ def from_ingredient_df( @classmethod def from_json( cls, - db: "Database", + db: 'Database', path: None | str, supplier: str | list | None = None, data: None | dict = None, - ) -> "IngredientSet": + ) -> 'IngredientSet': """Create an :class:`.IngredientSet` from JSON data or a JSON file :param db: HIPPO Database @@ -2589,7 +2557,7 @@ def from_json( if not data: import json - data = json.load(open(path, "rt")) + data = json.load(open(path)) from pandas import DataFrame @@ -2603,10 +2571,10 @@ def from_json( @classmethod def from_ingredient_dicts( cls, - db: "Database", + db: 'Database', dicts: list[dict], supplier: str | list | None = None, - ) -> "IngredientSet": + ) -> 'IngredientSet': """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries :param db: HIPPO Database @@ -2623,12 +2591,12 @@ def from_ingredient_dicts( def from_compounds( cls, *, - compounds: "CompoundSet | None" = None, + compounds: 'CompoundSet | None' = None, ids: list[int] | None = None, - db: "Database | None" = None, + db: 'Database | None' = None, amount: float | list[float] = 1, supplier: str | list | None = None, - ) -> "IngredientSet": + ) -> 'IngredientSet': """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) @@ -2664,27 +2632,27 @@ def from_compounds( ### PROPERTIES @property - def df(self) -> "DataFrame": + def df(self) -> 'DataFrame': """Access the raw DataFrame""" return self._data @property - def db(self) -> "Database": + def db(self) -> 'Database': """Linked HIPPO Database""" return self._db @property - def price_df(self) -> "DataFrame": + def price_df(self) -> 'DataFrame': """DataFrame including prices""" df = self.df.copy() tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] - df["price"] = [t[0] for t in tuples] - df["lead_time"] = [t[1] for t in tuples] - df["quote_supplier"] = [t[2] for t in tuples] + df['price'] = [t[0] for t in tuples] + df['lead_time'] = [t[1] for t in tuples] + df['quote_supplier'] = [t[2] for t in tuples] return df @property - def price(self) -> "Price": + def price(self) -> 'Price': """Total price of these ingredients""" return self.get_price() @@ -2695,7 +2663,6 @@ def supplier(self) -> str | list[str]: @supplier.setter def supplier(self, s): - if isinstance(s, list) or isinstance(s, tuple): for x in s: assert isinstance(x, str) @@ -2703,36 +2670,36 @@ def supplier(self, s): assert isinstance(s, str) self._supplier = s - self.df["supplier"] = [s] * len(self) + self.df['supplier'] = [s] * len(self) @property def smiles(self) -> list[str]: """SMILES for all ingredients""" - compound_ids = list(self.df["compound_id"]) + compound_ids = list(self.df['compound_id']) result = self.db.select_where( - query="compound_smiles", - table="compound", - key=f"compound_id in {tuple(compound_ids)}", + query='compound_smiles', + table='compound', + key=f'compound_id in {tuple(compound_ids)}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def inchikeys(self) -> list[str]: """InChI-keys for all ingredients""" - compound_ids = list(self.df["compound_id"]) + compound_ids = list(self.df['compound_id']) result = self.db.select_where( - query="compound_inchikey", - table="compound", - key=f"compound_id in {tuple(compound_ids)}", + query='compound_inchikey', + table='compound', + key=f'compound_id in {tuple(compound_ids)}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def compound_ids(self) -> list[int]: """Compound IDs for all ingredients""" - return list(self.df["compound_id"].values) + return list(self.df['compound_id'].values) @property def ids(self) -> list[int]: @@ -2743,16 +2710,16 @@ def ids(self) -> list[int]: def id_amount_pairs(self) -> list[tuple]: """Get a list of compound ID and amount pairs""" return [ - (id, amount) for id, amount in self.df[["compound_id", "amount"]].values + (id, amount) for id, amount in self.df[['compound_id', 'amount']].values ] @property def str_compound_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.df["compound_id"].values)).replace(",)", ")") + return str(tuple(self.df['compound_id'].values)).replace(',)', ')') @property - def compounds(self) -> "CompoundSet": + def compounds(self) -> 'CompoundSet': """:class:`.CompoundSet` of all compounds in this set""" return CompoundSet(self.db, self.compound_ids) @@ -2761,13 +2728,13 @@ def quote_ids(self) -> list[int]: """Get a list of quote ID's""" from pandas import isna - return [q for q in self.df["quote_id"].values if not isna(q) and q is not None] + return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] ### METHODS def get_price( - self, supplier: str | list[str] = None, none: str = "error", debug: bool = False - ) -> "Price": + self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False + ) -> 'Price': """Calculate the price with a given supplier :param supplier: supplier to use for all quoting, (Default value = ``None``) @@ -2776,30 +2743,29 @@ def get_price( from .price import Price - pairs = {i: q for i, q in enumerate(self.df["quote_id"])} + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] if debug: - mrich.debug("quote_ids", quote_ids) + mrich.debug('quote_ids', quote_ids) if quote_ids: - - quote_id_str = str(tuple(quote_ids)).replace(",)", ")") + quote_id_str = str(tuple(quote_ids)).replace(',)', ')') if supplier: result = self.db.select_where( - query="quote_price, quote_currency", - table="quote", + query='quote_price, quote_currency', + table='quote', key=f'quote_id in {quote_id_str} AND quote_supplier = "{supplier}"', multiple=True, none=none, ) else: result = self.db.select_where( - query="quote_price, quote_currency", - table="quote", - key=f"quote_id in {quote_id_str}", + query='quote_price, quote_currency', + table='quote', + key=f'quote_id in {quote_id_str}', multiple=True, none=none, ) @@ -2810,26 +2776,24 @@ def get_price( else: quoted = Price.null() - self.df["quote_id"] = None - pairs = {i: q for i, q in enumerate(self.df["quote_id"])} + self.df['quote_id'] = None + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} else: - quoted = Price.null() if debug: - mrich.debug("quoted", quoted) + mrich.debug('quoted', quoted) unquoted = [i for i, q in pairs.items() if q is None or isnan(q)] unquoted_price = Price.null() for i in unquoted: - ingredient = self[i] if debug: - mrich.debug("unquoted", i, ingredient) + mrich.debug('unquoted', i, ingredient) p = ingredient.price @@ -2841,19 +2805,19 @@ def get_price( quote = ingredient.quote if not quote: - mrich.warning("NULL Quote:", ingredient) + mrich.warning('NULL Quote:', ingredient) continue - self.df.loc[i, "quote_id"] = quote.id + self.df.loc[i, 'quote_id'] = quote.id assert quote.amount - self.df.loc[i, "quoted_amount"] = quote.amount + self.df.loc[i, 'quoted_amount'] = quote.amount if debug: - mrich.debug("quoted", quoted) - mrich.debug("unquoted_price", unquoted_price) - mrich.error("end of IngredientSet.get_price()") + mrich.debug('quoted', quoted) + mrich.debug('unquoted_price', unquoted_price) + mrich.error('end of IngredientSet.get_price()') return quoted + unquoted_price @@ -2863,7 +2827,7 @@ def interactive(self, **kwargs) -> None: def add( self, - ingredient: "Ingredient | None" = None, + ingredient: 'Ingredient | None' = None, *, compound_id: int | None = None, amount: float | None = None, @@ -2889,12 +2853,12 @@ def add( from pandas import DataFrame, concat if ingredient: - assert ingredient._table == "ingredient" + assert ingredient._table == 'ingredient' compound_id = ingredient.compound_id amount = ingredient.amount if (q := ingredient.quote) and not ingredient.quote_id: - mrich.warning(f"Losing quote! {ingredient.quote=}") + mrich.warning(f'Losing quote! {ingredient.quote=}') supplier = ingredient.supplier max_lead_time = ingredient.max_lead_time @@ -2935,24 +2899,23 @@ def add( self._data = addition else: - - if compound_id in self._data["compound_id"].values: + if compound_id in self._data['compound_id'].values: index = self._data.index[ - self._data["compound_id"] == compound_id + self._data['compound_id'] == compound_id ].tolist()[0] - self._data.loc[index, "amount"] += amount + self._data.loc[index, 'amount'] += amount # discard if the quote is no longer valid - if (a := self.df.loc[index, "quoted_amount"]) and a < self.df.loc[ - index, "amount" + if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ + index, 'amount' ]: - self._data.loc[index, "quote_id"] = None - self._data.loc[index, "quoted_amount"] = None + self._data.loc[index, 'quote_id'] = None + self._data.loc[index, 'quoted_amount'] = None if debug and supplier: - mrich.debug("Adding to existing ingredient") + mrich.debug('Adding to existing ingredient') mrich.debug(f'{self._data.loc[index, "supplier"]=}') - mrich.debug(f"{supplier=}") + mrich.debug(f'{supplier=}') else: # from numpy import nan @@ -2971,7 +2934,7 @@ def add( ) self._data = concat( - [self._data, addition], ignore_index=True, join="inner" + [self._data, addition], ignore_index=True, join='inner' ) if debug: @@ -2980,24 +2943,24 @@ def add( def _get_ingredient( self, series, - ) -> "Ingredient": + ) -> 'Ingredient': """Get ingredient from one of the DataFrame rows""" - q_id = series["quote_id"] + q_id = series['quote_id'] if isinstance(q_id, float) and isnan(q_id): q_id = None return Ingredient( db=self._db, - compound=series["compound_id"], - amount=series["amount"], + compound=series['compound_id'], + amount=series['amount'], quote=q_id, - supplier=series["supplier"], - max_lead_time=series["max_lead_time"], + supplier=series['supplier'], + max_lead_time=series['max_lead_time'], ) - def copy(self) -> "IngredientSet": + def copy(self) -> 'IngredientSet': """Return a copy of this :class:`.IngredientSet`""" return IngredientSet.from_ingredient_df( self.db, self.df, supplier=self.supplier @@ -3017,12 +2980,12 @@ def set_amounts( """ - self.df["amount"] = amount + self.df['amount'] = amount # if amounts are modified the quotes should be cleared - self.df["quote_id"] = None + self.df['quote_id'] = None - assert all(self.df["supplier"].isna()) and all(self.df["max_lead_time"].isna()) + assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) # update quotes pairs = self.db.execute( @@ -3040,10 +3003,10 @@ def set_amounts( ).fetchall() for compound_id, quote_id in pairs: - match = self.df.index[self.df["compound_id"] == compound_id][0] - self.df.loc[match, "quote_id"] = quote_id + match = self.df.index[self.df['compound_id'] == compound_id][0] + self.df.loc[match, 'quote_id'] = quote_id - def get_dict(self, data_orient: str = "list") -> dict: + def get_dict(self, data_orient: str = 'list') -> dict: """Get serialisable dictionary :param data_orient: passed to ``pandas.DataFrame.to_dict`` (Default value = 'list') @@ -3073,15 +3036,15 @@ def __len__(self): def __str__(self) -> str: """Unformatted string representation""" - return "{" f"Ingredient × {len(self)}" "}" + return f'{{Ingredient × {len(self)}}}' def __repr__(self) -> str: """ANSI ormatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __add__(self, other): """Add another :class:`.IngredientSet` this set""" @@ -3098,7 +3061,7 @@ def __add__(self, other): return self - def __getitem__(self, key: int) -> "Ingredient": + def __getitem__(self, key: int) -> 'Ingredient': """Get a member by it's index""" match key: case int(): @@ -3117,20 +3080,18 @@ def __call__( *, compound_id: int | None = None, tag: str | None = None, - ) -> "IngredientSet | Ingredient | CompoundSet": + ) -> 'IngredientSet | Ingredient | CompoundSet': """Get members based on a compound_id or tag""" if compound_id: - # get the ingredient with the matching compound ID - matches = self.df[self.df["compound_id"] == compound_id] + matches = self.df[self.df['compound_id'] == compound_id] if len(matches) == 0: return None elif len(matches) != 1: - - mrich.warning(f"Multiple ingredients in set with {compound_id=}") + mrich.warning(f'Multiple ingredients in set with {compound_id=}') # print(matches) return IngredientSet( diff --git a/hippo/db.py b/hippo/db.py index 1a28b87..90d6c9c 100644 --- a/hippo/db.py +++ b/hippo/db.py @@ -1,24 +1,23 @@ """SQLite database wrapper class""" -import mcol -import mrich - import json -import time import sqlite3 +import time from pathlib import Path -from pprint import pprint from sqlite3 import Error +import mcol +import mrich + +from .compound import Compound +from .feature import Feature +from .metadata import MetaData from .pose import Pose from .quote import Quote -from .target import Target -from .feature import Feature -from .compound import Compound from .reaction import Reaction -from .metadata import MetaData from .recipe import Recipe, Route -from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError, strip_sql +from .target import Target +from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles, strip_sql class Database: @@ -31,27 +30,27 @@ class Database: """ TABLES = [ - "compound" - "inspiration" - "scaffold" - "reaction" - "reactant" - "pose" - "tag" - "quote" - "target" - "feature" - "route" - "component" - "compound_pattern_bfp" - "interaction" - "subsite" - "subsite_tag" + 'compound' + 'inspiration' + 'scaffold' + 'reaction' + 'reactant' + 'pose' + 'tag' + 'quote' + 'target' + 'feature' + 'route' + 'component' + 'compound_pattern_bfp' + 'interaction' + 'subsite' + 'subsite_tag' ] - SQL_STRING_PLACEHOLDER = "?" - SQL_PK_DATATYPE = "INTEGER" - SQL_SCHEMA_PREFIX = "" + SQL_STRING_PLACEHOLDER = '?' + SQL_PK_DATATYPE = 'INTEGER' + SQL_SCHEMA_PREFIX = '' ERROR_UNIQUE_VIOLATION = sqlite3.IntegrityError @@ -97,66 +96,66 @@ class Database: SQL_INSERT_COMPOUND = """ INSERT INTO compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_pattern_bfp, - compound_morgan_bfp, + compound_inchikey, + compound_smiles, + compound_mol, + compound_pattern_bfp, + compound_morgan_bfp, compound_alias ) VALUES( - :inchikey, - :smiles, - mol_from_smiles(:smiles), - mol_pattern_bfp(mol_from_smiles(:smiles), 2048), - mol_morgan_bfp(mol_from_smiles(:smiles), 2, 2048), + :inchikey, + :smiles, + mol_from_smiles(:smiles), + mol_pattern_bfp(mol_from_smiles(:smiles), 2048), + mol_morgan_bfp(mol_from_smiles(:smiles), 2, 2048), :alias ) """ SQL_BULK_INSERT_INTERACTIONS = """ INSERT OR IGNORE INTO interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, interaction_energy ) VALUES(?,?,?,?,?,?,?,?,?,?) """ POSE_FIELDS = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", + 'pose_id', + 'pose_inchikey', + 'pose_alias', + 'pose_smiles', + 'pose_reference', + 'pose_path', + 'pose_compound', + 'pose_target', + 'pose_mol', + 'pose_fingerprint', + 'pose_energy_score', + 'pose_distance_score', + 'pose_inspiration_score', ] COMPOUND_PROPERTY_FUNCTIONS = { - "num_heavy_atoms": "mol_num_hvyatms", - "formula": "mol_formula", - "num_rings": "mol_num_rings", - "molecular_weight": "mol_amw", + 'num_heavy_atoms': 'mol_num_hvyatms', + 'formula': 'mol_formula', + 'num_rings': 'mol_num_rings', + 'molecular_weight': 'mol_amw', } def __init__( self, path: Path, - animal: "HIPPO", + animal: 'HIPPO', update_legacy: bool = False, auto_compute_bfps: bool = True, create_blank: bool = True, @@ -167,21 +166,21 @@ def __init__( ) -> None: """Database initialisation""" - self._in_memory = path == ":memory:" + self._in_memory = path == ':memory:' assert isinstance(path, Path) or self.in_memory if debug: - mrich.debug("hippo.Database.__init__()") + mrich.debug('hippo.Database.__init__()') self._path = path self._connection = None self._cursor = None self._animal = animal self._auto_compute_bfps = auto_compute_bfps - self._engine = "sqlite3" + self._engine = 'sqlite3' if debug: - mrich.debug(f"Database.path = {self.path}") + mrich.debug(f'Database.path = {self.path}') if not self.in_memory: try: @@ -216,69 +215,69 @@ def check_schema(self, update: bool = False) -> None: :param update: update the legacy database? """ - if "interaction" not in self.table_names: + if 'interaction' not in self.table_names: if not update: - mrich.error("This is a legacy format database (hippo-db < 0.3.23)") - mrich.error("Existing fingerprints will not be compatible") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.23") + mrich.error('This is a legacy format database (hippo-db < 0.3.23)') + mrich.error('Existing fingerprints will not be compatible') + mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') + raise LegacyDatabaseError('hippo-db < 0.3.23') else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.23)") - mrich.warning("Clearing legacy fingerprints...") + mrich.warning('This is a legacy format database (hippo-db < 0.3.23)') + mrich.warning('Clearing legacy fingerprints...') self.create_table_interaction() self.delete_interactions() - if "subsite" not in self.table_names or "subsite_tag" not in self.table_names: - mrich.warning("This is a legacy format database (hippo-db < 0.3.24)") + if 'subsite' not in self.table_names or 'subsite_tag' not in self.table_names: + mrich.warning('This is a legacy format database (hippo-db < 0.3.24)') self.create_table_subsite() self.create_table_subsite_tag() - if "scaffold" not in self.table_names: + if 'scaffold' not in self.table_names: if not update: - mrich.error("This is a legacy format database (hippo-db < 0.3.25)") - mrich.error("Existing base-elab relationships will not be compatible") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.25") + mrich.error('This is a legacy format database (hippo-db < 0.3.25)') + mrich.error('Existing base-elab relationships will not be compatible') + mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') + raise LegacyDatabaseError('hippo-db < 0.3.25') else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.25)") - mrich.warning("Migrating compound_base values to scaffold table...") + mrich.warning('This is a legacy format database (hippo-db < 0.3.25)') + mrich.warning('Migrating compound_base values to scaffold table...') self.create_table_scaffold() self.migrate_legacy_scaffolds() - if "route" not in self.table_names: + if 'route' not in self.table_names: self.create_table_route() self.create_table_component() - elif "component_amount" not in self.column_names("component"): + elif 'component_amount' not in self.column_names('component'): if not update: - mrich.error("This is a legacy format database (hippo-db < 0.3.29)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.29") + mrich.error('This is a legacy format database (hippo-db < 0.3.29)') + mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') + raise LegacyDatabaseError('hippo-db < 0.3.29') else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.29)") - mrich.warning("Updating legacy routes...") + mrich.warning('This is a legacy format database (hippo-db < 0.3.29)') + mrich.warning('Updating legacy routes...') self.update_legacy_routes() - if "reaction_metadata" not in self.column_names("reaction"): + if 'reaction_metadata' not in self.column_names('reaction'): if not update: - mrich.error("This is a legacy format database (hippo-db < 0.3.32)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.32") + mrich.error('This is a legacy format database (hippo-db < 0.3.32)') + mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') + raise LegacyDatabaseError('hippo-db < 0.3.32') else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.32)") - mrich.warning("Updating legacy reaction table...") + mrich.warning('This is a legacy format database (hippo-db < 0.3.32)') + mrich.warning('Updating legacy reaction table...') self.update_legacy_reaction_metadata() - if "pose_inspiration_score" not in self.column_names("pose"): + if 'pose_inspiration_score' not in self.column_names('pose'): if not update: - mrich.error("This is a legacy format database (hippo-db < 0.3.36)") - mrich.error("Re-initialise HIPPO object with update_legacy=True to fix") - raise LegacyDatabaseError("hippo-db < 0.3.36") + mrich.error('This is a legacy format database (hippo-db < 0.3.36)') + mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') + raise LegacyDatabaseError('hippo-db < 0.3.36') else: - mrich.warning("This is a legacy format database (hippo-db < 0.3.36)") - mrich.warning("Updating legacy pose table...") + mrich.warning('This is a legacy format database (hippo-db < 0.3.36)') + mrich.warning('Updating legacy pose table...') self.update_legacy_pose_inspiration_score() @@ -288,123 +287,122 @@ def create_indexes(self, update: bool = True, debug: bool = True) -> None: """Create and optionally update indexes""" INDEXES = [ - ("pose", "pose_inchikey"), + ('pose', 'pose_inchikey'), ( - "pose", - "pose_smiles", + 'pose', + 'pose_smiles', ), ( - "pose", - "pose_reference", + 'pose', + 'pose_reference', ), ( - "pose", - "pose_target", + 'pose', + 'pose_target', ), ( - "inspiration", - "inspiration_original", + 'inspiration', + 'inspiration_original', ), ( - "inspiration", - "inspiration_derivative", + 'inspiration', + 'inspiration_derivative', ), ( - "scaffold", - "scaffold_superstructure", + 'scaffold', + 'scaffold_superstructure', ), ( - "reaction", - "reaction_type", + 'reaction', + 'reaction_type', ), ( - "reaction", - "reaction_product", + 'reaction', + 'reaction_product', ), ( - "reactant", - "reactant_compound", + 'reactant', + 'reactant_compound', ), ( - "tag", - "tag_compound", + 'tag', + 'tag_compound', ), ( - "tag", - "tag_pose", + 'tag', + 'tag_pose', ), ( - "quote", - "quote_supplier", + 'quote', + 'quote_supplier', ), ( - "quote", - "quote_catalogue", + 'quote', + 'quote_catalogue', ), ( - "quote", - "quote_entry", + 'quote', + 'quote_entry', ), ( - "quote", - "quote_compound", + 'quote', + 'quote_compound', ), ( - "route", - "route_product", + 'route', + 'route_product', ), # ("subsite", "subsite_name",), # not enough rows to matter? ( - "subsite_tag", - "subsite_tag_pose", + 'subsite_tag', + 'subsite_tag_pose', ), ( - "interaction", - "interaction_pose", + 'interaction', + 'interaction_pose', ), # ("interaction", "interaction_type",), # mainly done on interaction_temp ( - "component", - "component_route", + 'component', + 'component_route', ), ( - "component", - "component_ref", + 'component', + 'component_ref', ), ( - "component", - ("component_type", "component_ref", "component_route"), + 'component', + ('component_type', 'component_ref', 'component_route'), ), ] existing = set(self.index_names()) for table, column in INDEXES: - if isinstance(column, tuple): - name = ["index_", table, *(c.removeprefix(table) for c in column)] - name = "".join(name) - col_str = f"({', '.join(column)})" + name = ['index_', table, *(c.removeprefix(table) for c in column)] + name = ''.join(name) + col_str = f'({", ".join(column)})' else: assert column.startswith(table) - name = f"index_{column}" - col_str = f"({column})" + name = f'index_{column}' + col_str = f'({column})' if name in existing: continue if debug: - mrich.debug(f"Creating {name}") + mrich.debug(f'Creating {name}') self.execute( - f"CREATE INDEX IF NOT EXISTS {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}" + f'CREATE INDEX IF NOT EXISTS {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}' ) if update: if debug: - mrich.debug("Updating indexes") - self.execute("ANALYZE") + mrich.debug('Updating indexes') + self.execute('ANALYZE') self.commit() @classmethod @@ -412,7 +410,7 @@ def copy_from( cls, source: Path, destination: Path, - animal: "HIPPO", + animal: 'HIPPO', update_legacy: bool = False, overwrite_existing: bool = False, pages: int = 10000, @@ -425,17 +423,17 @@ def copy_from( if destination.exists(): if overwrite_existing: - mrich.warning(f"Overwriting {destination}") + mrich.warning(f'Overwriting {destination}') else: - mrich.error(f"Will not overwrite {destination}") - mrich.error(f"Set overwrite_existing=True to override") - raise Exception("Set overwrite_existing=True to override") + mrich.error(f'Will not overwrite {destination}') + mrich.error('Set overwrite_existing=True to override') + raise Exception('Set overwrite_existing=True to override') - mrich.print(f"Copying {source} --> {destination}") + mrich.print(f'Copying {source} --> {destination}') def progress(status, remaining, total): """print progress""" - mrich.debug(f"Copied {total-remaining} of {total} pages...") + mrich.debug(f'Copied {total - remaining} of {total} pages...') src = sqlite3.connect(source) dst = sqlite3.connect(destination) @@ -468,14 +466,14 @@ def in_memory(self) -> bool: return self._in_memory @property - def connection(self) -> "sqlite3.connection": + def connection(self) -> 'sqlite3.connection': """Returns a ``sqlite3.connection`` to the database""" if not self._connection: self.connect() return self._connection @property - def cursor(self) -> "sqlite3.cursor": + def cursor(self) -> 'sqlite3.cursor': """Returns a ``sqlite3.cursor``""" if not self._cursor: self._cursor = self.connection.cursor() @@ -492,7 +490,7 @@ def table_names(self) -> list[str]: results = self.execute( "SELECT name FROM sqlite_master WHERE type='table';" ).fetchall() - return [n for n, in results] + return [n for (n,) in results] @property def auto_compute_bfps(self) -> bool: @@ -509,11 +507,11 @@ def auto_compute_bfps(self, b: bool): def close(self, debug: bool = False) -> None: """Close the connection""" if debug: - mrich.debug("hippo.Database.close()") + mrich.debug('hippo.Database.close()') if self.connection: self.connection.close() if debug: - mrich.success(f"Closed connection to {self.path}") + mrich.success(f'Closed connection to {self.path}') def backup( self, @@ -529,7 +527,7 @@ def connect(self, debug: bool = True) -> None: """Connect to the database""" if debug: - mrich.debug("hippo.Database.connect()") + mrich.debug('hippo.Database.connect()') conn = None @@ -540,19 +538,18 @@ def connect(self, debug: bool = True) -> None: conn = sqlite3.connect(self.path) if debug: - mrich.debug(f"{sqlite3.sqlite_version=}") + mrich.debug(f'{sqlite3.sqlite_version=}') conn.enable_load_extension(True) - conn.load_extension("chemicalite") + conn.load_extension('chemicalite') conn.enable_load_extension(False) if debug: - mrich.success("Database connected @", f"[file]{self.path}") + mrich.success('Database connected @', f'[file]{self.path}') except sqlite3.OperationalError as e: - - if "cannot open shared object file" in str(e): - mrich.error("chemicalite package not installed correctly") + if 'cannot open shared object file' in str(e): + mrich.error('chemicalite package not installed correctly') else: mrich.error(e) raise @@ -574,8 +571,6 @@ def execute( ): """Execute arbitrary SQL with retry if database is locked.""" if debug: - from .tools import strip_sql - mrich.debug(sql) while True: @@ -585,20 +580,20 @@ def execute( else: return self.cursor.execute(sql) except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: + if 'database is locked' in str(e) and retry: with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." + f'SQLite Database is locked, waiting {retry} second(s)...' ): time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") + mrich.print('[debug]SQLite Database was locked, retrying...') continue # retry without recursion - elif "syntax error" in str(e): + elif 'syntax error' in str(e): mrich.error(sql) mrich.error(payload) raise else: raise - except Exception as e: + except Exception: # from .tools import strip_sql # mrich.error(strip_sql(sql)) raise @@ -614,22 +609,21 @@ def executemany( """ - if "RETURNING" in sql: + if 'RETURNING' in sql: from .apsw import executemany return executemany(self.path, sql, payload) if batch_size and batch_size < len(payload): - from itertools import batched batches = list(batched(payload, batch_size)) n = len(batches) - for i, batch in enumerate(mrich.track(batches, prefix="batch execution")): - mrich.set_progress_field("i", i) - mrich.set_progress_field("n", n) + for i, batch in enumerate(mrich.track(batches, prefix='batch execution')): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', n) self.executemany(sql, batch, batch_size=None, retry=retry) @@ -638,13 +632,13 @@ def executemany( try: return self.cursor.executemany(sql, payload) except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: - mrich.print("[debug]SQLite Database was locked, waiting...") + if 'database is locked' in str(e) and retry: + mrich.print('[debug]SQLite Database was locked, waiting...') time.sleep(retry) return self.executemany(sql=sql, payload=payload, retry=retry) else: raise - except Exception as e: + except Exception: mrich.print(sql) mrich.print(payload[0]) raise @@ -657,12 +651,12 @@ def commit(self, *, retry: float | None = 1) -> None: try: self.connection.commit() except sqlite3.OperationalError as e: - if "database is locked" in str(e) and retry: + if 'database is locked' in str(e) and retry: with mrich.clock( - f"SQLite Database is locked, waiting {retry} second(s)..." + f'SQLite Database is locked, waiting {retry} second(s)...' ): time.sleep(retry) - mrich.print("[debug]SQLite Database was locked, retrying...") + mrich.print('[debug]SQLite Database was locked, retrying...') return self.commit() else: raise @@ -681,7 +675,7 @@ def get_lastrowid(self) -> int: def create_blank_db(self) -> None: """Create a blank database""" - with mrich.loading("Creating blank database..."): + with mrich.loading('Creating blank database...'): self.create_table_compound() self.create_table_pose() self.create_table_inspiration() @@ -702,7 +696,7 @@ def create_blank_db(self) -> None: def create_table_compound(self) -> None: """Create the compound table""" - mrich.debug("HIPPO.Database.create_table_compound()") + mrich.debug('HIPPO.Database.create_table_compound()') sql = self.SQL_CREATE_TABLE_COMPOUND @@ -710,7 +704,7 @@ def create_table_compound(self) -> None: def create_table_inspiration(self) -> None: """Create the inspiration table""" - mrich.debug("HIPPO.Database.create_table_inspiration()") + mrich.debug('HIPPO.Database.create_table_inspiration()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}inspiration( inspiration_original INTEGER, @@ -725,7 +719,7 @@ def create_table_inspiration(self) -> None: def create_table_scaffold(self) -> None: """Create the scaffold table""" - mrich.debug("HIPPO.Database.create_table_scaffold()") + mrich.debug('HIPPO.Database.create_table_scaffold()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}scaffold( scaffold_base INTEGER, @@ -740,7 +734,7 @@ def create_table_scaffold(self) -> None: def create_table_reaction(self) -> None: """Create the reaction table""" - mrich.debug("HIPPO.Database.create_table_reaction()") + mrich.debug('HIPPO.Database.create_table_reaction()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reaction( reaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, @@ -756,7 +750,7 @@ def create_table_reaction(self) -> None: def create_table_reactant(self) -> None: """Create the reactant table""" - mrich.debug("HIPPO.Database.create_table_reactant()") + mrich.debug('HIPPO.Database.create_table_reactant()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reactant( reactant_amount REAL, @@ -772,7 +766,7 @@ def create_table_reactant(self) -> None: def create_table_pose(self) -> None: """Create the pose table""" - mrich.debug("HIPPO.Database.create_table_pose()") + mrich.debug('HIPPO.Database.create_table_pose()') sql = self.SQL_CREATE_TABLE_POSE @@ -780,7 +774,7 @@ def create_table_pose(self) -> None: def create_table_tag(self) -> None: """Create the tag table""" - mrich.debug("HIPPO.Database.create_table_tag()") + mrich.debug('HIPPO.Database.create_table_tag()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}tag( tag_name TEXT, @@ -797,7 +791,7 @@ def create_table_tag(self) -> None: def create_table_quote(self) -> None: """Create the quote table""" - mrich.debug("HIPPO.Database.create_table_quote()") + mrich.debug('HIPPO.Database.create_table_quote()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}quote( quote_id {self.SQL_PK_DATATYPE} PRIMARY KEY, @@ -821,7 +815,7 @@ def create_table_quote(self) -> None: def create_table_target(self) -> None: """Create the target table""" - mrich.debug("HIPPO.Database.create_table_target()") + mrich.debug('HIPPO.Database.create_table_target()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}target( target_id {self.SQL_PK_DATATYPE} PRIMARY KEY, target_name TEXT, @@ -834,7 +828,7 @@ def create_table_target(self) -> None: def create_table_feature(self) -> None: """Create the feature table""" - mrich.debug("HIPPO.Database.create_table_feature()") + mrich.debug('HIPPO.Database.create_table_feature()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}feature( feature_id {self.SQL_PK_DATATYPE} PRIMARY KEY, feature_family TEXT, @@ -844,11 +838,11 @@ def create_table_feature(self) -> None: feature_residue_number INTEGER, feature_atom_names TEXT, CONSTRAINT UC_feature UNIQUE ( - feature_family, - feature_target, - feature_chain_name, - feature_residue_number, - feature_residue_name, + feature_family, + feature_target, + feature_chain_name, + feature_residue_number, + feature_residue_name, feature_atom_names ) ); @@ -858,7 +852,7 @@ def create_table_feature(self) -> None: def create_table_route(self) -> None: """Create the route table""" - mrich.debug("HIPPO.Database.create_table_route()") + mrich.debug('HIPPO.Database.create_table_route()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}route( route_id {self.SQL_PK_DATATYPE} PRIMARY KEY, route_product INTEGER, @@ -870,7 +864,7 @@ def create_table_route(self) -> None: def create_table_component(self) -> None: """Create the component table""" - mrich.debug("HIPPO.Database.create_table_component()") + mrich.debug('HIPPO.Database.create_table_component()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}component( component_id {self.SQL_PK_DATATYPE} PRIMARY KEY, component_route INTEGER, @@ -886,22 +880,22 @@ def create_table_component(self) -> None: def create_table_pattern_bfp(self) -> None: """Create the pattern_bfp table""" - mrich.debug("HIPPO.Database.create_table_pattern_bfp()") + mrich.debug('HIPPO.Database.create_table_pattern_bfp()') sql = """ - CREATE VIRTUAL TABLE compound_pattern_bfp + CREATE VIRTUAL TABLE compound_pattern_bfp USING rdtree(compound_id, fp bits(2048)) """ self.execute(sql) def create_table_interaction( - self, table: str = "interaction", debug: bool = True + self, table: str = 'interaction', debug: bool = True ) -> None: """Create an interaction table""" if debug: - mrich.debug(f"HIPPO.Database.create_table_interaction({table=})") + mrich.debug(f'HIPPO.Database.create_table_interaction({table=})') sql = f""" CREATE TABLE {self.SQL_SCHEMA_PREFIX}{table}( @@ -919,10 +913,10 @@ def create_table_interaction( FOREIGN KEY (interaction_feature) REFERENCES {self.SQL_SCHEMA_PREFIX}feature(feature_id), FOREIGN KEY (interaction_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), CONSTRAINT UC_interaction UNIQUE ( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, interaction_atom_ids ) ); @@ -933,7 +927,7 @@ def create_table_interaction( def create_table_subsite(self) -> None: """Create the subsite table""" - mrich.debug("HIPPO.Database.create_table_subsite()") + mrich.debug('HIPPO.Database.create_table_subsite()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite( subsite_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_target INTEGER NOT NULL, @@ -949,7 +943,7 @@ def create_table_subsite(self) -> None: def create_table_subsite_tag(self) -> None: """Create the subsite_tag table""" - mrich.debug("HIPPO.Database.create_table_subsite_tag()") + mrich.debug('HIPPO.Database.create_table_subsite_tag()') sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite_tag( subsite_tag_id {self.SQL_PK_DATATYPE} PRIMARY KEY, subsite_tag_ref INTEGER NOT NULL, @@ -965,7 +959,7 @@ def create_table_subsite_tag(self) -> None: def sql_return_id_str(self, key: str) -> str: """SQL suffix to return the lastrowid (for sqlite returns an empty string)""" - return "" + return '' ### INSERTION @@ -1003,22 +997,20 @@ def insert_compound( ) except self.ERROR_UNIQUE_VIOLATION as e: - constraints = [ - "compound_inchikey", - "compound_smiles", - "compound_pattern_bfp", - "compound_morgan_bfp", + 'compound_inchikey', + 'compound_smiles', + 'compound_pattern_bfp', + 'compound_morgan_bfp', ] message = str(e) for constraint in constraints: - match self.engine: - case "sqlite3": - test_str = f"UNIQUE constraint failed: compound.{constraint}" - case "psycopg": + case 'sqlite3': + test_str = f'UNIQUE constraint failed: compound.{constraint}' + case 'psycopg': test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' case _: raise NotImplementedError @@ -1026,7 +1018,7 @@ def insert_compound( if test_str in message: if warn_duplicate: mrich.warning( - f"Skipping compound with duplicate {constraint}, {smiles=}" + f'Skipping compound with duplicate {constraint}, {smiles=}' ) self.rollback() return None @@ -1056,13 +1048,13 @@ def insert_compound( result = self.insert_compound_pattern_bfp(compound_id, commit=commit) if not result: - mrich.error("Could not insert compound pattern bfp") + mrich.error('Could not insert compound pattern bfp') ### insert metadata if metadata: self.insert_metadata( - table="compound", id=compound_id, payload=metadata, commit=commit + table='compound', id=compound_id, payload=metadata, commit=commit ) return compound_id @@ -1082,7 +1074,7 @@ def insert_compound_pattern_bfp(self, compound_id: int, commit: bool = True) -> """ (bfp,) = self.select_where( - "compound_pattern_bfp", "compound", "id", compound_id + 'compound_pattern_bfp', 'compound', 'id', compound_id ) try: @@ -1145,7 +1137,7 @@ def insert_pose( if isinstance(target, str): target = self.get_target_id(name=target) if not target: - raise ValueError(f"No such {target=}") + raise ValueError(f'No such {target=}') target_name = self.get_target_name(id=target) if resolve_path: @@ -1154,31 +1146,31 @@ def insert_pose( path = path.resolve(strict=True) path = str(path) - except FileNotFoundError as e: - mrich.error(f"Path cannot be resolved: {mcol.file}{path}") + except FileNotFoundError: + mrich.error(f'Path cannot be resolved: {mcol.file}{path}') raise sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}pose( - pose_inchikey, - pose_alias, - pose_smiles, - pose_compound, - pose_target, - pose_path, - pose_reference, - pose_energy_score, + pose_inchikey, + pose_alias, + pose_smiles, + pose_compound, + pose_target, + pose_path, + pose_reference, + pose_energy_score, pose_distance_score ) VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER} ) {self.sql_return_id_str('pose')} @@ -1201,20 +1193,18 @@ def insert_pose( ) except self.ERROR_UNIQUE_VIOLATION as e: - constraints = [ - "pose_path", - "pose_alias", + 'pose_path', + 'pose_alias', ] message = str(e) for constraint in constraints: - match self.engine: - case "sqlite3": - test_str = f"UNIQUE constraint failed: pose.{constraint}" - case "psycopg": + case 'sqlite3': + test_str = f'UNIQUE constraint failed: pose.{constraint}' + case 'psycopg': test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' case _: raise NotImplementedError @@ -1222,7 +1212,7 @@ def insert_pose( if test_str in message: if warn_duplicate: mrich.warning( - f"Skipping pose with duplicate {constraint}, {alias=}, {path=}" + f'Skipping pose with duplicate {constraint}, {alias=}, {path=}' ) self.rollback() return None @@ -1247,7 +1237,7 @@ def insert_pose( if metadata: self.insert_metadata( - table="pose", id=pose_id, payload=metadata, commit=commit + table='pose', id=pose_id, payload=metadata, commit=commit ) return pose_id @@ -1271,9 +1261,9 @@ def insert_tag( :param commit: commit the changes to the database (Default value = True) """ - assert bool(compound) ^ bool( - pose - ), "Exactly one of compound or pose arguments must have a value" + assert bool(compound) ^ bool(pose), ( + 'Exactly one of compound or pose arguments must have a value' + ) sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}tag(tag_name, tag_compound, tag_pose) @@ -1283,7 +1273,7 @@ def insert_tag( try: self.execute(sql, (name, compound, pose)) - except self.ERROR_UNIQUE_VIOLATION as e: + except self.ERROR_UNIQUE_VIOLATION: return None except Exception as e: @@ -1315,12 +1305,12 @@ def insert_inspiration( if isinstance(derivative, Pose): derivative = derivative.id - assert isinstance( - original, int - ), "Must pass an integer ID or Pose object (original)" - assert isinstance( - derivative, int - ), "Must pass an integer ID or Pose object (derivative)" + assert isinstance(original, int), ( + 'Must pass an integer ID or Pose object (original)' + ) + assert isinstance(derivative, int), ( + 'Must pass an integer ID or Pose object (derivative)' + ) sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}inspiration(inspiration_original, inspiration_derivative) @@ -1330,10 +1320,10 @@ def insert_inspiration( try: self.execute(sql, (original, derivative)) - except self.ERROR_UNIQUE_VIOLATION as e: + except self.ERROR_UNIQUE_VIOLATION: if warn_duplicate: mrich.warning( - f"Skipping existing inspiration: {original=} {derivative=}" + f'Skipping existing inspiration: {original=} {derivative=}' ) return None @@ -1369,12 +1359,12 @@ def insert_scaffold( if isinstance(superstructure, Compound): superstructure = superstructure.id - assert isinstance( - scaffold, int - ), f"Must pass an integer ID or Compound object (scaffold) {scaffold=} {type(scaffold)}" - assert isinstance( - superstructure, int - ), f"Must pass an integer ID or Compound object (superstructure) {superstructure=} {type(superstructure)}" + assert isinstance(scaffold, int), ( + f'Must pass an integer ID or Compound object (scaffold) {scaffold=} {type(scaffold)}' + ) + assert isinstance(superstructure, int), ( + f'Must pass an integer ID or Compound object (superstructure) {superstructure=} {type(superstructure)}' + ) if scaffold == superstructure: # mrich.warning(f"Skipped self-referential scaffold assignment (C{scaffold})") @@ -1388,10 +1378,10 @@ def insert_scaffold( try: self.execute(sql, (scaffold, superstructure)) - except self.ERROR_UNIQUE_VIOLATION as e: + except self.ERROR_UNIQUE_VIOLATION: if warn_duplicate: mrich.warning( - f"Skipping existing scaffold: {scaffold=} {superstructure=}" + f'Skipping existing scaffold: {scaffold=} {superstructure=}' ) return None @@ -1426,7 +1416,7 @@ def insert_reaction( product = product.id # assert isinstance(product, Compound), f'incompatible {product=}' - assert isinstance(type, str), f"incompatible {type=}" + assert isinstance(type, str), f'incompatible {type=}' sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}reaction(reaction_type, reaction_product, reaction_product_yield) @@ -1468,8 +1458,8 @@ def insert_reactant( if isinstance(compound, int): compound = self.get_compound(id=compound) - assert isinstance(compound, Compound), f"incompatible {compound=}" - assert isinstance(reaction, Reaction), f"incompatible {reaction=}" + assert isinstance(compound, Compound), f'incompatible {compound=}' + assert isinstance(reaction, Reaction), f'incompatible {reaction=}' sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}reactant(reactant_amount, reactant_reaction, reactant_compound) @@ -1479,8 +1469,8 @@ def insert_reactant( try: self.execute(sql, (amount, reaction.id, compound.id)) - except self.ERROR_UNIQUE_VIOLATION as e: - mrich.warning(f"Skipping existing reactant: {reaction=} {compound=}") + except self.ERROR_UNIQUE_VIOLATION: + mrich.warning(f'Skipping existing reactant: {reaction=} {compound=}') except Exception as e: mrich.error(e) @@ -1526,19 +1516,19 @@ def insert_quote( """ if not isinstance(compound, int): - assert isinstance(compound, Compound), f"incompatible {compound=}" + assert isinstance(compound, Compound), f'incompatible {compound=}' compound = compound.id - assert currency in ["GBP", "EUR", "USD", None], f"incompatible {currency=}" + assert currency in ['GBP', 'EUR', 'USD', None], f'incompatible {currency=}' assert supplier in [ - "MCule", - "Enamine", - "Stock", - "Molport", - ], f"incompatible {supplier=}" + 'MCule', + 'Enamine', + 'Stock', + 'Molport', + ], f'incompatible {supplier=}' - smiles = smiles or "" + smiles = smiles or '' payload = [ smiles, @@ -1554,13 +1544,13 @@ def insert_quote( ] if date: - date_str = "?11" + date_str = '?11' payload.append(date) else: - date_str = "date()" + date_str = 'date()' match self.engine: - case "sqlite3": + case 'sqlite3': sql = f""" INSERT OR REPLACE INTO quote( quote_smiles, @@ -1576,21 +1566,21 @@ def insert_quote( quote_date ) VALUES( - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, + ?, {date_str} ); """ - case "psycopg": - sql = """ + case 'psycopg': + sql = f""" INSERT OR REPLACE INTO hippo.quote( quote_smiles, quote_amount, @@ -1605,16 +1595,16 @@ def insert_quote( quote_date ) VALUES( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, + %s, {date_str} ) ON CONFLICT @@ -1630,9 +1620,7 @@ def insert_quote( hippo.quote.quote_purity = EXCLUDED.quote_purity, hippo.quote.quote_compound = EXCLUDED.quote_compound, hippo.quote.quote_date = EXCLUDED.quote_date; - """.format( - date_str=date_str - ) + """ try: self.execute( @@ -1670,15 +1658,15 @@ def insert_target( sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}target(target_name) VALUES({self.SQL_STRING_PLACEHOLDER}) - {self.sql_return_id_str("target")} + {self.sql_return_id_str('target')} """ try: self.execute(sql, (name,)) - except self.ERROR_UNIQUE_VIOLATION as e: + except self.ERROR_UNIQUE_VIOLATION: if warn_duplicate: - mrich.warning(f"Skipping existing target with {name=}") + mrich.warning(f'Skipping existing target with {name=}') self.rollback() return None @@ -1728,31 +1716,31 @@ def insert_feature( from .prolif import FEATURE_FAMILIES if family: - assert family in FEATURE_FAMILIES, f"Unsupported {family=}" + assert family in FEATURE_FAMILIES, f'Unsupported {family=}' else: - family = "Unknown" + family = 'Unknown' sql = f""" INSERT INTO {self.SQL_SCHEMA_PREFIX}feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, feature_atom_names ) VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, + {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER} ) {self.sql_return_id_str('feature')} """ - atom_names = " ".join(sorted(atom_names)) + atom_names = ' '.join(sorted(atom_names)) try: self.execute( @@ -1761,15 +1749,14 @@ def insert_feature( ) except self.ERROR_UNIQUE_VIOLATION as e: - if warn_duplicate: mrich.warning(str(e)) - mrich.var("family", family) - mrich.var("target", target) - mrich.var("chain_name", chain_name) - mrich.var("residue_name", residue_name) - mrich.var("residue_number", residue_number) - mrich.var("atom_names", atom_names) + mrich.var('family', family) + mrich.var('target', target) + mrich.var('chain_name', chain_name) + mrich.var('residue_name', residue_name) + mrich.var('residue_number', residue_number) + mrich.var('atom_names', atom_names) self.rollback() @@ -1798,13 +1785,12 @@ def insert_features( payload = [] for d in dicts: - - chain_name = d["chain_name"] - family = d["family"] - target = d["target"] - atom_names = d["atom_names"] - residue_name = d["residue_name"] - residue_number = d["residue_number"] + chain_name = d['chain_name'] + family = d['family'] + target = d['target'] + atom_names = d['atom_names'] + residue_name = d['residue_name'] + residue_number = d['residue_number'] assert len(chain_name) == 1 assert len(residue_name) <= 4 @@ -1812,41 +1798,39 @@ def insert_features( assert len(a) <= 4 assert isinstance(target, int) - atom_names = " ".join(sorted(atom_names)) + atom_names = ' '.join(sorted(atom_names)) if family: - assert family in FEATURE_FAMILIES, f"Unsupported {family=}" + assert family in FEATURE_FAMILIES, f'Unsupported {family=}' else: - family = "Unknown" + family = 'Unknown' payload.append( (family, target, chain_name, residue_name, residue_number, atom_names) ) match self.engine: - case "sqlite3": - + case 'sqlite3': sql = """ INSERT OR IGNORE INTO feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, feature_atom_names ) VALUES(?,?,?,?,?,?) """ - case "psycopg": - + case 'psycopg': sql = """ INSERT INTO hippo.feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, + feature_family, + feature_target, + feature_chain_name, + feature_residue_name, + feature_residue_number, feature_atom_names ) VALUES(%s,%s,%s,%s,%s,%s) @@ -1901,7 +1885,7 @@ def insert_metadata( payload = json.dumps(payload) self.update( - table=table, id=id, key=f"{table}_metadata", value=payload, commit=commit + table=table, id=id, key=f'{table}_metadata', value=payload, commit=commit ) def insert_route( @@ -1965,15 +1949,13 @@ def insert_component( """ match self.engine: - case "sqlite3": - + case 'sqlite3': sql = """ INSERT INTO component(component_route, component_type, component_ref, component_amount) VALUES(:component_route, :component_type, :component_ref, :component_amount) """ - case "psycopg": - + case 'psycopg': sql = """ INSERT INTO hippo.component(component_route, component_type, component_ref, component_amount) VALUES(%(component_route)s, %(component_type)s, %(component_ref)s, %(component_amount)s) @@ -2000,10 +1982,9 @@ def insert_component( ), ) - except self.ERROR_UNIQUE_VIOLATION as e: - + except self.ERROR_UNIQUE_VIOLATION: mrich.warning( - f"Did not add existing component={ref} (type={component_type}) to {route=}" + f'Did not add existing component={ref} (type={component_type}) to {route=}' ) self.rollback() @@ -2031,7 +2012,7 @@ def insert_interaction( energy: float | None = None, warn_duplicate: bool = True, commit: bool = True, - table: str = "interaction", + table: str = 'interaction', ) -> int: """Insert an entry into the interaction table @@ -2059,51 +2040,51 @@ def insert_interaction( if isinstance(pose, Pose): pose = pose.id - from .prolif import FEATURE_FAMILIES, INTERACTION_TYPES + from .prolif import FEATURE_FAMILIES if family: - assert family in FEATURE_FAMILIES, f"Unsupported {family=}" + assert family in FEATURE_FAMILIES, f'Unsupported {family=}' else: - family = "Unknown" + family = 'Unknown' # assert type in INTERACTION_TYPES.values(), f"Unsupported {type=}" - assert isinstance(atom_ids, list), f"Unsupported {atom_ids=}" - assert not any( - [not isinstance(i, int) for i in atom_ids] - ), f"Unsupported {atom_ids=}" + assert isinstance(atom_ids, list), f'Unsupported {atom_ids=}' + assert not any([not isinstance(i, int) for i in atom_ids]), ( + f'Unsupported {atom_ids=}' + ) atom_ids = json.dumps(atom_ids) prot_coord = list(prot_coord) if prot_coord is not None else [] - assert len(prot_coord) == 3 or not prot_coord, f"Unsupported {prot_coord=}" - assert not any( - [not isinstance(i, float) for i in prot_coord] - ), f"Unsupported {prot_coord=}" + assert len(prot_coord) == 3 or not prot_coord, f'Unsupported {prot_coord=}' + assert not any([not isinstance(i, float) for i in prot_coord]), ( + f'Unsupported {prot_coord=}' + ) prot_coord = json.dumps(prot_coord) lig_coord = list(lig_coord) if lig_coord is not None else [] - assert len(lig_coord) == 3 or not lig_coord, f"Unsupported {lig_coord=}" - assert not any( - [not isinstance(i, float) for i in lig_coord] - ), f"Unsupported {lig_coord=}" + assert len(lig_coord) == 3 or not lig_coord, f'Unsupported {lig_coord=}' + assert not any([not isinstance(i, float) for i in lig_coord]), ( + f'Unsupported {lig_coord=}' + ) lig_coord = json.dumps(lig_coord) try: distance = float(distance) except ValueError: - raise ValueError(f"Unsupported {distance=}") + raise ValueError(f'Unsupported {distance=}') try: if angle is not None: angle = float(angle) except ValueError: - raise ValueError(f"Unsupported {angle=}") + raise ValueError(f'Unsupported {angle=}') if energy is not None: try: energy = float(energy) except ValueError: - raise ValueError(f"Unsupported {energy=}") + raise ValueError(f'Unsupported {energy=}') # insertion @@ -2155,7 +2136,7 @@ def insert_interaction( mrich.error(e) if warn_duplicate: mrich.warning( - f"Skipping existing interaction: {feature=} {pose=} {family=} {atom_ids=}" + f'Skipping existing interaction: {feature=} {pose=} {family=} {atom_ids=}' ) return None @@ -2189,8 +2170,8 @@ def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: try: self.execute(sql, (target, name)) - except self.ERROR_UNIQUE_VIOLATION as e: - mrich.warning(f"Skipping existing subsite for {target=} with {name=}") + except self.ERROR_UNIQUE_VIOLATION: + mrich.warning(f'Skipping existing subsite for {target=} with {name=}') return None except Exception as e: @@ -2228,13 +2209,13 @@ def insert_subsite_tag( if not target: (target,) = self.select_where( - table="pose", key="id", value=pose_id, query="pose_target" + table='pose', key='id', value=pose_id, query='pose_target' ) assert isinstance(target, int) if not subsite_id: - subsite_id = self.get_subsite_id(name=name, none="quiet") + subsite_id = self.get_subsite_id(name=name, none='quiet') if not subsite_id: subsite_id = self.insert_subsite(name=name, target=target) @@ -2249,9 +2230,9 @@ def insert_subsite_tag( try: self.execute(sql, (subsite_id, pose_id)) - except self.ERROR_UNIQUE_VIOLATION as e: + except self.ERROR_UNIQUE_VIOLATION: mrich.warning( - f"Skipping existing subsite_tag for {subsite_id=} with {pose_id=}" + f'Skipping existing subsite_tag for {subsite_id=} with {pose_id=}' ) return None @@ -2267,7 +2248,7 @@ def insert_subsite_tag( def register_route( self, *, - recipe: "Recipe", + recipe: 'Recipe', commit: bool = True, ) -> int: """ @@ -2329,12 +2310,12 @@ def select( """ - sql = f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table}" + sql = f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table}' try: self.execute(sql) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise if multiple: @@ -2351,7 +2332,7 @@ def select_where( key: str, value: str | None = None, multiple: bool = False, - none: str | None = "error", + none: str | None = 'error', sort: str = None, debug: bool = False, ) -> tuple | list[tuple]: @@ -2410,15 +2391,15 @@ def select_where( value = f"'{value}'" if value is not None: - where_str = f"{table}_{key}={value}" + where_str = f'{table}_{key}={value}' else: where_str = key if sort: - sql = f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str} ORDER BY {sort}" + sql = f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str} ORDER BY {sort}' else: sql = ( - f"SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str}" + f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str}' ) if debug: @@ -2426,8 +2407,8 @@ def select_where( try: self.execute(sql) - except sqlite3.OperationalError as e: - mrich.var("sql", strip_sql(sql)) + except sqlite3.OperationalError: + mrich.var('sql', strip_sql(sql)) raise if multiple: @@ -2435,12 +2416,12 @@ def select_where( else: result = self.cursor.fetchone() - if not result and none == "error": - mrich.error(f"No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}") + if not result and none == 'error': + mrich.error(f'No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}') return None - elif not result and none == "exception": + elif not result and none == 'exception': raise ValueError( - f"No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}" + f'No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}' ) # if not result: @@ -2454,7 +2435,7 @@ def select_id_where( key: str, value: str | None = None, multiple: bool = False, - none: str | None = "error", + none: str | None = 'error', ) -> tuple | list[tuple]: """Select ID's where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``{table}_id``. @@ -2467,7 +2448,7 @@ def select_id_where( """ return self.select_where( - query=f"{table}_id", + query=f'{table}_id', table=table, key=key, value=value, @@ -2481,7 +2462,7 @@ def select_all_where( key: str, value: str | None = None, multiple: bool = False, - none: str | None = "error", + none: str | None = 'error', ) -> tuple | list[tuple]: """Select entries where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``*``. @@ -2494,7 +2475,7 @@ def select_all_where( """ return self.select_where( - query="*", table=table, key=key, value=value, multiple=multiple, none=none + query='*', table=table, key=key, value=value, multiple=multiple, none=none ) ### DELETION @@ -2516,21 +2497,19 @@ def delete_where( """ if value is not None: - if isinstance(value, str): value = f"'{value}'" - sql = f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_{key}={value}" + sql = f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_{key}={value}' else: - - sql = f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {key}" + sql = f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {key}' try: result = self.execute(sql) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise if commit: @@ -2545,33 +2524,33 @@ def delete_tag( :param tag: tag name to match """ - self.delete_where(table="tag", key="name", value=tag) + self.delete_where(table='tag', key='name', value=tag) def delete_interactions(self) -> None: """Delete all calculated interactions and set pose_fingerprint appropriately""" - self.delete_where(table="interaction", key="interaction_id > 0") - self.update_all(table="pose", key="pose_fingerprint", value=0) + self.delete_where(table='interaction', key='interaction_id > 0') + self.update_all(table='pose', key='pose_fingerprint', value=0) def delete_features(self) -> None: """Delete all protein features""" - self.delete_where(table="feature", key="feature_id > 0") + self.delete_where(table='feature', key='feature_id > 0') def delete_reactions(self) -> None: """Delete all reaction data""" - tables = ["reaction", "reactant", "route", "component"] + tables = ['reaction', 'reactant', 'route', 'component'] for table in tables: - self.execute(f"DELETE FROM {self.SQL_SCHEMA_PREFIX}{table};") + self.execute(f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table};') self.commit() def delete_subsites(self) -> None: """Delete all protein subsites""" - self.delete_where(table="subsite", key="subsite_id > 0") - self.delete_where(table="subsite_tag", key="subsite_tag_id > 0") + self.delete_where(table='subsite', key='subsite_id > 0') + self.delete_where(table='subsite_tag', key='subsite_tag_id > 0') ### UPDATE @@ -2604,8 +2583,8 @@ def update( try: self.execute(sql, (value,)) - except self.ERROR_UNIQUE_VIOLATION as e: - mrich.var("sql", sql) + except self.ERROR_UNIQUE_VIOLATION: + mrich.var('sql', sql) self.rollback() raise @@ -2640,25 +2619,24 @@ def update_all( try: self.execute(sql, (value,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise if commit: self.commit() - def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: + def update_pose_mol(self, pose_id: int, mol: 'Chem.Mol') -> None: """Update the molecule stored for a specific pose""" - self.update(table="pose", id=pose_id, key="pose_mol", value=mol.ToBinary()) + self.update(table='pose', id=pose_id, key='pose_mol', value=mol.ToBinary()) ### COPYING / MIGRATION - def copy_temp_interactions(self, source_db: "Database | None" = None) -> None: + def copy_temp_interactions(self, source_db: 'Database | None' = None) -> None: """Copy the records from the 'temp_interaction' table to the 'interaction' table""" if source_db is not None: - sql = """ SELECT interaction_feature, @@ -2680,30 +2658,29 @@ def copy_temp_interactions(self, source_db: "Database | None" = None) -> None: cursor = self.executemany(self.SQL_BULK_INSERT_INTERACTIONS, records) else: - sql = f""" INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, interaction_energy ) - SELECT {self.SQL_SCHEMA_PREFIX}interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy + SELECT {self.SQL_SCHEMA_PREFIX}interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy FROM temp_interaction """ @@ -2718,27 +2695,27 @@ def copy_interactions_to_temp(self, pose_id: int) -> int: cursor = self.execute( f""" INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}temp_interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, interaction_energy ) - SELECT interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy + SELECT interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, + interaction_energy FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose = {pose_id} """ @@ -2752,7 +2729,7 @@ def migrate_legacy_scaffolds(self) -> int: :returns: ID of the last inserted scaffold record """ - mrich.debug("HIPPO.Database.migrate_legacy_scaffolds()") + mrich.debug('HIPPO.Database.migrate_legacy_scaffolds()') cursor = self.execute( f""" @@ -2781,16 +2758,14 @@ def update_legacy_routes(self) -> None: # set values match self.engine: - case "sqlite3": - + case 'sqlite3': sql = """ UPDATE component SET component_amount = :component_amount WHERE component_type = :component_type; """ - case "psycopg": - + case 'psycopg': sql = """ UPDATE hippo.component SET component_amount = %(component_amount)s @@ -2860,48 +2835,48 @@ def prune_duplicate_routes(self) -> None: flat_routes = [value for key, value in routes.items()] - mrich.var("#routes", len(flat_routes)) + mrich.var('#routes', len(flat_routes)) counter = Counter(flat_routes) duplicates = {item: count for item, count in counter.items() if count > 1} - mrich.var("products with duplicate routes", len(duplicates)) + mrich.var('products with duplicate routes', len(duplicates)) if not duplicates: - mrich.success("No duplicate routes found") + mrich.success('No duplicate routes found') return None delete = set() for dupe in duplicates: matched_ids = [k for k, v in routes.items() if v == dupe] mrich.print( - "compound", dupe[0], "has", len(matched_ids), "duplicate routes" + 'compound', dupe[0], 'has', len(matched_ids), 'duplicate routes' ) for route_id in matched_ids[1:]: delete.add(route_id) - str_ids = str(tuple(delete)).replace(",)", ")") - self.delete_where(table="component", key=f"component_route IN {str_ids}") - self.delete_where(table="route", key=f"route_id IN {str_ids}") + str_ids = str(tuple(delete)).replace(',)', ')') + self.delete_where(table='component', key=f'component_route IN {str_ids}') + self.delete_where(table='route', key=f'route_id IN {str_ids}') - mrich.success("Deleted", len(delete), "duplicate routes") + mrich.success('Deleted', len(delete), 'duplicate routes') return delete def reinitialise_molecules(self): """In the case where the Mol binaries in a database are throwing unpickling errors, run this to reinitialise them all from their smiles.""" - mrich.var("#compounds", self.count("compound")) + mrich.var('#compounds', self.count('compound')) sql = f""" UPDATE {self.SQL_SCHEMA_PREFIX}compound SET compound_mol = {self.SQL_SCHEMA_PREFIX}mol_from_smiles(compound_smiles); """ - with mrich.loading("Reinitialising compounds..."): + with mrich.loading('Reinitialising compounds...'): self.execute(sql) - mrich.success("compound_mol records updated") + mrich.success('compound_mol records updated') def fix_incorrect_pose_compound_assignments(self): """Fix pose_compound values that reference incorrect chemical structures""" @@ -2909,16 +2884,16 @@ def fix_incorrect_pose_compound_assignments(self): lookup = self.get_compound_id_smiles_dict() lookup = {v: k for k, v in lookup.items()} - count = self.count_where(table="pose", key="mol", value="NOT null") + count = self.count_where(table='pose', key='mol', value='NOT null') match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) FROM pose WHERE pose_mol IS NOT null """ - case "psycopg": + case 'psycopg': sql = """ SELECT pose_id, pose_compound, hippo.mol_to_smiles(hippo.mol_from_pkl(pose_mol)) FROM hippo.pose @@ -2932,21 +2907,21 @@ def fix_incorrect_pose_compound_assignments(self): for pose_id, pose_compound, smiles in mrich.track(c, total=count): try: flat_smiles = sanitise_smiles(smiles) - except Exception as e: - mrich.error("Could not sanitise", pose_id, smiles) + except Exception: + mrich.error('Could not sanitise', pose_id, smiles) comp_id = lookup.get(flat_smiles) if not comp_id: - mrich.error("No matching compound", pose_id, smiles) + mrich.error('No matching compound', pose_id, smiles) continue if comp_id != pose_compound: fix.add((comp_id, pose_id)) fix_count += 1 - mrich.set_progress_field("#fix", fix_count) + mrich.set_progress_field('#fix', fix_count) - mrich.var("#fix", len(fix)) + mrich.var('#fix', len(fix)) sql = f""" UPDATE {self.SQL_SCHEMA_PREFIX}pose @@ -2963,7 +2938,7 @@ def register_compounds( self, *, smiles: list[str], - radical: str = "warning", + radical: str = 'warning', sanitisation_verbosity: bool = True, sanitise: bool = True, debug: bool = False, @@ -2973,26 +2948,25 @@ def register_compounds( values = [] if len(smiles) > 1000: - generator = mrich.track(smiles, prefix="Sanitising...") + generator = mrich.track(smiles, prefix='Sanitising...') else: generator = smiles for s in generator: - if sanitise: try: new_smiles = sanitise_smiles( s, - sanitisation_failed="error", + sanitisation_failed='error', radical=radical, verbosity=sanitisation_verbosity, ) except SanitisationError as e: - mrich.error(f"Could not sanitise {s=}") + mrich.error(f'Could not sanitise {s=}') mrich.error(str(e)) continue except AssertionError: - mrich.error(f"Could not sanitise {s=}") + mrich.error(f'Could not sanitise {s=}') continue else: new_smiles = s @@ -3001,20 +2975,19 @@ def register_compounds( values.append((inchikey, new_smiles)) if self.auto_compute_bfps: - sql = f""" INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_pattern_bfp, + compound_inchikey, + compound_smiles, + compound_mol, + compound_pattern_bfp, compound_morgan_bfp ) VALUES( - ?1, - ?2, - mol_from_smiles(?2), - mol_pattern_bfp(mol_from_smiles(?2), 2048), + ?1, + ?2, + mol_from_smiles(?2), + mol_pattern_bfp(mol_from_smiles(?2), 2048), mol_morgan_bfp(mol_from_smiles(?2), 2, 2048) ) """ @@ -3022,25 +2995,24 @@ def register_compounds( self.executemany(sql, values) else: - match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol) VALUES(?1, ?2, mol_from_smiles(?2)) """ if debug: - mrich.debug("Inserting...") + mrich.debug('Inserting...') self.executemany(sql, values) - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.compound(compound_inchikey, compound_smiles, compound_mol) VALUES( - %(inchikey)s, - %(smiles)s, + %(inchikey)s, + %(smiles)s, hippo.mol_from_smiles(%(smiles)s) ) ON CONFLICT DO NOTHING; @@ -3085,62 +3057,62 @@ def register_poses(self, dicts: list[dict]) -> set[int]: ### POSES match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, + pose_inchikey, + pose_smiles, + pose_alias, + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_energy_score, + pose_distance_score, pose_metadata ) VALUES( - :inchikey, - :smiles, - :alias, - :reference, - :path, - :compound, - :target, - :mol, - :energy_score, - :distance_score, + :inchikey, + :smiles, + :alias, + :reference, + :path, + :compound, + :target, + :mol, + :energy_score, + :distance_score, :metadata ) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, + pose_inchikey, + pose_smiles, + pose_alias, + pose_reference, + pose_path, + pose_compound, + pose_target, + pose_mol, + pose_energy_score, + pose_distance_score, pose_metadata ) VALUES( - %(inchikey)s, - %(smiles)s, - %(alias)s, - %(reference)s, - %(path)s, - %(compound)s, - %(target)s, - %(mol)s, - %(energy_score)s, - %(distance_score)s, + %(inchikey)s, + %(smiles)s, + %(alias)s, + %(reference)s, + %(path)s, + %(compound)s, + %(target)s, + %(mol)s, + %(energy_score)s, + %(distance_score)s, %(metadata)s ) ON CONFLICT DO NOTHING; @@ -3148,9 +3120,8 @@ def register_poses(self, dicts: list[dict]) -> set[int]: values = [] for i, d in enumerate(dicts): - - alias = d["alias"] - reference_id = d.get("reference_id") + alias = d['alias'] + reference_id = d.get('reference_id') if reference_id: reference_id = int(reference_id) @@ -3160,21 +3131,21 @@ def register_poses(self, dicts: list[dict]) -> set[int]: try: values.append( dict( - inchikey=str(d["inchikey"]), - smiles=str(d["smiles"]), + inchikey=str(d['inchikey']), + smiles=str(d['smiles']), alias=alias, reference=reference_id, - path=str(d["path"]), - compound=int(d["compound_id"]), - target=int(d["target_id"]), - mol=d["mol"].ToBinary(), - energy_score=float(d["energy_score"]), - distance_score=float(d["distance_score"]), - metadata=dumps(d["metadata"]), + path=str(d['path']), + compound=int(d['compound_id']), + target=int(d['target_id']), + mol=d['mol'].ToBinary(), + energy_score=float(d['energy_score']), + distance_score=float(d['distance_score']), + metadata=dumps(d['metadata']), ) ) except KeyError as e: - mrich.error("Skipping", i, str(e)) + mrich.error('Skipping', i, str(e)) self.executemany(sql, values) self.commit() @@ -3187,24 +3158,24 @@ def register_poses(self, dicts: list[dict]) -> set[int]: pose_ids = set() for i, d in enumerate(dicts): - if "inspiration_ids" not in d: + if 'inspiration_ids' not in d: continue - derivative_id = lookup.get(str(d["path"])) + derivative_id = lookup.get(str(d['path'])) if not derivative_id: - mrich.error("Could not get derivative by path:", str(d["path"])) + mrich.error('Could not get derivative by path:', str(d['path'])) continue pose_ids.add(derivative_id) - for inspiration_id in d["inspiration_ids"]: + for inspiration_id in d['inspiration_ids']: values.append((inspiration_id, derivative_id)) match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) VALUES(?1, ?2) """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) VALUES(%s, %s) @@ -3219,24 +3190,24 @@ def register_poses(self, dicts: list[dict]) -> set[int]: def calculate_all_scaffolds(self) -> None: """Determine and insert records for all substructure/superstructure relationships in the Compound table""" - n_before = self.count("scaffold") + n_before = self.count('scaffold') - mrich.var("#compounds", self.count("compound")) - mrich.var("#scaffold defs", n_before) + mrich.var('#compounds', self.count('compound')) + mrich.var('#scaffold defs', n_before) sql = """ - SELECT compound_id, compound_mol, compound_pattern_bfp + SELECT compound_id, compound_mol, compound_pattern_bfp FROM compound """ - with mrich.loading("Fetching compounds..."): + with mrich.loading('Fetching compounds...'): records = self.execute(sql).fetchall() self.commit() sql = """ INSERT OR IGNORE INTO scaffold - SELECT ?1, c.compound_id + SELECT ?1, c.compound_id FROM compound AS c, compound_pattern_bfp AS fp WHERE c.compound_id = fp.compound_id AND c.compound_id <> ?1 @@ -3244,45 +3215,45 @@ def calculate_all_scaffolds(self) -> None: AND fp.compound_id MATCH rdtree_subset(?3) """ - with mrich.loading("Calculating scaffolds..."): + with mrich.loading('Calculating scaffolds...'): t1 = time.time() self.executemany(sql, records) - mrich.print("Took", f"{time.time() - t1:.1f}", "seconds") + mrich.print('Took', f'{time.time() - t1:.1f}', 'seconds') self.commit() - diff = self.count("scaffold") - n_before + diff = self.count('scaffold') - n_before if diff: mrich.success( - "Found", diff, "new substructure-superstructure relationships" + 'Found', diff, 'new substructure-superstructure relationships' ) else: mrich.warning( - "Found", diff, "new substructure-superstructure relationships" + 'Found', diff, 'new substructure-superstructure relationships' ) def calculate_all_murcko_scaffolds( self, generic: bool = True - ) -> "dict | (dict, dict)": + ) -> 'dict | (dict, dict)': """Determine Murcko and optionally generic Murcko scaffolds for all Compounds in the Database and add relevant records. :param generic: Calculate generic (single bonds and all carbon) scaffolds as well """ - n_before = self.count("scaffold") + n_before = self.count('scaffold') - mrich.var("#compounds", self.count("compound")) - mrich.var("#scaffold defs", n_before) + mrich.var('#compounds', self.count('compound')) + mrich.var('#scaffold defs', n_before) from rdkit.Chem import MolFromSmiles, MolToSmiles from rdkit.Chem.Scaffolds.MurckoScaffold import ( - MurckoScaffoldSmiles, MakeScaffoldGeneric, + MurckoScaffoldSmiles, ) compound_records = self.select( - query="compound_id, compound_smiles", table="compound", multiple=True + query='compound_id, compound_smiles', table='compound', multiple=True ) ### CALCULATE SCAFFOLDS @@ -3291,7 +3262,6 @@ def calculate_all_murcko_scaffolds( generic_data = {} generic_to_murcko = {} for c_id, smiles in mrich.track(compound_records): - # murcko try: @@ -3310,7 +3280,6 @@ def calculate_all_murcko_scaffolds( # generic if generic: - try: generic_smiles = sanitise_smiles( MolToSmiles(MakeScaffoldGeneric(MolFromSmiles(murcko_smiles))) @@ -3330,8 +3299,8 @@ def calculate_all_murcko_scaffolds( generic_data[generic_smiles].add(c_id) generic_to_murcko[generic_smiles].add(murcko_smiles) - mrich.var("#murcko scaffolds", len(murcko_data)) - mrich.var("#generic murcko scaffolds", len(generic_data)) + mrich.var('#murcko scaffolds', len(murcko_data)) + mrich.var('#generic murcko scaffolds', len(generic_data)) ### REGISTER MURCKOS @@ -3351,42 +3320,41 @@ def calculate_all_murcko_scaffolds( ### TAG MURCKOS murcko_ids = self.select_id_where( - table="compound", - key=f"compound_inchikey IN {tuple(murcko_s2i.values())}", + table='compound', + key=f'compound_inchikey IN {tuple(murcko_s2i.values())}', multiple=True, ) match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ - INSERT OR IGNORE INTO tag(tag_name, tag_compound) + INSERT OR IGNORE INTO tag(tag_name, tag_compound) VALUES (?,?) """ - case "psycopg": + case 'psycopg': sql = """ - INSERT INTO hippo.tag(tag_name, tag_compound) + INSERT INTO hippo.tag(tag_name, tag_compound) VALUES (%s,%s) ON CONFLICT DO NOTHING; """ self.executemany( sql, - [("MurckoScaffold", i) for i, in murcko_ids], + [('MurckoScaffold', i) for (i,) in murcko_ids], ) ### TAG GENERICS if generic: - generic_ids = self.select_id_where( - table="compound", - key=f"compound_inchikey IN {tuple(generic_s2i.values())}", + table='compound', + key=f'compound_inchikey IN {tuple(generic_s2i.values())}', multiple=True, ) self.executemany( sql, - [("GenericMurckoScaffold", i) for i, in generic_ids], + [('GenericMurckoScaffold', i) for (i,) in generic_ids], ) ### ADD MURCKO SCAFFOLD RELATIONS @@ -3401,18 +3369,18 @@ def calculate_all_murcko_scaffolds( pairs = [(a, b) for a, b in pairs if a != b] - mrich.var("#murcko scaffold relations", len(pairs)) + mrich.var('#murcko scaffold relations', len(pairs)) match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ - INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) + INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?) """ - case "psycopg": + case 'psycopg': sql = """ - INSERT INTO hippo.scaffold (scaffold_base, scaffold_superstructure) + INSERT INTO hippo.scaffold (scaffold_base, scaffold_superstructure) VALUES (%s, %s) ON CONFLICT DO NOTHING; """ @@ -3425,7 +3393,6 @@ def calculate_all_murcko_scaffolds( ### ADD GENERIC SCAFFOLD RELATIONS if generic: - pairs = [] generic_inchikey_lookup = self.get_compound_inchikey_id_dict( @@ -3444,10 +3411,10 @@ def calculate_all_murcko_scaffolds( pairs = [(a, b) for a, b in pairs if a != b] - mrich.var("#generic murcko scaffold relations", len(pairs)) + mrich.var('#generic murcko scaffold relations', len(pairs)) self.executemany( - "INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)", + 'INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)', pairs, ) @@ -3462,14 +3429,14 @@ def set_derivative_subsites(self, commit: bool = True) -> None: """Propagate all subsite assignments from inspirations to their derivatives""" match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) SELECT subsite_tag_ref, inspiration_derivative FROM subsite_tag INNER JOIN inspiration ON subsite_tag_pose = inspiration_original """ - case "psycopg": + case 'psycopg': sql = """ INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) SELECT subsite_tag_ref, inspiration_derivative FROM hippo.subsite_tag @@ -3483,7 +3450,7 @@ def set_derivative_subsites(self, commit: bool = True) -> None: self.commit() def set_subsites_from_metadata_field( - self, pose_str_ids: str, field="CanonSites alias" + self, pose_str_ids: str, field='CanonSites alias' ) -> None: """Create and assign subsite entries from a metadata field @@ -3495,9 +3462,9 @@ def set_subsites_from_metadata_field( from json import loads records = self.select_where( - table="pose", - query="pose_id, pose_target, pose_metadata", - key=f"pose_id IN {pose_str_ids}", + table='pose', + query='pose_id, pose_target, pose_metadata', + key=f'pose_id IN {pose_str_ids}', multiple=True, ) @@ -3505,25 +3472,24 @@ def set_subsites_from_metadata_field( subsite_tags = set() for pose_id, pose_target, metadata in records: - metadata = loads(metadata) key = metadata.get(field) if not key: - mrich.warning(field, "not in metadata pose_id=", pose_id) + mrich.warning(field, 'not in metadata pose_id=', pose_id) continue subsites.add((pose_target, key)) subsite_tags.add((pose_target, key, pose_id)) match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO subsite(subsite_target, subsite_name) VALUES(?, ?) """ - case "psycopg": + case 'psycopg': sql = strip_sql( """ INSERT INTO hippo.subsite(subsite_target, subsite_name) @@ -3535,20 +3501,20 @@ def set_subsites_from_metadata_field( self.executemany(sql, sorted(list(subsites))) subsite_records = self.select( - table="subsite", - query="subsite_id, subsite_target, subsite_name", + table='subsite', + query='subsite_id, subsite_target, subsite_name', multiple=True, ) subsite_lookup = {(t, name): i for i, t, name in subsite_records} match self.engine: - case "sqlite3": + case 'sqlite3': sql = """ INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) VALUES(?, ?) """ - case "psycopg": + case 'psycopg': sql = strip_sql( """ INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) @@ -3574,7 +3540,7 @@ def get_compound( inchikey: str | None = None, alias: str | None = None, smiles: str | None = None, - none: str = "error", + none: str = 'error', **kwargs, ) -> Compound: """Get a :class:`.Compound` using one of the following fields: ['id', 'inchikey', 'alias', 'smiles'] @@ -3593,13 +3559,13 @@ def get_compound( ) if not id: - if none == "error": - mrich.error(f"Invalid {id=}") + if none == 'error': + mrich.error(f'Invalid {id=}') return None - query = "compound_id, compound_inchikey, compound_alias, compound_smiles" + query = 'compound_id, compound_inchikey, compound_alias, compound_smiles' entry = self.select_where( - query=query, table="compound", key="id", value=id, none=none, **kwargs + query=query, table='compound', key='id', value=id, none=none, **kwargs ) compound = Compound(self._animal, self, *entry, metadata=None, mol=None) return compound @@ -3623,17 +3589,17 @@ def get_compound_id( if inchikey: entry = self.select_id_where( - table="compound", key="inchikey", value=inchikey, **kwargs + table='compound', key='inchikey', value=inchikey, **kwargs ) elif alias: entry = self.select_id_where( - table="compound", key="alias", value=alias, **kwargs + table='compound', key='alias', value=alias, **kwargs ) elif smiles: entry = self.select_id_where( - table="compound", key="smiles", value=smiles, **kwargs + table='compound', key='smiles', value=smiles, **kwargs ) else: @@ -3647,15 +3613,15 @@ def get_compound_id( def get_compound_mol( self, compound_id: int, - ) -> "Chem.Mol": + ) -> 'Chem.Mol': """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" from rdkit.Chem import Mol (bytestr,) = self.select_where( - query="mol_to_binary_mol(compound_mol)", - table="compound", - key="id", + query='mol_to_binary_mol(compound_mol)', + table='compound', + key='id', value=compound_id, ) @@ -3679,12 +3645,12 @@ def get_compound_computed_property( if not isinstance(function, str): function, extra = function else: - extra = "" + extra = '' (val,) = self.select_where( - query=f"{function}(compound_mol{extra})", - table="compound", - key="id", + query=f'{function}(compound_mol{extra})', + table='compound', + key='id', value=compound_id, multiple=False, ) @@ -3716,12 +3682,12 @@ def get_pose( return PoseSet(self, id) if not id: - mrich.error(f"Invalid {id=}") + mrich.error(f'Invalid {id=}') return None - query = ", ".join(self.POSE_FIELDS) + query = ', '.join(self.POSE_FIELDS) - entry = self.select_where(query=query, table="pose", key="id", value=id) + entry = self.select_where(query=query, table='pose', key='id', value=id) if debug: mrich.print(entry) @@ -3736,12 +3702,12 @@ def get_poses( ) -> list[Pose]: """Get list of initialised :class:`.Pose` objects with given ID's""" - query = ", ".join(self.POSE_FIELDS) + query = ', '.join(self.POSE_FIELDS) - str_ids = str(tuple(ids)).replace(",)", ")") + str_ids = str(tuple(ids)).replace(',)', ')') records = self.select_where( - query=query, table="pose", key=f"pose_id IN {str_ids}", multiple=True + query=query, table='pose', key=f'pose_id IN {str_ids}', multiple=True ) poses = [Pose(self, *entry) for entry in records] @@ -3766,16 +3732,16 @@ def get_pose_id( if inchikey: # inchikey might not be unique entries = self.select_id_where( - table="pose", key="inchikey", value=inchikey, multiple=True + table='pose', key='inchikey', value=inchikey, multiple=True ) if len(entries) != 1: - mrich.warning(f"Multiple poses with {inchikey=}") - return [i for i, in entries] + mrich.warning(f'Multiple poses with {inchikey=}') + return [i for (i,) in entries] else: entry = entries[0] elif alias: - entry = self.select_id_where(table="pose", key="alias", value=alias) + entry = self.select_id_where(table='pose', key='alias', value=alias) else: raise NotImplementedError @@ -3800,12 +3766,12 @@ def get_reaction( """ if not id: - mrich.error(f"Invalid {id=}") + mrich.error(f'Invalid {id=}') return None - query = "reaction_id, reaction_type, reaction_product, reaction_product_yield" + query = 'reaction_id, reaction_type, reaction_product, reaction_product_yield' entry = self.select_where( - query=query, table="reaction", key="id", value=id, none=none + query=query, table='reaction', key='id', value=id, none=none ) if not entry: @@ -3828,25 +3794,25 @@ def get_quote( """ - query = ", ".join( + query = ', '.join( [ - "quote_compound", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_amount", - "quote_price", - "quote_currency", - "quote_lead_time", - "quote_purity", - "quote_date", - "quote_smiles", - "quote_id", + 'quote_compound', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_amount', + 'quote_price', + 'quote_currency', + 'quote_lead_time', + 'quote_purity', + 'quote_date', + 'quote_smiles', + 'quote_id', ] ) entry = self.select_where( - query=query, table="quote", key="id", value=id, none=none + query=query, table='quote', key='id', value=id, none=none ) return Quote( @@ -3865,33 +3831,33 @@ def get_quote( smiles=entry[10], ) - def get_quote_df(self, ids: list[int]) -> "pd.DataFrame": + def get_quote_df(self, ids: list[int]) -> 'pd.DataFrame': """Get a pandas DataFrame representing quotes with given IDs""" from pandas import DataFrame - str_ids = str(tuple(ids)).replace(",)", ")") + str_ids = str(tuple(ids)).replace(',)', ')') - query = ", ".join( + query = ', '.join( [ - "quote_compound", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_amount", - "quote_price", - "quote_currency", - "quote_lead_time", - "quote_purity", - "quote_date", - "quote_smiles", - "quote_id", + 'quote_compound', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_amount', + 'quote_price', + 'quote_currency', + 'quote_lead_time', + 'quote_purity', + 'quote_date', + 'quote_smiles', + 'quote_id', ] ) records = self.select_where( query=query, - table="quote", - key=f"quote_id IN {str_ids}", + table='quote', + key=f'quote_id IN {str_ids}', multiple=True, ) @@ -3932,7 +3898,7 @@ def get_metadata( """ (payload,) = self.select_where( - query=f"{table}_metadata", table=table, key=f"id", value=id + query=f'{table}_metadata', table=table, key='id', value=id ) if payload: @@ -3975,9 +3941,9 @@ def get_target_name( """ - table = "target" + table = 'target' (payload,) = self.select_where( - query=f"{table}_name", table=table, key="id", value=id + query=f'{table}_name', table=table, key='id', value=id ) return payload @@ -3993,8 +3959,8 @@ def get_target_id( """ - table = "target" - entry = self.select_id_where(table=table, key="name", value=name) + table = 'target' + entry = self.select_id_where(table=table, key='name', value=name) if entry: return entry[0] @@ -4013,7 +3979,7 @@ def get_feature( """ - entry = self.select_all_where(table="feature", key="id", value=id) + entry = self.select_all_where(table='feature', key='id', value=id) return Feature(*entry) @@ -4035,16 +4001,16 @@ def get_route( from .rset import ReactionSet (product_id,) = self.select_where( - table="route", query="route_product", key="id", value=id + table='route', query='route_product', key='id', value=id ) if debug: - mrich.var("product_id", product_id) + mrich.var('product_id', product_id) triples = self.select_where( - table="component", - query="component_ref, component_type, component_amount", - key=f"component_route IS {id} ORDER BY component_id", + table='component', + query='component_ref, component_type, component_amount', + key=f'component_route IS {id} ORDER BY component_id', multiple=True, ) @@ -4065,10 +4031,10 @@ def get_route( intermediate_ids.append(ref) intermediate_amounts.append(amount) case _: - raise ValueError(f"Unknown component type {c_type}") + raise ValueError(f'Unknown component type {c_type}') if debug: - mrich.var("pairs", pairs) + mrich.var('pairs', pairs) products = CompoundSet(self, [product_id]) reactants = CompoundSet(self, reactant_ids) @@ -4094,34 +4060,33 @@ def get_route( ) if debug: - mrich.var("recipe", recipe) + mrich.var('recipe', recipe) return recipe - def get_route_products(self) -> "CompoundSet | None": + def get_route_products(self) -> 'CompoundSet | None': """Get a :class:`.CompoundSet` of all route products""" from .cset import CompoundSet records = self.execute( - f"SELECT DISTINCT route_product FROM {self.SQL_SCHEMA_PREFIX}route" + f'SELECT DISTINCT route_product FROM {self.SQL_SCHEMA_PREFIX}route' ).fetchall() if not records: return None - return CompoundSet(self, [i for i, in records]) + return CompoundSet(self, [i for (i,) in records]) def get_route_id_product_dict(self) -> dict[int, int]: """Get a dictionary mapping route ID's to their product :class:`.Compound`""" - records = self.execute("SELECT route_id, route_product FROM route").fetchall() + records = self.execute('SELECT route_id, route_product FROM route').fetchall() return {route_id: route_product for route_id, route_product in records} def get_product_id_routes_dict(self) -> dict[int, set[int]]: """Get a dictionary mapping product :class:`.Compound` to their route IDs""" - records = self.execute("SELECT route_id, route_product FROM route").fetchall() + records = self.execute('SELECT route_id, route_product FROM route').fetchall() lookup = {} for route_id, route_product in records: - if route_product not in lookup: lookup[route_product] = set() @@ -4148,10 +4113,10 @@ def get_route_id_reactant_ids_dict(self) -> dict[int, set[int]]: return lookup - def get_compound_id_pose_ids_dict(self, cset: "CompoundSet") -> dict[int, set]: + def get_compound_id_pose_ids_dict(self, cset: 'CompoundSet') -> dict[int, set]: """Get a dictionary mapping :class:`.Compound` ID's to their associated :class:`.Pose` ID's""" records = self.execute( - f"SELECT pose_compound, pose_id FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_compound IN {cset.str_ids}" + f'SELECT pose_compound, pose_id FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_compound IN {cset.str_ids}' ).fetchall() d = {} @@ -4162,11 +4127,11 @@ def get_compound_id_pose_ids_dict(self, cset: "CompoundSet") -> dict[int, set]: return d def get_compound_id_suppliers_dict( - self, cset: "CompoundSet" + self, cset: 'CompoundSet' ) -> dict[int, set[str]]: """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" records = self.execute( - f"SELECT quote_compound, quote_supplier FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {cset.str_ids}" + f'SELECT quote_compound, quote_supplier FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {cset.str_ids}' ).fetchall() d = {} @@ -4179,15 +4144,15 @@ def get_compound_id_suppliers_dict( def get_compound_id_smiles_dict( self, - cset: "CompoundSet | None" = None, + cset: 'CompoundSet | None' = None, ) -> dict[int, set[str]]: """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" if cset: - sql = f"SELECT compound_id, compound_smiles FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {cset.str_ids}" + sql = f'SELECT compound_id, compound_smiles FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {cset.str_ids}' else: - sql = "SELECT compound_id, compound_smiles FROM compound" + sql = 'SELECT compound_id, compound_smiles FROM compound' c = self.execute(sql) @@ -4200,13 +4165,13 @@ def get_compound_id_smiles_dict( def get_compound_inchikey_id_dict(self, inchikeys: list[str]) -> dict[str, int]: """Get a dictionary mapping :class:`.Compound` inchikeys to their ID's""" - inchikey_str = str(tuple(inchikeys)).replace(",)", ")") + inchikey_str = str(tuple(inchikeys)).replace(',)', ')') records = self.select_where( - table="compound", + table='compound', multiple=True, - query="compound_inchikey, compound_id", - key=f"compound_inchikey IN {inchikey_str}", + query='compound_inchikey, compound_id', + key=f'compound_inchikey IN {inchikey_str}', ) return { @@ -4217,8 +4182,8 @@ def get_compound_smiles_id_dict(self) -> dict[str, int]: """Get a dictionary mapping :class:`.Compound` smiles to their ID's""" records = self.select( - table="compound", - query="compound_smiles, compound_id", + table='compound', + query='compound_smiles, compound_id', multiple=True, ) @@ -4227,25 +4192,23 @@ def get_compound_smiles_id_dict(self) -> dict[str, int]: } def get_compound_id_inchikey_dict( - self, cset: "CompoundSet | None" = None + self, cset: 'CompoundSet | None' = None ) -> dict[int, str]: """Get a dictionary mapping :class:`.Compound` IDs to their inchikeys""" if cset: - records = self.select_where( - table="compound", + table='compound', multiple=True, - query="compound_id, compound_inchikey", - key=f"compound_id IN {cset.str_ids}", + query='compound_id, compound_inchikey', + key=f'compound_id IN {cset.str_ids}', ) else: - records = self.select( - table="compound", + table='compound', multiple=True, - query="compound_id, compound_inchikey", + query='compound_id, compound_inchikey', ) return { @@ -4256,22 +4219,22 @@ def get_id_metadata_dict(self, *, table: str, ids: list[int]) -> dict[int, dict] """Get a dictionary mapping IDs to metadata dictionaries""" from json import loads - str_ids = str(tuple(ids)).replace(",)", ")") + str_ids = str(tuple(ids)).replace(',)', ')') records = self.select_where( - query=f"{table}_id, {table}_metadata", + query=f'{table}_id, {table}_metadata', table=table, - key=f"{table}_id IN {str_ids}", + key=f'{table}_id IN {str_ids}', multiple=True, ) return {i: (loads(m) if m is not None else {}) for i, m in records} def get_compound_cluster_dict( self, - cset: "CompoundSet | None" = None, + cset: 'CompoundSet | None' = None, *, fractions: bool = False, max_scaffolds: int | None = None, - fraction_reference: "CompoundSet | None" = None, + fraction_reference: 'CompoundSet | None' = None, ) -> dict[tuple, set]: """Create a dictionary grouping compounds by their scaffold/base cluster. @@ -4327,8 +4290,8 @@ def get_compound_scaffold_dict(self) -> dict[int, set[int]]: """Get a dictionary mapping scaffold_base compound ID's to a set of their superstructure IDs""" records = self.select( - table="scaffold", - query="scaffold_base, scaffold_superstructure", + table='scaffold', + query='scaffold_base, scaffold_superstructure', multiple=True, ) @@ -4342,7 +4305,7 @@ def get_compound_scaffold_dict(self) -> dict[int, set[int]]: def get_compound_tag_dict( self, - cset: "CompoundSet | None" = None, + cset: 'CompoundSet | None' = None, ) -> dict[int, set[str]]: """Get a dictionary mapping compound ID's to their tags""" @@ -4350,7 +4313,7 @@ def get_compound_tag_dict( raise NotImplementedError records = self.select( - query="tag_name, tag_compound", table="tag", multiple=True + query='tag_name, tag_compound', table='tag', multiple=True ) data = {} @@ -4360,8 +4323,8 @@ def get_compound_tag_dict( data[compound_id].add(tag_name) # null IDS - comp_ids = self.select(table="compound", query="compound_id", multiple=True) - comp_ids = set(q for q, in comp_ids) + comp_ids = self.select(table='compound', query='compound_id', multiple=True) + comp_ids = set(q for (q,) in comp_ids) null_ids = comp_ids - set(data.keys()) @@ -4372,21 +4335,21 @@ def get_compound_tag_dict( def get_pose_tag_dict( self, - pset: "PoseSet | None" = None, + pset: 'PoseSet | None' = None, ) -> dict[int, set[str]]: """Get a dictionary mapping pose ID's to their tags""" if pset: records = self.select_where( - query="tag_name, tag_pose", - table="tag", - key=f"tag_pose IN {pset.str_ids}", + query='tag_name, tag_pose', + table='tag', + key=f'tag_pose IN {pset.str_ids}', multiple=True, ) else: records = self.select( - query="tag_name, tag_pose", table="tag", multiple=True + query='tag_name, tag_pose', table='tag', multiple=True ) data = {} @@ -4401,8 +4364,8 @@ def get_pose_tag_dict( null_ids = set(pset.ids) - set(data.keys()) else: - pose_ids = self.select(table="pose", query="pose_id", multiple=True) - pose_ids = set(q for q, in pose_ids) + pose_ids = self.select(table='pose', query='pose_id', multiple=True) + pose_ids = set(q for (q,) in pose_ids) null_ids = pose_ids - set(data.keys()) @@ -4417,13 +4380,13 @@ def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: lookup = { i: n for i, n in self.select( - query="subsite_id, subsite_name", table="subsite", multiple=True + query='subsite_id, subsite_name', table='subsite', multiple=True ) } records = self.select( - query="subsite_tag_ref, subsite_tag_pose", - table="subsite_tag", + query='subsite_tag_ref, subsite_tag_pose', + table='subsite_tag', multiple=True, ) @@ -4433,8 +4396,8 @@ def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: data[pose_id].add(lookup[subsite_id]) # null IDS - pose_ids = self.select(table="pose", query="pose_id", multiple=True) - pose_ids = set(q for q, in pose_ids) + pose_ids = self.select(table='pose', query='pose_id', multiple=True) + pose_ids = set(q for (q,) in pose_ids) null_ids = pose_ids - set(data.keys()) @@ -4443,10 +4406,10 @@ def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: return data - def get_pose_id_interaction_ids_dict(self, pset: "PoseSet") -> dict[int, set]: + def get_pose_id_interaction_ids_dict(self, pset: 'PoseSet') -> dict[int, set]: """Get a dictionary mapping :class:`.Pose` ID's to their associated :class:`.Interaction` ID's""" records = self.execute( - f"SELECT interaction_pose, interaction_id FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose IN {pset.str_ids}" + f'SELECT interaction_pose, interaction_id FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose IN {pset.str_ids}' ).fetchall() d = {} @@ -4456,20 +4419,20 @@ def get_pose_id_interaction_ids_dict(self, pset: "PoseSet") -> dict[int, set]: d[pose_id].add(interaction_id) return d - def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: + def get_pose_alias_id_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: records = self.execute( f""" - SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose + SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() else: records = self.execute( - """SELECT pose_id, pose_alias FROM pose + """SELECT pose_id, pose_alias FROM pose WHERE pose_alias IS NOT NULL""" ).fetchall() @@ -4479,13 +4442,13 @@ def get_pose_alias_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int return d - def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, str]: + def get_pose_alias_path_dict(self, pset: 'PoseSet | None' = None) -> dict[str, str]: """Get a dictionary mapping :class:`.Pose` aliases to paths""" if pset: records = self.execute( f""" - SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose + SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {pset.str_ids}""" ).fetchall() @@ -4500,20 +4463,20 @@ def get_pose_alias_path_dict(self, pset: "PoseSet | None" = None) -> dict[str, s return d - def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: + def get_pose_id_alias_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: records = self.execute( f""" - SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose + SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_alias IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() else: records = self.execute( - """SELECT pose_id, pose_alias FROM pose + """SELECT pose_id, pose_alias FROM pose WHERE pose_alias IS NOT NULL""" ).fetchall() @@ -4523,13 +4486,13 @@ def get_pose_id_alias_dict(self, pset: "PoseSet | None" = None) -> dict[str, int return d - def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int]: + def get_pose_path_id_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: """Get a dictionary mapping :class:`.Pose` aliases to ID's""" if pset: records = self.execute( f""" - SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose + SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL AND pose_id IN {pset.str_ids}""" ).fetchall() @@ -4537,7 +4500,7 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] else: records = self.execute( f""" - SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose + SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_path IS NOT NULL""" ).fetchall() @@ -4547,33 +4510,32 @@ def get_pose_path_id_dict(self, pset: "PoseSet | None" = None) -> dict[str, int] return d - def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": + def get_pose_id_obj_dict(self, pset: 'PoseSet') -> 'dict[id, Pose]': """Get a dictionary mapping :class:`.Pose` ID's to their objects""" - query = ", ".join( + query = ', '.join( [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", + 'pose_id', + 'pose_inchikey', + 'pose_alias', + 'pose_smiles', + 'pose_reference', + 'pose_path', + 'pose_compound', + 'pose_target', + 'pose_mol', + 'pose_fingerprint', + 'pose_energy_score', + 'pose_distance_score', ] ) records = self.select_where( - query=query, table="pose", key=f"pose_id IN {pset.str_ids}", multiple=True + query=query, table='pose', key=f'pose_id IN {pset.str_ids}', multiple=True ) d = {} for entry in records: - ( pose_id, pose_inchikey, @@ -4607,11 +4569,11 @@ def get_pose_id_obj_dict(self, pset: "PoseSet") -> "dict[id, Pose]": return d - def get_pose_id_interaction_tuples_dict(self, pset: "PoseSet") -> dict[int, set]: + def get_pose_id_interaction_tuples_dict(self, pset: 'PoseSet') -> dict[int, set]: """Get a dictionary mapping :class:`.Pose` ID's to lists of `(interaction_type, feature_id)` tuples describing their interactions""" sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM {self.SQL_SCHEMA_PREFIX}interaction + SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM {self.SQL_SCHEMA_PREFIX}interaction INNER JOIN {self.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id WHERE interaction_pose IN {pset.str_ids} """ @@ -4635,7 +4597,7 @@ def get_compound_id_inspiration_ids_dict(self) -> dict[int, set]: INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative """ - with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): + with mrich.spinner('Database.get_pose_id_interaction_ids_dict()'): records = self.execute(sql).fetchall() d = {} @@ -4648,7 +4610,7 @@ def get_compound_id_inspiration_ids_dict(self) -> dict[int, set]: def get_pose_id_inspiration_ids_dict( self, - pset: "PoseSet" = None, + pset: 'PoseSet' = None, ) -> dict[int, set]: """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" @@ -4660,13 +4622,12 @@ def get_pose_id_inspiration_ids_dict( """ else: - sql = f""" SELECT pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}pose INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative """ - with mrich.spinner("Database.get_pose_id_interaction_ids_dict()"): + with mrich.spinner('Database.get_pose_id_interaction_ids_dict()'): records = self.execute(sql).fetchall() d = {} @@ -4682,14 +4643,14 @@ def get_inspiration_tuples(self) -> list[int, int]: sql = f"""SELECT inspiration_original, inspiration_derivative FROM {self.SQL_SCHEMA_PREFIX}inspiration""" return self.execute(sql).fetchall() - def get_compound_id_obj_dict(self, cset: "CompoundSet") -> "dict[id, Compound]": + def get_compound_id_obj_dict(self, cset: 'CompoundSet') -> 'dict[id, Compound]': """Get a dictionary mapping :class:`.Compound` ID's to their objects""" - query = "compound_id, compound_inchikey, compound_alias, compound_smiles" + query = 'compound_id, compound_inchikey, compound_alias, compound_smiles' records = self.select_where( query=query, - table="compound", - key=f"compound_id IN {cset.str_ids}", + table='compound', + key=f'compound_id IN {cset.str_ids}', multiple=True, ) @@ -4708,7 +4669,7 @@ def get_compound_id_obj_dict(self, cset: "CompoundSet") -> "dict[id, Compound]": ) return d - def get_interaction(self, *, id: int, table: str = "interaction") -> "Interaction": + def get_interaction(self, *, id: int, table: str = 'interaction') -> 'Interaction': """Fetch the :class:`.Interaction` object with given ID :param id: the ID of the Interaction to retrieve @@ -4718,7 +4679,7 @@ def get_interaction(self, *, id: int, table: str = "interaction") -> "Interactio from .interaction import Interaction - result = self.select_all_where(table=table, key=f"interaction_id = {id}") + result = self.select_all_where(table=table, key=f'interaction_id = {id}') ( id, @@ -4755,7 +4716,7 @@ def get_reaction_map_from_products( ) -> dict[tuple[str, int], set[int]]: """Get a dictionary mapping (reaction_type, product_id) tuples to sets of reactant_ids""" - str_ids = str(tuple(product_ids)).replace(",)", ")") + str_ids = str(tuple(product_ids)).replace(',)', ')') records = self.execute( f""" @@ -4768,7 +4729,6 @@ def get_reaction_map_from_products( mapping = {} for reaction_type, reaction_product, reaction_id, reactant_compound in records: - key = (reaction_type, reaction_product) if key not in mapping: @@ -4793,40 +4753,40 @@ def get_possible_reaction_ids( """ - compound_ids_str = str(tuple(compound_ids)).replace(",)", ")") + compound_ids_str = str(tuple(compound_ids)).replace(',)', ')') result = self.execute( f""" - WITH possible_reactants AS + WITH possible_reactants AS ( - SELECT reactant_reaction, CASE - WHEN reactant_compound IN {compound_ids_str} - THEN reactant_compound END AS [possible_reactant] + SELECT reactant_reaction, CASE + WHEN reactant_compound IN {compound_ids_str} + THEN reactant_compound END AS [possible_reactant] FROM {self.SQL_SCHEMA_PREFIX}reactant ) , possible_reactions AS ( SELECT reactant_reaction, COUNT( - CASE - WHEN possible_reactant IS NULL - THEN 1 END) AS [count_null] + CASE + WHEN possible_reactant IS NULL + THEN 1 END) AS [count_null] FROM possible_reactants GROUP BY reactant_reaction ) - + SELECT reactant_reaction FROM possible_reactions WHERE count_null = 0 """ ).fetchall() - return [q for q, in result] + return [q for (q,) in result] def get_unsolved_reaction_tree( self, *, product_ids: list[int], debug: bool = False, - ) -> "(CompoundSet, ReactionSet)": + ) -> '(CompoundSet, ReactionSet)': """Given a set of product :class:`.Compound` IDs, recursively solve for all the reactants (:class:`.CompoundSet`) and reactions (:class:`.ReactionSet`) that could be involved in their synthesis. N.B. This evaluates all synthesis branches. :param product_ids: list of product :class:`.Compound` IDs @@ -4847,24 +4807,23 @@ def get_unsolved_reaction_tree( # all_reactants.add(product_id) for i in range(300): - if debug: - mrich.var("recursive depth", i + 1) + mrich.var('recursive depth', i + 1) if debug: - mrich.var("#products", len(product_ids)) + mrich.var('#products', len(product_ids)) - product_ids_str = str(tuple(product_ids)).replace(",)", ")") + product_ids_str = str(tuple(product_ids)).replace(',)', ')') reaction_ids = self.select_where( - table="reaction", - query="DISTINCT reaction_id", - key=f"reaction_product in {product_ids_str}", + table='reaction', + query='DISTINCT reaction_id', + key=f'reaction_product in {product_ids_str}', multiple=True, - none="quiet", + none='quiet', ) - reaction_ids = [q for q, in reaction_ids] + reaction_ids = [q for (q,) in reaction_ids] if not reaction_ids: break @@ -4873,21 +4832,21 @@ def get_unsolved_reaction_tree( all_reactions.add(reaction_id) if debug: - mrich.var("#reactions", len(reaction_ids)) + mrich.var('#reactions', len(reaction_ids)) - reaction_ids_str = str(tuple(reaction_ids)).replace(",)", ")") + reaction_ids_str = str(tuple(reaction_ids)).replace(',)', ')') reactant_ids = self.select_where( - table="reactant", - query="DISTINCT reactant_compound", - key=f"reactant_reaction in {reaction_ids_str}", + table='reactant', + query='DISTINCT reactant_compound', + key=f'reactant_reaction in {reaction_ids_str}', multiple=True, ) if debug: - mrich.var("#reactants", len(reactant_ids)) + mrich.var('#reactants', len(reactant_ids)) - reactant_ids = [q for q, in reactant_ids] + reactant_ids = [q for (q,) in reactant_ids] if not reactant_ids: break @@ -4900,13 +4859,13 @@ def get_unsolved_reaction_tree( # all intermediates ids = self.execute( f""" - SELECT DISTINCT reaction_product - FROM {self.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant + SELECT DISTINCT reaction_product + FROM {self.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant ON reaction_product = reactant_compound """ ).fetchall() - ids = [q for q, in ids] + ids = [q for (q,) in ids] intermediates = CompoundSet(self, ids) # remove intermediates @@ -4917,8 +4876,8 @@ def get_unsolved_reaction_tree( all_reactions = ReactionSet(self, all_reactions) if debug: - mrich.var("#all_reactants", len(all_reactants)) - mrich.var("#all_reactions", len(all_reactions)) + mrich.var('#all_reactants', len(all_reactants)) + mrich.var('#all_reactions', len(all_reactions)) return all_reactants, all_reactions @@ -4936,7 +4895,7 @@ def get_reaction_price_estimate( # get reactants for a given reaction - mrich.warning("Price estimate does not account for branching!") + mrich.warning('Price estimate does not account for branching!') reactants, _ = self.get_unsolved_reaction_tree( product_ids=reaction.reactant_ids ) @@ -4947,10 +4906,10 @@ def get_reaction_price_estimate( (price,) = self.execute( f""" - WITH unit_prices AS + WITH unit_prices AS ( - SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price - FROM {self.SQL_SCHEMA_PREFIX}quote + SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price + FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {reactants.str_ids} GROUP BY quote_compound ) @@ -4971,18 +4930,18 @@ def get_possible_reaction_product_ids( :returns: list of :class:`.Compound` IDs """ - reaction_ids_str = str(tuple(reaction_ids)).replace(",)", ")") + reaction_ids_str = str(tuple(reaction_ids)).replace(',)', ')') return [ q - for q, in self.select_where( - query="DISTINCT reaction_product", - table="reaction", - key=f"reaction_id IN {reaction_ids_str}", + for (q,) in self.select_where( + query='DISTINCT reaction_product', + table='reaction', + key=f'reaction_id IN {reaction_ids_str}', multiple=True, ) ] - def get_subsite(self, *, id) -> "Subsite": + def get_subsite(self, *, id) -> 'Subsite': """Get protein subsite with a given ID :param ID: the subsite ID @@ -4993,15 +4952,15 @@ def get_subsite(self, *, id) -> "Subsite": from .subsite import Subsite results = self.select_where( - table="subsite", - key="id", + table='subsite', + key='id', value=id, multiple=False, - query="subsite_name, subsite_target", + query='subsite_name, subsite_target', ) if not results: - mrich.error(f"No subsite with {id=}") + mrich.error(f'No subsite with {id=}') return None name, target = results @@ -5010,7 +4969,7 @@ def get_subsite(self, *, id) -> "Subsite": return subsite - def get_subsite_tag(self, *, id) -> "SubsiteTag": + def get_subsite_tag(self, *, id) -> 'SubsiteTag': """Get subsite_tag with a given ID :param ID: the subsite_tag ID @@ -5021,11 +4980,11 @@ def get_subsite_tag(self, *, id) -> "SubsiteTag": from .subsite import SubsiteTag subsite_id, pose_id = self.select_where( - table="subsite_tag", - key="id", + table='subsite_tag', + key='id', value=id, multiple=False, - query="subsite_tag_ref, subsite_tag_pose", + query='subsite_tag_ref, subsite_tag_pose', ) subsite_tag = SubsiteTag(db=self, id=id, subsite_id=subsite_id, pose_id=pose_id) @@ -5040,8 +4999,8 @@ def get_subsite_id(self, *, name: str, **kwargs) -> int | None: """ - table = "Subsite" - entry = self.select_id_where(table=table, key="name", value=name, **kwargs) + table = 'Subsite' + entry = self.select_id_where(table=table, key='name', value=name, **kwargs) if entry: return entry[0] @@ -5056,9 +5015,9 @@ def get_subsite_name(self, *, id: str, **kwargs) -> int | None: """ - table = "subsite" + table = 'subsite' entry = self.select_where( - query="subsite_name", table=table, key="id", value=id, **kwargs + query='subsite_name', table=table, key='id', value=id, **kwargs ) if entry: @@ -5067,7 +5026,7 @@ def get_subsite_name(self, *, id: str, **kwargs) -> int | None: return None def get_scaffold_similarity_dict( - self, scaffolds: "CompoundSet | None" = None + self, scaffolds: 'CompoundSet | None' = None ) -> list[dict]: """Get a dictionary mapping scaffold :class:`.Compound` IDs to their superstructure's IDs""" @@ -5079,8 +5038,7 @@ def get_scaffold_similarity_dict( """ if scaffolds: - - sql += f" WHERE a IN {scaffolds.str_ids}" + sql += f' WHERE a IN {scaffolds.str_ids}' records = self.execute(sql).fetchall() @@ -5096,16 +5054,16 @@ def get_reactant_product_tuples( """Get tuples of (reactant, product) :class:`.Compound` IDs""" sql = f""" - SELECT reactant_compound, reaction_product + SELECT reactant_compound, reaction_product FROM {self.SQL_SCHEMA_PREFIX}reactant - INNER JOIN {self.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.SQL_SCHEMA_PREFIX}reaction ON reactant_reaction = reaction_id """ if compound_ids: - str_ids = str(tuple(compound_ids)).replace(",)", ")") + str_ids = str(tuple(compound_ids)).replace(',)', ')') sql += ( - f"WHERE reactant_compound IN {str_ids} OR reaction_product IN {str_ids}" + f'WHERE reactant_compound IN {str_ids} OR reaction_product IN {str_ids}' ) records = self.execute(sql) @@ -5120,13 +5078,13 @@ def get_scaffold_tuples( """Get tuples of (reactant, product) :class:`.Compound` IDs""" sql = f""" - SELECT scaffold_base, scaffold_superstructure + SELECT scaffold_base, scaffold_superstructure FROM {self.SQL_SCHEMA_PREFIX}scaffold """ if compound_ids: - str_ids = str(tuple(compound_ids)).replace(",)", ")") - sql += f"WHERE scaffold_base IN {str_ids} OR scaffold_superstructure IN {str_ids}" + str_ids = str(tuple(compound_ids)).replace(',)', ')') + sql += f'WHERE scaffold_base IN {str_ids} OR scaffold_superstructure IN {str_ids}' records = self.execute(sql) return set((a, b) for a, b in records) @@ -5138,9 +5096,9 @@ def query_substructure( query: str, *, fast: bool = True, - none: str = "error", + none: str = 'error', smarts: bool = False, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Search for compounds by substructure :param query: SMILES string of the substructure @@ -5151,41 +5109,39 @@ def query_substructure( """ if smarts: - func = "mol_from_smarts" + func = 'mol_from_smarts' else: - func = "mol_from_smiles" + func = 'mol_from_smiles' # smiles if isinstance(query, str): - if fast: sql = f""" - SELECT compound.compound_id, compound.compound_inchikey - FROM {self.SQL_SCHEMA_PREFIX}compound, compound_pattern_bfp AS bfp - WHERE {self.SQL_SCHEMA_PREFIX}compound.compound_id = {self.SQL_SCHEMA_PREFIX}bfp.compound_id + SELECT compound.compound_id, compound.compound_inchikey + FROM {self.SQL_SCHEMA_PREFIX}compound, compound_pattern_bfp AS bfp + WHERE {self.SQL_SCHEMA_PREFIX}compound.compound_id = {self.SQL_SCHEMA_PREFIX}bfp.compound_id AND mol_is_substruct(compound.compound_mol, {func}(?)) """ else: sql = f""" - SELECT compound_id, compound_inchikey FROM {self.SQL_SCHEMA_PREFIX}compound + SELECT compound_id, compound_inchikey FROM {self.SQL_SCHEMA_PREFIX}compound WHERE mol_is_substruct(compound_mol, {func}(?)) """ else: - raise NotImplementedError try: self.execute(sql, (query,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise result = self.cursor.fetchall() - if not result and none == "error": - mrich.error(f"No compounds with substructure {query}") + if not result and none == 'error': + mrich.error(f'No compounds with substructure {query}') return None elif not result: return None @@ -5195,13 +5151,13 @@ def query_substructure( if not smarts: smiles = query try: - smiles = sanitise_smiles(smiles, sanitisation_failed="error") + smiles = sanitise_smiles(smiles, sanitisation_failed='error') except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') mrich.error(str(e)) return None except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') return None return c inchikey = inchikey_from_smiles(smiles) @@ -5215,13 +5171,13 @@ def query_substructure( def query_most_similar( self, query: str, - subset: "CompoundSet", - fp="pattern", + subset: 'CompoundSet', + fp='pattern', bits=2048, morgan_radius=1, return_similarity: bool = False, - none="error", - ) -> "Compound | (Compound, float)": + none='error', + ) -> 'Compound | (Compound, float)': """Search for the most similar compound by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` :param query: SMILES string @@ -5231,9 +5187,7 @@ def query_most_similar( :returns: :class:`.Compound` and optionally a similarity values """ - from .compound import Compound - - if fp == "pattern" and bits == 2048: + if fp == 'pattern' and bits == 2048: sql = f""" WITH subset AS ( SELECT compound_id, fp @@ -5241,26 +5195,25 @@ def query_most_similar( JOIN compound_pattern_bfp USING (compound_id) WHERE compound_id IN {subset.str_ids} ) - - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) + + SELECT compound_id, + bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity FROM subset ORDER BY similarity DESC LIMIT 1 """ - elif fp == "morgan": - + elif fp == 'morgan': sql = f""" WITH subset AS ( SELECT compound_id, mol_{fp}_bfp(compound_mol, {morgan_radius}, {bits}) AS fp FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {subset.str_ids} ) - - SELECT compound_id, - bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) + + SELECT compound_id, + bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) AS similarity FROM subset ORDER BY similarity DESC @@ -5268,15 +5221,14 @@ def query_most_similar( """ else: - sql = f""" WITH subset AS ( SELECT compound_id, mol_{fp}_bfp(compound_mol, {bits}) AS fp FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {subset.str_ids} ) - - SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) + + SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) AS similarity FROM subset ORDER BY similarity DESC @@ -5285,8 +5237,8 @@ def query_most_similar( try: self.execute(sql, (query,)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise compound_id, similarity = self.cursor.fetchone() @@ -5301,9 +5253,9 @@ def query_similarity( query: str, threshold: float, return_similarity: bool = False, - subset: "CompoundSet" = None, - none="error", - ) -> "CompoundSet | (CompoundSet, list[float])": + subset: 'CompoundSet' = None, + none='error', + ) -> 'CompoundSet | (CompoundSet, list[float])': """Search compounds by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` :param query: SMILES string @@ -5319,15 +5271,14 @@ def query_similarity( # smiles if subset: - if return_similarity: sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM {self.SQL_SCHEMA_PREFIX}compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) + SELECT compound_id, + bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), + mol_pattern_bfp(compound.compound_mol, 2048)) as t + FROM {self.SQL_SCHEMA_PREFIX}compound + JOIN compound_pattern_bfp AS mfp + USING(compound_id) WHERE mfp.compound_id MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) AND compound_id IN {subset.str_ids} @@ -5335,31 +5286,30 @@ def query_similarity( """ else: sql = f""" - SELECT compound_id - FROM compound_pattern_bfp AS bfp + SELECT compound_id + FROM compound_pattern_bfp AS bfp WHERE bfp.compound_id MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) AND compound_id IN {subset.str_ids} """ elif isinstance(query, str): - if return_similarity: sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM {self.SQL_SCHEMA_PREFIX}compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) + SELECT compound_id, + bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), + mol_pattern_bfp(compound.compound_mol, 2048)) as t + FROM {self.SQL_SCHEMA_PREFIX}compound + JOIN compound_pattern_bfp AS mfp + USING(compound_id) WHERE mfp.compound_id - MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) + MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) ORDER BY t DESC """ else: - sql = f""" - SELECT compound_id - FROM compound_pattern_bfp AS bfp + sql = """ + SELECT compound_id + FROM compound_pattern_bfp AS bfp WHERE bfp.compound_id MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) """ @@ -5368,22 +5318,22 @@ def query_similarity( try: self.execute(sql, (query, threshold)) - except sqlite3.OperationalError as e: - mrich.var("sql", sql) + except sqlite3.OperationalError: + mrich.var('sql', sql) raise result = self.cursor.fetchall() - if not result and none == "error": - mrich.error(f"No compounds with similarity >= {threshold} to {query}") + if not result and none == 'error': + mrich.error(f'No compounds with similarity >= {threshold} to {query}') return None if return_similarity: - ids, similarities = zip(*result) + ids, similarities = zip(*result, strict=False) cset = CompoundSet(self, ids) return cset, similarities - ids = [r for r, in result] + ids = [r for (r,) in result] cset = CompoundSet(self, ids) return cset @@ -5392,7 +5342,7 @@ def query_exact( self, query: str, threshold: float = 0.989, - ) -> "CompoundSet": + ) -> 'CompoundSet': """Search for exact match compounds (default similarity > 0.989) :param query: SMILES string @@ -5413,8 +5363,8 @@ def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: pairs = self.execute( f""" - SELECT {table}_id, {table}_metadata - FROM {self.SQL_SCHEMA_PREFIX}{table} + SELECT {table}_id, {table}_metadata + FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_metadata LIKE '%"{key}": "%' """ ).fetchall() @@ -5438,7 +5388,7 @@ def count( """ - sql = f"SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table};" + sql = f'SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table};' self.execute(sql) return self.cursor.fetchone()[0] @@ -5463,11 +5413,11 @@ def count_where( value = f"'{value}'" if value is not None: - where_str = f"{table}_{key}={value}" + where_str = f'{table}_{key}={value}' else: where_str = key - sql = f"SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str};" + sql = f'SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str};' self.execute(sql) return self.cursor.fetchone()[0] @@ -5484,7 +5434,7 @@ def min_id( """ - (id,) = self.select(table=table, query=f"MIN({table}_id)") + (id,) = self.select(table=table, query=f'MIN({table}_id)') return id def max_id( @@ -5497,7 +5447,7 @@ def max_id( :returns: the largest entry ID """ - (id,) = self.select(table=table, query=f"MAX({table}_id)") + (id,) = self.select(table=table, query=f'MAX({table}_id)') return id def slice_ids( @@ -5528,27 +5478,27 @@ def slice_ids( if not (start >= 0 and start <= max_id): raise IndexError( - f"Slice {start=} outside of DB {table}_id range ({min_id}, {max_id})" + f'Slice {start=} outside of DB {table}_id range ({min_id}, {max_id})' ) if not (stop >= 0 and stop <= max_id + 1): raise IndexError( - f"Slice {stop=} outside of DB {table}_id range ({min_id}, {max_id})" + f'Slice {stop=} outside of DB {table}_id range ({min_id}, {max_id})' ) if step != 1: - raise NotImplementedError(f"Slice {step=} not supported") + raise NotImplementedError(f'Slice {step=} not supported') ids = self.select_where( table=table, - query=f"{table}_id", - key=f"{table}_id >= {start} AND {table}_id < {stop}", + query=f'{table}_id', + key=f'{table}_id >= {start} AND {table}_id < {stop}', multiple=True, ) - ids = [q for q, in ids] + ids = [q for (q,) in ids] if name: - return ids, f"{table}s[{start}:{stop}]" + return ids, f'{table}s[{start}:{stop}]' else: return ids @@ -5556,7 +5506,7 @@ def slice_ids( def prune_reactions( self, - reactions: "ReactionSet", + reactions: 'ReactionSet', ) -> list[Reaction]: """Remove duplicate reactions @@ -5569,7 +5519,6 @@ def prune_reactions( del_list = [] for i, reaction in enumerate(reactions): - matches = [r for r in pruned if r == reaction] if not matches: @@ -5579,8 +5528,8 @@ def prune_reactions( del_list.append(reaction) for reaction in del_list: - mrich.warning(f"Deleted duplicate {reaction=}") - self.delete_where("reaction", "id", reaction.id) + mrich.warning(f'Deleted duplicate {reaction=}') + self.delete_where('reaction', 'id', reaction.id) return pruned @@ -5604,16 +5553,15 @@ def remove_metadata_list_item( # get id's with specific metadata key and value value_str = json.dumps(value) result = self.select_where( - query=f"{table}_id, {table}_metadata", + query=f'{table}_id, {table}_metadata', table=table, - key=f"{table}_metadata LIKE '%\"export\": [%{value_str}%]%'", + key=f'{table}_metadata LIKE \'%"export": [%{value_str}%]%\'', multiple=True, - none="quiet", + none='quiet', ) # loop over all matches for id, metadata_str in result: - # read the metadata metadata = json.loads(metadata_str) @@ -5629,7 +5577,7 @@ def remove_metadata_list_item( self.update( table=table, id=id, - key=f"{table}_metadata", + key=f'{table}_metadata', value=metadata_str, commit=False, ) @@ -5656,7 +5604,7 @@ def print_table( def table_df( self, table: str, - ) -> "pandas.DataFrame": + ) -> 'pandas.DataFrame': """Get a DataFrame of a table :param table: the table to get @@ -5668,11 +5616,11 @@ def table_df( column_names = self.column_names(table) - self.execute(f"SELECT * FROM {self.SQL_SCHEMA_PREFIX}{table}") + self.execute(f'SELECT * FROM {self.SQL_SCHEMA_PREFIX}{table}') for record in self.cursor: d = {} - for key, value in zip(column_names, record): + for key, value in zip(column_names, record, strict=False): d[key] = value data.append(d) @@ -5691,7 +5639,7 @@ def table_info( """ - self.execute(f"PRAGMA table_info({self.SQL_SCHEMA_PREFIX}{table})") + self.execute(f'PRAGMA table_info({self.SQL_SCHEMA_PREFIX}{table})') return self.cursor.fetchall() def column_names(self, table: str) -> list[str]: @@ -5710,24 +5658,24 @@ def index_names(self) -> list[str]: """ ) - return [n for n, in cursor] + return [n for (n,) in cursor] ### DUNDERS def __str__(self): """Unformatted string representation""" if self.in_memory: - return f"Database [IN-MEMORY]" + return 'Database [IN-MEMORY]' else: - return f"Database @ {self.path.resolve()}" + return f'Database @ {self.path.resolve()}' def __repr__(self): """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.clear}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.clear}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class LegacyDatabaseError(Exception): @@ -5740,7 +5688,7 @@ def backup( source: Path | str, destination: Path | str | None = None, pages: int = 10_000, -) -> "Path": +) -> 'Path': """Create a backup of the database""" from .tools import dt_hash @@ -5748,17 +5696,16 @@ def backup( source = Path(source) if not destination: - destination = str(source.resolve()).replace(".sqlite", f"_{dt_hash()}.sqlite") + destination = str(source.resolve()).replace('.sqlite', f'_{dt_hash()}.sqlite') destination = Path(destination) - with mrich.spinner(f"Backing up {source}..."): - + with mrich.spinner(f'Backing up {source}...'): mrich.writing(destination) def progress(status, remaining, total): """print progress""" - mrich.debug(f"Copied {total-remaining} of {total} pages...") + mrich.debug(f'Copied {total - remaining} of {total} pages...') src = sqlite3.connect(source) dst = sqlite3.connect(destination) diff --git a/hippo/feature.py b/hippo/feature.py index afe0cf5..b0e2ec3 100644 --- a/hippo/feature.py +++ b/hippo/feature.py @@ -1,10 +1,9 @@ """Classes to work with pharmacophoric features""" -import mcol -import mrich - from dataclasses import dataclass +import mcol + from .target import Target @@ -32,25 +31,25 @@ class Feature: def __str__(self) -> str: """Unformatted string representation""" - return f"F{self.id}" + return f'F{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Representation for mrich""" - return f"[bold underline]{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]" + return f'[bold underline]{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]' @property def chain_res_name_number_str(self) -> str: """Return a string representation of the feature""" - return f"{self.chain_name} {self.residue_name} {self.residue_number}" + return f'{self.chain_name} {self.residue_name} {self.residue_number}' @property def res_name_number_str(self) -> str: """Return a string representation of the feature""" - return f"{self.residue_name} {self.residue_number}" + return f'{self.residue_name} {self.residue_number}' @property def res_number_name_tuple(self) -> str: @@ -60,14 +59,14 @@ def res_number_name_tuple(self) -> str: @property def res_name_number_family_str(self) -> str: """Return a string representation of the feature""" - return f"{self.residue_name} {self.residue_number} {self.family}" + return f'{self.residue_name} {self.residue_number} {self.family}' @property def backbone(self) -> bool: """Are any of the atoms referenced by this feature on the backbone?""" from molparse.amino import BB_NAMES - for atom_name in self.atom_names.split(","): + for atom_name in self.atom_names.split(','): if atom_name in BB_NAMES: return True @@ -78,9 +77,8 @@ def sidechain(self) -> bool: """Are any of the atoms referenced by this feature on the sidechain?""" from molparse.amino import BB_NAMES - for atom_name in self.atom_names.split(","): - - if atom_name.startswith("H"): + for atom_name in self.atom_names.split(','): + if atom_name.startswith('H'): continue if atom_name not in BB_NAMES: diff --git a/hippo/fragalysis.py b/hippo/fragalysis.py index f3df006..48e4aa5 100644 --- a/hippo/fragalysis.py +++ b/hippo/fragalysis.py @@ -13,28 +13,29 @@ def generate_header( generation_date: str | None = None, extras=None, metadata: bool = True, -) -> "Chem.Mol": +) -> 'Chem.Mol': """Generate a header molecule for Fragalysis RHS upload""" extras = extras or {} - from rdkit.Chem.AllChem import EmbedMolecule - from molparse.rdkit import mol_from_smiles from datetime import date + from molparse.rdkit import mol_from_smiles + from rdkit.Chem.AllChem import EmbedMolecule + header = mol_from_smiles(pose.compound.smiles) - header.SetProp("_Name", "ver_1.2") + header.SetProp('_Name', 'ver_1.2') EmbedMolecule(header) generation_date = str(generation_date or date.today()) - header.SetProp("ref_url", ref_url) - header.SetProp("submitter_name", submitter_name) - header.SetProp("submitter_email", submitter_email) - header.SetProp("submitter_institution", submitter_institution) - header.SetProp("generation_date", generation_date) - header.SetProp("method", method) + header.SetProp('ref_url', ref_url) + header.SetProp('submitter_name', submitter_name) + header.SetProp('submitter_email', submitter_email) + header.SetProp('submitter_institution', submitter_institution) + header.SetProp('generation_date', generation_date) + header.SetProp('method', method) if metadata: for k, v in pose.metadata.items(): @@ -61,7 +62,7 @@ def parse_observation_longcode(longcode: str) -> dict[str]: import re match = re.search( - r"(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG", longcode + r'(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG', longcode ) if not match: @@ -69,18 +70,16 @@ def parse_observation_longcode(longcode: str) -> dict[str]: cryst_str, lig_str, _, _ = match.groups() - chain, residue_number, version = lig_str.split("_") + chain, residue_number, version = lig_str.split('_') residue_number = int(residue_number) version = int(version) - if match := re.search(r"(.*)-(\w[0-9]{4})", cryst_str): - + if match := re.search(r'(.*)-(\w[0-9]{4})', cryst_str): target_name = match.group(0) crystal = match.group(1) else: - target_name = None crystal = cryst_str @@ -103,26 +102,24 @@ def find_observation_longcode_matches( keys = dq.keys() if debug: - mrich.var("allow_version_none", allow_version_none) - mrich.var("dq", str(dq)) + mrich.var('allow_version_none', allow_version_none) + mrich.var('dq', str(dq)) matches = [] for code in codes: - if code == query: if debug: - mrich.debug("exact match") + mrich.debug('exact match') matches.append(code) continue dc = parse_observation_longcode(code) for key in keys: - if ( allow_version_none - and key == "version" + and key == 'version' and (dc[key] is None or dq[key] is None) ): continue @@ -131,11 +128,11 @@ def find_observation_longcode_matches( break else: if debug: - mrich.debug(f"{query} matches {code}") + mrich.debug(f'{query} matches {code}') matches.append(code) if debug: - mrich.var("#matches", len(matches)) + mrich.var('#matches', len(matches)) if len(matches) < 1 and not allow_version_none: return find_observation_longcode_matches(query, codes, allow_version_none=True) @@ -144,8 +141,8 @@ def find_observation_longcode_matches( STACK_URLS = { - "production": "https://fragalysis.diamond.ac.uk", - "staging": "https://fragalysis.xchem.diamond.ac.uk", + 'production': 'https://fragalysis.diamond.ac.uk', + 'staging': 'https://fragalysis.xchem.diamond.ac.uk', } diff --git a/hippo/interaction.py b/hippo/interaction.py index b681d39..6235636 100644 --- a/hippo/interaction.py +++ b/hippo/interaction.py @@ -15,7 +15,7 @@ class Interaction: def __init__( self, - db: "Database", + db: 'Database', id: int, feature_id: int, pose_id: int, @@ -27,7 +27,7 @@ def __init__( distance: float, angle: float, energy: float | None, - table: str = "interaction", + table: str = 'interaction', ) -> None: """Interaction initialisation""" @@ -66,7 +66,7 @@ def table(self) -> str: return self._table @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -81,7 +81,7 @@ def pose_id(self) -> int: return self._pose_id @property - def pose(self) -> "Pose": + def pose(self) -> 'Pose': """Returns the associated :class:`.Pose`'s object""" if not self._pose: self._pose = self.db.get_pose(id=self.pose_id) @@ -93,7 +93,7 @@ def feature_id(self) -> int: return self._feature_id @property - def feature(self) -> "Feature": + def feature(self) -> 'Feature': """Returns the associated :class:`.Feature`'s object""" if not self._feature: self._feature = self.db.get_feature(id=self.feature_id) @@ -142,7 +142,7 @@ def energy(self) -> float | None: @property def family_str(self) -> str: """String of the two feature families""" - return f"{repr(self.feature)} ~ {self.family}" + return f'{repr(self.feature)} ~ {self.family}' @property def type(self) -> str: @@ -154,9 +154,9 @@ def type(self) -> str: @property def description(self) -> str: """One line description of this interaction""" - s = f"{self.type} [{self.feature.chain_res_name_number_str}] {self.distance:.1f} Å" + s = f'{self.type} [{self.feature.chain_res_name_number_str}] {self.distance:.1f} Å' if self.angle: - s += f", {self.angle:.1f} degrees" + s += f', {self.angle:.1f} degrees' return s ### METHODS @@ -164,28 +164,28 @@ def description(self) -> str: def summary(self) -> None: """Print a summary of this interaction's properties""" - mrich.header(f"Interaction {self.id}") + mrich.header(f'Interaction {self.id}') - mrich.var("feature", self.feature) - mrich.var("pose", self.pose) - mrich.var("family", self.family) - mrich.var("atom_ids", self.atom_ids) - mrich.var("prot_coord", self.prot_coord) - mrich.var("lig_coord", self.lig_coord) - mrich.var("distance", self.distance) - mrich.var("angle", self.angle) - mrich.var("energy", self.energy) + mrich.var('feature', self.feature) + mrich.var('pose', self.pose) + mrich.var('family', self.family) + mrich.var('atom_ids', self.atom_ids) + mrich.var('prot_coord', self.prot_coord) + mrich.var('lig_coord', self.lig_coord) + mrich.var('distance', self.distance) + mrich.var('angle', self.angle) + mrich.var('energy', self.energy) ### DUNDERS def __str__(self) -> str: """Plain string representation""" - return f"I{self.id}" + return f'I{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' diff --git a/hippo/iset.py b/hippo/iset.py index 7e34119..4b88f74 100644 --- a/hippo/iset.py +++ b/hippo/iset.py @@ -13,7 +13,7 @@ class InteractionTable: """ - def __init__(self, db: "Database", table: str = "interaction") -> None: + def __init__(self, db: 'Database', table: str = 'interaction') -> None: """InteractionTable initialisation""" self._db = db @@ -23,7 +23,7 @@ def __init__(self, db: "Database", table: str = "interaction") -> None: ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns the associated :class:`.Database`""" return self._db @@ -33,7 +33,7 @@ def table(self) -> str: return self._table @property - def df(self) -> "pandas.DataFrame": + def df(self) -> 'pandas.DataFrame': """DataFrame representation of the interactions :returns: a ``pandas.Dataframe`` of the interactions @@ -42,7 +42,7 @@ def df(self) -> "pandas.DataFrame": if self._df is None: records = self.db.select_all_where( - table="interaction", key=f"interaction_id > 0", multiple=True + table='interaction', key='interaction_id > 0', multiple=True ) df = df_from_interaction_records(self.db, records) self._df = df @@ -57,15 +57,15 @@ def __len__(self) -> int: def __str__(self) -> str: """Unformatted command-line representation""" - return "{" f"I × {len(self)}" "}" + return f'{{I × {len(self)}}}' def __repr__(self) -> str: """ANSI formatted command-line representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich formatted command-line representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class InteractionSet: @@ -79,9 +79,9 @@ class InteractionSet: def __init__( self, - db: "Database", + db: 'Database', indices: list = None, - table: str = "interaction", + table: str = 'interaction', ) -> None: """InteractionSet initialisation""" @@ -103,10 +103,10 @@ def __init__( @classmethod def from_pose( cls, - pose: "Pose | PoseSet", - table: str = "interaction", - db: "Database | None" = None, - ) -> "InteractionSet": + pose: 'Pose | PoseSet', + table: str = 'interaction', + db: 'Database | None' = None, + ) -> 'InteractionSet': """Construct a :class:`.InteractionSet` from one or more poses. :param pose: a :class:`.Pose` or :class:`.PoseSet` object @@ -124,16 +124,15 @@ def from_pose( from .pset import PoseSet if isinstance(pose, PoseSet): - # check if all poses have fingerprints (has_invalid_fps,) = db.select_where( - query="COUNT(1)", - table="pose", - key=f"pose_id IN {pose.str_ids} AND pose_fingerprint = 0", + query='COUNT(1)', + table='pose', + key=f'pose_id IN {pose.str_ids} AND pose_fingerprint = 0', ) if has_invalid_fps: - mrich.warning(f"{has_invalid_fps} Poses have not been fingerprinted") + mrich.warning(f'{has_invalid_fps} Poses have not been fingerprinted') sql = f""" SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} @@ -141,7 +140,6 @@ def from_pose( """ else: - sql = f""" SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} WHERE interaction_pose = {pose.id} @@ -149,7 +147,7 @@ def from_pose( ids = db.execute(sql).fetchall() - ids = [i for i, in ids] + ids = [i for (i,) in ids] self.__init__(db, ids, table=table) @@ -158,20 +156,20 @@ def from_pose( @classmethod def all( cls, - db: "Database", - table: str = "interaction", - ) -> "InteractionSet": + db: 'Database', + table: str = 'interaction', + ) -> 'InteractionSet': """Construct a :class:`.InteractionSet` for all interactions in the table. :returns: an :class:`.InteractionSet` """ - sql = f"SELECT interaction_id FROM {table}" + sql = f'SELECT interaction_id FROM {table}' ids = db.execute(sql).fetchall() - ids = [i for i, in ids] + ids = [i for (i,) in ids] self = cls.__new__(cls) self.__init__(db, ids, table=table) @@ -181,11 +179,11 @@ def all( @classmethod def from_residue( cls, - db: "Database", + db: 'Database', residue_number: int, chain: None | str = None, - target: "Target | int" = 1, - ) -> "InteractionSet": + target: 'Target | int' = 1, + ) -> 'InteractionSet': """Get the set of interactions for a given residue number (and chain) :param db: HIPPO :class:`.Database` @@ -215,7 +213,7 @@ def from_residue( ids = db.execute(sql).fetchall() - ids = [i for i, in ids] + ids = [i for (i,) in ids] self.__init__(db, ids) @@ -237,15 +235,15 @@ def ids(self) -> list[int]: def types(self) -> list[str]: """Returns the ids of interactions in this set""" records = self.db.select_where( - query="interaction_type", + query='interaction_type', table=self.table, - key=f"interaction_id IN {self.str_ids}", + key=f'interaction_id IN {self.str_ids}', multiple=True, ) - return [r for r, in records] + return [r for (r,) in records] @property - def db(self) -> "Database": + def db(self) -> 'Database': """The associated HIPPO :class:`.Database`""" return self._db @@ -257,18 +255,18 @@ def table(self) -> str: @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property def feature_ids(self) -> list[int]: """Return a list of :class:`.Feature` ID's""" records = self.db.select_where( - query="DISTINCT interaction_feature", + query='DISTINCT interaction_feature', table=self.table, - key=f"interaction_id IN {self.str_ids}", + key=f'interaction_id IN {self.str_ids}', multiple=True, ) - return [r for r, in records] + return [r for (r,) in records] @property def classic_fingerprint(self) -> dict: @@ -276,7 +274,7 @@ def classic_fingerprint(self) -> dict: return self.get_classic_fingerprint() @property - def df(self) -> "pandas.DataFrame": + def df(self) -> 'pandas.DataFrame': """DataFrame representation of the interactions :returns: a ``pandas.Dataframe`` of the interactions @@ -286,7 +284,7 @@ def df(self) -> "pandas.DataFrame": if self._df is None: records = self.db.select_all_where( table=self.table, - key=f"interaction_id IN {self.str_ids}", + key=f'interaction_id IN {self.str_ids}', multiple=True, ) df = df_from_interaction_records(self.db, records) @@ -321,6 +319,7 @@ def avg_num_residues_per_pose(self) -> list[tuple]: records = self.db.execute(sql).fetchall() from collections import defaultdict + from numpy import mean d = defaultdict(set) @@ -342,6 +341,7 @@ def avg_num_interactions_per_pose(self) -> list[tuple]: records = self.db.execute(sql).fetchall() from collections import defaultdict + from numpy import mean d = defaultdict(int) @@ -365,6 +365,7 @@ def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: records = self.db.execute(sql).fetchall() from collections import defaultdict + from numpy import mean d = defaultdict(set) @@ -433,8 +434,6 @@ def per_feature_count_hirsch(self) -> float: counts = [count for f_id, count in counts] - from numpy import std - # return -std(counts) from hirsch import hirsch @@ -458,12 +457,12 @@ def summary( # print(interaction) # mrich.var(f'{interaction.family_str}', f'{interaction.distance:.1f}') - s = f"{interaction.description}" + s = f'{interaction.description}' if families: - s += f" {interaction.feature.family} ~ {interaction.family}" + s += f' {interaction.feature.family} ~ {interaction.family}' - mrich.var(s, f"{interaction.distance:.1f}", "Å") + mrich.var(s, f'{interaction.distance:.1f}', 'Å') def get_classic_fingerprint(self) -> dict: """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" @@ -484,7 +483,7 @@ def resolve( commit: bool = True, feature_cache: dict | None = None, # table: str = 'interaction', - ) -> "InteractionSet": + ) -> 'InteractionSet': """Resolve into predicted key interactions. In place modification. :param debug: Increased verbosity for debugging (Default value = False) @@ -575,7 +574,7 @@ def resolve( """ records = self.db.execute(sql).fetchall() - ids = [a for a, in records] + ids = [a for (a,) in records] keep_list += ids ### hydrophobic @@ -597,26 +596,25 @@ def resolve( lumped_hydrophobic_in_lumped_lumped = {} for interaction in subset: - feature = feature_cache[interaction.feature_id] families = (feature.family, interaction.family) - if families == ("LumpedHydrophobe", "Hydrophobe"): + if families == ('LumpedHydrophobe', 'Hydrophobe'): for name in feature.atom_names.split(): key = (name, interaction.atom_ids[0]) if key not in hydrophobic_interactions_in_lumped: hydrophobic_interactions_in_lumped[key] = [] hydrophobic_interactions_in_lumped[key].append(interaction.id) - elif families == ("Hydrophobe", "LumpedHydrophobe"): + elif families == ('Hydrophobe', 'LumpedHydrophobe'): for atom_id in interaction.atom_ids: key = (feature.atom_names, atom_id) if key not in hydrophobic_interactions_in_lumped: hydrophobic_interactions_in_lumped[key] = [] hydrophobic_interactions_in_lumped[key].append(interaction.id) - elif families == ("LumpedHydrophobe", "LumpedHydrophobe"): + elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): for name in feature.atom_names.split(): for atom_id in interaction.atom_ids: key = (name, atom_id) @@ -635,19 +633,17 @@ def resolve( # modify keep list by those covered in lumped for interaction in subset: - feature = feature_cache[interaction.feature_id] families = (feature.family, interaction.family) - if families == ("Hydrophobe", "Hydrophobe"): + if families == ('Hydrophobe', 'Hydrophobe'): key = (feature.atom_names, interaction.atom_ids[0]) if key in hydrophobic_interactions_in_lumped: keep_hydrophobic_ids -= set([interaction.id]) - elif families == ("LumpedHydrophobe", "Hydrophobe"): - + elif families == ('LumpedHydrophobe', 'Hydrophobe'): key = feature.atom_names if key in lumped_hydrophobic_in_lumped_lumped: @@ -656,12 +652,10 @@ def resolve( if atom_id in value: keep_hydrophobic_ids -= set([interaction.id]) - elif families == ("Hydrophobe", "LumpedHydrophobe"): - + elif families == ('Hydrophobe', 'LumpedHydrophobe'): key = tuple(interaction.atom_ids) if key in rev_hydrophobic_in_lumped_lumped: - atom_name = feature.atom_names value = rev_hydrophobic_in_lumped_lumped[key] @@ -676,7 +670,7 @@ def resolve( cull_iset = InteractionSet(self.db, cull_list) self.db.delete_where( table=table, - key=f"interaction_id IN {cull_iset.str_ids}", + key=f'interaction_id IN {cull_iset.str_ids}', commit=commit, ) self._indices = sorted(list(set(keep_list))) @@ -703,7 +697,7 @@ def resolve( cull_iset = InteractionSet(self.db, cull_list) self.db.delete_where( table=table, - key=f"interaction_id IN {cull_iset.str_ids}", + key=f'interaction_id IN {cull_iset.str_ids}', commit=commit, ) self._indices = sorted(list(set(keep_list) - cull_list)) @@ -721,15 +715,15 @@ def __len__(self) -> int: def __str__(self) -> str: """Unformatted command-line representation""" - return "{" f"I × {len(self)}" "}" + return f'{{I × {len(self)}}}' def __repr__(self) -> str: """ANSI formatted command-line representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich formatted command-line representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __iter__(self): """Iterate through interactions in this set""" @@ -737,7 +731,7 @@ def __iter__(self): self.db.get_interaction(id=i, table=self.table) for i in self.indices ) - def __getitem__(self, key) -> "Interaction | InteractionSet": + def __getitem__(self, key) -> 'Interaction | InteractionSet': """Get interaction or subsets thereof from this set""" match key: case int(): @@ -753,17 +747,17 @@ def __getitem__(self, key) -> "Interaction | InteractionSet": def df_from_interaction_records( - db: "Database", + db: 'Database', records: list[tuple], -) -> "pandas.DataFrame": +) -> 'pandas.DataFrame': """Construct a dataframe from the 'interaction' table records""" import json + from pandas import DataFrame data = [] for record in records: - ( id, feature_id, @@ -782,32 +776,32 @@ def df_from_interaction_records( d = dict(id=id) - d["feature_id"] = feature_id - d["pose_id"] = pose_id - d["target_id"] = feature.target + d['feature_id'] = feature_id + d['pose_id'] = pose_id + d['target_id'] = feature.target # d['type'] = INTERACTION_TYPES[(feature.family, family)] - d["type"] = type + d['type'] = type - d["prot_family"] = feature.family - d["lig_family"] = family + d['prot_family'] = feature.family + d['lig_family'] = family - d["residue_name"] = feature.residue_name - d["residue_number"] = feature.residue_number - d["chain_name"] = feature.chain_name + d['residue_name'] = feature.residue_name + d['residue_number'] = feature.residue_number + d['chain_name'] = feature.chain_name - d["distance"] = distance - d["angle"] = angle - d["energy"] = energy + d['distance'] = distance + d['angle'] = angle + d['energy'] = energy - d["prot_coord"] = json.loads(prot_coord) - d["lig_coord"] = json.loads(lig_coord) + d['prot_coord'] = json.loads(prot_coord) + d['lig_coord'] = json.loads(lig_coord) - d["prot_atoms"] = feature.atom_names - d["lig_atoms"] = atom_ids + d['prot_atoms'] = feature.atom_names + d['lig_atoms'] = atom_ids - d["backbone"] = feature.backbone - d["sidechain"] = feature.sidechain + d['backbone'] = feature.backbone + d['sidechain'] = feature.sidechain data.append(d) diff --git a/hippo/metadata.py b/hippo/metadata.py index 962ea0c..3752224 100644 --- a/hippo/metadata.py +++ b/hippo/metadata.py @@ -1,7 +1,7 @@ """Class for working with database stored JSON metadata""" -from typing import Mapping from collections import UserDict +from collections.abc import Mapping class MetaData(UserDict): @@ -41,7 +41,7 @@ def id(self) -> int: return self._id @property - def db(self) -> "Database": + def db(self) -> 'Database': """associated :class:`.Database`""" return self._db diff --git a/hippo/migration.py b/hippo/migration.py index a634048..8f1cb3a 100644 --- a/hippo/migration.py +++ b/hippo/migration.py @@ -5,8 +5,8 @@ def migrate_compounds( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -15,12 +15,12 @@ def migrate_compounds( # source data compound_records = source.select( - table="compound", - query="compound_id, compound_inchikey, compound_smiles", + table='compound', + query='compound_id, compound_inchikey, compound_smiles', multiple=True, ) - mrich.var("source: #compounds", len(compound_records)) + mrich.var('source: #compounds', len(compound_records)) if not compound_records: return migration_data @@ -28,13 +28,13 @@ def migrate_compounds( # insertion query sql = """ INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, + compound_inchikey, + compound_smiles, compound_mol ) VALUES( - %(inchikey)s, - %(smiles)s, + %(inchikey)s, + %(smiles)s, hippo.mol_from_smiles(%(smiles)s) ) ON CONFLICT DO NOTHING; @@ -48,7 +48,7 @@ def migrate_compounds( # do the insertion if execute: - executemany(destination, "compound", sql, compound_dicts, batch_size) + executemany(destination, 'compound', sql, compound_dicts, batch_size) # map to the destination records destination_inchikey_map = destination.get_compound_inchikey_id_dict( @@ -60,15 +60,15 @@ def migrate_compounds( for i, inchikey, smiles in compound_records } - migration_data["compound_id_map"] = compound_id_map + migration_data['compound_id_map'] = compound_id_map return migration_data def migrate_scaffolds( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -77,8 +77,8 @@ def migrate_scaffolds( # source data scaffold_records = source.select( - table="scaffold", - query="scaffold_base, scaffold_superstructure", + table='scaffold', + query='scaffold_base, scaffold_superstructure', multiple=True, ) @@ -88,13 +88,13 @@ def migrate_scaffolds( # map to new IDs scaffold_records = [ ( - migration_data["compound_id_map"][base_id], - migration_data["compound_id_map"][superstructure_id], + migration_data['compound_id_map'][base_id], + migration_data['compound_id_map'][superstructure_id], ) for (base_id, superstructure_id) in scaffold_records ] - mrich.var("source: #scaffolds", len(scaffold_records)) + mrich.var('source: #scaffolds', len(scaffold_records)) # insert new data @@ -105,15 +105,15 @@ def migrate_scaffolds( """ if execute: - executemany(destination, "scaffold", sql, scaffold_records, batch_size) + executemany(destination, 'scaffold', sql, scaffold_records, batch_size) return migration_data def migrate_targets( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -122,7 +122,7 @@ def migrate_targets( # source data target_records = source.select( - table="target", query="target_id, target_name", multiple=True + table='target', query='target_id, target_name', multiple=True ) if not target_records: @@ -136,21 +136,21 @@ def migrate_targets( destination_target_name_map = { name: i for i, name in destination.select( - table="target", query="target_id, target_name", multiple=True + table='target', query='target_id, target_name', multiple=True ) } target_id_map = {i: destination_target_name_map[name] for i, name in target_records} - migration_data["target_id_map"] = target_id_map + migration_data['target_id_map'] = target_id_map return migration_data def migrate_poses( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -160,24 +160,24 @@ def migrate_poses( from rdkit.Chem import Mol pose_fields = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_path", - "pose_compound", - "pose_target", - "pose_mol", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", - "pose_metadata", + 'pose_id', + 'pose_inchikey', + 'pose_alias', + 'pose_smiles', + 'pose_path', + 'pose_compound', + 'pose_target', + 'pose_mol', + 'pose_fingerprint', + 'pose_energy_score', + 'pose_distance_score', + 'pose_inspiration_score', + 'pose_metadata', ] # source data pose_records = source.select( - table="pose", query=", ".join(pose_fields), multiple=True + table='pose', query=', '.join(pose_fields), multiple=True ) if not pose_records: @@ -224,8 +224,8 @@ def migrate_poses( alias=alias, smiles=smiles, path=path, - compound=migration_data["compound_id_map"][compound_id], - target=migration_data["target_id_map"][target_id], + compound=migration_data['compound_id_map'][compound_id], + target=migration_data['target_id_map'][target_id], mol=Mol(mol).ToBinary() if mol else None, fingerprint=fingerprint, energy_score=energy_score, @@ -250,7 +250,7 @@ def migrate_poses( ) in pose_records ] - mrich.var("source: #poses", len(pose_dicts)) + mrich.var('source: #poses', len(pose_dicts)) ### THIS DEVELOPMENT WAS NOT COMPLETED, ### TO IMPLEMENT WOULD REQUIRE FIRST INSERTING ALL @@ -260,24 +260,24 @@ def migrate_poses( # do the insertion if execute: - executemany(destination, "pose", sql, pose_dicts, batch_size) + executemany(destination, 'pose', sql, pose_dicts, batch_size) # map to the destination records destination_pose_path_map = destination.get_pose_path_id_dict() # return destination_pose_path_map - pose_id_map = {p["id"]: destination_pose_path_map[p["path"]] for p in pose_dicts} + pose_id_map = {p['id']: destination_pose_path_map[p['path']] for p in pose_dicts} - migration_data["pose_id_map"] = pose_id_map + migration_data['pose_id_map'] = pose_id_map return migration_data def migrate_pose_references( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -286,8 +286,8 @@ def migrate_pose_references( # source data reference_records = source.select( - table="pose", - query="pose_id, pose_reference", + table='pose', + query='pose_id, pose_reference', multiple=True, ) @@ -297,14 +297,14 @@ def migrate_pose_references( # map to new IDs reference_dicts = [ dict( - pose=migration_data["pose_id_map"][pose_id], - reference=migration_data["pose_id_map"][reference_id], + pose=migration_data['pose_id_map'][pose_id], + reference=migration_data['pose_id_map'][reference_id], ) for pose_id, reference_id in reference_records if reference_id ] - mrich.var("source: #references", len(reference_dicts)) + mrich.var('source: #references', len(reference_dicts)) # insert new data @@ -322,8 +322,8 @@ def migrate_pose_references( def migrate_inspirations( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -332,8 +332,8 @@ def migrate_inspirations( # source data inspiration_records = source.select( - table="inspiration", - query="inspiration_original, inspiration_derivative", + table='inspiration', + query='inspiration_original, inspiration_derivative', multiple=True, ) @@ -343,20 +343,20 @@ def migrate_inspirations( # map to new IDs inspiration_dicts = [ dict( - original=migration_data["pose_id_map"][a], - derivative=migration_data["pose_id_map"][b], + original=migration_data['pose_id_map'][a], + derivative=migration_data['pose_id_map'][b], ) for a, b in inspiration_records if b ] - mrich.var("source: #inspirations", len(inspiration_dicts)) + mrich.var('source: #inspirations', len(inspiration_dicts)) # insert new data sql = """ INSERT INTO hippo.inspiration( - inspiration_original, + inspiration_original, inspiration_derivative ) VALUES ( @@ -367,15 +367,15 @@ def migrate_inspirations( """ if execute: - executemany(destination, "inspiration", sql, inspiration_dicts, batch_size) + executemany(destination, 'inspiration', sql, inspiration_dicts, batch_size) return migration_data def migrate_tags( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -386,9 +386,9 @@ def migrate_tags( # unique tag names - tag_names = source.select(table="tag", query="DISTINCT tag_name", multiple=True) + tag_names = source.select(table='tag', query='DISTINCT tag_name', multiple=True) - tag_names = sorted([t for t, in tag_names]) + tag_names = sorted([t for (t,) in tag_names]) if not tag_names: return migration_data @@ -397,8 +397,7 @@ def migrate_tags( tag_name_map = {} for tag in tag_names: - for pattern, template in migration_data["tag_compound_id_regex"]: - + for pattern, template in migration_data['tag_compound_id_regex']: match = re.match(pattern, tag) if not match: @@ -406,14 +405,14 @@ def migrate_tags( groups = match.groups() - assert ( - len(groups) == 1 - ), f"tag_compound_id_regex replacement not supported with multiple groups, {pattern=}" + assert len(groups) == 1, ( + f'tag_compound_id_regex replacement not supported with multiple groups, {pattern=}' + ) groups = [g for g in groups] compound_id = int(groups[0]) - new_compound_id = migration_data["compound_id_map"][compound_id] + new_compound_id = migration_data['compound_id_map'][compound_id] replacement = template.format(new_compound_id=new_compound_id) @@ -426,26 +425,26 @@ def migrate_tags( # source data tag_records = source.select( - table="tag", - query="tag_name, tag_compound, tag_pose", + table='tag', + query='tag_name, tag_compound, tag_pose', multiple=True, ) - mrich.var("source: #tags", len(tag_records)) + mrich.var('source: #tags', len(tag_records)) if tag_name_map: - mrich.warning("renamed", len(tag_name_map), "tags") + mrich.warning('renamed', len(tag_name_map), 'tags') # insertion query sql = """ INSERT INTO hippo.tag( - tag_name, - tag_compound, + tag_name, + tag_compound, tag_pose ) VALUES( - %(name)s, - %(compound)s, + %(name)s, + %(compound)s, %(pose)s ) ON CONFLICT DO NOTHING; @@ -456,9 +455,9 @@ def migrate_tags( dict( name=tag_name_map.get(name, name), compound=( - migration_data["compound_id_map"][compound_id] if compound_id else None + migration_data['compound_id_map'][compound_id] if compound_id else None ), - pose=migration_data["pose_id_map"][pose_id] if pose_id else None, + pose=migration_data['pose_id_map'][pose_id] if pose_id else None, ) for name, compound_id, pose_id in tag_records ] @@ -468,19 +467,19 @@ def migrate_tags( if tag not in tag_name_map: tag_name_map[tag] = tag - migration_data["tag_name_map"] = tag_name_map + migration_data['tag_name_map'] = tag_name_map # do the insertion if execute: - executemany(destination, "tag", sql, tag_dicts, batch_size) + executemany(destination, 'tag', sql, tag_dicts, batch_size) return migration_data def migrate_reactions_and_reactants( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, ) -> dict: @@ -488,34 +487,34 @@ def migrate_reactions_and_reactants( # get source reaction data source_reaction_dicts, reactant_records = get_reaction_id_reaction_dict_map( - source, migration_data["compound_id_map"] + source, migration_data['compound_id_map'] ) - mrich.var("source: #reactions", len(source_reaction_dicts)) + mrich.var('source: #reactions', len(source_reaction_dicts)) if not source_reaction_dicts: return migration_data # get destination reaction data destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(destination) - mrich.var("destination: #reactions", len(destination_reaction_dicts)) + mrich.var('destination: #reactions', len(destination_reaction_dicts)) # create keyed lookups source_reaction_lookup = { ( - d["product"], - d["type"], - tuple(sorted(list(d["reactant_ids"]))), - ): d["id"] + d['product'], + d['type'], + tuple(sorted(list(d['reactant_ids']))), + ): d['id'] for d in source_reaction_dicts.values() } destination_reaction_lookup = { ( - d["product"], - d["type"], - tuple(sorted(list(d["reactant_ids"]))), - ): d["id"] + d['product'], + d['type'], + tuple(sorted(list(d['reactant_ids']))), + ): d['id'] for d in destination_reaction_dicts.values() } @@ -525,24 +524,22 @@ def migrate_reactions_and_reactants( new_reaction_dicts = [] for key, reaction_id in list(source_reaction_lookup.items()): - if key in destination_reaction_lookup: # EXISTING REACTION reaction_id_map[reaction_id] = destination_reaction_lookup[key] else: - # NEW REACTION new_reaction_dicts.append(source_reaction_dicts[reaction_id]) - mrich.var("existing #reactions:", len(reaction_id_map)) - mrich.var("new #reactions:", len(new_reaction_dicts)) + mrich.var('existing #reactions:', len(reaction_id_map)) + mrich.var('new #reactions:', len(new_reaction_dicts)) # reaction insertion query sql = """ INSERT INTO hippo.reaction( - reaction_type, - reaction_product, + reaction_type, + reaction_product, reaction_product_yield ) VALUES( @@ -557,37 +554,37 @@ def migrate_reactions_and_reactants( # massage the data reaction_dicts = [ dict( - type=d["type"], - product=d["product"], - product_yield=d["product_yield"], + type=d['type'], + product=d['product'], + product_yield=d['product_yield'], ) for d in new_reaction_dicts ] # do the insertion inserted_reaction_ids = executemany( - destination, "reaction", sql, reaction_dicts, batch_size + destination, 'reaction', sql, reaction_dicts, batch_size ) if inserted_reaction_ids: - inserted_reaction_ids = [i for i, in inserted_reaction_ids] + inserted_reaction_ids = [i for (i,) in inserted_reaction_ids] else: inserted_reaction_ids = [] # add to the map for reaction_dict, new_reaction_id in zip( - new_reaction_dicts, inserted_reaction_ids + new_reaction_dicts, inserted_reaction_ids, strict=False ): - reaction_id = reaction_dict["id"] + reaction_id = reaction_dict['id'] reaction_id_map[reaction_id] = new_reaction_id - migration_data["reaction_id_map"] = reaction_id_map + migration_data['reaction_id_map'] = reaction_id_map # reactant insertion query sql = """ INSERT INTO hippo.reactant( - reactant_amount, - reactant_reaction, + reactant_amount, + reactant_reaction, reactant_compound ) VALUES( @@ -602,23 +599,23 @@ def migrate_reactions_and_reactants( dict( amount=amount, reaction=reaction_id_map[reaction_id], - compound=migration_data["compound_id_map"][compound_id], + compound=migration_data['compound_id_map'][compound_id], ) for amount, reaction_id, compound_id in reactant_records ] - mrich.var("source: #reactants", len(reactant_dicts)) + mrich.var('source: #reactants', len(reactant_dicts)) # do the insertion - executemany(destination, "reactant", sql, reactant_dicts, batch_size) + executemany(destination, 'reactant', sql, reactant_dicts, batch_size) return migration_data def migrate_features( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -627,12 +624,12 @@ def migrate_features( # source data feature_records = source.select( - table="feature", - query="feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names", + table='feature', + query='feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names', multiple=True, ) - mrich.var("source: #features", len(feature_records)) + mrich.var('source: #features', len(feature_records)) if not feature_records: return migration_data @@ -662,7 +659,7 @@ def migrate_features( feature_dicts = [ dict( family=family, - target=migration_data["target_id_map"][target_id], + target=migration_data['target_id_map'][target_id], chain_name=chain_name, residue_name=residue_name, residue_number=residue_number, @@ -681,7 +678,7 @@ def migrate_features( # do the insertion if execute: - executemany(destination, "feature", sql, feature_dicts, batch_size) + executemany(destination, 'feature', sql, feature_dicts, batch_size) # get destination values feature_map = { @@ -702,8 +699,8 @@ def migrate_features( residue_number, atom_names, ) in destination.select( - table="feature", - query="feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names", + table='feature', + query='feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names', multiple=True, ) } @@ -713,7 +710,7 @@ def migrate_features( i: feature_map[ ( family, - migration_data["target_id_map"][target_id], + migration_data['target_id_map'][target_id], chain_name, residue_name, residue_number, @@ -731,15 +728,15 @@ def migrate_features( ) in feature_records } - migration_data["feature_id_map"] = feature_id_map + migration_data['feature_id_map'] = feature_id_map return migration_data def migrate_interactions( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -747,27 +744,27 @@ def migrate_interactions( """migrate interactions""" interaction_fields = [ - "interaction_id", - "interaction_feature", - "interaction_pose", - "interaction_type", - "interaction_family", - "interaction_atom_ids", - "interaction_prot_coord", - "interaction_lig_coord", - "interaction_distance", - "interaction_angle", - "interaction_energy", + 'interaction_id', + 'interaction_feature', + 'interaction_pose', + 'interaction_type', + 'interaction_family', + 'interaction_atom_ids', + 'interaction_prot_coord', + 'interaction_lig_coord', + 'interaction_distance', + 'interaction_angle', + 'interaction_energy', ] # source data interaction_records = source.select( - table="interaction", - query=", ".join(interaction_fields), + table='interaction', + query=', '.join(interaction_fields), multiple=True, ) - mrich.var("source: #interactions", len(interaction_records)) + mrich.var('source: #interactions', len(interaction_records)) if not interaction_records: return migration_data @@ -804,8 +801,8 @@ def migrate_interactions( # format the data interaction_dicts = [ dict( - feature=migration_data["feature_id_map"][feature_id], - pose=migration_data["pose_id_map"][pose_id], + feature=migration_data['feature_id_map'][feature_id], + pose=migration_data['pose_id_map'][pose_id], type=type, family=family, atom_ids=atom_ids, @@ -832,7 +829,7 @@ def migrate_interactions( # do the insertion if execute: - executemany(destination, "interaction", sql, interaction_dicts, batch_size) + executemany(destination, 'interaction', sql, interaction_dicts, batch_size) # get destination values interaction_map = { @@ -855,8 +852,8 @@ def migrate_interactions( angle, energy, ) in destination.select( - table="interaction", - query=", ".join(interaction_fields), + table='interaction', + query=', '.join(interaction_fields), multiple=True, ) } @@ -865,8 +862,8 @@ def migrate_interactions( interaction_id_map = { i: interaction_map[ ( - migration_data["feature_id_map"][feature_id], - migration_data["pose_id_map"][pose_id], + migration_data['feature_id_map'][feature_id], + migration_data['pose_id_map'][pose_id], type, family, ) @@ -886,15 +883,15 @@ def migrate_interactions( ) in interaction_records } - migration_data["interaction_id_map"] = interaction_id_map + migration_data['interaction_id_map'] = interaction_id_map return migration_data def migrate_subsites( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -903,12 +900,12 @@ def migrate_subsites( # source data subsite_records = source.select( - table="subsite", - query="subsite_id, subsite_target, subsite_name, subsite_metadata", + table='subsite', + query='subsite_id, subsite_target, subsite_name, subsite_metadata', multiple=True, ) - mrich.var("source: #subsites", len(subsite_records)) + mrich.var('source: #subsites', len(subsite_records)) if not subsite_records: return migration_data @@ -916,8 +913,8 @@ def migrate_subsites( # insertion query sql = """ INSERT INTO hippo.subsite( - subsite_target, - subsite_name, + subsite_target, + subsite_name, subsite_metadata ) VALUES( @@ -931,7 +928,7 @@ def migrate_subsites( # format the data subsite_dicts = [ dict( - target=migration_data["target_id_map"][target_id], + target=migration_data['target_id_map'][target_id], name=name, metadata=metadata, ) @@ -940,41 +937,41 @@ def migrate_subsites( # do the insertion if execute: - executemany(destination, "subsite", sql, subsite_dicts, batch_size) + executemany(destination, 'subsite', sql, subsite_dicts, batch_size) # map to the destination records subsite_map = { (target_id, name): i for i, target_id, name, metadata in destination.select( - table="subsite", - query="subsite_id, subsite_target, subsite_name, subsite_metadata", + table='subsite', + query='subsite_id, subsite_target, subsite_name, subsite_metadata', multiple=True, ) } subsite_id_map = { - i: subsite_map[(migration_data["target_id_map"][target_id], name)] + i: subsite_map[(migration_data['target_id_map'][target_id], name)] for i, target_id, name, metadata in subsite_records } - migration_data["subsite_id_map"] = subsite_id_map + migration_data['subsite_id_map'] = subsite_id_map ### subsite_tags # source data subsite_tag_records = source.select( - table="subsite_tag", - query="subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata", + table='subsite_tag', + query='subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata', multiple=True, ) - mrich.var("source: #subsite_tags", len(subsite_tag_records)) + mrich.var('source: #subsite_tags', len(subsite_tag_records)) # insertion query sql = """ INSERT INTO hippo.subsite_tag( - subsite_tag_ref, - subsite_tag_pose, + subsite_tag_ref, + subsite_tag_pose, subsite_tag_metadata ) VALUES( @@ -988,8 +985,8 @@ def migrate_subsites( # format the data subsite_tag_dicts = [ dict( - subsite=migration_data["subsite_id_map"][subsite_id], - pose=migration_data["pose_id_map"][pose_id], + subsite=migration_data['subsite_id_map'][subsite_id], + pose=migration_data['pose_id_map'][pose_id], metadata=metadata, ) for i, subsite_id, pose_id, metadata in subsite_tag_records @@ -997,14 +994,14 @@ def migrate_subsites( # do the insertion if execute: - executemany(destination, "subsite_tag", sql, subsite_tag_dicts, batch_size) + executemany(destination, 'subsite_tag', sql, subsite_tag_dicts, batch_size) # map to the destination records subsite_tag_map = { (subsite_id, pose_id): i for i, subsite_id, pose_id, metadata in destination.select( - table="subsite_tag", - query="subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata", + table='subsite_tag', + query='subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata', multiple=True, ) } @@ -1012,22 +1009,22 @@ def migrate_subsites( subsite_tag_id_map = { i: subsite_tag_map[ ( - migration_data["subsite_id_map"][subsite_id], - migration_data["pose_id_map"][pose_id], + migration_data['subsite_id_map'][subsite_id], + migration_data['pose_id_map'][pose_id], ) ] for i, subsite_id, pose_id, metadata in subsite_tag_records } - migration_data["subsite_tag_id_map"] = subsite_tag_id_map + migration_data['subsite_tag_id_map'] = subsite_tag_id_map return migration_data def migrate_quotes( *, - source: "Database", - destination: "PostgresDatabase", + source: 'Database', + destination: 'PostgresDatabase', migration_data: dict, batch_size: int, execute: bool = True, @@ -1035,28 +1032,28 @@ def migrate_quotes( """migrate quotes""" quote_fields = [ - "quote_id", - "quote_smiles", - "quote_amount", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_lead_time", - "quote_price", - "quote_currency", - "quote_purity", - "quote_date", - "quote_compound", + 'quote_id', + 'quote_smiles', + 'quote_amount', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_lead_time', + 'quote_price', + 'quote_currency', + 'quote_purity', + 'quote_date', + 'quote_compound', ] # source data quote_records = source.select( - table="quote", - query=", ".join(quote_fields), + table='quote', + query=', '.join(quote_fields), multiple=True, ) - mrich.var("source: #quotes", len(quote_records)) + mrich.var('source: #quotes', len(quote_records)) if not quote_records: return migration_data @@ -1118,7 +1115,7 @@ def migrate_quotes( currency=currency, purity=purity, date=date, - compound=migration_data["compound_id_map"][compound_id], + compound=migration_data['compound_id_map'][compound_id], ) for ( i, @@ -1138,7 +1135,7 @@ def migrate_quotes( # do the insertion if execute: - executemany(destination, "quote", sql, quote_dicts, batch_size) + executemany(destination, 'quote', sql, quote_dicts, batch_size) # map to the destination records quote_map = { @@ -1157,8 +1154,8 @@ def migrate_quotes( date, compound_id, ) in destination.select( - table="quote", - query=", ".join(quote_fields), + table='quote', + query=', '.join(quote_fields), multiple=True, ) } @@ -1181,20 +1178,20 @@ def migrate_quotes( ) in quote_records } - migration_data["quote_id_map"] = quote_id_map + migration_data['quote_id_map'] = quote_id_map return migration_data def get_reaction_id_reaction_dict_map( - db: "Database | PostgresDatabase", compound_id_map: dict = None + db: 'Database | PostgresDatabase', compound_id_map: dict = None ) -> (dict, list): """Get serialised reaction and reactant data""" # reactions reaction_records = db.select( - table="reaction", - query="reaction_id, reaction_type, reaction_product, reaction_product_yield", + table='reaction', + query='reaction_id, reaction_type, reaction_product, reaction_product_yield', multiple=True, ) @@ -1210,8 +1207,8 @@ def get_reaction_id_reaction_dict_map( # reactants reactant_records = db.select( - table="reactant", - query="reactant_amount, reactant_reaction, reactant_compound", + table='reactant', + query='reactant_amount, reactant_reaction, reactant_compound', multiple=True, ) @@ -1219,31 +1216,31 @@ def get_reaction_id_reaction_dict_map( for amount, reaction_id, compound_id in reactant_records: compound_id = compound_id_map[compound_id] if compound_id_map else compound_id - reaction_id_reaction_dict_map[reaction_id].setdefault("reactants", set()) - reaction_id_reaction_dict_map[reaction_id]["reactants"].add( + reaction_id_reaction_dict_map[reaction_id].setdefault('reactants', set()) + reaction_id_reaction_dict_map[reaction_id]['reactants'].add( (compound_id, amount) ) - reaction_id_reaction_dict_map[reaction_id].setdefault("reactant_ids", set()) - reaction_id_reaction_dict_map[reaction_id]["reactant_ids"].add(compound_id) + reaction_id_reaction_dict_map[reaction_id].setdefault('reactant_ids', set()) + reaction_id_reaction_dict_map[reaction_id]['reactant_ids'].add(compound_id) return reaction_id_reaction_dict_map, reactant_records def executemany( - db: "PostgresDatabase", table: str, sql: str, payload: list, batch_size: int + db: 'PostgresDatabase', table: str, sql: str, payload: list, batch_size: int ) -> None | list: """Bulk execution with console logging""" n = db.count(table) - mrich.var(f"destination: #{table}s", n) + mrich.var(f'destination: #{table}s', n) result = db.executemany(sql, payload, batch_size=batch_size) if d := db.count(table) - n: - mrich.success("Inserted", d, f"new {table}s") + mrich.success('Inserted', d, f'new {table}s') else: - mrich.warning("Inserted", d, f"new {table}s") + mrich.warning('Inserted', d, f'new {table}s') return result @@ -1257,9 +1254,9 @@ def rename_pose_paths( import re mrich.var( - "pose_path_compound_id_regex", migration_data["pose_path_compound_id_regex"] + 'pose_path_compound_id_regex', migration_data['pose_path_compound_id_regex'] ) - mrich.var("pose_path_pose_id_regex", migration_data["pose_path_pose_id_regex"]) + mrich.var('pose_path_pose_id_regex', migration_data['pose_path_pose_id_regex']) # compound IDs @@ -1267,13 +1264,11 @@ def rename_pose_paths( # pose_path_map_log = {} for pose_dict in pose_dicts: - - orig_path = pose_dict["path"] + orig_path = pose_dict['path'] path = orig_path - for pattern, template in migration_data["pose_path_compound_id_regex"]: - + for pattern, template in migration_data['pose_path_compound_id_regex']: if orig_path in pose_path_map: path = pose_path_map[orig_path] @@ -1287,14 +1282,14 @@ def rename_pose_paths( groups = match.groups() - assert ( - len(groups) == 1 - ), f"pose_path_compound_id_regex replacement not supported with multiple groups, {pattern=}" + assert len(groups) == 1, ( + f'pose_path_compound_id_regex replacement not supported with multiple groups, {pattern=}' + ) groups = [g for g in groups] compound_id = int(groups[0]) - new_compound_id = migration_data["compound_id_map"][compound_id] + new_compound_id = migration_data['compound_id_map'][compound_id] replacement = template.format(new_compound_id=new_compound_id) @@ -1303,7 +1298,7 @@ def rename_pose_paths( if new_path != path: pose_path_map[orig_path] = new_path - raise NotImplementedError("pose_path_pose_id_regex development was not completed") + raise NotImplementedError('pose_path_pose_id_regex development was not completed') # for pattern, template in migration_data["pose_path_pose_id_regex"]: @@ -1344,7 +1339,7 @@ def dump_json(data: dict, file: str) -> None: from json import dump mrich.writing(file) - dump(data, open(file, "wt")) + dump(data, open(file, 'w')) def dump_xlsx(data: dict, file: str) -> None: @@ -1359,15 +1354,14 @@ def dump_xlsx(data: dict, file: str) -> None: if not isinstance(value, dict): meta.append(dict(key=key, value=value)) - meta_df = pd.DataFrame(meta).set_index("key") + meta_df = pd.DataFrame(meta).set_index('key') - source = meta_df.loc["source", "value"] - destination = meta_df.loc["destination", "value"] + source = meta_df.loc['source', 'value'] + destination = meta_df.loc['destination', 'value'] sheets = {} for key, value in data.items(): if isinstance(value, dict): - data = [{source: k, destination: v} for k, v in value.items()] if len(data) > 1_000_000: @@ -1377,15 +1371,14 @@ def dump_xlsx(data: dict, file: str) -> None: for i, batch in enumerate(batches): df = pd.DataFrame(batch) - sheets[f"{key} ({i+1})"] = df.set_index(source) + sheets[f'{key} ({i + 1})'] = df.set_index(source) else: df = pd.DataFrame(data) sheets[key] = df.set_index(source) with pd.ExcelWriter(file) as writer: - - meta_df.to_excel(writer, sheet_name="meta") + meta_df.to_excel(writer, sheet_name='meta') for name, df in sheets.items(): df.to_excel(writer, sheet_name=name, index=True) diff --git a/hippo/pca.py b/hippo/pca.py index 7b6e92f..8982ed3 100644 --- a/hippo/pca.py +++ b/hippo/pca.py @@ -19,14 +19,14 @@ class FP: Names of the features """ - def __init__(self, fp: "np.array", names: list[str]) -> None: + def __init__(self, fp: 'np.array', names: list[str]) -> None: """FP initialisation""" self.fp = fp self.names = names def __str__(self) -> str: """string representation""" - return "%d bit FP" % len(self.fp) + return '%d bit FP' % len(self.fp) def __len__(self) -> int: """length""" @@ -34,7 +34,7 @@ def __len__(self) -> int: def get_cfps( - mol: "rdkit.Chem.Mol", + mol: 'rdkit.Chem.Mol', radius: int = 2, nBits: int = 1024, useFeatures: bool = False, @@ -72,7 +72,6 @@ def get_cfps( ) DataStructs.ConvertToNumpyArray(fp, arr) else: - # https://greglandrum.github.io/rdkit-blog/posts/2023-01-18-fingerprint-generator-tutorial.html#additional-information-explaining-bits fmgen = rdFingerprintGenerator.GetMorganGenerator( radius=radius, diff --git a/hippo/plotting.py b/hippo/plotting.py index a74f490..8f48964 100644 --- a/hippo/plotting.py +++ b/hippo/plotting.py @@ -1,14 +1,13 @@ """Functions to generate standard HIPPO plots""" -import mrich -import molparse as mp - import functools + +import molparse as mp +import mrich import pandas as pd import plotly.express as px import plotly.graph_objects as go - """ ALL GRAPHS DEFINED HERE SHOULD: @@ -24,7 +23,7 @@ def hippo_graph(func): """HIPPO graph decorator""" @functools.wraps(func) - def wrapper(animal, *args, logo="top right", **kwargs): + def wrapper(animal, *args, logo='top right', **kwargs): """ :param animal: @@ -35,7 +34,7 @@ def wrapper(animal, *args, logo="top right", **kwargs): """ wrapper_kwargs = {} - wrapper_keys = ["show", "html", "pdf", "png"] + wrapper_keys = ['show', 'html', 'pdf', 'png'] for key in wrapper_keys: wrapper_kwargs[key] = kwargs.pop(key, None) @@ -44,25 +43,25 @@ def wrapper(animal, *args, logo="top right", **kwargs): if not isinstance(fig, go.Figure): return fig - if wrapper_kwargs["show"]: + if wrapper_kwargs['show']: fig.show() - if wrapper_kwargs["html"]: - file = wrapper_kwargs["html"] - if not file.endswith(".html"): - file = f"{file}.html" + if wrapper_kwargs['html']: + file = wrapper_kwargs['html'] + if not file.endswith('.html'): + file = f'{file}.html' mp.write(file, fig) - if wrapper_kwargs["pdf"]: - file = wrapper_kwargs["pdf"] - if not file.endswith(".pdf"): - file = f"{file}.pdf" + if wrapper_kwargs['pdf']: + file = wrapper_kwargs['pdf'] + if not file.endswith('.pdf'): + file = f'{file}.pdf' mp.write(file, fig) - if wrapper_kwargs["png"]: - file = wrapper_kwargs["png"] - if not file.endswith(".png"): - file = f"{file}.png" + if wrapper_kwargs['png']: + file = wrapper_kwargs['png'] + if not file.endswith('.png'): + file = f'{file}.png' mp.write(file, fig) if not fig.layout.images and logo: @@ -76,7 +75,7 @@ def wrapper(animal, *args, logo="top right", **kwargs): @hippo_graph def plot_tag_statistics( animal, - color="type", + color='type', subtitle=None, log_y=False, show_compounds=True, @@ -104,43 +103,42 @@ def plot_tag_statistics( plot_data = [] for tag in animal.tags.unique: - if tag in skip: continue if show_compounds: num_compounds = len(compounds.get_by_tag(tag=tag)) if num_compounds: - data = dict(tag=tag, number=num_compounds, type="compounds") + data = dict(tag=tag, number=num_compounds, type='compounds') plot_data.append(data) if show_poses: num_poses = len(poses.get_by_tag(tag=tag)) if num_poses: - data = dict(tag=tag, number=num_poses, type="poses") + data = dict(tag=tag, number=num_poses, type='poses') plot_data.append(data) from pandas import DataFrame df = DataFrame(plot_data) - df.sort_values(by="tag", inplace=True) + df.sort_values(by='tag', inplace=True) - fig = px.bar(df, x="tag", y="number", color=color, log_y=log_y) + fig = px.bar(df, x='tag', y='number', color=color, log_y=log_y) if not title: - title = "Tag Statistics" + title = 'Tag Statistics' if subtitle: - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' else: - title = f"{animal.name}: {title}" + title = f'{animal.name}: {title}' fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" + title=title, title_automargin=False, title_yref='container', barmode='group' ) - fig.update_layout(xaxis_title="Tag", yaxis_title="#") + fig.update_layout(xaxis_title='Tag', yaxis_title='#') return fig @@ -175,41 +173,41 @@ def plot_interaction_histogram( data = dict(str=key, count=count) - data["family"] = feature_metadata[key]["family"] - data["res_name"] = feature_metadata[key]["res_name"] - data["res_number"] = feature_metadata[key]["res_number"] - data["res_chain"] = feature_metadata[key]["res_chain"] - data["atom_numbers"] = feature_metadata[key]["atom_numbers"] + data['family'] = feature_metadata[key]['family'] + data['res_name'] = feature_metadata[key]['res_name'] + data['res_number'] = feature_metadata[key]['res_number'] + data['res_chain'] = feature_metadata[key]['res_chain'] + data['atom_numbers'] = feature_metadata[key]['atom_numbers'] - data["res_name_number_chain_str"] = ( - f"{feature_metadata[key]['res_name']} {feature_metadata[key]['res_number']} {feature_metadata[key]['res_chain']}" + data['res_name_number_chain_str'] = ( + f'{feature_metadata[key]["res_name"]} {feature_metadata[key]["res_number"]} {feature_metadata[key]["res_chain"]}' ) plot_data.append(data) plot_df = pd.DataFrame(plot_data) - plot_df.sort_values(["res_chain", "res_number", "family"], inplace=True) + plot_df.sort_values(['res_chain', 'res_number', 'family'], inplace=True) plot_df fig = px.bar( plot_df, - x="res_name_number_chain_str", - y="count", - color="family", + x='res_name_number_chain_str', + y='count', + color='family', hover_data=plot_df.columns, ) - title = "Leveraged protein features" + title = 'Leveraged protein features' if subtitle: - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' else: - title = f"{animal.name}: {title}" + title = f'{animal.name}: {title}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(xaxis_title="Residue") - fig.update_layout(yaxis_title="#Interactions") + fig.update_layout(xaxis_title='Residue') + fig.update_layout(yaxis_title='#Interactions') return fig @@ -220,7 +218,7 @@ def plot_interaction_punchcard( poses=None, subtitle=None, opacity=1.0, - group: str = "pose_name", + group: str = 'pose_name', ignore_chains=False, ): """ @@ -235,6 +233,7 @@ def plot_interaction_punchcard( """ import plotly + from .pset import PoseTable poses = poses or animal.poses @@ -244,73 +243,73 @@ def plot_interaction_punchcard( else: iset = poses.interactions - mrich.var("#poses", len(poses)) - mrich.var("#interactions", len(iset)) + mrich.var('#poses', len(poses)) + mrich.var('#interactions', len(iset)) plot_data = iset.df name_lookup = poses.id_name_dict names = [] - for pose_id in plot_data["pose_id"].values: + for pose_id in plot_data['pose_id'].values: names.append(name_lookup[pose_id]) - plot_data["pose_name"] = names + plot_data['pose_name'] = names if ignore_chains: - x = "res_name_number" - plot_data[x] = plot_data[["residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 + x = 'res_name_number' + plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( + lambda x: ' '.join([str(i) for i in x]), axis=1 ) - plot_data = plot_data.sort_values([group, x, "residue_number"]) + plot_data = plot_data.sort_values([group, x, 'residue_number']) sort_key = lambda x: x[1] else: - x = "chain_res_name_number_str" - plot_data[x] = plot_data[["chain_name", "residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 + x = 'chain_res_name_number_str' + plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( + lambda x: ' '.join([str(i) for i in x]), axis=1 ) sort_key = lambda x: (x[2], x[1]) - title = "Interaction Punch-Card" + title = 'Interaction Punch-Card' if subtitle: - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' else: - title = f"{animal.name}: {title}" + title = f'{animal.name}: {title}' fig = px.scatter( plot_data, x=x, - y="type", - marginal_x="histogram", - marginal_y="histogram", + y='type', + marginal_x='histogram', + marginal_y='histogram', hover_data=plot_data.columns, color=group, title=title, ) - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(xaxis_title="Residue", yaxis_title="Feature Family") + fig.update_layout(xaxis_title='Residue', yaxis_title='Feature Family') # x-axis sorting - categoryarray = plot_data[[x, "residue_number", "chain_name"]].agg(tuple, axis=1) + categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg(tuple, axis=1) categoryarray = sorted([v for v in categoryarray.values], key=sort_key) categoryarray = [v[0] for v in categoryarray] # sort axes - fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) - fig.update_yaxes(categoryorder="category descending") + fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) + fig.update_yaxes(categoryorder='category descending') for trace in fig.data: if type(trace) == plotly.graph_objs._histogram.Histogram: trace.opacity = 1 trace.xbins.size = 1 else: - trace["marker"]["size"] = 10 - trace["marker"]["opacity"] = opacity + trace['marker']['size'] = 10 + trace['marker']['opacity'] = opacity - fig.update_layout(barmode="stack") - fig.update_layout(scattermode="group", scattergap=0.75) + fig.update_layout(barmode='stack') + fig.update_layout(scattermode='group', scattergap=0.75) return add_punchcard_logo(fig) @@ -320,10 +319,10 @@ def plot_interaction_punchcard_by_tags( animal, tags: dict[str, str] | list[str], permitted_residues: dict[str, list[int]] | None = None, - yaxis_title: str = "Tag", + yaxis_title: str = 'Tag', subtitle=None, opacity=0.7, - group="type", + group='type', marginal_histogram_x: bool = True, # marginal_histogram_y: bool = False, sizeref=0.08, @@ -345,8 +344,8 @@ def plot_interaction_punchcard_by_tags( """ - import plotly import numpy as np + import plotly if isinstance(tags, list): tags = {v: v for v in tags} @@ -360,111 +359,108 @@ def plot_interaction_punchcard_by_tags( name_lookup = poses.id_name_dict - with mrich.loading("Getting interactions dataframe"): + with mrich.loading('Getting interactions dataframe'): df = poses.interactions.df - df["tag"] = tag - df["group_name"] = group_name - df["pose_name"] = [name_lookup[pose_id] for pose_id in df["pose_id"].values] + df['tag'] = tag + df['group_name'] = group_name + df['pose_name'] = [name_lookup[pose_id] for pose_id in df['pose_id'].values] if group_name in permitted_residues: - subset = df[df["residue_number"].isin(permitted_residues[group_name])] + subset = df[df['residue_number'].isin(permitted_residues[group_name])] diff = len(df) - len(subset) if diff: mrich.warning( - "Skipping", + 'Skipping', diff, - "markers due unpermitted residue numbers for: ", + 'markers due unpermitted residue numbers for: ', group_name, ) df = subset dfs.append(df) - mrich.debug("Concatenating dataframes") + mrich.debug('Concatenating dataframes') plot_data = pd.concat(dfs, ignore_index=True) ### add permitted residues if permitted_residues and ignore_chains: - permitted_df = [] for group_name, ids in permitted_residues.items(): - - unique_combinations = plot_data[plot_data["group_name"] == group_name][ - ["residue_name", "residue_number"] + unique_combinations = plot_data[plot_data['group_name'] == group_name][ + ['residue_name', 'residue_number'] ].drop_duplicates() for resid in ids: - try: resname = unique_combinations[ - unique_combinations["residue_number"] == resid - ]["residue_name"].values[0] + unique_combinations['residue_number'] == resid + ]['residue_name'].values[0] except IndexError: continue # if plot_data[['residue_name', 'residue_number']] permitted_df.append( - dict(res_name_number=f"{resname} {resid}", group_name=group_name) + dict(res_name_number=f'{resname} {resid}', group_name=group_name) ) permitted_df = pd.DataFrame(permitted_df) - mrich.debug("Building plot_data") + mrich.debug('Building plot_data') if backbone_only: - plot_data = plot_data[plot_data["backbone"] == True] + plot_data = plot_data[plot_data['backbone'] == True] if sidechain_only: - plot_data = plot_data[plot_data["sidechain"] == True] + plot_data = plot_data[plot_data['sidechain'] == True] if ignore_chains: - x = "res_name_number" - plot_data[x] = plot_data[["residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 + x = 'res_name_number' + plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( + lambda x: ' '.join([str(i) for i in x]), axis=1 ) - plot_data = plot_data.sort_values([group, x, "residue_number"]) + plot_data = plot_data.sort_values([group, x, 'residue_number']) sort_key = lambda x: x[1] else: - x = "chain_res_name_number_str" - plot_data[x] = plot_data[["chain_name", "residue_name", "residue_number"]].agg( - lambda x: " ".join([str(i) for i in x]), axis=1 + x = 'chain_res_name_number_str' + plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( + lambda x: ' '.join([str(i) for i in x]), axis=1 ) sort_key = lambda x: (x[2], x[1]) if counts: - mrich.debug("Summing by residue") + mrich.debug('Summing by residue') orig_data = plot_data.copy() if ignore_chains: plot_data = ( - plot_data.groupby(["group_name", "type", x, "residue_number"]) + plot_data.groupby(['group_name', 'type', x, 'residue_number']) .size() - .reset_index(name="count") + .reset_index(name='count') ) else: plot_data = ( plot_data.groupby( - ["group_name", "type", x, "residue_number", "chain_name"] + ['group_name', 'type', x, 'residue_number', 'chain_name'] ) .size() - .reset_index(name="count") + .reset_index(name='count') ) - plot_data["size"] = np.sqrt(plot_data["count"]) + plot_data['size'] = np.sqrt(plot_data['count']) # add a size reference - type_str = "Size" + type_str = 'Size' sizes = [1, 50, 100, 250] dicts = [] - for group_name, size in zip(tags.keys(), sizes): + for group_name, size in zip(tags.keys(), sizes, strict=False): dicts.append( dict( group_name=group_name, - type="type_str", - res_name_number="", + type='type_str', + res_name_number='', residue_number=999, count=size, size=np.sqrt(size), @@ -474,55 +470,54 @@ def plot_interaction_punchcard_by_tags( plot_data = pd.concat([plot_data, pd.DataFrame(dicts)]) - mrich.debug("Making scatter plot") + mrich.debug('Making scatter plot') fig = px.scatter( plot_data, x=x, - y="group_name", + y='group_name', hover_data=plot_data.columns, color=group, - size="size" if counts else None, - text="text", + size='size' if counts else None, + text='text', # color_discrete_sequence=px.colors.qualitative.Dark2 ) - fig.update_traces(textposition="middle right") + fig.update_traces(textposition='middle right') if return_plot_data: data_snapshot1 = plot_data.copy() # fig.update_layout(title=title, title_automargin=False, title_yref="container") - fig.update_layout(xaxis_title="Residue", yaxis_title=yaxis_title) + fig.update_layout(xaxis_title='Residue', yaxis_title=yaxis_title) # x-axis sorting if ignore_chains: - categoryarray = plot_data[[x, "residue_number"]].agg(tuple, axis=1) + categoryarray = plot_data[[x, 'residue_number']].agg(tuple, axis=1) else: - categoryarray = plot_data[[x, "residue_number", "chain_name"]].agg( + categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg( tuple, axis=1 ) categoryarray = sorted([v for v in categoryarray.values], key=sort_key) categoryarray = [v[0] for v in categoryarray] # sort axes - fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) + fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) for trace in fig.data: if type(trace) == plotly.graph_objs._histogram.Histogram: trace.opacity = 1 trace.xbins.size = 1 else: - trace["marker"]["opacity"] = opacity + trace['marker']['opacity'] = opacity if marginal_histogram_x: - from plotly.subplots import make_subplots subplot_fig = make_subplots( rows=2, cols=1, - specs=[[{"type": "histogram"}], [{"type": "scatter"}]], + specs=[[{'type': 'histogram'}], [{'type': 'scatter'}]], shared_xaxes=True, shared_yaxes=False, vertical_spacing=0.02, @@ -531,7 +526,7 @@ def plot_interaction_punchcard_by_tags( # add in scatter traces for trace in fig.data: - trace.yaxis = "y2" + trace.yaxis = 'y2' trace.showlegend = False trace.marker.sizeref = sizeref trace.marker.line.width = 0 @@ -539,17 +534,17 @@ def plot_interaction_punchcard_by_tags( # aggregate data for histogram plot plot_data = ( - orig_data.groupby(["type", x, "residue_number"]) + orig_data.groupby(['type', x, 'residue_number']) .size() - .reset_index(name="count") + .reset_index(name='count') ) # generate histogram - fig2 = px.histogram(plot_data, x=x, y="count", color="type") + fig2 = px.histogram(plot_data, x=x, y='count', color='type') # add in histogram traces for trace in fig2.data: - trace.yaxis = "y1" + trace.yaxis = 'y1' subplot_fig.add_trace(trace) # if permitted_residues and ignore_chains: @@ -567,31 +562,31 @@ def plot_interaction_punchcard_by_tags( # clean up the axes subplot_fig.update_layout( - xaxis=dict(anchor="y2", visible=True, showticklabels=True, side="bottom"), + xaxis=dict(anchor='y2', visible=True, showticklabels=True, side='bottom'), # xaxis2=dict(visible=True, showticklabels=True, side="bottom"), yaxis2=dict( - anchor="x", - overlaying="x", - side="top", - categoryorder="category descending", + anchor='x', + overlaying='x', + side='top', + categoryorder='category descending', ), # Secondary y-axis for x marginal ) # x-axis sorting if ignore_chains: - categoryarray = plot_data[[x, "residue_number"]].agg(tuple, axis=1) + categoryarray = plot_data[[x, 'residue_number']].agg(tuple, axis=1) else: raise NotImplementedError categoryarray = sorted([v for v in categoryarray.values], key=sort_key) categoryarray = [v[0] for v in categoryarray] - subplot_fig.update_xaxes(categoryorder="array", categoryarray=categoryarray) + subplot_fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) # y-axis sorting categoryarray = list(reversed(tags.keys())) - subplot_fig.update_yaxes(categoryorder="array", categoryarray=categoryarray) + subplot_fig.update_yaxes(categoryorder='array', categoryarray=categoryarray) # stack histogram bars on top of each other - subplot_fig.update_layout(barmode="stack") + subplot_fig.update_layout(barmode='stack') subplot_fig.update_layout( margin=dict(l=20, r=20, t=20, b=20), @@ -633,7 +628,7 @@ def plot_residue_interactions( if not poses: poses = animal.poses - mrich.var("#poses", len(poses)) + mrich.var('#poses', len(poses)) from .iset import InteractionSet @@ -643,7 +638,7 @@ def plot_residue_interactions( # return iset - mrich.var("#interactions", len(iset)) + mrich.var('#interactions', len(iset)) plot_data = iset.df @@ -653,38 +648,38 @@ def plot_residue_interactions( # print(name_lookup) names = [] - for pose_id in plot_data["pose_id"].values: + for pose_id in plot_data['pose_id'].values: names.append(name_lookup[int(pose_id)]) - plot_data["pose_name"] = names + plot_data['pose_name'] = names - fig = px.histogram(plot_data, x="pose_name", color="type") + fig = px.histogram(plot_data, x='pose_name', color='type') # return plot_data[plot_data['pose_name'] == ' x1762b'] - fig.update_xaxes(categoryorder="total descending") + fig.update_xaxes(categoryorder='total descending') # set customdata from x-axis labels for trace in fig.data: - trace["customdata"] = trace["x"] + trace['customdata'] = trace['x'] if not subtitle: - subtitle = f"#Poses={len(poses)}" + subtitle = f'#Poses={len(poses)}' residue_name = animal.db.get_feature( - id=plot_data["feature_id"].values[0] + id=plot_data['feature_id'].values[0] ).residue_name - title = f"Interactions w/ {residue_name} {residue_number}" + title = f'Interactions w/ {residue_name} {residue_number}' if chain: - title += f" {chain}" + title += f' {chain}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_xaxes(title="Pose") - fig.update_yaxes(title="#Interactions") + fig.update_xaxes(title='Pose') + fig.update_yaxes(title='#Interactions') return fig @@ -692,7 +687,7 @@ def plot_residue_interactions( @hippo_graph # def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): def plot_reactant_amounts( - animal, subtitle=None, color="has_price_picker", named_only=False, most_common=None + animal, subtitle=None, color='has_price_picker', named_only=False, most_common=None ): """ @@ -710,37 +705,37 @@ def plot_reactant_amounts( bbs = animal.building_blocks - mrich.debug("making plot_data") + mrich.debug('making plot_data') plot_data = [] for bb in bbs: d = bb.dict # if most_common and d['amount'] is not None: - if not named_only or not d["name_is_smiles"]: + if not named_only or not d['name_is_smiles']: plot_data.append(d) if most_common: - mrich.debug("sorting") - plot_data = sorted(plot_data, key=lambda x: x["amount"], reverse=True)[ + mrich.debug('sorting') + plot_data = sorted(plot_data, key=lambda x: x['amount'], reverse=True)[ :most_used_number ] fig = px.bar( - plot_data, x="name", y="amount", color=color, hover_data=plot_data[0].keys() + plot_data, x='name', y='amount', color=color, hover_data=plot_data[0].keys() ) # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - title = "Building Blocks" + title = 'Building Blocks' if not subtitle: # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" + subtitle = f'#BBs={len(bbs)}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(xaxis_title="Reactant", yaxis_title="#Reactions") + fig.update_layout(xaxis_title='Reactant', yaxis_title='#Reactions') return fig @@ -765,35 +760,35 @@ def plot_reactant_price(animal, subtitle=None, amount=20): plot_data = [] for bb in bbs: d = bb.dict - if not d["has_price_picker"]: + if not d['has_price_picker']: continue - d[f"price_{amount}mg"] = bb.get_price(amount) - d[f"min_amount"] = bb.price_picker.min_amount + d[f'price_{amount}mg'] = bb.get_price(amount) + d['min_amount'] = bb.price_picker.min_amount plot_data.append(d) # fig = px.bar(plot_data, x='name', y=f'price_{amount}mg', color='lead_time', log_y=True, hover_data=plot_data[0].keys()) fig = px.histogram( plot_data, - x=f"price_{amount}mg", - color="lead_time", + x=f'price_{amount}mg', + color='lead_time', hover_data=plot_data[0].keys(), ) # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - title = "Reactant Pricing" + title = 'Reactant Pricing' if not subtitle: # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" + subtitle = f'#BBs={len(bbs)}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') fig.update_layout( - yaxis_title="Number of reactants", xaxis_title=f"Price for {amount}mg [$USD]" + yaxis_title='Number of reactants', xaxis_title=f'Price for {amount}mg [$USD]' ) return fig @@ -819,37 +814,37 @@ def plot_reactants_2d(animal, subtitle=None, amount=20): plot_data = [] for bb in bbs: d = bb.dict - if not d["has_price_picker"]: + if not d['has_price_picker']: continue - d[f"price_{amount}mg"] = bb.get_price(amount) - d[f"min_amount"] = bb.price_picker.min_amount + d[f'price_{amount}mg'] = bb.get_price(amount) + d['min_amount'] = bb.price_picker.min_amount plot_data.append(d) fig = px.scatter( plot_data, - y="amount", - x=f"price_{amount}mg", - color="name", + y='amount', + x=f'price_{amount}mg', + color='name', hover_data=plot_data[0].keys(), ) # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - title = "Building Blocks" + title = 'Building Blocks' if not subtitle: # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" + subtitle = f'#BBs={len(bbs)}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(scattermode="group", scattergap=0.75) + fig.update_layout(scattermode='group', scattergap=0.75) fig.update_layout( - yaxis_title="Quantity [mg]", xaxis_title=f"Price for {amount}mg [$USD]" + yaxis_title='Quantity [mg]', xaxis_title=f'Price for {amount}mg [$USD]' ) return fig @@ -857,7 +852,7 @@ def plot_reactants_2d(animal, subtitle=None, amount=20): @hippo_graph # def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_building_blocks(animal, subtitle=None, color="name_is_smiles"): +def plot_building_blocks(animal, subtitle=None, color='name_is_smiles'): """ :param animal: @@ -876,26 +871,26 @@ def plot_building_blocks(animal, subtitle=None, color="name_is_smiles"): for bb in bbs: plot_data.append(bb.dict) - fig = px.scatter(plot_data, x="name", y="max", color="amount") + fig = px.scatter(plot_data, x='name', y='max', color='amount') # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - title = "Building Blocks" + title = 'Building Blocks' if not subtitle: # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f"#BBs={len(bbs)}" + subtitle = f'#BBs={len(bbs)}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(xaxis_title="Reactant", yaxis_title="Quantity") + fig.update_layout(xaxis_title='Reactant', yaxis_title='Quantity') return fig @hippo_graph -def plot_synthetic_routes(animal, subtitle=None, cset="elabs", color="num_reactants"): +def plot_synthetic_routes(animal, subtitle=None, cset='elabs', color='num_reactants'): """ :param animal: @@ -912,18 +907,18 @@ def plot_synthetic_routes(animal, subtitle=None, cset="elabs", color="num_reacta plot_data.append(reax.dict) # fig = px.bar(plot_data, x='name', y='amount', color=color) - fig = px.histogram(plot_data, x="type", color=color) + fig = px.histogram(plot_data, x='type', color=color) - title = "Synthetic Routes" + title = 'Synthetic Routes' if not subtitle: subtitle = f'"{cset.name}": #compounds={len(cset)}' - title = f"{animal.name}: {title}
{subtitle}" + title = f'{animal.name}: {title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') - fig.update_layout(xaxis_title="Compound", yaxis_title="#Routes") + fig.update_layout(xaxis_title='Compound', yaxis_title='#Routes') return fig @@ -950,40 +945,40 @@ def plot_numbers(animal, subtitle=None): # cset = animal.compound_sets[cset] plot_data = [ - dict(category="Experimental Hits", number=len(animal.hits), type="compound"), - dict(category="Experimental Hits", number=len(animal.hits.poses), type="poses"), - dict(category="Base compounds", number=len(animal.scaffolds), type="compound"), + dict(category='Experimental Hits', number=len(animal.hits), type='compound'), + dict(category='Experimental Hits', number=len(animal.hits.poses), type='poses'), + dict(category='Base compounds', number=len(animal.scaffolds), type='compound'), dict( - category="Base compounds", number=len(animal.scaffolds.poses), type="poses" + category='Base compounds', number=len(animal.scaffolds.poses), type='poses' ), dict( - category="Syndirella Elaborations", + category='Syndirella Elaborations', number=len(animal.elabs), - type="compound", + type='compound', ), dict( - category="Syndirella Elaborations", + category='Syndirella Elaborations', number=len(animal.elabs.poses), - type="poses", + type='poses', ), dict( - category="Unique Reactants", + category='Unique Reactants', number=len(animal.building_blocks), - type="compound", + type='compound', ), ] - fig = px.bar(plot_data, x="category", y="number", log_y=True, color="type") + fig = px.bar(plot_data, x='category', y='number', log_y=True, color='type') - title = "Compounds & Poses" + title = 'Compounds & Poses' - title = f"{animal.name}: {title}
" + title = f'{animal.name}: {title}
' fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" + title=title, title_automargin=False, title_yref='container', barmode='group' ) - fig.update_layout(xaxis_title=None, yaxis_title="Log(Quantity)") + fig.update_layout(xaxis_title=None, yaxis_title='Log(Quantity)') return fig @@ -993,7 +988,7 @@ def plot_compound_property( animal, prop, compounds=None, - style="bar", + style='bar', null=None, hover_data=None, custom_data=None, @@ -1023,18 +1018,15 @@ def plot_compound_property( compounds = animal.compounds if len(compounds) > 1000: - compounds = mrich.track(compounds, prefix="Generating plot data") + compounds = mrich.track(compounds, prefix='Generating plot data') for comp in compounds: - data = comp.dict for p in prop: - # has attr if p not in data: - # get attr if hasattr(comp, p): v = getattr(comp, p) @@ -1050,20 +1042,18 @@ def plot_compound_property( plot_data.append(data) if len(prop) == 1: - - title = f"Compound {prop[0]}" + title = f'Compound {prop[0]}' fig = px.histogram(plot_data, x=prop[0]) - fig.update_layout(xaxis_title=prop[0], yaxis_title="Quantity") + fig.update_layout(xaxis_title=prop[0], yaxis_title='Quantity') elif len(prop) == 2: + hover_data = prop + ['smiles'] + hover_data - hover_data = prop + ["smiles"] + hover_data - - title = f"Compound {prop[0]} vs {prop[1]}" + title = f'Compound {prop[0]} vs {prop[1]}' - func = eval(f"px.{style}") + func = eval(f'px.{style}') fig = func( plot_data, x=prop[0], @@ -1075,12 +1065,12 @@ def plot_compound_property( fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) else: - mrich.error("Unsupported") + mrich.error('Unsupported') - title = f"{animal.name}: {title}
" + title = f'{animal.name}: {title}
' fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" + title=title, title_automargin=False, title_yref='container', barmode='group' ) return fig @@ -1091,7 +1081,7 @@ def plot_pose_property( animal, prop, poses=None, - style="scatter", + style='scatter', title=None, null=None, color=None, @@ -1121,108 +1111,104 @@ def plot_pose_property( """ # fetch these directly from the database - if prop in ["energy_score", "distance_score"]: - + if prop in ['energy_score', 'distance_score']: p = prop if not poses: # great all poses! poses = animal.poses n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - field = f"pose_{p}" - title = title or f"{p} of all poses" + mrich.out(f'Querying database for {n_poses} poses...') + field = f'pose_{p}' + title = title or f'{p} of all poses' query = animal.db.select_where( - table="pose", query=field, key=f"{field} is not NULL", multiple=True + table='pose', query=field, key=f'{field} is not NULL', multiple=True ) else: - # subset of poses - assert poses.table == "pose", f"{poses=} is not a set of Pose objects" + assert poses.table == 'pose', f'{poses=} is not a set of Pose objects' n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - field = f"pose_{p}" - title = title or f"{p} of pose subset" + mrich.out(f'Querying database for {n_poses} poses...') + field = f'pose_{p}' + title = title or f'{p} of pose subset' query = animal.db.select_where( - table="pose", + table='pose', query=field, - key=f"{field} is not NULL and pose_id in {poses.str_ids}", + key=f'{field} is not NULL and pose_id in {poses.str_ids}', multiple=True, ) - plot_data = [{p: v} for v, in query] + plot_data = [{p: v} for (v,) in query] - if p == "energy_score": + if p == 'energy_score': subtitle = ( subtitle - or f"#poses={n_poses}, energy_score < 0 = {len([None for d in plot_data if d[p] < 0])/n_poses:.1%}" + or f'#poses={n_poses}, energy_score < 0 = {len([None for d in plot_data if d[p] < 0]) / n_poses:.1%}' ) - elif p == "distance_score": + elif p == 'distance_score': subtitle = ( subtitle - or f"#poses={n_poses}, distance_score < 2 = {len([None for d in plot_data if d[p] < 2])/n_poses:.1%}" + or f'#poses={n_poses}, distance_score < 2 = {len([None for d in plot_data if d[p] < 2]) / n_poses:.1%}' ) else: - subtitle = subtitle or f"#poses={n_poses}" + subtitle = subtitle or f'#poses={n_poses}' prop = [prop] - elif prop == ["energy_score", "distance_score"] or prop == [ - "distance_score", - "energy_score", + elif prop == ['energy_score', 'distance_score'] or prop == [ + 'distance_score', + 'energy_score', ]: - - query = f"pose_id, pose_distance_score, pose_energy_score" + query = 'pose_id, pose_distance_score, pose_energy_score' # hardcoded errorbars distance_score_err = 0.03 energy_score_err = 6 if color: - query += f", {color}" + query += f', {color}' if not poses: # great all poses! poses = animal.poses n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - title = f"distance & energy scores of all poses" - query = animal.db.select(table="pose", query=query, multiple=True) + mrich.out(f'Querying database for {n_poses} poses...') + title = 'distance & energy scores of all poses' + query = animal.db.select(table='pose', query=query, multiple=True) else: - # subset of poses n_poses = len(poses) - mrich.out(f"Querying database for {n_poses} poses...") - title = f"distance & energy scores of pose subset" + mrich.out(f'Querying database for {n_poses} poses...') + title = 'distance & energy scores of pose subset' query = animal.db.select_where( - table="pose", + table='pose', query=query, - key=f"pose_id in {poses.str_ids}", + key=f'pose_id in {poses.str_ids}', multiple=True, ) plot_data = [] for q in query: d = { - "id": q[0], - "distance_score": q[1], - "energy_score": q[2], - "distance_score_err": distance_score_err, - "energy_score_err": energy_score_err, + 'id': q[0], + 'distance_score': q[1], + 'energy_score': q[2], + 'distance_score_err': distance_score_err, + 'energy_score_err': energy_score_err, } if color: d[color] = q[-1] - if color == "pose_compound": - d[color] = f"C{d[color]}" + if color == 'pose_compound': + d[color] = f'C{d[color]}' plot_data.append(d) - kwargs["error_x"] = "energy_score_err" - kwargs["error_y"] = "distance_score_err" + kwargs['error_x'] = 'energy_score_err' + kwargs['error_y'] = 'distance_score_err' - subtitle = subtitle or f"#poses={n_poses}" + subtitle = subtitle or f'#poses={n_poses}' # elif prop == ['num_atoms_added', 'energy_score'] or prop == ['num_atoms_added', 'energy_score']: # mrich.error('Use animal.plot_pose_risk_vs_placement') @@ -1251,32 +1237,29 @@ def plot_pose_property( # subtitle = f'#poses={n_poses}' else: - if not poses: poses = animal.poses - if prop == "tags": - + if prop == 'tags': plot_data = [] for tag in poses.tags: - num_poses = len(poses.get_by_tag(tag=tag)) data = dict(tag=tag, number=num_poses) plot_data.append(data) - fig = px.bar(plot_data, x="tag", y="number", color=color, log_y=log_y) + fig = px.bar(plot_data, x='tag', y='number', color=color, log_y=log_y) - title = "Tag Statistics" + title = 'Tag Statistics' fig.update_layout( title=title, title_automargin=False, - title_yref="container", - barmode="group", + title_yref='container', + barmode='group', ) - fig.update_layout(xaxis_title="Tag", yaxis_title="#") + fig.update_layout(xaxis_title='Tag', yaxis_title='#') return fig @@ -1285,7 +1268,7 @@ def plot_pose_property( plot_data = [] if len(poses) > 1000: - poses = mrich.track(poses, prefix="Generating plot data") + poses = mrich.track(poses, prefix='Generating plot data') for pose in poses: if len(prop) > 1: @@ -1326,31 +1309,27 @@ def plot_pose_property( if data_only: return plot_data - hover_data = ["id"] # , 'alias', 'inchikey'] #, 'tags', 'inspirations'] + hover_data = ['id'] # , 'alias', 'inchikey'] #, 'tags', 'inspirations'] if len(prop) == 1: - - title = title or f"Pose {prop[0]}" + title = title or f'Pose {prop[0]}' fig = px.histogram(plot_data, x=prop[0], hover_data=None, color=color, **kwargs) - fig.update_layout(xaxis_title=prop[0], yaxis_title="Quantity") + fig.update_layout(xaxis_title=prop[0], yaxis_title='Quantity') elif len(prop) == 2: - - if style == "histogram": - + if style == 'histogram': x = [d[prop[0]] for d in plot_data] y = [d[prop[1]] for d in plot_data] fig = go.Figure(go.Histogram2d(x=x, y=y, **kwargs)) else: - # if style == "bar": # style = "scatter" - func = eval(f"px.{style}") + func = eval(f'px.{style}') fig = func( plot_data, x=prop[0], @@ -1361,19 +1340,19 @@ def plot_pose_property( **kwargs, ) - title = title or f"Pose {prop[0]} vs {prop[1]}" + title = title or f'Pose {prop[0]} vs {prop[1]}' fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) else: - mrich.error("Unsupported") + mrich.error('Unsupported') - title = title or f"{animal.name}: {title}
" + title = title or f'{animal.name}: {title}
' if subtitle: - title = f"{title}
{subtitle}" + title = f'{title}
{subtitle}' fig.update_layout( - title=title, title_automargin=False, title_yref="container", barmode="group" + title=title, title_automargin=False, title_yref='container', barmode='group' ) return fig @@ -1390,30 +1369,29 @@ def plot_compound_availability(animal, compounds=None, title=None, subtitle=None """ - from .cset import CompoundTable, CompoundSet + from .cset import CompoundSet, CompoundTable compounds = compounds or animal.compounds match compounds: case CompoundTable(): pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", + table='quote', + query='DISTINCT quote_supplier, quote_catalogue', multiple=True, ) plot_data = [] for supplier, catalogue in pairs: - if catalogue is None: - catalogue = "None" - cat_str = "NULL" + catalogue = 'None' + cat_str = 'NULL' else: cat_str = f'"{catalogue}"' (count,) = animal.db.select_where( - table="quote", - query="COUNT(DISTINCT quote_compound)", + table='quote', + query='COUNT(DISTINCT quote_compound)', key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str}', ) @@ -1422,25 +1400,23 @@ def plot_compound_availability(animal, compounds=None, title=None, subtitle=None ) case CompoundSet(): - pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", + table='quote', + query='DISTINCT quote_supplier, quote_catalogue', multiple=True, ) plot_data = [] for supplier, catalogue in pairs: - if catalogue is None: - catalogue = "None" - cat_str = "NULL" + catalogue = 'None' + cat_str = 'NULL' else: cat_str = f'"{catalogue}"' (count,) = animal.db.select_where( - table="quote", - query="COUNT(DISTINCT quote_compound)", + table='quote', + query='COUNT(DISTINCT quote_compound)', key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', ) @@ -1454,14 +1430,14 @@ def plot_compound_availability(animal, compounds=None, title=None, subtitle=None case _: raise NotImplementedError - fig = px.bar(plot_data, x="catalogue", y="count", color="supplier") + fig = px.bar(plot_data, x='catalogue', y='count', color='supplier') - title = "Compound availability" + title = 'Compound availability' - title = title or f"{animal.name}: Compound availability
" + title = title or f'{animal.name}: Compound availability
' if subtitle: - title = f"{title}
{subtitle}" + title = f'{title}
{subtitle}' fig.update_layout( title=title @@ -1482,21 +1458,19 @@ def plot_compound_availability_venn(animal, compounds): """ from venn import venn - from .cset import CompoundTable, CompoundSet pairs = animal.db.select( - table="quote", - query="DISTINCT quote_supplier, quote_catalogue", + table='quote', + query='DISTINCT quote_supplier, quote_catalogue', multiple=True, ) plot_data = {} for supplier, catalogue in pairs: - if catalogue is None: - catalogue = "None" - cat_str = "NULL" + catalogue = 'None' + cat_str = 'NULL' else: cat_str = f'"{catalogue}"' @@ -1504,11 +1478,11 @@ def plot_compound_availability_venn(animal, compounds): plot_data[(supplier, catalogue)] = set() records = animal.db.select_where( - table="quote", - query="quote_compound", + table='quote', + query='quote_compound', key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', multiple=True, - none="quiet", + none='quiet', ) if not records: @@ -1531,7 +1505,7 @@ def plot_compound_price( min_amount=1, subtitle=None, title=None, - style="histogram", + style='histogram', **kwargs, ): """ @@ -1546,26 +1520,23 @@ def plot_compound_price( """ - from .cset import CompoundTable, CompoundSet import numpy as np + from .cset import CompoundSet, CompoundTable + compounds = compounds or animal.compounds match compounds: case CompoundTable(): - - if style == "scatter": - + if style == 'scatter': sql = f""" SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM {animal.db.SQL_SCHEMA_PREFIX}quote + FROM {animal.db.SQL_SCHEMA_PREFIX}quote INNER JOIN compound ON quote.quote_compound = compound.compound_id INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound WHERE quote_amount >= {min_amount} GROUP BY quote_compound - """.format( - min_amount=min_amount - ) + """.format(min_amount=min_amount) results = animal.db.execute(sql).fetchall() @@ -1595,9 +1566,9 @@ def plot_compound_price( else: data = animal.db.select_where( - table="quote", - query="quote_amount, MIN(quote_price)", - key=f"quote_amount >= {min_amount} GROUP BY quote_compound", + table='quote', + query='quote_amount, MIN(quote_price)', + key=f'quote_amount >= {min_amount} GROUP BY quote_compound', multiple=True, ) @@ -1608,20 +1579,16 @@ def plot_compound_price( plot_data.append(dict(min_price=price, quoted_amount=amount)) case CompoundSet(): - - if style == "scatter": - + if style == 'scatter': sql = f""" SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM {animal.db.SQL_SCHEMA_PREFIX}quote + FROM {animal.db.SQL_SCHEMA_PREFIX}quote INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}compound ON quote.quote_compound = compound.compound_id INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}reactant ON quote.quote_compound = reactant.reactant_compound WHERE quote_amount >= {min_amount} AND quote_compound IN {str_ids} GROUP BY quote_compound - """.format( - min_amount=min_amount, str_ids=compounds.str_ids - ) + """.format(min_amount=min_amount, str_ids=compounds.str_ids) results = animal.db.execute(sql).fetchall() @@ -1651,9 +1618,9 @@ def plot_compound_price( else: data = animal.db.select_where( - table="quote", - query="quote_amount, MIN(quote_price)", - key=f"quote_amount >= {min_amount} AND quote_compound IN {compounds.str_ids} GROUP BY quote_compound", + table='quote', + query='quote_amount, MIN(quote_price)', + key=f'quote_amount >= {min_amount} AND quote_compound IN {compounds.str_ids} GROUP BY quote_compound', multiple=True, ) @@ -1664,39 +1631,38 @@ def plot_compound_price( plot_data.append(dict(min_price=price, quoted_amount=amount)) case _: - raise NotImplementedError("CompoundSet not yet supported") + raise NotImplementedError('CompoundSet not yet supported') - plot_data = sorted(plot_data, key=lambda x: x["quoted_amount"]) + plot_data = sorted(plot_data, key=lambda x: x['quoted_amount']) match style: - - case "histogram": + case 'histogram': fig = px.histogram( - plot_data, color="quoted_amount", x="min_price", **kwargs + plot_data, color='quoted_amount', x='min_price', **kwargs ) - case "violin": - fig = px.violin(plot_data, color="quoted_amount", x="min_price", **kwargs) + case 'violin': + fig = px.violin(plot_data, color='quoted_amount', x='min_price', **kwargs) - case "scatter": + case 'scatter': fig = px.scatter( plot_data, - color="log_price_per_reaction", - x="min_price", - y="lead_time", + color='log_price_per_reaction', + x='min_price', + y='lead_time', hover_data=plot_data[0].keys(), **kwargs, ) case _: - raise NotImplementedError(f"{style=}") + raise NotImplementedError(f'{style=}') - subtitle = subtitle or f"#compounds={n_compounds}, {min_amount=} mg" + subtitle = subtitle or f'#compounds={n_compounds}, {min_amount=} mg' - title = title or f"{animal.name}: Compound price
" + title = title or f'{animal.name}: Compound price
' if subtitle: - title = f"{title}
{subtitle}" + title = f'{title}
{subtitle}' fig.update_layout( title=title @@ -1723,30 +1689,30 @@ def plot_reaction_funnel(animal, title=None, subtitle=None): compounds.num_intermediates, compounds.num_products, ], - category=["Reactants", "Intermediates", "Products"], + category=['Reactants', 'Intermediates', 'Products'], ) - fig = px.funnel(data, x="category", y="number") + fig = px.funnel(data, x='category', y='number') - title = title or f"{animal.name}: Reaction statistics" + title = title or f'{animal.name}: Reaction statistics' if subtitle: - title = f"{title}
{subtitle}" + title = f'{title}
{subtitle}' - fig.update_layout(title=title, title_automargin=False, title_yref="container") + fig.update_layout(title=title, title_automargin=False, title_yref='container') return fig -HIPPO_LOGO_URL = "https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_logo_tightcrop.png" +HIPPO_LOGO_URL = 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_logo_tightcrop.png' HIPPO_HEAD_URL = ( - "https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png" + 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png' ) def plot_pose_interactions( - animal: "HIPPO", pose: "Pose" -) -> "plotly.graph_objects.Figure": + animal: 'HIPPO', pose: 'Pose' +) -> 'plotly.graph_objects.Figure': """3d figure showing the interactions between a :class:`.Pose` and the protein. In a Jupyter notebook this figure may be unusable, instead write it as a HTML file and open it in your browser: :: @@ -1773,7 +1739,7 @@ def plot_pose_interactions( # get residues residues = [] for resnum, chain in pairs: - residues.append(protein.get_chain(chain).residues[f"n{resnum}"]) + residues.append(protein.get_chain(chain).residues[f'n{resnum}']) # get ligand lig_group = mp.rdkit.mol_to_AtomGroup(pose.mol) @@ -1796,18 +1762,18 @@ def plot_pose_interactions( @hippo_graph def plot_compound_tsnee( - animal: "HIPPO | None" = None, - compounds: "CompoundSet | None" = None, - df: "pd.DataFrame | None" = None, + animal: 'HIPPO | None' = None, + compounds: 'CompoundSet | None' = None, + df: 'pd.DataFrame | None' = None, title: str | None = None, subtitle: str | None = None, legend: bool = False, - symbol: str = "type", - sort_by: str = "type", - color: str = "cluster", - cluster_by: str = "scaffolds", + symbol: str = 'type', + sort_by: str = 'type', + color: str = 'cluster', + cluster_by: str = 'scaffolds', **kwargs, -) -> "plotly.graph_objects.Figure": +) -> 'plotly.graph_objects.Figure': """Plot a compound tanimoto similarity plot with principal components determined by pattern binary fingerprint similarity. :param compounds: compounds to plot @@ -1822,35 +1788,35 @@ def plot_compound_tsnee( """ - from .pca import get_cfps - from sklearn.decomposition import PCA import numpy as np + from sklearn.decomposition import PCA + + from .pca import get_cfps if compounds: - mrich.var("#compounds", len(compounds)) + mrich.var('#compounds', len(compounds)) if df is None: - with mrich.loading("Getting Compound DataFrame"): + with mrich.loading('Getting Compound DataFrame'): df = compounds.get_df(mol=True, scaffolds=True, inchikey=True, alias=True) df = df.reset_index() - df["scaffolds"] = df["scaffolds"].map( + df['scaffolds'] = df['scaffolds'].map( lambda x: x if not isinstance(x, float) else None ) else: - # check dataframe columns - if "mol" not in df.columns: + if 'mol' not in df.columns: mrich.error("'mol' column not in dataframe") return None if cluster_by not in df.columns: - mrich.error(f"{cluster_by=} column not in dataframe") + mrich.error(f'{cluster_by=} column not in dataframe') return None - with mrich.loading("Getting Compound fingerprints"): - df["FP"] = df["mol"].map(get_cfps) + with mrich.loading('Getting Compound fingerprints'): + df['FP'] = df['mol'].map(get_cfps) def get_cluster(row): """Get cluster""" @@ -1858,7 +1824,7 @@ def get_cluster(row): scaffolds = row[cluster_by] if not scaffolds: - return row["id"] + return row['id'] if scaffolds is None: mrich.error(row) @@ -1873,47 +1839,47 @@ def get_type(row): """Get type""" if row[cluster_by] is None: - return "scaffold" + return 'scaffold' - return "elaboration" + return 'elaboration' - with mrich.loading("Adding columns"): + with mrich.loading('Adding columns'): df[cluster_by] = df[cluster_by].apply(tuple) - df["cluster"] = df.apply(get_cluster, axis=1) - df["type"] = df.apply(get_type, axis=1) + df['cluster'] = df.apply(get_cluster, axis=1) + df['type'] = df.apply(get_type, axis=1) if sort_by: df = df.sort_values(by=sort_by) - X = np.array([x.fp for x in df["FP"]]) + X = np.array([x.fp for x in df['FP']]) - with mrich.loading("Computing PCA"): + with mrich.loading('Computing PCA'): pca = PCA(n_components=2, random_state=0) pca_fit = pca.fit_transform(X) - df["PC1"] = pca_fit.T[0] - df["PC2"] = pca_fit.T[1] + df['PC1'] = pca_fit.T[0] + df['PC2'] = pca_fit.T[1] hover_data = [ - "id", - "smiles", - "alias", - "inchikey", - "PC1", - "PC2", + 'id', + 'smiles', + 'alias', + 'inchikey', + 'PC1', + 'PC2', cluster_by, - "cluster", - "type", + 'cluster', + 'type', ] - df["scaffolds"] = df[cluster_by].astype(str) - df["cluster"] = df["cluster"].astype(str) + df['scaffolds'] = df[cluster_by].astype(str) + df['cluster'] = df['cluster'].astype(str) - with mrich.loading("Creating figure"): + with mrich.loading('Creating figure'): fig = px.scatter( df, - x="PC1", - y="PC2", + x='PC1', + y='PC2', hover_data=hover_data, color=color, symbol=symbol, @@ -1921,12 +1887,12 @@ def get_type(row): **kwargs, ) - subtitle = subtitle or f"#compounds={len(df)}" + subtitle = subtitle or f'#compounds={len(df)}' - title = title or f"{compounds} PCA
" + title = title or f'{compounds} PCA
' if subtitle: - title = f"{title}
{subtitle}" + title = f'{title}
{subtitle}' fig.update_layout(title=title) @@ -1936,7 +1902,7 @@ def get_type(row): return fig -def add_hippo_logo(fig, in_plot=True, position="top right"): +def add_hippo_logo(fig, in_plot=True, position='top right'): """ :param fig: @@ -1945,38 +1911,37 @@ def add_hippo_logo(fig, in_plot=True, position="top right"): """ - assert fig.layout.title.text, "Figure must have a title to add the HIPPO logo" + assert fig.layout.title.text, 'Figure must have a title to add the HIPPO logo' if in_plot: - sizex = 0.3 sizey = 0.3 - if "top" in position: - yanchor = "top" + if 'top' in position: + yanchor = 'top' y = 0.95 - elif "bottom" in position: - yanchor = "bottom" + elif 'bottom' in position: + yanchor = 'bottom' y = 0.05 else: - yanchor = "middle" + yanchor = 'middle' y = 0.50 - if "left" in position: - xanchor = "left" + if 'left' in position: + xanchor = 'left' x = 0.05 - elif "right" in position: - xanchor = "right" + elif 'right' in position: + xanchor = 'right' x = 0.95 else: - xanchor = "center" + xanchor = 'center' x = 0.50 fig.add_layout_image( dict( source=HIPPO_LOGO_URL, - xref="paper", - yref="paper", + xref='paper', + yref='paper', # layer='below', x=x, y=y, @@ -1993,34 +1958,32 @@ def add_hippo_logo(fig, in_plot=True, position="top right"): fig.layout.margin.t = None if has_legend: - fig.add_layout_image( dict( - source="", - xref="paper", - yref="paper", + source='', + xref='paper', + yref='paper', x=1, y=1.05, sizex=0.4, sizey=0.4, - xanchor="left", - yanchor="bottom", + xanchor='left', + yanchor='bottom', ) ) else: - fig.add_layout_image( dict( source=HIPPO_LOGO_URL, - xref="paper", - yref="paper", + xref='paper', + yref='paper', x=1, y=1.05, sizex=0.3, sizey=0.3, - xanchor="right", - yanchor="bottom", + xanchor='right', + yanchor='bottom', ) ) @@ -2033,14 +1996,14 @@ def add_punchcard_logo(fig): fig.add_layout_image( dict( source=HIPPO_HEAD_URL, - xref="paper", - yref="paper", + xref='paper', + yref='paper', x=1, y=1, sizex=0.25, sizey=0.25, - xanchor="right", - yanchor="top", + xanchor='right', + yanchor='top', ) ) diff --git a/hippo/pose.py b/hippo/pose.py index 4c2c3b6..50904c5 100644 --- a/hippo/pose.py +++ b/hippo/pose.py @@ -1,31 +1,28 @@ """Classes for working with poses""" -import mcol -import mrich -from mrich import print - - -import pickle -import numpy as np -from rdkit import Chem from pathlib import Path -from .tags import TagSet - +import mcol import molparse as mp +import mrich +import numpy as np from molparse.rdkit.features import ( - FEATURE_FAMILIES, COMPLEMENTARY_FEATURES, + FEATURE_FAMILIES, INTERACTION_TYPES, ) +from mrich import print +from rdkit import Chem + +from .tags import TagSet INTERACTION_CUTOFF = { - "Hydrophobic": 4.5, - "Hydrogen Bond": 3.5, - "Electrostatic": 4.5, - "π-stacking": 6.0, - "π-cation": 4.5, - "Sulfur-Sulfur": 4.0, # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 + 'Hydrophobic': 4.5, + 'Hydrogen Bond': 3.5, + 'Electrostatic': 4.5, + 'π-stacking': 6.0, + 'π-cation': 4.5, + 'Sulfur-Sulfur': 4.0, # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 } PI_STACK_MIN_CUTOFF = 3.8 @@ -43,11 +40,11 @@ class Pose: """ - _table = "pose" + _table = 'pose' def __init__( self, - db: "Database", + db: 'Database', id: int, inchikey: str | None, alias: str | None, @@ -86,7 +83,7 @@ def __init__( if fingerprint is None: self._has_fingerprint = False elif not isinstance(fingerprint, int): - mrich.warning("Legacy fingerprint data format") + mrich.warning('Legacy fingerprint data format') self.has_fingerprint = False else: self.has_fingerprint = bool(fingerprint) @@ -111,7 +108,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -145,14 +142,14 @@ def alias(self, n) -> None: """Set the pose's alias""" assert isinstance(n, str) self._alias = n - self.db.update(table="pose", id=self.id, key="pose_alias", value=n) + self.db.update(table='pose', id=self.id, key='pose_alias', value=n) @inchikey.setter def inchikey(self, n) -> None: """Set the pose's inchikey""" assert isinstance(n, str) self._inchikey = n - self.db.update(table="pose", id=self.id, key="pose_inchikey", value=n) + self.db.update(table='pose', id=self.id, key='pose_inchikey', value=n) @property def smiles(self) -> str: @@ -166,15 +163,15 @@ def smiles(self) -> str: self._smiles = mol_to_smiles(mol) self.inchikey = MolToInchiKey(mol) self.db.update( - table="pose", id=self.id, key="pose_smiles", value=self._smiles + table='pose', id=self.id, key='pose_smiles', value=self._smiles ) except InvalidMolError: - mrich.warning(f"Taking smiles from {self.compound}") + mrich.warning(f'Taking smiles from {self.compound}') self._smiles = self.compound.smiles return self._smiles @property - def target(self) -> "Target": + def target(self) -> 'Target': """Returns the pose's associated target""" if isinstance(self._target, int): self._target = self.db.get_target(id=self._target) @@ -186,7 +183,7 @@ def compound_id(self) -> int: return self._compound_id @property - def compound(self) -> "Compound": + def compound(self) -> 'Compound': """Returns the pose's associated compound""" return self.get_compound() @@ -196,7 +193,7 @@ def path(self) -> str: return self._path @property - def reference(self) -> "Pose": + def reference(self) -> 'Pose': """Returns the pose's protein reference (another pose)""" if isinstance(self._reference, int): self._reference = self.db.get_pose(id=self._reference) @@ -211,19 +208,17 @@ def reference_id(self) -> int: def reference(self, p): """Set the pose's reference""" if not isinstance(p, int): - assert p._table == "pose" + assert p._table == 'pose' p = p.id self._reference = p self._reference_id = p - self.db.update(table="pose", id=self.id, key="pose_reference", value=p) + self.db.update(table='pose', id=self.id, key='pose_reference', value=p) @property - def mol(self) -> "rdkit.Chem.Mol": + def mol(self) -> 'rdkit.Chem.Mol': """Returns a pose's rdkit.Chem.Mol""" if not self._mol and self.path: - - if self.path.endswith(".pdb"): - + if self.path.endswith('.pdb'): mrich.reading(self.path) # mrich.reading(self.path) @@ -231,22 +226,21 @@ def mol(self) -> "rdkit.Chem.Mol": self.protein_system = sys.protein_system - sdf_path = list(Path(self.path).parent.glob("*_ligand.sdf")) + sdf_path = list(Path(self.path).parent.glob('*_ligand.sdf')) if len(sdf_path) == 1: - supplier = Chem.SDMolSupplier(sdf_path[0]) mols = [mol for mol in supplier if mol is not None] if len(mols) > 1: - mrich.warning(f"Multiple molecules in SDF {self}") + mrich.warning(f'Multiple molecules in SDF {self}') self.mol = mols[0] return self._mol # look for ligand mol from Fragalysis mol_path = list( - Path(self.path).parent.glob("*_ligand.mol") + Path(self.path).parent.glob('*_ligand.mol') ) # str(Path(self.path).name).replace('.pdb','_ligand.mol') if len(mol_path) == 1: @@ -258,20 +252,19 @@ def mol(self) -> "rdkit.Chem.Mol": mol = MolFromMolFile(str(self._mol_path)) elif len(mol_path) == 0: - - lig_residues = sys["rLIG"] + lig_residues = sys['rLIG'] if not lig_residues: - lig_residues = [r for r in sys.residues if r.type == "LIG"] + lig_residues = [r for r in sys.residues if r.type == 'LIG'] if len(lig_residues) > 1: - mrich.warning(f"Multiple ligands in PDB {self}") + mrich.warning(f'Multiple ligands in PDB {self}') lig_res = lig_residues[0] if not (mol := lig_res.rdkit_mol): mrich.error( - f"[{self}] Error computing RDKit Mol from PDB={self.path}" + f'[{self}] Error computing RDKit Mol from PDB={self.path}' ) print(lig_res.pdb_block) @@ -282,8 +275,8 @@ def mol(self) -> "rdkit.Chem.Mol": # clean up bond orders from rdkit.Chem.AllChem import ( - MolFromSmiles, AssignBondOrdersFromTemplate, + MolFromSmiles, ) template = MolFromSmiles(self.compound.smiles) @@ -291,24 +284,23 @@ def mol(self) -> "rdkit.Chem.Mol": mol = AssignBondOrdersFromTemplate(template, mol) except Exception as e: mrich.error( - f"Exception occured during AssignBondOrdersFromTemplate for {self}.mol" + f'Exception occured during AssignBondOrdersFromTemplate for {self}.mol' ) - print(f"template_smiles={self.compound.smiles}") - print(f"pdbblock={print(lig_res.pdb_block)}") + print(f'template_smiles={self.compound.smiles}') + print(f'pdbblock={print(lig_res.pdb_block)}') mrich.error(e) mol = lig_res.rdkit_mol else: - path = Path(self.path) parent_dir = path.parent mol_path = parent_dir / path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_ligand.mol") + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_ligand.mol') if not mol_path.exists(): mrich.warning( - f"There are multiple *_ligand.mol files in {Path(self.path).parent}" + f'There are multiple *_ligand.mol files in {Path(self.path).parent}' ) raise FileNotFoundError(mol_path) @@ -318,8 +310,7 @@ def mol(self) -> "rdkit.Chem.Mol": self.mol = mol - elif self.path.endswith(".mol"): - + elif self.path.endswith('.mol'): mrich.reading(self.path) # mrich.reading(self.path) @@ -327,7 +318,7 @@ def mol(self) -> "rdkit.Chem.Mol": if not mol: mrich.error( - f"[{self}] Error computing RDKit Mol from .mol={self.path}" + f'[{self}] Error computing RDKit Mol from .mol={self.path}' ) raise InvalidMolError @@ -335,11 +326,10 @@ def mol(self) -> "rdkit.Chem.Mol": self.mol = mol else: - raise NotImplementedError if not mol: - mrich.error(f"Could not parse {self}.path={self.path}") + mrich.error(f'Could not parse {self}.path={self.path}') return self._mol @@ -353,7 +343,7 @@ def mol(self, m): self.db.update_pose_mol(pose_id=self.id, mol=self._mol) @property - def protonated_mol(self) -> "rdkit.Chem.Mol": + def protonated_mol(self) -> 'rdkit.Chem.Mol': """Guess hydrogen positions""" from rdkit.Chem import AllChem @@ -362,15 +352,15 @@ def protonated_mol(self) -> "rdkit.Chem.Mol": try: protonated_mol = AllChem.ConstrainedEmbed(protonated_mol, mol) except Exception as e: - mrich.error("Error while embedding protonated molecule") + mrich.error('Error while embedding protonated molecule') mrich.error(e) return mol return protonated_mol @property - def protein_system(self) -> "molparse.System": + def protein_system(self) -> 'molparse.System': """Returns the pose's protein molparse.System""" - if self._protein_system is None and self.path.endswith(".pdb"): + if self._protein_system is None and self.path.endswith('.pdb'): # mrich.debug(f'getting pose protein system {self}') self.protein_system = mp.parse(self.path, verbosity=False).protein_system return self._protein_system @@ -381,20 +371,19 @@ def protein_system(self, a): self._protein_system = a @property - def complex_system(self) -> "molparse.System": + def complex_system(self) -> 'molparse.System': """Get molparse.System representation of the protein-ligand complex""" if self.has_complex_pdb_path: return mp.parse(self.path, verbosity=False) elif self.reference: - # construct from .mol and reference system = self.reference.protein_system.copy() system.name = ( - f"{self.target.name}_{self.reference.name}_{self.compound.name}" + f'{self.target.name}_{self.reference.name}_{self.compound.name}' ) from molparse.rdkit import mol_to_AtomGroup @@ -407,19 +396,18 @@ def complex_system(self) -> "molparse.System": return system else: - raise NotImplementedError @property def has_complex_pdb_path(self) -> bool: """Does this pose have a PDB file?""" - return self.path.endswith(".pdb") + return self.path.endswith('.pdb') @property - def metadata(self) -> "MetaData": + def metadata(self) -> 'MetaData': """Returns the pose's metadata""" if self._metadata is None: - self._metadata = self.db.get_metadata(table="pose", id=self.id) + self._metadata = self.db.get_metadata(table='pose', id=self.id) return self._metadata @property @@ -432,24 +420,24 @@ def has_fingerprint(self, fp): self.set_has_fingerprint(fp) @property - def tags(self) -> "TagSet": + def tags(self) -> 'TagSet': """Returns the pose's tags""" if not self._tags: self._tags = self.get_tags() return self._tags @property - def inspirations(self) -> "PoseSet": + def inspirations(self) -> 'PoseSet': """Returns the pose's inspirations""" return self.get_inspirations() @property - def derivatives(self) -> "PoseSet": + def derivatives(self) -> 'PoseSet': """Returns the pose's derivatives""" return self.get_derivatives() @property - def features(self) -> "list[molparse.rdkit.Feature]": + def features(self) -> 'list[molparse.rdkit.Feature]': """Returns the pose's features""" return mp.rdkit.features_from_mol(self.mol) @@ -468,7 +456,7 @@ def num_heavy_atoms(self) -> int: """Number of heavy atoms""" if not self._num_heavy_atoms: self._num_heavy_atoms = self.db.get_compound_computed_property( - "num_heavy_atoms", self.compound_id + 'num_heavy_atoms', self.compound_id ) return self._num_heavy_atoms @@ -490,10 +478,9 @@ def num_atoms_added_wrt_inspirations(self) -> int | None: """Calculate the number of atoms added relative to its inspirations""" if self._num_atoms_added_wrt_inspirations is None or self._db_changed: - sql = f""" WITH inspirations AS ( - SELECT SUM({self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol)) AS sum, inspiration_derivative + SELECT SUM({self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol)) AS sum, inspiration_derivative FROM {self.db.SQL_SCHEMA_PREFIX}inspiration INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON inspiration_original = pose_id INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON pose_compound = compound_id @@ -523,14 +510,14 @@ def scaffold_ids(self) -> list[int] | None: """Get the scaffold :class:`.Compound` IDs""" if self._scaffold_ids is None: records = self.db.select_where( - table="scaffold", - query="scaffold_base", - key="superstructure", + table='scaffold', + query='scaffold_base', + key='superstructure', value=self.compound_id, multiple=True, - none="quiet", + none='quiet', ) - records = [i for i, in records] + records = [i for (i,) in records] self._scaffold_ids = records return self._scaffold_ids @@ -550,7 +537,7 @@ def inspiration_score(self) -> float | None: return self._inspiration_score @property - def interactions(self) -> "InteractionSet": + def interactions(self) -> 'InteractionSet': """Get a :class:`.InteractionSet` for this :class:`.Pose`""" if not self._interactions: from .iset import InteractionSet @@ -564,18 +551,18 @@ def classic_fingerprint(self) -> dict: return self.interactions.classic_fingerprint @property - def subsites(self) -> "list[SubsiteTag]": + def subsites(self) -> 'list[SubsiteTag]': """Get member :class:`.SubsiteTag`""" from .subsite import SubsiteTag records = self.db.select_where( - table="subsite_tag", - key="pose", + table='subsite_tag', + key='pose', value=self.id, multiple=True, - query="subsite_tag_id, subsite_tag_ref", - none="quiet", + query='subsite_tag_id, subsite_tag_ref', + none='quiet', ) if not records: @@ -598,33 +585,33 @@ def _db_changed(self) -> bool: return False @property - def mol_path(self) -> "Path": + def mol_path(self) -> 'Path': """Get Path to molecule file""" path = Path(self.path) - if path.name.endswith(".pdb"): - mol_path = path.parent / path.name.replace("_hippo.pdb", ".pdb").replace( - ".pdb", "_ligand.mol" + if path.name.endswith('.pdb'): + mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_ligand.mol' ) if not mol_path.exists(): mol_path = path.parent / path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_ligand.sdf") + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_ligand.sdf') if not mol_path.exists(): - mrich.error("Could not find ligand mol/sdf:", mol_path) + mrich.error('Could not find ligand mol/sdf:', mol_path) return None return mol_path - elif path.name.endswith(".mol"): + elif path.name.endswith('.mol'): return path else: raise NotImplementedError @property - def apo_path(self) -> "Path": + def apo_path(self) -> 'Path': """Get path to apo protein file""" path = Path(self.path) - if path.name.endswith(".pdb"): - apo_path = path.parent / path.name.replace("_hippo.pdb", ".pdb").replace( - ".pdb", "_apo-desolv.pdb" + if path.name.endswith('.pdb'): + apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_apo-desolv.pdb' ) if not apo_path.exists(): return None @@ -659,64 +646,64 @@ def score_inspiration( ) if debug: - mrich.var("energy_score", self.energy_score) - mrich.var("distance_score", self.distance_score) + mrich.var('energy_score', self.energy_score) + mrich.var('distance_score', self.distance_score) for inspiration in self.inspirations: mrich.var( - f"{inspiration} SuCOS", + f'{inspiration} SuCOS', MuCOS_score(inspiration.mol, self.mol, print_scores=debug), ) - mrich.var(f"multi SuCOS", multi_sucos) + mrich.var('multi SuCOS', multi_sucos) return multi_sucos - def get_compound(self) -> "Compound": + def get_compound(self) -> 'Compound': """Get the :class:`.Compound` that this pose is a conformer of""" return self.db.get_compound(id=self._compound_id) - def get_tags(self) -> "TagSet": + def get_tags(self) -> 'TagSet': """Get this Pose's tags""" tags = self.db.select_where( - query="tag_name", - table="tag", - key="pose", + query='tag_name', + table='tag', + key='pose', value=self.id, multiple=True, - none="quiet", + none='quiet', ) return TagSet(self, {t[0] for t in tags}) def get_inspiration_ids(self) -> list[int]: """Get the :class:`.Pose` IDs of this pose's inspirations""" inspirations = self.db.select_where( - query="inspiration_original", - table="inspiration", - key="derivative", + query='inspiration_original', + table='inspiration', + key='derivative', value=self.id, multiple=True, - none="quiet", + none='quiet', ) if not inspirations: return None - return set([v for v, in inspirations]) + return set([v for (v,) in inspirations]) def get_derivative_ids(self) -> list[int]: """Get the :class:`.Pose` IDs of this pose's derivatives""" derivatives = self.db.select_where( - query="inspiration_derivative", - table="inspiration", - key="original", + query='inspiration_derivative', + table='inspiration', + key='original', value=self.id, multiple=True, - none="quiet", + none='quiet', ) if not derivatives: return None - return set([v for v, in derivatives]) + return set([v for (v,) in derivatives]) - def get_inspirations(self) -> "PoseSet": + def get_inspirations(self) -> 'PoseSet': """Get a :class:`.PoseSet` of this pose's inspirations""" if not (inspirations := self.get_inspiration_ids()): return None @@ -725,7 +712,7 @@ def get_inspirations(self) -> "PoseSet": return PoseSet(self.db, indices=inspirations) - def get_derivatives(self) -> "PoseSet": + def get_derivatives(self) -> 'PoseSet': """Get a :class:`.PoseSet` of this pose's derivatives""" if not (derivatives := self.get_derivative_ids()): return None @@ -745,7 +732,7 @@ def get_dict( sanitise_null_metadata_values: bool = False, skip_metadata: list[str] | None = None, sanitise_tag_list_separator: str | None = None, - sanitise_metadata_list_separator: str | None = ";", + sanitise_metadata_list_separator: str | None = ';', tags: bool = True, ) -> dict: """Returns a dictionary representing this Pose. Arguments: @@ -763,15 +750,15 @@ def get_dict( skip_metadata = skip_metadata or [] serialisable_fields = [ - "id", - "inchikey", - "alias", - "name", - "smiles", - "path", - "distance_score", - "energy_score", - "inspiration_score", + 'id', + 'inchikey', + 'alias', + 'name', + 'smiles', + 'path', + 'distance_score', + 'energy_score', + 'inspiration_score', ] data = {} @@ -780,50 +767,49 @@ def get_dict( if duplicate_name: assert isinstance(duplicate_name, str) - data[duplicate_name] = data["name"] + data[duplicate_name] = data['name'] if mol: try: - data["mol"] = self.mol + data['mol'] = self.mol except InvalidMolError: - data["mol"] = None + data['mol'] = None - data["compound"] = self.compound.name - data["compound_id"] = self.compound.id - data["target"] = self.target.name + data['compound'] = self.compound.name + data['compound_id'] = self.compound.id + data['target'] = self.target.name if tags: - data["tags"] = self.tags + data['tags'] = self.tags if sanitise_tag_list_separator: - data["tags"] = sanitise_tag_list_separator.join(data["tags"]) + data['tags'] = sanitise_tag_list_separator.join(data['tags']) - if inspirations == "names": + if inspirations == 'names': if not self.inspirations: - data["inspirations"] = None + data['inspirations'] = None else: - data["inspirations"] = ",".join([p.name for p in self.inspirations]) + data['inspirations'] = ','.join([p.name for p in self.inspirations]) elif inspirations: - data["inspirations"] = self.inspirations + data['inspirations'] = self.inspirations - if subsites == "names": + if subsites == 'names': if not (sites := self.subsites): - data["subsites"] = None + data['subsites'] = None else: - data["subsites"] = ",".join([p.name for p in sites]) + data['subsites'] = ','.join([p.name for p in sites]) elif subsites: - data["subsites"] = self.subsites + data['subsites'] = self.subsites - if reference == "name": + if reference == 'name': if not self.reference: - data["reference"] = "" + data['reference'] = '' else: - data["reference"] = self.reference.name + data['reference'] = self.reference.name elif reference: - data["reference"] = self.reference + data['reference'] = self.reference if metadata and (metadict := self.metadata): for key in metadict: - value = metadict[key] if key in skip_metadata: @@ -837,7 +823,6 @@ def get_dict( value = None elif sanitise_metadata_list_separator and isinstance(value, list): - new_values = [] for v in value: @@ -859,7 +844,7 @@ def get_dict( return data - def add_subsite(self, name: str, commit: bool = True) -> "SubsiteTag": + def add_subsite(self, name: str, commit: bool = True) -> 'SubsiteTag': """Tag this pose with a protein subsite :param name: the name of the subsite @@ -945,11 +930,10 @@ def angle_between(v1, v2): ### IN-MEMORY DB if in_memory_db: - from .db import Database temp_db = Database( - ":memory:", + ':memory:', animal=None, create_blank=False, check_legacy=False, @@ -965,34 +949,34 @@ def angle_between(v1, v2): ### create temporary table - if "temp_interaction" in temp_db.table_names: - self.db.execute("DROP TABLE temp_interaction") + if 'temp_interaction' in temp_db.table_names: + self.db.execute('DROP TABLE temp_interaction') - temp_db.create_table_interaction(table="temp_interaction", debug=False) + temp_db.create_table_interaction(table='temp_interaction', debug=False) ### load the ligand structure if debug: - mrich.debug("path", self.path) + mrich.debug('path', self.path) - if self.path.endswith(".pdb"): + if self.path.endswith('.pdb'): from molparse import parse protein_system = self.protein_system if not self.protein_system: protein_system = parse(self.path, verbosity=False).protein_system - elif self.path.endswith(".mol") and self.reference: + elif self.path.endswith('.mol') and self.reference: protein_system = self.reference.protein_system else: - mrich.debug("Unsupported: Pose.calculate_interactions()") - raise NotImplementedError(f"{self}, {self.reference=}, {self.path=}") + mrich.debug('Unsupported: Pose.calculate_interactions()') + raise NotImplementedError(f'{self}, {self.reference=}, {self.path=}') assert protein_system if not self.mol: - mrich.error(f"Could not read molecule for {self}") + mrich.error(f'Could not read molecule for {self}') return ### get features @@ -1000,14 +984,14 @@ def angle_between(v1, v2): comp_features = self.features if debug: - mrich.debug("Getting protein features...") + mrich.debug('Getting protein features...') protein_features = self.target.calculate_features( protein_system, reference_id=self.reference_id ) if debug: - print("ligand features", comp_features) + print('ligand features', comp_features) ### organise ligand features by family comp_features_by_family = {} @@ -1024,7 +1008,6 @@ def angle_between(v1, v2): # loop over protein features for prot_feature in protein_features: - # skip chains that aren't present if prot_feature.chain_name not in chains: continue @@ -1033,7 +1016,7 @@ def angle_between(v1, v2): prot_residue = protein_system.get_chain( prot_feature.chain_name - ).residues[f"n{prot_feature.residue_number}"] + ).residues[f'n{prot_feature.residue_number}'] if not prot_residue: continue @@ -1045,14 +1028,14 @@ def angle_between(v1, v2): for cf in comp_features ): mutation_warnings.add( - f"{prot_residue.name} {prot_residue.number} -> {prot_feature.residue_name} {prot_feature.residue_number}" + f'{prot_residue.name} {prot_residue.number} -> {prot_feature.residue_name} {prot_feature.residue_number}' ) mutation_count += 1 continue ### calculate protein coordinate prot_atoms = [] - for atom_name in prot_feature.atom_names.split(" "): + for atom_name in prot_feature.atom_names.split(' '): atom = prot_residue.get_atom(atom_name, verbosity=0) if atom: prot_atoms.append(atom) @@ -1073,7 +1056,6 @@ def angle_between(v1, v2): # print(prot_family, complementary_families) for complementary_family in complementary_families: - interaction_type = INTERACTION_TYPES[ (prot_family, complementary_family) ] @@ -1083,7 +1065,6 @@ def angle_between(v1, v2): ] for lig_feature in complementary_comp_features: - distance = np.linalg.norm(lig_feature - prot_coord) angle = None @@ -1095,15 +1076,14 @@ def angle_between(v1, v2): continue # special rules for aromatics - if interaction_type.startswith("π"): + if interaction_type.startswith('π'): lig_coords = [ self.mol.GetConformer().GetAtomPosition(i - 1) for i in lig_feature.atom_numbers ] # special rules for pi-stacking - if interaction_type == "π-stacking": - + if interaction_type == 'π-stacking': # calculate minimum distance min_distance = None for lig_coord in lig_coords: @@ -1116,7 +1096,7 @@ def angle_between(v1, v2): if min_distance > PI_STACK_MIN_CUTOFF + distance_padding: if debug: print( - f"skipping {prot_feature} due to pi-stack min_distance" + f'skipping {prot_feature} due to pi-stack min_distance' ) print( prot_feature.residue_name, @@ -1144,10 +1124,9 @@ def angle_between(v1, v2): continue # special rules for pi-cation - elif interaction_type == "π-cation": - + elif interaction_type == 'π-cation': # construct vectors - if prot_family == "Aromatic": + if prot_family == 'Aromatic': aromatic_norm = norm(prot_coords) cation_vec = lig_feature.position - prot_coord else: @@ -1165,7 +1144,7 @@ def angle_between(v1, v2): continue if debug: - print("Prot:", prot_feature, "Lig:", lig_feature) + print('Prot:', prot_feature, 'Lig:', lig_feature) # insert into the Database temp_db.insert_interaction( @@ -1180,12 +1159,12 @@ def angle_between(v1, v2): angle=angle, energy=None, commit=False, - table="temp_interaction", + table='temp_interaction', ) if mutation_warnings: mrich.warning( - f"Skipped {mutation_count} protein features because the residue was mutated:" + f'Skipped {mutation_count} protein features because the residue was mutated:' ) for mutation in mutation_warnings: mrich.warning(mutation) @@ -1195,13 +1174,13 @@ def angle_between(v1, v2): from .iset import InteractionSet interactions = InteractionSet.from_pose( - self, table="temp_interaction", db=temp_db + self, table='temp_interaction', db=temp_db ) - feature_ids = str(tuple(interactions.feature_ids)).replace(",)", ")") + feature_ids = str(tuple(interactions.feature_ids)).replace(',)', ')') records = self.db.select_all_where( - table="feature", key=f"feature_id IN {feature_ids}", multiple=True + table='feature', key=f'feature_id IN {feature_ids}', multiple=True ) feature_cache = { @@ -1221,7 +1200,7 @@ def angle_between(v1, v2): ### transfer interactions from temporary table self.db.delete_where( - table="interaction", key="pose", value=self.id, commit=commit + table='interaction', key='pose', value=self.id, commit=commit ) if in_memory_db: @@ -1237,7 +1216,7 @@ def angle_between(v1, v2): temp_db.close(debug=False) elif debug: - mrich.warning(f"{self} is already fingerprinted, no new calculation") + mrich.warning(f'{self} is already fingerprinted, no new calculation') def calculate_prolif_interactions( self, @@ -1248,28 +1227,27 @@ def calculate_prolif_interactions( clear_existing: bool = True, debug: bool = False, resolve: bool = True, - ) -> "prolif.Fingerprint": + ) -> 'prolif.Fingerprint': """Use ProLIF to populate the interactions table""" if not self.has_fingerprint or force: - ### clear old interactions if clear_existing: self.db.delete_where( - table="interaction", key="pose", value=self.id, commit=False + table='interaction', key='pose', value=self.id, commit=False ) self.set_has_fingerprint(False, commit=False) ### create temporary table - table = "temp_interaction" + table = 'temp_interaction' - if "temp_interaction" in self.db.table_names: - mrich.warning("Deleting existing temp_interaction table") - self.db.execute("DROP TABLE temp_interaction") + if 'temp_interaction' in self.db.table_names: + mrich.warning('Deleting existing temp_interaction table') + self.db.execute('DROP TABLE temp_interaction') - self.db.create_table_interaction(table="temp_interaction", debug=False) + self.db.create_table_interaction(table='temp_interaction', debug=False) if not clear_existing: self.db.copy_interactions_to_temp(pose_id=self.id) @@ -1277,13 +1255,15 @@ def calculate_prolif_interactions( # clear cached InteractionSet self._interactions = None - import prolif as plf + import logging from tempfile import NamedTemporaryFile - from .prolif import parse_prolif_interactions + + import prolif as plf from MDAnalysis import Universe - import logging - mdanalysis_logger = logging.getLogger("MDAnalysis") + from .prolif import parse_prolif_interactions + + mdanalysis_logger = logging.getLogger('MDAnalysis') mdanalysis_logger.setLevel(logging.WARNING) ## prepare inputs @@ -1291,9 +1271,9 @@ def calculate_prolif_interactions( # decide if MDA is needed unprotonated_sys = self.protein_system residue_names = set(r.name for r in unprotonated_sys.residues) - nonstandard = ["HID", "HIE", "HSE", "HSD", "HSP"] + nonstandard = ['HID', 'HIE', 'HSE', 'HSD', 'HSP'] if any(r in residue_names for r in nonstandard): - mrich.debug("Using MDA") + mrich.debug('Using MDA') use_mda = True # protonated protein @@ -1304,11 +1284,11 @@ def calculate_prolif_interactions( ) if use_mda: - with mrich.loading("Creating MDAnalysis.Universe"): + with mrich.loading('Creating MDAnalysis.Universe'): universe = Universe(protein_file.name) protein_mol = plf.Molecule.from_mda(universe) else: - with mrich.loading("Creating protein rdkit.Chem.Mol"): + with mrich.loading('Creating protein rdkit.Chem.Mol'): rdkit_prot = Chem.MolFromPDBFile( protein_file.name, removeHs=False ) @@ -1318,19 +1298,19 @@ def calculate_prolif_interactions( except Exception as e: mrich.warning( - f"Could not create satisfactory protein molecule, attempts = {i+1}/{max_retry}" + f'Could not create satisfactory protein molecule, attempts = {i + 1}/{max_retry}' ) mrich.warning(e) use_mda = True continue else: mrich.error( - f"Tried {max_retry} times to create protein molecule and failed" + f'Tried {max_retry} times to create protein molecule and failed' ) return None # ligand - ligand_file = NamedTemporaryFile(mode="w+t", suffix=".sdf") + ligand_file = NamedTemporaryFile(mode='w+t', suffix='.sdf') writer = Chem.SDWriter(ligand_file.name) writer.write(self.protonated_mol) writer.close() @@ -1349,7 +1329,7 @@ def calculate_prolif_interactions( if resolve: from .iset import InteractionSet - interactions = InteractionSet.from_pose(self, table="temp_interaction") + interactions = InteractionSet.from_pose(self, table='temp_interaction') interactions.resolve(debug=debug) self.db.copy_temp_interactions() @@ -1357,7 +1337,7 @@ def calculate_prolif_interactions( ### delete temporary table - self.db.execute("DROP TABLE temp_interaction") + self.db.execute('DROP TABLE temp_interaction') ## close files protein_file.close() @@ -1372,8 +1352,7 @@ def calculate_classic_fingerprint( ) -> dict: """Calculate the pose's interaction fingerprint""" - if self.path.endswith(".pdb"): - + if self.path.endswith('.pdb'): import molparse as mp protein_system = self.protein_system @@ -1381,15 +1360,13 @@ def calculate_classic_fingerprint( # mrich.reading(self.path) protein_system = mp.parse(self.path, verbosity=False).protein_system - elif self.path.endswith(".mol") and self.reference: - + elif self.path.endswith('.mol') and self.reference: # mrich.debug('fingerprint from .mol and reference pose') protein_system = self.reference.protein_system else: - - mrich.debug("Unsupported: Pose.calculate_fingerprint()") - raise NotImplementedError(f"{self.reference=}, {self.path=}") + mrich.debug('Unsupported: Pose.calculate_fingerprint()') + raise NotImplementedError(f'{self.reference=}, {self.path=}') assert protein_system @@ -1413,14 +1390,13 @@ def calculate_classic_fingerprint( chains = protein_system.chain_names for prot_feature in protein_features: - if prot_feature.chain_name not in chains: continue prot_family = prot_feature.family prot_residue = protein_system.get_chain(prot_feature.chain_name).residues[ - f"n{prot_feature.residue_number}" + f'n{prot_feature.residue_number}' ] if not prot_residue: @@ -1430,11 +1406,11 @@ def calculate_classic_fingerprint( # mrich.debug(repr(prot_feature)) if prot_residue.name != prot_feature.residue_name: - mrich.warning(f"Feature {repr(prot_feature)}") + mrich.warning(f'Feature {repr(prot_feature)}') continue prot_atoms = [ - prot_residue.get_atom(a) for a in prot_feature.atom_names.split(" ") + prot_residue.get_atom(a) for a in prot_feature.atom_names.split(' ') ] prot_coords = [a.np_pos for a in prot_atoms if a is not None] @@ -1445,7 +1421,7 @@ def calculate_classic_fingerprint( complementary_comp_features = comp_features_by_family[complementary_family] - cutoff = FEATURE_PAIR_CUTOFFS[f"{prot_family} {complementary_family}"] + cutoff = FEATURE_PAIR_CUTOFFS[f'{prot_family} {complementary_family}'] valid_features = [ f @@ -1456,7 +1432,7 @@ def calculate_classic_fingerprint( if valid_features: if debug: mrich.debug( - f"PROT: {prot_feature.residue_name} {prot_feature.residue_number} {prot_feature.atom_names}, LIG: #{len(valid_features)} {[f for f in valid_features]}" + f'PROT: {prot_feature.residue_name} {prot_feature.residue_number} {prot_feature.atom_names}, LIG: #{len(valid_features)} {[f for f in valid_features]}' ) fingerprint[prot_feature.id] = len(valid_features) @@ -1499,9 +1475,9 @@ def draw2d( def render( self, - protein="cartoon", - ligand="stick", - protein_color="spectrum", + protein='cartoon', + ligand='stick', + protein_color='spectrum', interactions: bool = True, file: str | None = None, ) -> None: @@ -1517,7 +1493,7 @@ def render( sys = self.complex_system - def make_view(width="640px", height="480px"): + def make_view(width='640px', height='480px'): """Create py3Dmol view""" view = render( @@ -1531,12 +1507,12 @@ def make_view(width="640px", height="480px"): if interactions: COLORS = { - "Hydrophobic": "green", - "Hydrogen Bond": "blue", - "π-stacking": "purple", - "π-cation": "pink", - "Electrostatic": "red", - "Sulfur-Sulfur": "yellow", + 'Hydrophobic': 'green', + 'Hydrogen Bond': 'blue', + 'π-stacking': 'purple', + 'π-cation': 'pink', + 'Electrostatic': 'red', + 'Sulfur-Sulfur': 'yellow', } iset = self.interactions @@ -1549,44 +1525,43 @@ def make_view(width="640px", height="480px"): residues = set() for i, row in df.iterrows(): - - prot_coord = row["prot_coord"] - lig_coord = row["lig_coord"] - type = row["type"] - color = COLORS.get(type, "black") + prot_coord = row['prot_coord'] + lig_coord = row['lig_coord'] + type = row['type'] + color = COLORS.get(type, 'black') view.addCylinder( { - "start": { - "x": prot_coord[0], - "y": prot_coord[1], - "z": prot_coord[2], + 'start': { + 'x': prot_coord[0], + 'y': prot_coord[1], + 'z': prot_coord[2], }, - "end": { - "x": lig_coord[0], - "y": lig_coord[1], - "z": lig_coord[2], + 'end': { + 'x': lig_coord[0], + 'y': lig_coord[1], + 'z': lig_coord[2], }, # 'radius': radius, - "color": color, + 'color': color, } ) - residues.add((row["residue_name"], row["residue_number"])) + residues.add((row['residue_name'], row['residue_number'])) for res_name, res_num in residues: - res = sys.residues[f"{res_name} n{res_num}"] - view.addModel(res.pdb_block, "pdb") - view.setStyle({"model": -1}, {ligand: {}}) + res = sys.residues[f'{res_name} n{res_num}'] + view.addModel(res.pdb_block, 'pdb') + view.setStyle({'model': -1}, {ligand: {}}) return view if file: - view = make_view(width="100%", height="100%") + view = make_view(width='100%', height='100%') html = view._make_html() mrich.writing(file) - with open(file, "w") as f: + with open(file, 'w') as f: f.write(html) view = make_view() @@ -1594,8 +1569,8 @@ def make_view(width="640px", height="480px"): def grid(self) -> None: """Draw a grid of this pose with its inspirations""" - from molparse.rdkit import draw_grid from IPython.display import display + from molparse.rdkit import draw_grid mols = [self.compound.mol] labels = [self.plain_repr()] @@ -1614,29 +1589,29 @@ def summary( """ if self.alias: - mrich.header(f"{str(self)}: {self.alias}") + mrich.header(f'{str(self)}: {self.alias}') else: - mrich.header(f"{str(self)}: {self.inchikey}") - mrich.var("inchikey", self.inchikey) - mrich.var("alias", self.alias) - mrich.var("smiles", self.smiles) - mrich.var("compound", self.compound) - mrich.var("path", self.path) - mrich.var("target", self.target) - mrich.var("reference", self.reference) + mrich.header(f'{str(self)}: {self.inchikey}') + mrich.var('inchikey', self.inchikey) + mrich.var('alias', self.alias) + mrich.var('smiles', self.smiles) + mrich.var('compound', self.compound) + mrich.var('path', self.path) + mrich.var('target', self.target) + mrich.var('reference', self.reference) if tags: - mrich.var("tags", self.tags) + mrich.var('tags', self.tags) if subsites: - mrich.var("subsites", self.subsites) - mrich.var("num_heavy_atoms", self.num_heavy_atoms) - mrich.var("distance_score", self.distance_score) - mrich.var("energy_score", self.energy_score) - mrich.var("inspiration_score", self.inspiration_score) + mrich.var('subsites', self.subsites) + mrich.var('num_heavy_atoms', self.num_heavy_atoms) + mrich.var('distance_score', self.distance_score) + mrich.var('energy_score', self.energy_score) + mrich.var('inspiration_score', self.inspiration_score) if inspirations := self.inspirations: - mrich.var("inspirations", self.inspirations.names) - mrich.var("num_atoms_added", self.num_atoms_added) + mrich.var('inspirations', self.inspirations.names) + mrich.var('num_atoms_added', self.num_atoms_added) if metadata: - mrich.var("metadata", str(self.metadata)) + mrich.var('metadata', str(self.metadata)) def showcase(self) -> None: """Print and render this pose as if you were using :meth:`.PoseSet.interactive`""" @@ -1646,7 +1621,7 @@ def showcase(self) -> None: self.draw() from pprint import pprint - mrich.title("Metadata:") + mrich.title('Metadata:') pprint(self.metadata) def plain_repr(self) -> str: @@ -1654,13 +1629,13 @@ def plain_repr(self) -> str: if self.name: return f'{self.compound}->{self}: "{self.name}"' else: - return f"{self.compound}->{self}" + return f'{self.compound}->{self}' def plot3d( self, features: bool = False, **kwargs, - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Use Molparse/Plotly to create a 3d figure of this pose :param features: include the features in the figure @@ -1684,9 +1659,9 @@ def set_has_fingerprint(self, fp: bool, commit: bool = True) -> None: assert isinstance(fp, bool) self._has_fingerprint = fp self.db.update( - table="pose", + table='pose', id=self.id, - key=f"pose_fingerprint", + key='pose_fingerprint', value=int(fp), commit=commit, ) @@ -1695,30 +1670,30 @@ def posebusters(self, debug: bool = False) -> bool: """Run a posebusters ligand check on this pose's molecule""" # use syndirella implementation - from syndirella.slipper import intra_geometry, flatness + from syndirella.slipper import flatness, intra_geometry geometries: Dict = intra_geometry.check_geometry(self.mol, threshold_clash=0.4) flat_results: Dict = flatness.check_flatness(self.mol) - if not geometries["results"]["bond_lengths_within_bounds"]: + if not geometries['results']['bond_lengths_within_bounds']: if debug: - mrich.debug(self, "did not pass bond length checks.") + mrich.debug(self, 'did not pass bond length checks.') return False - if not geometries["results"]["bond_angles_within_bounds"]: + if not geometries['results']['bond_angles_within_bounds']: if debug: - mrich.debug(self, "did not pass bond angle checks.") + mrich.debug(self, 'did not pass bond angle checks.') return False - if not geometries["results"]["no_internal_clash"]: + if not geometries['results']['no_internal_clash']: if debug: - mrich.debug(self, "did not pass internal clash checks.") + mrich.debug(self, 'did not pass internal clash checks.') return False - if not flat_results["results"]["flatness_passes"]: + if not flat_results['results']['flatness_passes']: if debug: - mrich.debug(self, "did not pass flatness checks.") + mrich.debug(self, 'did not pass flatness checks.') return False return True - def to_syndirella(self, out_key: "str | Path") -> "DataFrame": + def to_syndirella(self, out_key: 'str | Path') -> 'DataFrame': """Create syndirella inputs. See :meth:`.PoseSet.to_syndirella`""" from .pset import PoseSet @@ -1730,17 +1705,17 @@ def to_syndirella(self, out_key: "str | Path") -> "DataFrame": def __str__(self) -> str: """Unformatted string representation""" - return f"P{self.id}" + return f'P{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.plain_repr()}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self.plain_repr()}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Formatted string representation""" - return f"[bold underline]{self.plain_repr()}" + return f'[bold underline]{self.plain_repr()}' - def __eq__(self, other: "Pose") -> bool: + def __eq__(self, other: 'Pose') -> bool: """Compare this pose with another instance""" if isinstance(other, int): @@ -1750,8 +1725,8 @@ def __eq__(self, other: "Pose") -> bool: def __add__( self, - other: "Pose | PoseSet", - ) -> "PoseSet": + other: 'Pose | PoseSet', + ) -> 'PoseSet': """Add a :class:`.PoseSet` to this pose""" from .pset import PoseSet diff --git a/hippo/postgres.py b/hippo/postgres.py index cb6e89e..1340f3f 100644 --- a/hippo/postgres.py +++ b/hippo/postgres.py @@ -1,10 +1,9 @@ """PostgreSQL database wrapper class using psycopg3""" -import mcol -import mrich +from pathlib import Path +import mrich import psycopg -from pathlib import Path from .db import Database from .tools import strip_sql @@ -20,27 +19,27 @@ class PostgresDatabase(Database): """ TABLES = [ - "subsite", - "subsite_tag", - "scaffold", - "compound", - "pose", - "inspiration", - "reaction", - "reactant", - "tag", - "quote", - "route", - "component", - "feature", - "interaction", - "target", + 'subsite', + 'subsite_tag', + 'scaffold', + 'compound', + 'pose', + 'inspiration', + 'reaction', + 'reactant', + 'tag', + 'quote', + 'route', + 'component', + 'feature', + 'interaction', + 'target', ] - SQL_STRING_PLACEHOLDER = "%s" - SQL_PK_DATATYPE = "SERIAL" - SQL_SCHEMA = "hippo" - SQL_SCHEMA_PREFIX = f"{SQL_SCHEMA}." + SQL_STRING_PLACEHOLDER = '%s' + SQL_PK_DATATYPE = 'SERIAL' + SQL_SCHEMA = 'hippo' + SQL_SCHEMA_PREFIX = f'{SQL_SCHEMA}.' ERROR_UNIQUE_VIOLATION = psycopg.errors.UniqueViolation @@ -84,15 +83,15 @@ class PostgresDatabase(Database): SQL_INSERT_COMPOUND = """ INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, - compound_mol, + compound_inchikey, + compound_smiles, + compound_mol, compound_alias ) VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s), + %(inchikey)s, + %(smiles)s, + hippo.mol_from_smiles(%(smiles)s), %(alias)s ) RETURNING compound_id; @@ -100,15 +99,15 @@ class PostgresDatabase(Database): SQL_BULK_INSERT_INTERACTIONS = """ INSERT INTO hippo.interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, + interaction_feature, + interaction_pose, + interaction_type, + interaction_family, + interaction_atom_ids, + interaction_prot_coord, + interaction_lig_coord, + interaction_distance, + interaction_angle, interaction_energy ) VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) @@ -116,36 +115,36 @@ class PostgresDatabase(Database): """ POSE_FIELDS = [ - "pose_id", - "pose_inchikey", - "pose_alias", - "pose_smiles", - "pose_reference", - "pose_path", - "pose_compound", - "pose_target", - "hippo.mol_to_pkl(pose_mol)", - "pose_fingerprint", - "pose_energy_score", - "pose_distance_score", - "pose_inspiration_score", + 'pose_id', + 'pose_inchikey', + 'pose_alias', + 'pose_smiles', + 'pose_reference', + 'pose_path', + 'pose_compound', + 'pose_target', + 'hippo.mol_to_pkl(pose_mol)', + 'pose_fingerprint', + 'pose_energy_score', + 'pose_distance_score', + 'pose_inspiration_score', ] COMPOUND_PROPERTY_FUNCTIONS = { - "num_heavy_atoms": "hippo.mol_numheavyatoms", - "formula": ("hippo.mol_formula", ", false, false"), - "num_rings": "hippo.mol_numrings", - "molecular_weight": "hippo.mol_amw", + 'num_heavy_atoms': 'hippo.mol_numheavyatoms', + 'formula': ('hippo.mol_formula', ', false, false'), + 'num_rings': 'hippo.mol_numrings', + 'molecular_weight': 'hippo.mol_amw', } def __init__( self, - animal: "HIPPO", + animal: 'HIPPO', username: str, password: str, - host: str = "localhost", + host: str = 'localhost', port: int = 5432, - dbname: str = "hippo", + dbname: str = 'hippo', update_legacy: bool = False, auto_compute_bfps: bool = False, create_blank: bool = True, @@ -161,7 +160,7 @@ def __init__( assert isinstance(port, int) if debug: - mrich.debug("hippo.PostgresDatabase.__init__()") + mrich.debug('hippo.PostgresDatabase.__init__()') self._username = username self._password = password @@ -172,26 +171,25 @@ def __init__( self._cursor = None self._animal = animal self._auto_compute_bfps = auto_compute_bfps - self._engine = "psycopg" + self._engine = 'psycopg' self._dbname = dbname if debug: - mrich.debug(f"PostgresDatabase.username = {self.username}") - mrich.debug(f"PostgresDatabase.password = {self.password}") - mrich.debug(f"PostgresDatabase.host = {self.host}") - mrich.debug(f"PostgresDatabase.port = {self.port}") + mrich.debug(f'PostgresDatabase.username = {self.username}') + mrich.debug(f'PostgresDatabase.password = {self.password}') + mrich.debug(f'PostgresDatabase.host = {self.host}') + mrich.debug(f'PostgresDatabase.port = {self.port}') self.connect() if not self.table_names: - if create_blank: self.create_schema() self.create_blank_db() else: - mrich.error("Database is empty!", self.path) + mrich.error('Database is empty!', self.path) raise ValueError( - "Database is empty! Check connection or run with create_blank=True" + 'Database is empty! Check connection or run with create_blank=True' ) if check_legacy: @@ -206,7 +204,7 @@ def __init__( def path(self) -> None: """PostgresDatabase path""" # raise NotImplementedError("PostgresDatabase has no path") - return f"postgresql://{self.username}@{self.host}:{self.port}" + return f'postgresql://{self.username}@{self.host}:{self.port}' @property def username(self) -> str: @@ -244,7 +242,7 @@ def table_names(self) -> list[str]: AND table_type = 'BASE TABLE'; """ ).fetchall() - return [n for n, in results] + return [n for (n,) in results] def index_names(self) -> list[str]: """Get the index names""" @@ -257,12 +255,12 @@ def index_names(self) -> list[str]: """ ) - return [n for n, in cursor] + return [n for (n,) in cursor] @property def total_changes(self) -> int: """Return the current transaction ID as a proxy of sqlite's total_changes.""" - cursor = self.execute("SELECT txid_current()") + cursor = self.execute('SELECT txid_current()') return cursor.fetchone()[0] ### GENERAL SQL @@ -271,7 +269,7 @@ def connect(self, debug: bool = True) -> None: """Connect to the database""" if debug: - mrich.debug("hippo.PostgresDatabase.connect()") + mrich.debug('hippo.PostgresDatabase.connect()') conn = None @@ -305,7 +303,7 @@ def connect(self, debug: bool = True) -> None: conn.execute("SET client_encoding TO 'UTF8'") except Exception as e: - mrich.error("Could not connect to", self.path) + mrich.error('Could not connect to', self.path) mrich.error(e) raise @@ -325,7 +323,6 @@ def execute( mrich.debug(sql) if time: - import re from time import perf_counter start = perf_counter() @@ -335,14 +332,14 @@ def execute( records = self.cursor.execute(sql, payload) else: records = self.cursor.execute(sql) - except Exception as e: + except Exception: # mrich.error(e) # mrich.print(strip_sql(sql)) self.rollback() raise if time: - mrich.debug(f"{perf_counter()-start:.2}s: ", strip_sql(sql)) + mrich.debug(f'{perf_counter() - start:.2}s: ', strip_sql(sql)) return records @@ -359,14 +356,14 @@ def executemany( :param batch_size: optional batch size for the execution""" - returning = "RETURNING" in sql + returning = 'RETURNING' in sql if debug: from .tools import strip_sql mrich.debug(strip_sql(sql)) - mrich.debug("len(payload):", len(payload)) - mrich.debug(f"{returning=}") + mrich.debug('len(payload):', len(payload)) + mrich.debug(f'{returning=}') if time: import re @@ -375,7 +372,6 @@ def executemany( start = perf_counter() if batch_size and batch_size < len(payload): - from itertools import batched, chain batches = list(batched(payload, batch_size)) @@ -383,9 +379,9 @@ def executemany( n = len(batches) results = [] - for i, batch in enumerate(mrich.track(batches, prefix="batch execution")): - mrich.set_progress_field("i", i) - mrich.set_progress_field("n", n) + for i, batch in enumerate(mrich.track(batches, prefix='batch execution')): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', n) self.cursor.executemany(sql, batch, returning=returning) @@ -396,7 +392,7 @@ def executemany( results.append(result) else: - mrich.set_progress_field("i", n) + mrich.set_progress_field('i', n) if results: records = list(chain.from_iterable(results)) @@ -404,7 +400,6 @@ def executemany( records = None else: - self.cursor.executemany(sql, payload, returning=returning) if returning: @@ -413,8 +408,8 @@ def executemany( records = None if time: - sql = re.sub(r"\s+", " ", sql).strip() - mrich.debug(f"{perf_counter()-start:.2}s: ", sql) + sql = re.sub(r'\s+', ' ', sql).strip() + mrich.debug(f'{perf_counter() - start:.2}s: ', sql) return records @@ -425,7 +420,7 @@ def rollback(self) -> None: def sql_return_id_str(self, key: str) -> str: """Add this to SQL queries to return the entry primary key""" - return f"RETURNING {key}_id" + return f'RETURNING {key}_id' def get_lastrowid(self) -> int: """Get ID of last inserted row""" @@ -442,7 +437,7 @@ def column_names(self, table: str) -> list[str]: ORDER BY ordinal_position; """ - return [n for n, in self.execute(sql).fetchall()] + return [n for (n,) in self.execute(sql).fetchall()] ### CREATE TABLES @@ -463,21 +458,21 @@ def create_schema(self) -> None: if exists: return None - self.execute("CREATE SCHEMA IF NOT EXISTS hippo;") + self.execute('CREATE SCHEMA IF NOT EXISTS hippo;') self.commit() def create_table_pattern_bfp(self) -> None: """Create the pattern_bfp table""" mrich.warning( - "HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented" + 'HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented' ) return - mrich.debug("HIPPO.PostgresDatabase.create_table_pattern_bfp()") + mrich.debug('HIPPO.PostgresDatabase.create_table_pattern_bfp()') sql = """ - CREATE VIRTUAL TABLE compound_pattern_bfp + CREATE VIRTUAL TABLE compound_pattern_bfp USING rdtree(compound_id, fp bits(2048)) """ @@ -488,15 +483,15 @@ def create_table_pattern_bfp(self) -> None: def get_compound_mol( self, compound_id: int, - ) -> "Chem.Mol": + ) -> 'Chem.Mol': """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" from rdkit.Chem import Mol (bytestr,) = self.select_where( - query="hippo.mol_to_pkl(compound_mol)", - table="compound", - key="id", + query='hippo.mol_to_pkl(compound_mol)', + table='compound', + key='id', value=compound_id, ) @@ -504,12 +499,10 @@ def get_compound_mol( ### SINGLE UPDATES - def update_pose_mol(self, pose_id: int, mol: "Chem.Mol") -> None: + def update_pose_mol(self, pose_id: int, mol: 'Chem.Mol') -> None: """Update the molecule stored for a specific pose""" - from rdkit.Chem import MolToMolBlock - - sql = f""" + sql = """ UPDATE hippo.pose SET pose_mol = hippo.mol_from_pkl(%s) WHERE pose_id = %s; @@ -557,38 +550,38 @@ def migrate_sqlite( from .animal import HIPPO from .migration import ( + dump_json, + dump_xlsx, migrate_compounds, - migrate_scaffolds, - migrate_targets, - migrate_poses, - migrate_pose_references, - migrate_inspirations, - migrate_tags, - migrate_reactions_and_reactants, migrate_features, + migrate_inspirations, migrate_interactions, - migrate_subsites, + migrate_pose_references, + migrate_poses, migrate_quotes, - dump_xlsx, - dump_json, + migrate_reactions_and_reactants, + migrate_scaffolds, + migrate_subsites, + migrate_tags, + migrate_targets, ) - mrich.var("source", source) - mrich.var("batch_size", batch_size) + mrich.var('source', source) + mrich.var('batch_size', batch_size) source_path = Path(source).resolve() assert source_path.exists() - json_file_name = f"{source_path.name.removesuffix('.sqlite')}_migration.json" - xlsx_file_name = f"{source_path.name.removesuffix('.sqlite')}_migration.xlsx" - mrich.var("json_file_name", json_file_name) - mrich.var("xlsx_file_name", xlsx_file_name) + json_file_name = f'{source_path.name.removesuffix(".sqlite")}_migration.json' + xlsx_file_name = f'{source_path.name.removesuffix(".sqlite")}_migration.xlsx' + mrich.var('json_file_name', json_file_name) + mrich.var('xlsx_file_name', xlsx_file_name) if not tag_compound_id_regex: tag_compound_id_regex = [ - (r"^C([0-9]+)", "C{new_compound_id}"), + (r'^C([0-9]+)', 'C{new_compound_id}'), ] - mrich.var("tag_compound_id_regex", tag_compound_id_regex) + mrich.var('tag_compound_id_regex', tag_compound_id_regex) ### THIS DEV WAS NOT COMPLETED @@ -607,17 +600,16 @@ def migrate_sqlite( # ] # mrich.var("pose_path_pose_id_regex", pose_path_pose_id_regex) - source = HIPPO("source", source_path) + source = HIPPO('source', source_path) ### helper functions try: - migration_data = { - "source": str(source_path.resolve()), - "destination": self.path, - "time": str(datetime.now()), - "tag_compound_id_regex": tag_compound_id_regex, + 'source': str(source_path.resolve()), + 'destination': self.path, + 'time': str(datetime.now()), + 'tag_compound_id_regex': tag_compound_id_regex, # "pose_path_compound_id_regex": pose_path_compound_id_regex, # "pose_path_pose_id_regex": pose_path_pose_id_regex, } @@ -635,7 +627,6 @@ def migrate_sqlite( ### scaffolds if scaffolds: - migration_data = migrate_scaffolds( source=source.db, destination=self, @@ -697,7 +688,6 @@ def migrate_sqlite( ### reactions & reactants if reactions: - migration_data = migrate_reactions_and_reactants( source=source.db, destination=self, @@ -708,7 +698,6 @@ def migrate_sqlite( ### features if features or interactions: - migration_data = migrate_features( source=source.db, destination=self, @@ -720,7 +709,6 @@ def migrate_sqlite( ### interactions if interactions: - migration_data = migrate_interactions( source=source.db, destination=self, @@ -732,7 +720,6 @@ def migrate_sqlite( ### subsites if subsites: - migration_data = migrate_subsites( source=source.db, destination=self, @@ -744,7 +731,6 @@ def migrate_sqlite( ### quotes if quotes: - migration_data = migrate_quotes( source=source.db, destination=self, @@ -759,10 +745,10 @@ def migrate_sqlite( mrich.error(e) json_file_name = ( - f"{source.db.path.name.removesuffix('.sqlite')}_migration_partial.json" + f'{source.db.path.name.removesuffix(".sqlite")}_migration_partial.json' ) xlsx_file_name = ( - f"{source.db.path.name.removesuffix('.sqlite')}_migration_partial.xlsx" + f'{source.db.path.name.removesuffix(".sqlite")}_migration_partial.xlsx' ) dump_json(migration_data, json_file_name) @@ -778,7 +764,7 @@ def migrate_sqlite( source.db.close() mrich.success( - "Migration staged. Please review and db.commit() or db.rollback() the changes" + 'Migration staged. Please review and db.commit() or db.rollback() the changes' ) ### MAINTENANCE @@ -786,14 +772,14 @@ def migrate_sqlite( def _drop_schema(self) -> None: """Empty the Database schema entirely and recreate it""" - self.execute(f"DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE;") + self.execute(f'DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE;') self.commit() def _drop_tables(self) -> None: """Delete all HIPPO tables and restart sequences""" for table in self.TABLES: - self.execute(f"DROP TABLE IF EXISTS {self.SQL_SCHEMA}.{table} CASCADE;") + self.execute(f'DROP TABLE IF EXISTS {self.SQL_SCHEMA}.{table} CASCADE;') # sql = f""" # DO $$ @@ -822,4 +808,4 @@ def _drop_tables(self) -> None: def __str__(self): """Unformatted string representation""" - return f"Database @ {self.path}" + return f'Database @ {self.path}' diff --git a/hippo/price.py b/hippo/price.py index fb0601b..88f74b2 100644 --- a/hippo/price.py +++ b/hippo/price.py @@ -1,12 +1,11 @@ """Class for working with prices""" import mcol -import mrich CURRENCIES = { - "USD": "$", - "EUR": "€", - "GBP": "£", + 'USD': '$', + 'EUR': '€', + 'GBP': '£', } @@ -27,7 +26,7 @@ def __init__(self, amount: float | None, currency: str | None): """Price initialisation""" if currency not in CURRENCIES: - assert currency is None, f"Unrecognised {currency=}" + assert currency is None, f'Unrecognised {currency=}' assert not amount, f"Null Price can't have {amount=}" amount = None @@ -40,7 +39,7 @@ def __init__(self, amount: float | None, currency: str | None): ### FACTORIES @classmethod - def null(cls) -> "Price": + def null(cls) -> 'Price': """Zero in any currency""" self = cls.__new__(cls) self.__init__(None, None) @@ -50,7 +49,7 @@ def null(cls) -> "Price": def from_dict( cls, d: dict, - ) -> "Price": + ) -> 'Price': """Create a :class:`.Price` object from a dictionary: :: @@ -61,7 +60,7 @@ def from_dict( """ self = cls.__new__(cls) - self.__init__(d["amount"], d["currency"]) + self.__init__(d['amount'], d['currency']) return self ### PROPERTIES @@ -98,7 +97,7 @@ def get_dict(self) -> dict: """ return dict(amount=self.amount, currency=self.currency) - def copy(self) -> "Price": + def copy(self) -> 'Price': """Return a copy of this :class:`.Price`""" return Price(amount=self.amount, currency=self.currency) @@ -107,19 +106,19 @@ def copy(self) -> "Price": def __str__(self) -> str: """Unformatted string representation""" if self.currency is None: - return "Null Price" + return 'Null Price' - return f"{self.symbol}{self.amount:.2f} {self.currency}" + return f'{self.symbol}{self.amount:.2f} {self.currency}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' - def __add__(self, other: "Price") -> "Price": + def __add__(self, other: 'Price') -> 'Price': """Add two :class:`.Price` objects :param other: :class:`.Price` object @@ -138,11 +137,11 @@ def __add__(self, other: "Price") -> "Price": if self.currency != other.currency: raise NotImplementedError( - f"Adding two different currencies: {self.currency} != {other.currency}" + f'Adding two different currencies: {self.currency} != {other.currency}' ) return Price(self.amount + other.amount, self.currency) - def __truediv__(self, other: "Price | float | int") -> "Price | float": + def __truediv__(self, other: 'Price | float | int') -> 'Price | float': """Divide this :class:`.Price` by another object :param other: :class:`.Price` or float or int @@ -160,9 +159,9 @@ def __truediv__(self, other: "Price | float | int") -> "Price | float": assert not other.is_null return self.amount / other.amount - raise TypeError(f"Division not supported between Price and {type(other)}") + raise TypeError(f'Division not supported between Price and {type(other)}') - def __mul__(self, other: "Price | float | int") -> "Price | float": + def __mul__(self, other: 'Price | float | int') -> 'Price | float': """Multiply this :class:`.Price` by another object :param other: :class:`.Price` or float or int @@ -175,9 +174,9 @@ def __mul__(self, other: "Price | float | int") -> "Price | float": return self return Price(amount=self.amount * other, currency=self.currency) - raise TypeError(f"Multiplication not supported between Price and {type(other)}") + raise TypeError(f'Multiplication not supported between Price and {type(other)}') - def __eq__(self, other: "Price") -> bool: + def __eq__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -194,12 +193,12 @@ def __eq__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return False - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount == other.amount - def __lt__(self, other: "Price") -> bool: + def __lt__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -216,12 +215,12 @@ def __lt__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return False - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount < other.amount - def __gt__(self, other: "Price") -> bool: + def __gt__(self, other: 'Price') -> bool: """Compare two :class:`.Price` objects""" if isinstance(other, int) or isinstance(other, float): @@ -238,13 +237,13 @@ def __gt__(self, other: "Price") -> bool: if not self.is_null and other.is_null: return True - assert ( - self.currency == other.currency - ), f"Comparing different currencies: {self.currency} != {other.currency}" + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) return self.amount > other.amount def __hash__(self) -> int: """Allow for Prices to be hashed for comparison""" if self.is_null: - return hash("NULL") - return hash(f"{self.currency} {self.amount}") + return hash('NULL') + return hash(f'{self.currency} {self.amount}') diff --git a/hippo/prolif.py b/hippo/prolif.py index 1cef130..b49403b 100644 --- a/hippo/prolif.py +++ b/hippo/prolif.py @@ -1,14 +1,11 @@ """Functions for ProLIF interaction profiling""" -import mrich -from mrich import print - import molparse as mp - +import mrich from molparse.rdkit.features import FEATURE_FAMILIES, INTERACTION_TYPES -INTERACTION_TYPES = list(INTERACTION_TYPES.values()) + ["VdWContact"] -FEATURE_FAMILIES = list(FEATURE_FAMILIES) + ["VdWSphere"] +INTERACTION_TYPES = list(INTERACTION_TYPES.values()) + ['VdWContact'] +FEATURE_FAMILIES = list(FEATURE_FAMILIES) + ['VdWSphere'] def guess_feature_families( @@ -21,93 +18,93 @@ def guess_feature_families( """ match interaction_type: - case "VdWContact": - lig_feature_family = "VdWSphere" - prot_feature_family = "VdWSphere" + case 'VdWContact': + lig_feature_family = 'VdWSphere' + prot_feature_family = 'VdWSphere' - case "Hydrophobic": + case 'Hydrophobic': lig_feature_family = ( - "LumpedHydrophobe" if len(lig_atom_ids) > 1 else "Hydrophobe" + 'LumpedHydrophobe' if len(lig_atom_ids) > 1 else 'Hydrophobe' ) prot_feature_family = ( - "LumpedHydrophobe" if len(prot_atom_names) > 1 else "Hydrophobe" + 'LumpedHydrophobe' if len(prot_atom_names) > 1 else 'Hydrophobe' ) - case "Anionic": - lig_feature_family = "NegIonizable" - prot_feature_family = "PosIonizable" - interaction_type = "Electrostatic" - - case "Cationic": - lig_feature_family = "PosIonizable" - prot_feature_family = "NegIonizable" - interaction_type = "Electrostatic" - - case "CationPi": - lig_feature_family = "PosIonizable" - prot_feature_family = "Aromatic" - interaction_type = "π-cation" - - case "PiCation": - lig_feature_family = "Aromatic" - prot_feature_family = "PosIonizable" - interaction_type = "π-cation" - - case "PiStacking": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking" - - case "PiStacking": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking" - - case "EdgeToFace": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking (EdgeToFace)" - - case "FaceToFace": - lig_feature_family = "Aromatic" - prot_feature_family = "Aromatic" - interaction_type = "π-stacking (FaceToFace)" - - case "HBAcceptor": - lig_feature_family = "Acceptor" - prot_feature_family = "Donor" - interaction_type = "Hydrogen Bond" - - case "HBDonor": - lig_feature_family = "Donor" - prot_feature_family = "Acceptor" - interaction_type = "Hydrogen Bond" - - case "XBAcceptor": - lig_feature_family = "Acceptor" - prot_feature_family = "Donor" - interaction_type = "Halogen Bond" - - case "XBDonor": - lig_feature_family = "Donor" - prot_feature_family = "Acceptor" - interaction_type = "Halogen Bond" - - case "MetalAcceptor": + case 'Anionic': + lig_feature_family = 'NegIonizable' + prot_feature_family = 'PosIonizable' + interaction_type = 'Electrostatic' + + case 'Cationic': + lig_feature_family = 'PosIonizable' + prot_feature_family = 'NegIonizable' + interaction_type = 'Electrostatic' + + case 'CationPi': + lig_feature_family = 'PosIonizable' + prot_feature_family = 'Aromatic' + interaction_type = 'π-cation' + + case 'PiCation': + lig_feature_family = 'Aromatic' + prot_feature_family = 'PosIonizable' + interaction_type = 'π-cation' + + case 'PiStacking': + lig_feature_family = 'Aromatic' + prot_feature_family = 'Aromatic' + interaction_type = 'π-stacking' + + case 'PiStacking': + lig_feature_family = 'Aromatic' + prot_feature_family = 'Aromatic' + interaction_type = 'π-stacking' + + case 'EdgeToFace': + lig_feature_family = 'Aromatic' + prot_feature_family = 'Aromatic' + interaction_type = 'π-stacking (EdgeToFace)' + + case 'FaceToFace': + lig_feature_family = 'Aromatic' + prot_feature_family = 'Aromatic' + interaction_type = 'π-stacking (FaceToFace)' + + case 'HBAcceptor': + lig_feature_family = 'Acceptor' + prot_feature_family = 'Donor' + interaction_type = 'Hydrogen Bond' + + case 'HBDonor': + lig_feature_family = 'Donor' + prot_feature_family = 'Acceptor' + interaction_type = 'Hydrogen Bond' + + case 'XBAcceptor': + lig_feature_family = 'Acceptor' + prot_feature_family = 'Donor' + interaction_type = 'Halogen Bond' + + case 'XBDonor': + lig_feature_family = 'Donor' + prot_feature_family = 'Acceptor' + interaction_type = 'Halogen Bond' + + case 'MetalAcceptor': lig_feature_family = None - prot_feature_family = "Metal" - interaction_type = "Metal complexation" + prot_feature_family = 'Metal' + interaction_type = 'Metal complexation' - case "MetalDonor": - lig_feature_family = "Metal" + case 'MetalDonor': + lig_feature_family = 'Metal' prot_feature_family = None - interaction_type = "Metal complexation" + interaction_type = 'Metal complexation' case _: prot_feature_family = None lig_feature_family = None - mrich.error("Unsupported interaction_type", interaction_type) + mrich.error('Unsupported interaction_type', interaction_type) assert interaction_type in INTERACTION_TYPES assert prot_feature_family in FEATURE_FAMILIES or prot_feature_family is None @@ -117,10 +114,10 @@ def guess_feature_families( def parse_prolif_interactions( - pose: "Pose", - fp: "plf.Fingerprint", - protonated_sys: "molparse.System", - table: str = "temp_interaction", + pose: 'Pose', + fp: 'plf.Fingerprint', + protonated_sys: 'molparse.System', + table: str = 'temp_interaction', debug: bool = False, ) -> None: """Parse ProLIF output into HIPPO database table""" @@ -128,7 +125,6 @@ def parse_prolif_interactions( target_id = pose.target.id for key, value in fp.ifp[0].items(): - res_number = key[1].number res_name = key[1].name chain_name = key[1].chain @@ -140,23 +136,21 @@ def parse_prolif_interactions( prot_group = mp.AtomGroup.from_pdb_block(mp.rdkit.mol_to_pdb_block(prot_mol)) if not residue.name == res_name: - mrich.debug(protonated_sys.name + ".pdb") + mrich.debug(protonated_sys.name + '.pdb') raise AssertionError( - f"Residue name mismatch: [sys]={residue.name} [prolif]={res_name}" + f'Residue name mismatch: [sys]={residue.name} [prolif]={res_name}' ) for interaction_type, interaction_dicts in value.items(): - for interaction_dict in interaction_dicts: + angle = interaction_dict.get('angle') + distance = interaction_dict.get('distance') - angle = interaction_dict.get("angle") - distance = interaction_dict.get("distance") - - lig_atom_ids = list(interaction_dict["indices"]["ligand"]) + lig_atom_ids = list(interaction_dict['indices']['ligand']) # insert a dummy protein feature prot_interaction_atoms = [ - prot_group.atoms[i] for i in interaction_dict["indices"]["protein"] + prot_group.atoms[i] for i in interaction_dict['indices']['protein'] ] prot_atom_names = [a.name for a in prot_interaction_atoms] @@ -176,19 +170,18 @@ def parse_prolif_interactions( ) if not feature_id: - sql = f""" - feature_target = {target_id} - AND feature_family = '{prot_family}' - AND feature_chain_name = '{chain_name}' - AND feature_residue_name = '{res_name}' + feature_target = {target_id} + AND feature_family = '{prot_family}' + AND feature_chain_name = '{chain_name}' + AND feature_residue_name = '{res_name}' AND feature_residue_number = {residue.number} - AND feature_atom_names = '{" ".join(sorted(prot_atom_names))}' + AND feature_atom_names = '{' '.join(sorted(prot_atom_names))}' """ try: (feature_id,) = pose.db.select_id_where( - table="feature", key=sql + table='feature', key=sql ) except: feature_id = pose.db.insert_feature( @@ -221,5 +214,5 @@ def parse_prolif_interactions( if debug: mrich.debug( - f"Residue: {res_name} {res_number} {chain_name}. Interaction: {interaction_type}. Ligand: {lig_family} ({lig_atom_ids}). Protein: {prot_family} ({prot_atom_names})" + f'Residue: {res_name} {res_number} {chain_name}. Interaction: {interaction_type}. Ligand: {lig_family} ({lig_atom_ids}). Protein: {prot_family} ({prot_atom_names})' ) diff --git a/hippo/pset.py b/hippo/pset.py index 7ab67ce..9166deb 100644 --- a/hippo/pset.py +++ b/hippo/pset.py @@ -1,14 +1,12 @@ """Classes to work with sets of Poses""" +from collections.abc import Callable + import mcol import mrich -import os -from typing import Callable - -from .pose import Pose from .db import Database -from .cset import IngredientSet +from .pose import Pose class PoseTable: @@ -58,8 +56,8 @@ class PoseTable: """ - _table = "pose" - _name = "all poses" + _table = 'pose' + _name = 'all poses' def __init__( self, @@ -95,38 +93,38 @@ def names(self) -> list[str]: @property def aliases(self) -> list[str]: """Returns the aliases of child poses""" - result = self.db.select(table=self.table, query="pose_alias", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='pose_alias', multiple=True) + return [q for (q,) in result] @property def inchikeys(self) -> list[str]: """Returns the inchikeys of child poses""" - result = self.db.select(table=self.table, query="pose_inchikey", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='pose_inchikey', multiple=True) + return [q for (q,) in result] @property def ids(self) -> list[int]: """Returns the IDs of child poses""" - result = self.db.select(table=self.table, query="pose_id", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='pose_id', multiple=True) + return [q for (q,) in result] @property def tags(self) -> set[str]: """Returns the set of unique tags present in this pose set""" values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key="tag_pose IS NOT NULL", + table='tag', + query='DISTINCT tag_name', + key='tag_pose IS NOT NULL', multiple=True, ) - return set(v for v, in values) + return set(v for (v,) in values) @property def num_fingerprinted(self) -> int: """Count the number of fingerprinted poses""" return self.db.count_where( - table="pose", - key="fingerprint", + table='pose', + key='fingerprint', value=1, ) @@ -135,7 +133,7 @@ def id_name_dict(self) -> dict[int, str]: """Return a dictionary mapping pose ID's to their name""" records = self.db.select( - table=self.table, query="pose_id, pose_inchikey, pose_alias", multiple=True + table=self.table, query='pose_id, pose_inchikey, pose_alias', multiple=True ) lookup = {} @@ -148,7 +146,7 @@ def id_name_dict(self) -> dict[int, str]: return lookup @property - def interactions(self) -> "InteractionSet": + def interactions(self) -> 'InteractionSet': """Get a :class:`.InteractionSet`""" if self._interactions is None: from .iset import InteractionSet @@ -163,7 +161,7 @@ def get_by_tag( self, tag: str, inverse: bool = False, - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses with a certain tag :param tag: tag to search for @@ -173,33 +171,31 @@ def get_by_tag( """ if not inverse: - values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True + query='tag_pose', table='tag', key='name', value=tag, multiple=True ) else: - values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True + query='tag_pose', table='tag', key='name', value=tag, multiple=True ) if not values: return self - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_id NOT IN {self.str_ids}", + query='pose_id', + table='pose', + key=f'pose_id NOT IN {self.str_ids}', multiple=True, ) if not values: return None - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] pset = self[ids] @@ -213,7 +209,7 @@ def get_by_target( self, *, id: int, - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses with a certain :class:`.Target` ID: :param id: :class:`.Target` ID @@ -222,9 +218,9 @@ def get_by_target( """ assert isinstance(id, int) values = self.db.select_where( - query="pose_id", table="pose", key="target", value=id, multiple=True + query='pose_id', table='pose', key='target', value=id, multiple=True ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] target = self.db.get_target(id=id) @@ -232,19 +228,19 @@ def get_by_target( pset._name = f'poses for "{target}"' return pset - def get_by_smiles(self, smiles: str) -> "Pose | PoseSet | None": + def get_by_smiles(self, smiles: str) -> 'Pose | PoseSet | None': """Get a member pose by it's smiles""" - from .tools import inchikey_from_smiles, sanitise_smiles, SanitisationError + from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles try: - flat_smiles = sanitise_smiles(smiles, sanitisation_failed="error") + flat_smiles = sanitise_smiles(smiles, sanitisation_failed='error') except SanitisationError as e: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') mrich.error(str(e)) return None except AssertionError: - mrich.error(f"Could not sanitise {smiles=}") + mrich.error(f'Could not sanitise {smiles=}') return None return c @@ -253,7 +249,7 @@ def get_by_smiles(self, smiles: str) -> "Pose | PoseSet | None": flat_inchikey = inchikey_from_smiles(flat_smiles) comp_id = self.db.select_id_where( - table="compound", key="inchikey", value=flat_inchikey + table='compound', key='inchikey', value=flat_inchikey ) if not comp_id: @@ -264,13 +260,13 @@ def get_by_smiles(self, smiles: str) -> "Pose | PoseSet | None": # get the poses pose_ids = self.db.select_id_where( - table="pose", key="compound", value=comp_id, multiple=True + table='pose', key='compound', value=comp_id, multiple=True ) if not pose_ids: return None - pose_ids = [i for i, in pose_ids] + pose_ids = [i for (i,) in pose_ids] pset = self[pose_ids] # identify the pose @@ -284,7 +280,7 @@ def get_by_smiles(self, smiles: str) -> "Pose | PoseSet | None": matches = list(matches) if not matches: - mrich.error(f"Did not find pose matching stereochemistry (C{comp_id})") + mrich.error(f'Did not find pose matching stereochemistry (C{comp_id})') return None if len(matches) == 1: @@ -296,7 +292,7 @@ def get_by_subsite( self, *, id: int, - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses with a certain :class:`.Subsite` ID: :param id: :class:`.Subsite` ID @@ -305,13 +301,13 @@ def get_by_subsite( """ assert isinstance(id, int) values = self.db.select_where( - query="subsite_tag_pose", - table="subsite_tag", - key="ref", + query='subsite_tag_pose', + table='subsite_tag', + key='ref', value=id, multiple=True, ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] subsite = self.db.get_subsite_name(id=id) @@ -323,7 +319,7 @@ def get_by_metadata( self, key: str, value: str | None = None, - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient :param key: metadata key to match @@ -332,16 +328,16 @@ def get_by_metadata( """ results = self.db.select( - query="pose_id, pose_metadata", table="pose", multiple=True + query='pose_id, pose_metadata', table='pose', multiple=True ) if value is None: ids = [i for i, d in results if d and f'"{key}":' in d] - name = f"poses with {key} in metadata" + name = f'poses with {key} in metadata' else: if isinstance(value, str): value = f'"{value}"' ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f"poses with metadata[{key}] == {value}" + name = f'poses with metadata[{key}] == {value}' pset = self[ids] pset._name = name @@ -350,24 +346,24 @@ def get_by_metadata( def get_by_metadata_substring_match( self, substring: str, - ) -> "PoseSet": + ) -> 'PoseSet': """Get :class:`.PoseSet` of poses with metadata JSON containing substring""" assert substring assert isinstance(substring, str) pose_ids = self.db.select_where( - table="pose", - query="pose_id", + table='pose', + query='pose_id', key=f"""pose_metadata LIKE '%{substring}%'""", multiple=True, ) if not pose_ids: - mrich.error("No poses with export ") + mrich.error('No poses with export ') return None - pose_ids = [i for i, in pose_ids] + pose_ids = [i for (i,) in pose_ids] name = f"poses with '{substring}' in metadata" @@ -389,14 +385,14 @@ def draw( self[:].draw() else: mrich.warning( - f"Too many poses: {len(self)} > {max_draw=}. Increase max_draw or use animal.poses[:].draw()" + f'Too many poses: {len(self)} > {max_draw=}. Increase max_draw or use animal.poses[:].draw()' ) def summary(self) -> None: """Print a summary of this pose set""" - mrich.header("PoseTable()") - mrich.var("#poses", len(self)) - mrich.var("tags", self.tags) + mrich.header('PoseTable()') + mrich.var('#poses', len(self)) + mrich.var('tags', self.tags) def interactive(self) -> None: """Interactive widget to navigate poses in the table @@ -418,7 +414,7 @@ def __call__( target: int | None = None, subsite: int | None = None, smiles: str | None = None, - ) -> "PoseSet": + ) -> 'PoseSet': """Filter poses by a given tag, subsite ID, or target ID. See :meth:`.PoseTable.get_by_tag`, :meth:`.PoseTable.get_by_target`, amd :meth:`.PoseTable.get_by_subsite`""" if tag: @@ -442,11 +438,10 @@ def __getitem__( """ + from numpy import int64, ndarray from pandas import Series - from numpy import ndarray, int64 match key: - case int(): if key == 0: return self.__getitem__(key=1) @@ -471,7 +466,6 @@ def __getitem__( or isinstance(key, Series) or isinstance(key, ndarray) ): - indices = [] for i in key: if isinstance(i, int): @@ -504,7 +498,7 @@ def __getitem__( case _: mrich.error( - f"Unsupported type for PoseTable.__getitem__(): {type(key)}" + f'Unsupported type for PoseTable.__getitem__(): {type(key)}' ) return None @@ -512,21 +506,21 @@ def __getitem__( def __str__(self): """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"P × {len(self)}" "}" + s += f'{{P × {len(self)}}}' return s def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """Total number of compounds""" @@ -584,7 +578,7 @@ class PoseSet: """ - _table = "pose" + _table = 'pose' def __init__( self, @@ -608,7 +602,6 @@ def __init__( if sort: self._indices = sorted(list(set(indices))) else: - # remove duplicates but keep order self._indices = dict() for i in indices: @@ -624,7 +617,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns the associated :class:`.Database`""" return self._db @@ -658,7 +651,7 @@ def aliases(self) -> list[str]: """Returns the aliases of child poses""" return [ self.db.select_where( - table=self.table, query="pose_alias", key="id", value=i, multiple=False + table=self.table, query='pose_alias', key='id', value=i, multiple=False )[0] for i in self.indices ] @@ -669,8 +662,8 @@ def inchikeys(self) -> list[str]: return [ self.db.select_where( table=self.table, - query="pose_inchikey", - key="id", + query='pose_inchikey', + key='id', value=i, multiple=False, )[0] @@ -683,8 +676,8 @@ def id_name_dict(self) -> dict: records = self.db.select_where( table=self.table, - query="pose_id, pose_inchikey, pose_alias", - key=f"pose_id IN {self.str_ids}", + query='pose_id, pose_inchikey, pose_alias', + key=f'pose_id IN {self.str_ids}', multiple=True, ) @@ -702,8 +695,8 @@ def smiles(self) -> list[str]: """Returns the smiles of poses in this set""" pairs = self.db.select_where( table=self.table, - query="pose_id, pose_smiles", - key=f"pose_id IN {self.str_ids}", + query='pose_id, pose_smiles', + key=f'pose_id IN {self.str_ids}', multiple=True, ) @@ -721,29 +714,29 @@ def smiles(self) -> list[str]: def tags(self) -> set[str]: """Returns the set of unique tags present in this pose set""" values = self.db.select_where( - table="tag", - query="DISTINCT tag_name", - key=f"tag_pose in {self.str_ids}", + table='tag', + query='DISTINCT tag_name', + key=f'tag_pose in {self.str_ids}', multiple=True, ) - return set(v for v, in values) + return set(v for (v,) in values) @property - def compounds(self) -> "CompoundSet": + def compounds(self) -> 'CompoundSet': """Get the compounds associated to this set of poses""" from .cset import CompoundSet ids = self.db.select_where( - table="pose", - query="DISTINCT pose_compound", - key=f"pose_id in {self.str_ids}", + table='pose', + query='DISTINCT pose_compound', + key=f'pose_id in {self.str_ids}', multiple=True, ) - ids = [v for v, in ids] + ids = [v for (v,) in ids] return CompoundSet(self.db, ids) @property - def mols(self) -> "list[rdkit.Chem.mol]": + def mols(self) -> 'list[rdkit.Chem.mol]': """Get the rdkit Molecules contained in this set""" return [p.mol for p in self] @@ -753,12 +746,12 @@ def num_compounds(self) -> int: return len(self.compounds) @property - def df(self) -> "pandas.DataFrame": + def df(self) -> 'pandas.DataFrame': """Get a DataFrame of the poses in this set""" return self.get_df(mol=True) @property - def references(self) -> "PoseSet": + def references(self) -> 'PoseSet': """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" return PoseSet(self.db, self.reference_ids) @@ -766,13 +759,13 @@ def references(self) -> "PoseSet": def reference_ids(self) -> set[int]: """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" values = self.db.select_where( - table="pose", - query="DISTINCT pose_reference", - key=f"pose_reference IS NOT NULL and pose_id in {self.str_ids}", + table='pose', + query='DISTINCT pose_reference', + key=f'pose_reference IS NOT NULL and pose_id in {self.str_ids}', value=None, multiple=True, ) - return set(v for v, in values) + return set(v for (v,) in values) @property def inspiration_sets(self) -> list[set[int]]: @@ -806,9 +799,9 @@ def num_inspiration_sets(self) -> int: def num_inspirations(self) -> int: """Return the number of unique inspirations for poses in this set""" (count,) = self.db.select_where( - table="inspiration", - query="COUNT(DISTINCT inspiration_original)", - key=f"inspiration_derivative IN {self.str_ids}", + table='inspiration', + query='COUNT(DISTINCT inspiration_original)', + key=f'inspiration_derivative IN {self.str_ids}', ) return count @@ -817,24 +810,24 @@ def num_inspirations(self) -> int: def inspirations(self) -> int: """Return the number of unique inspirations for poses in this set""" records = self.db.select_where( - table="inspiration", - query="DISTINCT inspiration_original", - key=f"inspiration_derivative IN {self.str_ids}", + table='inspiration', + query='DISTINCT inspiration_original', + key=f'inspiration_derivative IN {self.str_ids}', multiple=True, ) if not records: return None - return PoseSet(self.db, [i for i, in records]) + return PoseSet(self.db, [i for (i,) in records]) @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property - def targets(self) -> "list[Target]": + def targets(self) -> 'list[Target]': """Returns the :class:`.Target` objects of poses in this set""" return [self.db.get_target(id=q) for q in self.target_ids] @@ -848,11 +841,11 @@ def target_ids(self) -> list[int]: """Returns the :class:`.Target` objects ID's of poses in this set""" result = self.db.select_where( table=self.table, - query="DISTINCT pose_target", - key=f"pose_id in {self.str_ids}", + query='DISTINCT pose_target', + key=f'pose_id in {self.str_ids}', multiple=True, ) - return [q for q, in result] + return [q for (q,) in result] @property def best_placed_pose(self) -> Pose: @@ -866,14 +859,14 @@ def best_placed_pose_id(self) -> int: if len(self) == 1: return self.ids[0] - query = f"pose_id, MIN(pose_distance_score)" + query = 'pose_id, MIN(pose_distance_score)' query = self.db.select_where( - table="pose", query=query, key=f"pose_id in {self.str_ids}", multiple=False + table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False ) return query[0] @property - def interactions(self) -> "InteractionSet": + def interactions(self) -> 'InteractionSet': """Get a :class:`.InteractionSet` for this :class:`.Pose`""" if self._interactions is None: from .iset import InteractionSet @@ -885,7 +878,7 @@ def interactions(self) -> "InteractionSet": def pose_id_metadata_dict(self) -> dict[int, dict]: """Get a dictionary mapping pose_ids to metadata dicts""" if self._metadata_dict is None: - metadata_lookup = self.db.get_id_metadata_dict(table="pose", ids=self.ids) + metadata_lookup = self.db.get_id_metadata_dict(table='pose', ids=self.ids) metadata = {} for pose_id in self.ids: metadata[pose_id] = metadata_lookup[pose_id] @@ -898,9 +891,9 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: from itertools import combinations sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + SELECT DISTINCT interaction_pose, feature_id, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id WHERE interaction_pose IN {self.str_ids} """ @@ -922,7 +915,6 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: pairs = set() for pose_j, pose_k in combinations(ids, 2): - iset_j = ISETS[pose_j] iset_k = ISETS[pose_k] @@ -939,19 +931,20 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: return count - def get_interaction_clusters(self) -> "dict[int, PoseSet]": + def get_interaction_clusters(self) -> 'dict[int, PoseSet]': """Cluster poses based on shared interactions.""" - import networkx as nx - import community as louvain from itertools import combinations + import community as louvain + import networkx as nx + # get interaction records sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id WHERE interaction_pose IN {self.str_ids} """ @@ -987,7 +980,7 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": # partition the graph - partition = louvain.best_partition(G, weight="weight") + partition = louvain.best_partition(G, weight='weight') # find the clusters @@ -998,7 +991,7 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": # create the PoseSets psets = { - i: PoseSet(self.db, ids, name=f"Cluster {i}") + i: PoseSet(self.db, ids, name=f'Cluster {i}') for i, ids in enumerate(clusters.values()) } @@ -1007,13 +1000,12 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": # calculate modal interactions for i, cluster in psets.items(): - - mrich.var(cluster.name, len(cluster), unit="poses") + mrich.var(cluster.name, len(cluster), unit='poses') df = cluster.interactions.df - unique_counts = df.groupby(["type", "residue_name", "residue_number"])[ - "pose_id" + unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ + 'pose_id' ].nunique() max_count = unique_counts.max() @@ -1024,11 +1016,11 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": residue_name, residue_number, ) in max_pairs.index.values: - mrich.print(interaction_type, "w/", residue_name, residue_number) + mrich.print(interaction_type, 'w/', residue_name, residue_number) # unclustered - unclustered = set((i for i in self.ids if i not in all_ids)) - psets[None] = PoseSet(self.db, unclustered, name="Unclustered") + unclustered = set(i for i in self.ids if i not in all_ids) + psets[None] = PoseSet(self.db, unclustered, name='Unclustered') return psets @@ -1036,7 +1028,7 @@ def get_interaction_clusters(self) -> "dict[int, PoseSet]": def num_fingerprinted(self) -> int: """Count the number of fingerprinted poses in this set""" return self.db.count_where( - table="pose", key=f"pose_id IN {self.str_ids} AND pose_fingerprint = 1" + table='pose', key=f'pose_id IN {self.str_ids} AND pose_fingerprint = 1' ) @property @@ -1048,10 +1040,10 @@ def fraction_fingerprinted(self) -> float: def num_subsites(self) -> int: """Count the number of subsites that poses in this set come into contact with""" (count,) = self.db.select_where( - query="COUNT(DISTINCT subsite_tag_ref)", - table="subsite_tag", - key=f"subsite_tag_pose IN {self.str_ids}", - none="quiet", + query='COUNT(DISTINCT subsite_tag_ref)', + table='subsite_tag', + key=f'subsite_tag_pose IN {self.str_ids}', + none='quiet', ) if count is None: count = 0 @@ -1064,7 +1056,7 @@ def subsite_balance(self) -> float: from numpy import std sql = f""" - SELECT COUNT(DISTINCT subsite_tag_ref) + SELECT COUNT(DISTINCT subsite_tag_ref) FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag WHERE subsite_tag_pose IN {self.str_ids} GROUP BY subsite_tag_pose @@ -1072,7 +1064,7 @@ def subsite_balance(self) -> float: counts = self.db.execute(sql).fetchall() - counts = [c for c, in counts] + [0 for _ in range(len(self) - len(counts))] + counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] return -std(counts) @@ -1081,7 +1073,7 @@ def subsite_ids(self) -> set[int]: """Return a list of subsite id's of member poses""" sql = f""" - SELECT DISTINCT subsite_tag_ref + SELECT DISTINCT subsite_tag_ref FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag WHERE subsite_tag_pose IN {self.str_ids} """ @@ -1091,7 +1083,7 @@ def subsite_ids(self) -> set[int]: if not subsite_ids: return set() - subsite_ids = set([i for i, in subsite_ids]) + subsite_ids = set([i for (i,) in subsite_ids]) return subsite_ids @@ -1102,13 +1094,13 @@ def avg_energy_score(self) -> float: from numpy import mean sql = f""" - SELECT pose_energy_score + SELECT pose_energy_score FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} """ scores = self.db.execute(sql).fetchall() - return mean([s for s, in scores if s is not None]) + return mean([s for (s,) in scores if s is not None]) @property def avg_distance_score(self) -> float: @@ -1117,30 +1109,30 @@ def avg_distance_score(self) -> float: from numpy import mean sql = f""" - SELECT pose_distance_score + SELECT pose_distance_score FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} """ scores = self.db.execute(sql).fetchall() - return mean([s for s, in scores if s is not None]) + return mean([s for (s,) in scores if s is not None]) @property - def derivatives(self) -> "PoseSet": + def derivatives(self) -> 'PoseSet': """Get the :class:`.PoseSet` of derivatives""" ids = self.db.select_where( - table="inspiration", - query="inspiration_derivative", - key=f"inspiration_original IN {self.str_ids}", + table='inspiration', + query='inspiration_derivative', + key=f'inspiration_original IN {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) if not ids: return None - ids = [i for i, in ids] - pset = PoseSet(self.db, ids, name=f"derivatives of {self}") + ids = [i for (i,) in ids] + pset = PoseSet(self.db, ids, name=f'derivatives of {self}') return pset ### FILTERING @@ -1149,7 +1141,7 @@ def get_by_tag( self, tag: str, inverse: bool = False, - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses with a certain tag :param tag: tag to filter by @@ -1157,18 +1149,18 @@ def get_by_tag( """ values = self.db.select_where( - query="tag_pose", table="tag", key="name", value=tag, multiple=True + query='tag_pose', table='tag', key='name', value=tag, multiple=True ) if inverse: - matches = [v for v, in values if v] + matches = [v for (v,) in values if v] ids = [i for i in self.ids if i not in matches] else: - ids = [v for v, in values if v and v in self.ids] + ids = [v for (v,) in values if v and v in self.ids] return PoseSet(self.db, ids) def get_by_metadata( self, key: str, value: str | None = None, debug: bool = False - ) -> "PoseSet": + ) -> 'PoseSet': """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient :param key: metadata key to search for @@ -1176,9 +1168,9 @@ def get_by_metadata( """ results = self.db.select_where( - query="pose_id, pose_metadata", - key=f"pose_id IN {self.str_ids}", - table="pose", + query='pose_id, pose_metadata', + key=f'pose_id IN {self.str_ids}', + table='pose', multiple=True, ) @@ -1259,7 +1251,7 @@ def get_df( expand_tags: bool = False, subsites: bool = False, # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs - ) -> "pandas.DataFrame": + ) -> 'pandas.DataFrame': """Get a DataFrame of the poses in this set. :param smiles: include SMILES column (Default value = True) @@ -1284,53 +1276,54 @@ def get_df( """ from json import loads - from rdkit.Chem import Mol + from pandas import DataFrame + from rdkit.Chem import Mol get_alias = alias if name: alias = True - query = ["pose_id"] + query = ['pose_id'] if smiles: - query.append("pose_smiles") + query.append('pose_smiles') if inchikey: - query.append("pose_inchikey") + query.append('pose_inchikey') if alias: - query.append("pose_alias") + query.append('pose_alias') if reference_id or reference_alias: - query.append("pose_reference") + query.append('pose_reference') if path: - query.append("pose_path") + query.append('pose_path') if compound_id: - query.append("pose_compound") + query.append('pose_compound') if target_id: - query.append("pose_target") + query.append('pose_target') if mol: - query.append("pose_mol") + query.append('pose_mol') if energy_score: - query.append("pose_energy_score") + query.append('pose_energy_score') if distance_score: - query.append("pose_distance_score") + query.append('pose_distance_score') if inspiration_score: - query.append("pose_inspiration_score") + query.append('pose_inspiration_score') if metadata: - query.append("pose_metadata") + query.append('pose_metadata') - query = ", ".join(query) + query = ', '.join(query) sql = f""" SELECT {query} @@ -1340,7 +1333,7 @@ def get_df( if debug: # print(sql) - mrich.debug("querying...") + mrich.debug('querying...') records = self.db.execute(sql).fetchall() if debug: @@ -1350,48 +1343,46 @@ def get_df( data = [] for row in generator: - row = list(row) d = dict(id=row.pop(0)) if smiles: - d["smiles"] = row.pop(0) + d['smiles'] = row.pop(0) if inchikey: - d["inchikey"] = row.pop(0) + d['inchikey'] = row.pop(0) if alias: - d["alias"] = row.pop(0) + d['alias'] = row.pop(0) if reference_id or reference_alias: - d["reference_id"] = row.pop(0) + d['reference_id'] = row.pop(0) if path: - d["path"] = row.pop(0) + d['path'] = row.pop(0) if compound_id: - d["compound_id"] = row.pop(0) + d['compound_id'] = row.pop(0) if target_id: - d["target_id"] = row.pop(0) + d['target_id'] = row.pop(0) if mol: mol_bytes = row.pop(0) if mol_bytes: - d["mol"] = Mol(mol_bytes) + d['mol'] = Mol(mol_bytes) if energy_score: - d["energy_score"] = row.pop(0) + d['energy_score'] = row.pop(0) if distance_score: - d["distance_score"] = row.pop(0) + d['distance_score'] = row.pop(0) if inspiration_score: - d["inspiration_score"] = row.pop(0) + d['inspiration_score'] = row.pop(0) if metadata and (meta_str := row.pop(0)): - meta_dict = loads(meta_str) or {} if expand_metadata: @@ -1399,7 +1390,7 @@ def get_df( d[k] = v else: - d["metadata"] = meta_dict + d['metadata'] = meta_dict data.append(d) @@ -1407,7 +1398,7 @@ def get_df( if inspiration_ids or derivative_ids or inspiration_aliases: if debug: - mrich.debug("adding inspiration column(s)") + mrich.debug('adding inspiration column(s)') tuples = self.db.get_inspiration_tuples() @@ -1416,150 +1407,149 @@ def get_df( for inspiration, derivative in tuples: lookup.setdefault(derivative, set()) lookup[derivative].add(inspiration) - df["inspiration_ids"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['inspiration_ids'] = df['id'].apply(lambda x: lookup.get(x, set())) if derivative_ids: lookup = {} for inspiration, derivative in tuples: lookup.setdefault(inspiration, set()) lookup[inspiration].add(derivative) - df["derivative_ids"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['derivative_ids'] = df['id'].apply(lambda x: lookup.get(x, set())) if inspiration_aliases: inspirations = PoseSet( - self.db, set.union(*list(df["inspiration_ids"].values)) + self.db, set.union(*list(df['inspiration_ids'].values)) ) lookup = self.db.get_pose_id_alias_dict(pset=inspirations) - df["inspiration_aliases"] = df["inspiration_ids"].apply( + df['inspiration_aliases'] = df['inspiration_ids'].apply( lambda x: {lookup[i] for i in x} ) if not inspiration_ids: - df = df.drop(columns=["inspiration_ids"]) + df = df.drop(columns=['inspiration_ids']) if reference_alias: references = PoseSet( self.db, - set([int(x) for x in df["reference_id"].values if x is not None]), + set([int(x) for x in df['reference_id'].values if x is not None]), ) if references: lookup = self.db.get_pose_id_alias_dict(pset=references) - df["reference_alias"] = df["reference_id"].apply(lambda x: lookup[x]) + df['reference_alias'] = df['reference_id'].apply(lambda x: lookup[x]) else: - df["reference_alias"] = None + df['reference_alias'] = None if not reference_id: - df = df.drop(columns=["reference_id"]) + df = df.drop(columns=['reference_id']) if tags: if debug: - mrich.debug("adding tag column") + mrich.debug('adding tag column') lookup = self.db.get_pose_tag_dict() if not expand_tags: - df["tags"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['tags'] = df['id'].apply(lambda x: lookup.get(x, set())) else: for i, row in df.iterrows(): - for tag in lookup.get(row["id"], set()): + for tag in lookup.get(row['id'], set()): df.loc[i, tag] = True if subsites: if debug: - mrich.debug("adding subsite column") + mrich.debug('adding subsite column') lookup = self.db.get_pose_subsite_names_dict() - df["subsites"] = df["id"].apply(lambda x: lookup.get(x, set())) + df['subsites'] = df['id'].apply(lambda x: lookup.get(x, set())) if name: - df["name"] = df.apply(lambda row: row["alias"] or f'P{row["id"]}', axis=1) + df['name'] = df.apply(lambda row: row['alias'] or f'P{row["id"]}', axis=1) if not get_alias: - df = df.drop(columns=["alias"]) + df = df.drop(columns=['alias']) - df = df.set_index("id") + df = df.set_index('id') ### Fill missing smiles entries - smiles_missing = smiles and "smiles" in df.columns and df["smiles"].isna().any() + smiles_missing = smiles and 'smiles' in df.columns and df['smiles'].isna().any() inchikey_missing = ( - inchikey and "inchikey" in df.columns and df["inchikey"].isna().any() + inchikey and 'inchikey' in df.columns and df['inchikey'].isna().any() ) if smiles_missing or inchikey_missing: + mrich.error('None in smiles/inchikey column') - mrich.error("None in smiles/inchikey column") - - empty = df[df["smiles"].isna()] + empty = df[df['smiles'].isna()] empty_poses = PoseSet(self.db, set(empty.index)) for pose in mrich.track( - empty_poses, prefix=f"generating smiles/inchikeys ({len(empty)} poses)" + empty_poses, prefix=f'generating smiles/inchikeys ({len(empty)} poses)' ): pose.smiles records = self.db.select_where( - table="pose", - query="pose_id, pose_smiles, pose_inchikey, pose_mol", - key=f"pose_id IN {empty_poses.str_ids}", + table='pose', + query='pose_id, pose_smiles, pose_inchikey, pose_mol', + key=f'pose_id IN {empty_poses.str_ids}', multiple=True, ) for pose_id, pose_smiles, pose_inchikey, pose_mol in records: - df.loc[pose_id, "smiles"] = pose_smiles - df.loc[pose_id, "inchikey"] = pose_inchikey - df.loc[pose_id, "mol"] = Mol(pose_mol) + df.loc[pose_id, 'smiles'] = pose_smiles + df.loc[pose_id, 'inchikey'] = pose_inchikey + df.loc[pose_id, 'mol'] = Mol(pose_mol) - assert not df["smiles"].isna().any() - assert not df["inchikey"].isna().any() + assert not df['smiles'].isna().any() + assert not df['inchikey'].isna().any() ### Fill missing molecule entries - if mol and df["mol"].isna().any(): - empty = df[df["mol"].isna()] + if mol and df['mol'].isna().any(): + empty = df[df['mol'].isna()] mrich.warning(len(empty), "rows have empty 'mol'") empty_poses = PoseSet(self.db, set(empty.index)) - for pose in mrich.track(empty_poses, prefix="generating Mols"): + for pose in mrich.track(empty_poses, prefix='generating Mols'): pose.mol records = self.db.select_where( - table="pose", - query="pose_id, pose_mol", - key=f"pose_id IN {empty_poses.str_ids}", + table='pose', + query='pose_id, pose_mol', + key=f'pose_id IN {empty_poses.str_ids}', multiple=True, ) for pose_id, pose_mol in records: - df.loc[pose_id, "mol"] = Mol(pose_mol) + df.loc[pose_id, 'mol'] = Mol(pose_mol) - assert not len(df[df["mol"].isna()]) + assert not len(df[df['mol'].isna()]) return df def get_by_reference( self, ref_id: int, - ) -> "PoseSet | None": + ) -> 'PoseSet | None': """Get poses with a certain reference id :param ref_id: reference :class:`.Pose` ID """ values = self.db.select_where( - table="pose", - query="pose_id", - key=f"pose_reference={ref_id} AND pose_id in {self.str_ids}", + table='pose', + query='pose_id', + key=f'pose_reference={ref_id} AND pose_id in {self.str_ids}', multiple=True, ) if not values: return None - return PoseSet(self.db, [v for v, in values]) + return PoseSet(self.db, [v for (v,) in values]) def get_by_compound( self, *, - compound: "int | Compound | CompoundSet", - ) -> "PoseSet | None": + compound: 'int | Compound | CompoundSet', + ) -> 'PoseSet | None': """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. :param compound: :class:`.Compound` object or ID @@ -1571,36 +1561,35 @@ def get_by_compound( if isinstance(compound, CompoundSet): values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound IN {compound.str_ids} AND pose_id in {self.str_ids}", + query='pose_id', + table='pose', + key=f'pose_compound IN {compound.str_ids} AND pose_id in {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) else: - if isinstance(compound, Compound): compound = compound.id values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_compound={compound} AND pose_id in {self.str_ids}", + query='pose_id', + table='pose', + key=f'pose_compound={compound} AND pose_id in {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) if not values: return None - ids = [v for v, in values if v] - return PoseSet(self.db, [v for v, in values]) + ids = [v for (v,) in values if v] + return PoseSet(self.db, [v for (v,) in values]) def get_by_target( self, *, id: int, - ) -> "PoseSet | None": + ) -> 'PoseSet | None': """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. :param id: :class:`.Target` ID @@ -1609,13 +1598,13 @@ def get_by_target( """ assert isinstance(id, int) values = self.db.select_where( - query="pose_id", - table="pose", - key=f"pose_target is {id} AND pose_id in {self.str_ids}", + query='pose_id', + table='pose', + key=f'pose_target is {id} AND pose_id in {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] if not ids: return None return PoseSet(self.db, ids) @@ -1624,7 +1613,7 @@ def get_by_subsite( self, *, id: int, - ) -> "PoseSet | None": + ) -> 'PoseSet | None': """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. :param id: :class:`.Subsite` ID @@ -1633,18 +1622,18 @@ def get_by_subsite( """ assert isinstance(id, int) values = self.db.select_where( - query="subsite_tag_pose", - table="subsite_tag", - key=f"subsite_tag_ref is {id} AND subsite_tag_pose in {self.str_ids}", + query='subsite_tag_pose', + table='subsite_tag', + key=f'subsite_tag_ref is {id} AND subsite_tag_pose in {self.str_ids}', multiple=True, - none="quiet", + none='quiet', ) - ids = [v for v, in values if v] + ids = [v for (v,) in values if v] if not ids: return None if self.name: - name = f"{self.name} & subsite={id}" + name = f'{self.name} & subsite={id}' else: name = None @@ -1654,7 +1643,7 @@ def get_best_placed_poses_per_compound(self): """Choose the best placed pose (best distance_score) grouped by compound""" sql = f""" - SELECT pose_id, MIN(pose_distance_score) + SELECT pose_id, MIN(pose_distance_score) FROM {self.db.SQL_SCHEMA_PREFIX}pose WHERE pose_id IN {self.str_ids} GROUP BY pose_compound @@ -1672,7 +1661,7 @@ def filter( *, key: str = None, value: str = None, - operator="=", + operator='=', inverse: bool = False, ): """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values @@ -1686,7 +1675,6 @@ def filter( """ if function: - ids = set() for pose in self: value = function(pose) @@ -1706,7 +1694,7 @@ def filter( cursor = self.db.execute(sql) - ids = [i for i, in cursor] + ids = [i for (i,) in cursor] return PoseSet(self.db, ids) @@ -1716,19 +1704,19 @@ def filter( def reference(self): """Bulk set the references for poses in this set""" raise NotImplementedError( - "This attribute only allows setting, ``PoseSet.reference = ...``" + 'This attribute only allows setting, ``PoseSet.reference = ...``' ) @reference.setter def reference(self, r) -> None: """Bulk set the references for poses in this set""" if not isinstance(r, int): - assert r._table == "pose" + assert r._table == 'pose' r = r.id for i in self.indices: self.db.update( - table="pose", id=i, key="pose_reference", value=r, commit=False + table='pose', id=i, key='pose_reference', value=r, commit=False ) self.db.commit() @@ -1760,13 +1748,13 @@ def append_to_metadata( """ for id in self.indices: - metadata = self.db.get_metadata(table="pose", id=id) + metadata = self.db.get_metadata(table='pose', id=id) try: metadata.append(key, value) except AttributeError: - mrich.error(f"Could not append to metadata {key=}. Not a list?") + mrich.error(f'Could not append to metadata {key=}. Not a list?') - def set_subsites_from_metadata_field(self, field="CanonSites alias") -> None: + def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: """Create and assign subsite entries from a metadata field :param field: the metadata field to use @@ -1779,8 +1767,8 @@ def calculate_inspiration_scores( self, alpha: float = 0.95, beta: float = 0.05, - score_type: str = "combo", - ) -> "pd.DataFrame": + score_type: str = 'combo', + ) -> 'pd.DataFrame': """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky :param alpha: Tversky alpha parameter @@ -1801,37 +1789,36 @@ def calculate_inspiration_scores( inspirations = {p.id: p for p in self.inspirations} - df["inspiration_mols"] = df["inspiration_ids"].apply( + df['inspiration_mols'] = df['inspiration_ids'].apply( lambda x: [inspirations[i].mol for i in x] ) n = len(df) for j, (i, row) in mrich.track( - enumerate(df.iterrows()), prefix="MoCASSIn", total=n + enumerate(df.iterrows()), prefix='MoCASSIn', total=n ): - - mrich.set_progress_field("j", j) - mrich.set_progress_field("n", n) + mrich.set_progress_field('j', j) + mrich.set_progress_field('n', n) try: combo, shape, colour = calculate_mocassin_tversky( - row["inspiration_mols"], - row["mol"], + row['inspiration_mols'], + row['mol'], alpha=0.95, beta=0.05, ) - df.loc[i, f"mocassin_combo({alpha},{beta})"] = combo - df.loc[i, f"mocassin_shape({alpha},{beta})"] = shape - df.loc[i, f"mocassin_colour({alpha},{beta})"] = colour + df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo + df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape + df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour except Exception as e: mrich.error(e) - tuples = df[f"mocassin_{score_type}({alpha},{beta})"].items() + tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" - mrich.debug("Updating pose_inspiration_score values") + mrich.debug('Updating pose_inspiration_score values') self.db.executemany(sql, [(b, a) for a, b in tuples]) self.db.commit() @@ -1839,7 +1826,7 @@ def calculate_inspiration_scores( ### SPLITTING - def split_by_reference(self) -> "dict[int,PoseSet]": + def split_by_reference(self) -> 'dict[int,PoseSet]': """Split this :class:`.PoseSet` into subsets grouped by reference ID :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values @@ -1853,7 +1840,7 @@ def split_by_reference(self) -> "dict[int,PoseSet]": def split_by_inspirations( self, single_set: bool = False, - ) -> "dict[PoseSet,PoseSet] | PoseSet": + ) -> 'dict[PoseSet,PoseSet] | PoseSet': """Split this :class:`.PoseSet` into subsets grouped by inspirations :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) @@ -1870,7 +1857,7 @@ def split_by_inspirations( sets.setdefault(key, set()) sets[key].add(pose_id) - mrich.var("#unique inspiration combinations", len(sets)) + mrich.var('#unique inspiration combinations', len(sets)) if single_set: return PoseSet(self.db, sum([s.ids for s in sets.values()], []), sort=False) @@ -1885,7 +1872,7 @@ def split_by_inspirations( def write_sdf( self, out_path: str, - name_col: str = "alias", + name_col: str = 'alias', inspiration_ids: bool = False, inspiration_aliases: bool = False, **kwargs, @@ -1899,23 +1886,23 @@ def write_sdf( :param fragalysis_inspirations: create inspirations column "ref_mols" """ - from pathlib import Path import json + from pathlib import Path df = self.get_df( mol=True, inspiration_ids=inspiration_ids, inspiration_aliases=inspiration_aliases, - name=name_col == "name", + name=name_col == 'name', **kwargs, ) - if name_col not in ["name", "alias", "inchikey", "id"]: + if name_col not in ['name', 'alias', 'inchikey', 'id']: # try getting name from metadata records = self.db.select_where( - table="pose", - query="pose_id, pose_metadata", - key=f"pose_id IN {self.str_ids}", + table='pose', + query='pose_id, pose_metadata', + key=f'pose_id IN {self.str_ids}', multiple=True, ) @@ -1930,29 +1917,29 @@ def write_sdf( values = [] for i, row in df.iterrows(): - values.append(longcode_lookup[row["id"]]) + values.append(longcode_lookup[row['id']]) df[name_col] = values - df.rename(inplace=True, columns={name_col: "_Name", "mol": "ROMol"}) + df.rename(inplace=True, columns={name_col: '_Name', 'mol': 'ROMol'}) mrich.writing(out_path) from rdkit.Chem import PandasTools - PandasTools.WriteSDF(df, out_path, "ROMol", "_Name", list(df.columns)) + PandasTools.WriteSDF(df, out_path, 'ROMol', '_Name', list(df.columns)) # keep record of export value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table="pose", key="exports", value=value) - self.append_to_metadata(key="exports", value=value) + self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + self.append_to_metadata(key='exports', value=value) def to_fragalysis( self, out_path: str, *, method: str, - ref_url: str = "https://hippo.winokan.com", + ref_url: str = 'https://hippo.winokan.com', submitter_name: str, submitter_email: str, submitter_institution: str, @@ -1992,61 +1979,61 @@ def to_fragalysis( """ - from .fragalysis import generate_header from pathlib import Path - from rdkit.Chem import SDWriter, PandasTools - assert out_path.endswith(".sdf") + from rdkit.Chem import PandasTools, SDWriter + + from .fragalysis import generate_header + + assert out_path.endswith('.sdf') - _name_col = "_Name" - mol_col = "ROMol" + _name_col = '_Name' + mol_col = 'ROMol' # make sure references are defined: - mrich.debug(len(self), "poses in set") + mrich.debug(len(self), 'poses in set') poses = None if skip_no_reference: - values = self.db.select_where( - table="pose", - query="DISTINCT pose_id", - key=f"pose_reference IS NOT NULL and pose_id in {self.str_ids}", + table='pose', + query='DISTINCT pose_id', + key=f'pose_reference IS NOT NULL and pose_id in {self.str_ids}', multiple=True, - none="error", + none='error', ) if not values: return - poses = PoseSet(self.db, [i for i, in values]) + poses = PoseSet(self.db, [i for (i,) in values]) - mrich.debug(len(poses), "remaining after skipping null reference") + mrich.debug(len(poses), 'remaining after skipping null reference') if skip_no_inspirations: - if not poses: poses = self values = self.db.select_where( - table="inspiration", - query="DISTINCT inspiration_derivative", - key=f"inspiration_derivative IN {poses.str_ids}", + table='inspiration', + query='DISTINCT inspiration_derivative', + key=f'inspiration_derivative IN {poses.str_ids}', multiple=True, - none="error", + none='error', ) if not values: return - poses = PoseSet(self.db, [i for i, in values]) + poses = PoseSet(self.db, [i for (i,) in values]) - mrich.debug(len(poses), "remaining after skipping null inspirations") + mrich.debug(len(poses), 'remaining after skipping null inspirations') if not poses: poses = PoseSet(self.db, self.ids) - mrich.var("#poses", len(poses)) + mrich.var('#poses', len(poses)) # get the dataframe of poses @@ -2080,12 +2067,12 @@ def to_fragalysis( inspiration_strs = [] for i, row in pose_df.iterrows(): strs = [] - for i in row["inspiration_ids"]: + for i in row['inspiration_ids']: alias = lookup.get(i) if not alias: continue strs.append(alias) - inspiration_strs.append(",".join(strs)) + inspiration_strs.append(','.join(strs)) # comma separate subsites if subsites: @@ -2093,63 +2080,63 @@ def to_fragalysis( def fix_subsites(subsite_list: list[str]): """Fix subsites""" if not subsite_list: - return "None" - return ",".join(subsite_list) + return 'None' + return ','.join(subsite_list) - pose_df["subsites"] = pose_df["subsites"].apply(fix_subsites) + pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) if tags: - pose_df["tags"] = pose_df["tags"].apply(lambda x: ",".join(x)) + pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(x)) - pose_df["ref_mols"] = inspiration_strs - pose_df["ref_pdb"] = pose_df["reference_id"].apply(lambda x: lookup[x]) + pose_df['ref_mols'] = inspiration_strs + pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup[x]) # add compound identifier column (inchikey?) - drops = ["inspiration_ids", "reference_id"] + drops = ['inspiration_ids', 'reference_id'] # if ingredients: # drops.pop(drops.index("compound")) if skip_no_reference: prev = len(pose_df) - pose_df = pose_df[pose_df["reference_id"].notna()] + pose_df = pose_df[pose_df['reference_id'].notna()] if len(pose_df) < prev: - mrich.warning(f"Skipping {prev - len(pose_df)} Poses with no reference") + mrich.warning(f'Skipping {prev - len(pose_df)} Poses with no reference') - pose_df = pose_df.drop(columns=drops, errors="ignore") + pose_df = pose_df.drop(columns=drops, errors='ignore') - pose_df[_name_col] = pose_df["name"] + pose_df[_name_col] = pose_df['name'] pose_df.rename( inplace=True, columns={ - "id": "HIPPO Pose ID", - "compound_id": "HIPPO Compound ID", - "mol": mol_col, + 'id': 'HIPPO Pose ID', + 'compound_id': 'HIPPO Compound ID', + 'mol': mol_col, # "smiles": "original SMILES", # "compound_id": "compound inchikey", }, ) extras = { - "HIPPO Pose ID": "HIPPO Pose ID", - "HIPPO Compound ID": "HIPPO Compound ID", - "smiles": "smiles", - "ref_pdb": "protein reference", - "ref_mols": "fragment inspirations", - "alias": "alias", + 'HIPPO Pose ID': 'HIPPO Pose ID', + 'HIPPO Compound ID': 'HIPPO Compound ID', + 'smiles': 'smiles', + 'ref_pdb': 'protein reference', + 'ref_mols': 'fragment inspirations', + 'alias': 'alias', # "compound inchikey": "compound inchikey", - "distance_score": "distance_score", - "energy_score": "energy_score", - "inspiration_score": "inspiration_score", + 'distance_score': 'distance_score', + 'energy_score': 'energy_score', + 'inspiration_score': 'inspiration_score', } if subsites: - extras["subsites"] = "subsites" + extras['subsites'] = 'subsites' if tags: - extras["tags"] = "tags" + extras['tags'] = 'tags' if extra_cols: for key, value in extra_cols.items(): @@ -2198,28 +2185,25 @@ def fix_subsites(subsite_list: list[str]): # extras["Amount (mg)"] = "Quoted amount" out_path = Path(out_path).resolve() - mrich.var("out_path", out_path) + mrich.var('out_path', out_path) if generate_pdbs: - from zipfile import ZipFile # output subdirectory - out_key = Path(out_path).name.removesuffix(".sdf") + out_key = Path(out_path).name.removesuffix('.sdf') pdb_dir = Path(out_path).parent / Path(out_key) pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f"{out_key}_pdbs.zip" + zip_path = Path(out_path).parent / f'{out_key}_pdbs.zip' # create the zip archive - with ZipFile(str(zip_path.resolve()), "w") as z: - + with ZipFile(str(zip_path.resolve()), 'w') as z: # loop over poses - for (i, row), pose in zip(pose_df.iterrows(), poses): - + for (i, row), pose in zip(pose_df.iterrows(), poses, strict=False): # filenames - pdb_name = f"{out_key}_{row._Name}.pdb" + pdb_name = f'{out_key}_{row._Name}.pdb' pdb_path = pdb_dir / pdb_name - pose_df.loc[i, "ref_pdb"] = pdb_name + pose_df.loc[i, 'ref_pdb'] = pdb_name # generate the PL-complex sys = pose.complex_system @@ -2229,35 +2213,34 @@ def fix_subsites(subsite_list: list[str]): sys.write(pdb_path, verbosity=0) z.write(pdb_path) - mrich.writing(f"{out_key}_pdbs.zip") + mrich.writing(f'{out_key}_pdbs.zip') if copy_reference_pdbs: - - from zipfile import ZipFile import shutil + from zipfile import ZipFile # output subdirectory - out_key = Path(out_path).name.removesuffix(".sdf") + out_key = Path(out_path).name.removesuffix('.sdf') pdb_dir = Path(out_path).parent / Path(out_key) pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f"{out_key}_refs.zip" + zip_path = Path(out_path).parent / f'{out_key}_refs.zip' references = self.references lookup = self.db.get_pose_alias_path_dict(references) zips = set() - for ref_alias in pose_df["ref_pdb"].values: + for ref_alias in pose_df['ref_pdb'].values: source_path = Path(lookup[ref_alias]) apo_path = source_path.parent / source_path.name.replace( - "_hippo.pdb", ".pdb" - ).replace(".pdb", "_apo-desolv.pdb") + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_apo-desolv.pdb') if not apo_path.exists(): sys = mp.parse(source_path).protein_system sys.write(apo_path, verbosity=0) - target_path = pdb_dir / f"{ref_alias}.pdb" + target_path = pdb_dir / f'{ref_alias}.pdb' if not target_path.exists(): mrich.writing(target_path) @@ -2266,11 +2249,11 @@ def fix_subsites(subsite_list: list[str]): zips.add(target_path) # create the zip archive - with ZipFile(str(zip_path.resolve()), "w") as z: + with ZipFile(str(zip_path.resolve()), 'w') as z: for path in zips: z.write(path, arcname=path.name) - mrich.writing(f"{out_key}_refs.zip") + mrich.writing(f'{out_key}_refs.zip') # create the header molecule @@ -2314,7 +2297,7 @@ def fix_subsites(subsite_list: list[str]): mrich.writing(out_path) - with open(out_path, "w") as sdfh: + with open(out_path, 'w') as sdfh: with SDWriter(sdfh) as w: w.write(header) PandasTools.WriteSDF( @@ -2323,8 +2306,8 @@ def fix_subsites(subsite_list: list[str]): # keep record of export value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table="pose", key="exports", value=value) - self.append_to_metadata(key="exports", value=value) + self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + self.append_to_metadata(key='exports', value=value) return pose_df @@ -2337,73 +2320,69 @@ def to_pymol(self, prefix: str | None = None) -> None: commands = [] - prefix = prefix or "" + prefix = prefix or '' if prefix: - prefix = f"{prefix}_" + prefix = f'{prefix}_' from pathlib import Path for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): - ref_pose = self.db.get_pose(id=ref_id) ref_name = ref_pose.name or ref_id # create the subdirectory - ref_dir = Path(f"{prefix}ref_{ref_name}") + ref_dir = Path(f'{prefix}ref_{ref_name}') mrich.writing(ref_dir) ref_dir.mkdir(parents=True, exist_ok=True) # write the reference protein - ref_pdb = ref_dir / f"ref_{ref_name}.pdb" + ref_pdb = ref_dir / f'ref_{ref_name}.pdb' ref_pose.protein_system.write(ref_pdb, verbosity=0) # color the reference: - commands.append(f"load {ref_pdb.resolve()}") - commands.append("hide") - commands.append("show lines") - commands.append("show surface") - commands.append("util.cbaw") - commands.append("set surface_color, white") - commands.append("set transparency, 0.4") + commands.append(f'load {ref_pdb.resolve()}') + commands.append('hide') + commands.append('show lines') + commands.append('show surface') + commands.append('util.cbaw') + commands.append('set surface_color, white') + commands.append('set transparency, 0.4') for j, (insp_ids, poses) in enumerate( poses.split_by_inspirations().items() ): - inspirations = PoseSet(self.db, insp_ids) - insp_names = "-".join(inspirations.names) + insp_names = '-'.join(inspirations.names) # create the subdirectory insp_dir = ref_dir / insp_names insp_dir.mkdir(parents=True, exist_ok=True) # write the inspirations - insp_sdf = insp_dir / f"{insp_names}_frags.sdf" + insp_sdf = insp_dir / f'{insp_names}_frags.sdf' inspirations.write_sdf(insp_sdf) - commands.append(f"load {insp_sdf.resolve()}") - commands.append( - f"set all_states, on, {insp_sdf.name.removesuffix('.sdf')}" - ) + commands.append(f'load {insp_sdf.resolve()}') commands.append( - f"util.rainbow \"{insp_sdf.name.removesuffix('.sdf')}\"" + f'set all_states, on, {insp_sdf.name.removesuffix(".sdf")}' ) + commands.append(f'util.rainbow "{insp_sdf.name.removesuffix(".sdf")}"') # write the poses - pose_sdf = insp_dir / f"{insp_names}_derivatives.sdf" + pose_sdf = insp_dir / f'{insp_names}_derivatives.sdf' poses.write_sdf(pose_sdf) - commands.append(f"load {pose_sdf.resolve()}") + commands.append(f'load {pose_sdf.resolve()}') commands.append(f'util.cbaw "{pose_sdf.name.removesuffix(".sdf")}"') if j > 0: - commands.append(f"disable \"{insp_sdf.name.removesuffix('.sdf')}\"") + commands.append(f'disable "{insp_sdf.name.removesuffix(".sdf")}"') commands.append(f'disable "{pose_sdf.name.removesuffix(".sdf")}"') - return "; ".join(commands) + return '; '.join(commands) def to_knitwork( - self, out_path: str, path_root: str = ".", aligned_files_dir: str | None = None + self, out_path: str, path_root: str = '.', aligned_files_dir: str | None = None ) -> None: """Knitwork takes a CSV input with: @@ -2422,31 +2401,28 @@ def to_knitwork( out_path = Path(out_path).resolve() path_root = Path(path_root).resolve() - mrich.var("out_path", out_path) - mrich.var("path_root", path_root) - mrich.var("aligned_files_dir", aligned_files_dir) + mrich.var('out_path', out_path) + mrich.var('path_root', path_root) + mrich.var('aligned_files_dir', aligned_files_dir) - assert out_path.name.endswith(".csv") - - with open(out_path, "wt") as f: + assert out_path.name.endswith('.csv') + with open(out_path, 'w') as f: mrich.writing(out_path) for pose in self: - assert pose.alias - assert "hits" in pose.tags + assert 'hits' in pose.tags if aligned_files_dir: - mol = str(pose.mol_path) pdb = str(pose.apo_path) - assert "aligned_files" in mol - assert "aligned_files" in pdb + assert 'aligned_files' in mol + assert 'aligned_files' in pdb - mol = mol.split("aligned_files/")[-1] - pdb = pdb.split("aligned_files/")[-1] + mol = mol.split('aligned_files/')[-1] + pdb = pdb.split('aligned_files/')[-1] aligned_files_dir = Path(aligned_files_dir) @@ -2459,92 +2435,91 @@ def to_knitwork( data = [pose.alias, pose.compound.smiles, mol, pdb] - f.write(",".join(data)) - f.write("\n") + f.write(','.join(data)) + f.write('\n') def to_syndirella( - self, out_key: "str | Path", separate: bool = False - ) -> "DataFrame": + self, out_key: 'str | Path', separate: bool = False + ) -> 'DataFrame': """Create syndirella inputs""" from pathlib import Path - out_key = Path(".") / out_key + out_key = Path('.') / out_key out_dir = out_key.parent out_key = out_key.name - mrich.var("out_key", out_key) - mrich.var("#poses", len(self)) + mrich.var('out_key', out_key) + mrich.var('#poses', len(self)) out_dir.mkdir(parents=True, exist_ok=True) import shutil - from pandas import DataFrame ### Prepare Syndirella CSV data df = self.get_df( inchikey=False, alias=False, reference_alias=True, inspiration_aliases=True ) - df = df.rename(columns={"reference_alias": "template"}) + df = df.rename(columns={'reference_alias': 'template'}) # compound_set if separate: - df["compound_set"] = df.apply( - lambda row: f"{out_key}_{row['name']}", axis=1 + df['compound_set'] = df.apply( + lambda row: f'{out_key}_{row["name"]}', axis=1 ) else: - df["compound_set"] = out_key + df['compound_set'] = out_key # template - null_template = df["template"].isnull() + null_template = df['template'].isnull() if null_template.any(): mrich.warning( - len(null_template), "poses have no reference. Setting to self" + len(null_template), 'poses have no reference. Setting to self' ) - mrich.print(df.loc[null_template, "name"].values) - df["template"] = df["template"].fillna(df["name"]) + mrich.print(df.loc[null_template, 'name'].values) + df['template'] = df['template'].fillna(df['name']) # inspirations - null_inspirations = df["inspiration_aliases"].apply(lambda x: not x) + null_inspirations = df['inspiration_aliases'].apply(lambda x: not x) if null_inspirations.any(): mrich.warning( - len(null_inspirations), "poses have no inspirations. Setting to self" + len(null_inspirations), 'poses have no inspirations. Setting to self' ) - mrich.print(df.loc[null_inspirations, "name"].values) - df.loc[null_inspirations, "inspiration_aliases"] = df.loc[ + mrich.print(df.loc[null_inspirations, 'name'].values) + df.loc[null_inspirations, 'inspiration_aliases'] = df.loc[ null_inspirations - ].apply(lambda row: set([row["name"]]), axis=1) + ].apply(lambda row: set([row['name']]), axis=1) for i, row in df.iterrows(): - for j, inspiration in enumerate(row["inspiration_aliases"]): - df.loc[i, f"hit{j+1}"] = inspiration + for j, inspiration in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = inspiration - all_inspirations = set.union(*list(df["inspiration_aliases"].values)) + all_inspirations = set.union(*list(df['inspiration_aliases'].values)) - df = df.drop(columns=["name", "inspiration_aliases"]) + df = df.drop(columns=['name', 'inspiration_aliases']) ### Copy Templates - template_dir = out_dir / "templates" + template_dir = out_dir / 'templates' mrich.writing(template_dir) template_dir.mkdir(parents=True, exist_ok=True) - templates = df["template"].unique() + templates = df['template'].unique() records = self.db.select_id_where( - table="pose", - key=f"pose_alias IN {str(tuple(templates)).replace(',)', ')')}", + table='pose', + key=f'pose_alias IN {str(tuple(templates)).replace(",)", ")")}', multiple=True, ) - templates = PoseSet(self.db, [i for i, in records]) + templates = PoseSet(self.db, [i for (i,) in records]) for ref in templates: template = template_dir / ref.apo_path.name @@ -2555,34 +2530,34 @@ def to_syndirella( ### Inspirations records = self.db.select_id_where( - table="pose", - key=f"pose_alias IN {str(tuple(all_inspirations)).replace(',)', ')')}", + table='pose', + key=f'pose_alias IN {str(tuple(all_inspirations)).replace(",)", ")")}', multiple=True, ) - all_inspirations = PoseSet(self.db, [i for i, in records]) + all_inspirations = PoseSet(self.db, [i for (i,) in records]) ### Write CSV if separate: for i, row in df.iterrows(): - csv_name = out_dir / f"{row['compound_set']}_syndirella_input.csv" + csv_name = out_dir / f'{row["compound_set"]}_syndirella_input.csv' mrich.writing(csv_name) row.to_frame().T.to_csv(csv_name, index=False) else: - csv_name = out_dir / f"{out_key}_syndirella_input.csv" + csv_name = out_dir / f'{out_key}_syndirella_input.csv' mrich.writing(csv_name) df.to_csv(csv_name, index=False) ### Write Inspirations - sdf_name = out_dir / f"{out_key}_syndirella_inspiration_hits.sdf" + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' all_inspirations.write_sdf( sdf_name, tags=False, metadata=False, - name_col="name", + name_col='name', ) return df @@ -2604,18 +2579,18 @@ def interactive( """ + from pprint import pprint + + from IPython.display import display from ipywidgets import ( - interactive, BoundedIntText, Checkbox, - interactive_output, - HBox, GridBox, Layout, VBox, + interactive, + interactive_output, ) - from IPython.display import display - from pprint import pprint if method: @@ -2635,7 +2610,7 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description="Pose:", + description='Pose:', disabled=False, ), ) @@ -2656,38 +2631,37 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description="Pose:", + description='Pose:', disabled=False, ), ) else: - a = BoundedIntText( value=0, min=0, max=len(self) - 1, step=1, - description=f"Pose (/{len(self)}):", + description=f'Pose (/{len(self)}):', disabled=False, ) - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - h = Checkbox(description="Tags", value=False) - i = Checkbox(description="Subsites", value=False) - d = Checkbox(description="2D (Comp.)", value=False) - e = Checkbox(description="2D (Pose)", value=False) - f = Checkbox(description="3D", value=True) - g = Checkbox(description="Metadata", value=False) + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + h = Checkbox(description='Tags', value=False) + i = Checkbox(description='Subsites', value=False) + d = Checkbox(description='2D (Comp.)', value=False) + e = Checkbox(description='2D (Pose)', value=False) + f = Checkbox(description='3D', value=True) + g = Checkbox(description='Metadata', value=False) ui1 = GridBox( [b, c, d, h], - layout=Layout(grid_template_columns="repeat(4, 100px)"), + layout=Layout(grid_template_columns='repeat(4, 100px)'), ) ui2 = GridBox( [e, f, g, i], - layout=Layout(grid_template_columns="repeat(4, 100px)"), + layout=Layout(grid_template_columns='repeat(4, 100px)'), ) ui = VBox([a, ui1, ui2]) @@ -2720,21 +2694,21 @@ def widget( if draw: pose.draw() if metadata: - mrich.title("Metadata:") + mrich.title('Metadata:') pprint(pose.metadata) out = interactive_output( widget, { - "i": a, - "name": b, - "summary": c, - "grid": d, - "draw2d": e, - "draw": f, - "metadata": g, - "tags": h, - "subsites": i, + 'i': a, + 'name': b, + 'summary': c, + 'grid': d, + 'draw2d': e, + 'draw': f, + 'metadata': g, + 'tags': h, + 'subsites': i, }, ) @@ -2742,10 +2716,10 @@ def widget( def summary(self) -> None: """Print a summary of this pose set""" - mrich.header("PoseSet()") - mrich.var("#poses", len(self)) - mrich.var("#compounds", self.num_compounds) - mrich.var("tags", self.tags) + mrich.header('PoseSet()') + mrich.var('#poses', len(self)) + mrich.var('#compounds', self.num_compounds) + mrich.var('tags', self.tags) def draw(self) -> None: """Render this pose set with Py3Dmol""" @@ -2769,7 +2743,7 @@ def grid(self) -> None: drawing = draw_grid(mols, labels=labels) display(drawing) - def subsite_summary(self) -> "pd.DataFrame": + def subsite_summary(self) -> 'pd.DataFrame': """Print a table counting poses by subsite""" from pandas import DataFrame @@ -2788,9 +2762,9 @@ def subsite_summary(self) -> "pd.DataFrame": [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] ) - df = df.set_index("id") + df = df.set_index('id') - df = df.sort_values(by="num_poses", ascending=False) + df = df.sort_values(by='num_poses', ascending=False) mrich.print(df) @@ -2802,36 +2776,36 @@ def _delete(self, *, force: bool = False) -> None: """Delete poses in this set""" if not force: - mrich.warning("Deleting Poses is risky! Set force=True to continue") + mrich.warning('Deleting Poses is risky! Set force=True to continue') return str_ids = self.str_ids # delete the poses in this set self.db.delete_where( - table=self.table, key=f"pose_id IN {str_ids}", commit=False + table=self.table, key=f'pose_id IN {str_ids}', commit=False ) # check for other references to this pose - self.db.delete_where(table="tag", key=f"tag_pose IN {str_ids}", commit=False) + self.db.delete_where(table='tag', key=f'tag_pose IN {str_ids}', commit=False) self.db.delete_where( - table="inspiration", - key=f"inspiration_original IN {str_ids}", + table='inspiration', + key=f'inspiration_original IN {str_ids}', commit=False, ) self.db.delete_where( - table="inspiration", - key=f"inspiration_derivative IN {str_ids}", + table='inspiration', + key=f'inspiration_derivative IN {str_ids}', commit=False, ) self.db.delete_where( - table="subsite_tag", - key=f"subsite_tag_pose IN {str_ids}", + table='subsite_tag', + key=f'subsite_tag_pose IN {str_ids}', commit=False, ) self.db.delete_where( - table="interaction", - key=f"interaction_pose IN {str_ids}", + table='interaction', + key=f'interaction_pose IN {str_ids}', commit=False, ) @@ -2850,21 +2824,21 @@ def _delete(self, *, force: bool = False) -> None: def __str__(self): """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"P × {len(self)}" "}" + s += f'{{P × {len(self)}}}' return s def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """The number of poses in this set""" @@ -2877,19 +2851,18 @@ def __iter__(self): def __getitem__( self, key: int | slice, - ) -> "Pose | PoseSet": + ) -> 'Pose | PoseSet': """Get poses or subsets thereof from this set :param key: integer index or slice of indices """ match key: - case int(): try: index = self.indices[key] except IndexError: - mrich.error(f"list index out of range: {key=} for {self}") + mrich.error(f'list index out of range: {key=} for {self}') raise return self.db.get_pose(id=index) @@ -2902,8 +2875,8 @@ def __getitem__( def __add__( self, - other: "PoseSet", - ) -> "PoseSet": + other: 'PoseSet', + ) -> 'PoseSet': """Add a :class:`.PoseSet` to this set""" if isinstance(other, PoseSet): return PoseSet(self.db, self.ids + other.ids, sort=False) @@ -2914,8 +2887,8 @@ def __add__( def __sub__( self, - other: "PoseSet", - ) -> "PoseSet": + other: 'PoseSet', + ) -> 'PoseSet': """Substract a :class:`.PoseSet` from this set""" match other: case PoseSet(): @@ -2925,11 +2898,10 @@ def __sub__( # assert other in set(self.ids) return PoseSet(self.db, [i for i in self.ids if i != other], sort=False) - def __and__(self, other: "PoseSet"): + def __and__(self, other: 'PoseSet'): """AND set operation, returns only poses in both sets""" match other: - case PoseSet(): ids = set(self.ids) & set(other.ids) return PoseSet(self.db, ids) @@ -2937,11 +2909,10 @@ def __and__(self, other: "PoseSet"): case _: raise NotImplementedError - def __or__(self, other: "PoseSet"): + def __or__(self, other: 'PoseSet'): """OR set operation, returns union of both sets""" match other: - case PoseSet(): ids = set(self.ids) | set(other.ids) return PoseSet(self.db, ids) @@ -2949,11 +2920,10 @@ def __or__(self, other: "PoseSet"): case _: raise NotImplementedError - def __xor__(self, other: "PoseSet"): + def __xor__(self, other: 'PoseSet'): """Exclusive OR set operation, returns all poses in either set but not both""" match other: - case PoseSet(): ids = set(self.ids) ^ set(other.ids) return PoseSet(self.db, ids) @@ -2967,7 +2937,7 @@ def __call__( tag: str = None, target: int = None, subsite: int = None, - ) -> "PoseSet": + ) -> 'PoseSet': """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" if tag: diff --git a/hippo/pyvis.py b/hippo/pyvis.py index b2eb2ff..a6bfc62 100644 --- a/hippo/pyvis.py +++ b/hippo/pyvis.py @@ -5,8 +5,8 @@ def get_scaffold_network( animal, - compounds="CompoundSet | None", - scaffolds="CompoundSet | None", + compounds='CompoundSet | None', + scaffolds='CompoundSet | None', # filename: "str | Path" = "network.html", notebook: bool = True, depth: int = 5, @@ -14,20 +14,20 @@ def get_scaffold_network( exclude_tag: str | None = None, physics: bool = True, arrows: bool = True, -) -> "pyvis.network.Network": +) -> 'pyvis.network.Network': """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" - from pyvis.network import Network from molparse.rdkit import smiles_to_pngstr + from pyvis.network import Network - net = Network(notebook=notebook, cdn_resources="in_line") + net = Network(notebook=notebook, cdn_resources='in_line') nodes = set() edges = set() - arrows = "to" if arrows else None + arrows = 'to' if arrows else None - def add_node(compound: "Compound") -> None: + def add_node(compound: 'Compound') -> None: """Add node to network""" if compound.id in nodes: @@ -39,14 +39,14 @@ def add_node(compound: "Compound") -> None: compound.id, label=compound.alias or str(compound), title=str(compound), - shape="circularImage", - image=f"data:image/png;base64,{pngstr}", + shape='circularImage', + image=f'data:image/png;base64,{pngstr}', physics=physics, ) nodes.add(compound.id) - def add_edge(scaffold: "Compound", compound: "Compound") -> None: + def add_edge(scaffold: 'Compound', compound: 'Compound') -> None: """Add edge to network""" key = (scaffold.id, compound.id) @@ -66,35 +66,33 @@ def add_edge(scaffold: "Compound", compound: "Compound") -> None: edges.add(key) def get_scaffold_records( - scaffolds: "None | CompoundSet" = None, compounds: "None | CompoundSet" = None + scaffolds: 'None | CompoundSet' = None, compounds: 'None | CompoundSet' = None ): """Get scaffold records""" if scaffolds: return animal.db.select_all_where( - table="scaffold", - key=f"scaffold_base IN {scaffolds.str_ids}", + table='scaffold', + key=f'scaffold_base IN {scaffolds.str_ids}', multiple=True, - none="quiet", + none='quiet', ) elif compounds: return animal.db.select_all_where( - table="scaffold", - key=f"scaffold_superstructure IN {compounds.str_ids}", + table='scaffold', + key=f'scaffold_superstructure IN {compounds.str_ids}', multiple=True, - none="quiet", + none='quiet', ) raise ValueError if compounds and not scaffolds: - - mrich.var("recursion depth", depth) - mrich.var("#compounds", len(compounds)) + mrich.var('recursion depth', depth) + mrich.var('#compounds', len(compounds)) records = [] n = 0 while n < depth: - if not compounds: break @@ -109,18 +107,16 @@ def get_scaffold_records( if n == depth: mrich.warning( - "Reached recursion depth. More scaffolds may be in the database" + 'Reached recursion depth. More scaffolds may be in the database' ) elif scaffolds and not compounds: - - mrich.var("recursion depth", depth) - mrich.var("#scaffolds", len(scaffolds)) + mrich.var('recursion depth', depth) + mrich.var('#scaffolds', len(scaffolds)) records = [] n = 0 while n < depth: - if not scaffolds: break @@ -135,19 +131,18 @@ def get_scaffold_records( if n == depth: mrich.warning( - "Reached recursion depth. More superstructures may be in the database" + 'Reached recursion depth. More superstructures may be in the database' ) else: raise ValueError - mrich.var("#edges", len(records)) + mrich.var('#edges', len(records)) if records: for scaffold_id, compound_id in mrich.track( - records, prefix="Adding nodes and edges" + records, prefix='Adding nodes and edges' ): - scaffold = animal.db.get_compound(id=scaffold_id) scaffold_tags = scaffold.tags diff --git a/hippo/quote.py b/hippo/quote.py index f899e58..c415035 100644 --- a/hippo/quote.py +++ b/hippo/quote.py @@ -19,7 +19,7 @@ class Quote: def __init__( self, - db: "Database", + db: 'Database', id: int, compound: int, smiles: str, @@ -54,7 +54,7 @@ def __init__( from datetime import datetime - quote_age = (datetime.today() - datetime.strptime(self.date, "%Y-%m-%d")).days + quote_age = (datetime.today() - datetime.strptime(self.date, '%Y-%m-%d')).days # if quote_age > 30: # mrich.warning(f'Quote is {quote_age} days old') # mrich.warning(self) @@ -65,9 +65,9 @@ def __init__( def combination( cls, required_amount: float, - quotes: list["Quote"], + quotes: list['Quote'], debug: bool = False, - ) -> "Quote": + ) -> 'Quote': """Combine a list of quotes into one :class:`.Quote` object. * Start with biggest pack @@ -96,17 +96,17 @@ def combination( purity=biggest_pack.purity, lead_time=biggest_pack.lead_time, date=biggest_pack.date, - type=f"estimate from quote={biggest_pack.id}", + type=f'estimate from quote={biggest_pack.id}', ) if debug: - mrich.debug(f"Quote.combination()") - mrich.debug(f"{required_amount=}") + mrich.debug('Quote.combination()') + mrich.debug(f'{required_amount=}') for quote in quotes: mrich.debug(quote) - mrich.debug(f"{biggest_pack=}") - mrich.debug(f"{unit_price=}") - mrich.debug(f"{estimated_price=}") + mrich.debug(f'{biggest_pack=}') + mrich.debug(f'{unit_price=}') + mrich.debug(f'{estimated_price=}') mrich.print(quote_data) self = cls.__new__(cls) @@ -120,12 +120,12 @@ def combination( def entry_str(self) -> str: """Unformatted string including the supplier, catalogue (if available), and entry name of the quote""" if self.catalogue: - return f"{self.supplier}:{self.catalogue}:{self.entry}" + return f'{self.supplier}:{self.catalogue}:{self.entry}' else: - return f"{self.supplier}:{self.entry}" + return f'{self.supplier}:{self.entry}' @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -165,7 +165,7 @@ def amount(self) -> float: return self._amount @property - def price(self) -> "Price": + def price(self) -> 'Price': """Price""" return self._price @@ -222,21 +222,21 @@ def currency_symbol(self) -> str: def __str__(self): """Unformatted string representation""" if self.purity: - purity = f" @ {self.purity:.0%}" + purity = f' @ {self.purity:.0%}' else: - purity = "" + purity = '' - if self.supplier == "Stock": - return f"C{self.compound} In Stock: {self.amount:}mg{purity}" + if self.supplier == 'Stock': + return f'C{self.compound} In Stock: {self.amount:}mg{purity}' elif self.type: - return f"C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles} [{self.type}]" + return f'C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles} [{self.type}]' else: - return f"C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles}" + return f'C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' diff --git a/hippo/reaction.py b/hippo/reaction.py index 60a42b4..a6b479e 100644 --- a/hippo/reaction.py +++ b/hippo/reaction.py @@ -3,8 +3,8 @@ import mcol import mrich -from .recipe import Recipe from .compound import Compound +from .recipe import Recipe class Reaction: @@ -17,11 +17,11 @@ class Reaction: """ - _table = "reaction" + _table = 'reaction' def __init__( self, - db: "Database", + db: 'Database', id: int, type: str, product: int, @@ -50,7 +50,7 @@ def type(self) -> str: return self._type @property - def product(self) -> "Compound": + def product(self) -> 'Compound': """Returns the reaction's product :class:`.Compound`""" if self._product is None: self._product = self.db.get_compound(id=self.product_id) @@ -62,12 +62,12 @@ def product_yield(self) -> float: return self._product_yield @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @property - def reactants(self) -> "CompoundSet": + def reactants(self) -> 'CompoundSet': """Returns a :class:`.CompoundSet` of the reactants""" from .cset import CompoundSet @@ -76,8 +76,8 @@ def reactants(self) -> "CompoundSet": @property def reaction_str(self) -> str: """Returns a string representing the reaction""" - s = " + ".join([str(r) for r in self.reactants]) - s = f"{s} -> {str(self.product)}" + s = ' + '.join([str(r) for r in self.reactants]) + s = f'{s} -> {str(self.product)}' return s @property @@ -88,7 +88,7 @@ def reactant_ids(self) -> set[int]: @property def reactant_str_ids(self) -> str: """Return an SQL formatted tuple string of the reactant :class:`.Compound` IDs""" - return str(tuple(self.reactant_ids)).replace(",)", ")") + return str(tuple(self.reactant_ids)).replace(',)', ')') @property def product_id(self) -> int: @@ -123,13 +123,13 @@ def price_estimate(self) -> float: @property def plain_repr(self) -> str: """Unformatted long string representation""" - return f"{self}: {self.reaction_str} via {self.type}" + return f'{self}: {self.reaction_str} via {self.type}' @property - def metadata(self) -> "MetaData": + def metadata(self) -> 'MetaData': """Returns the compound's metadata dict""" if self._metadata is None: - self._metadata = self.db.get_metadata(table="reaction", id=self.id) + self._metadata = self.db.get_metadata(table='reaction', id=self.id) return self._metadata ### METHODS @@ -142,9 +142,9 @@ def get_reactant_amount_pairs(self, compound_object: bool = True) -> list[tuple] """ compound_ids = self.db.select_where( - query="reactant_compound, reactant_amount", - table="reactant", - key="reaction", + query='reactant_compound, reactant_amount', + table='reactant', + key='reaction', value=self.id, multiple=True, ) @@ -168,15 +168,15 @@ def get_reactant_ids(self) -> list[int]: """ compound_ids = self.db.select_where( - query="reactant_compound", - table="reactant", - key="reaction", + query='reactant_compound', + table='reactant', + key='reaction', value=self.id, multiple=True, ) if compound_ids: - return [id for id, in compound_ids] + return [id for (id,) in compound_ids] else: return [] @@ -185,9 +185,9 @@ def get_recipes( amount: float = 1, # in mg debug: bool = False, pick_cheapest: bool = False, - permitted_reactions: "None | ReactionSet" = None, + permitted_reactions: 'None | ReactionSet' = None, supplier: str | None = None, - ) -> "Recipe | list[Recipe]": + ) -> 'Recipe | list[Recipe]': """Get a :class:`.Recipe` describing how to make the product :param amount: Amount in ``mg``, defaults to ``1`` @@ -220,15 +220,15 @@ def summary( """ - print(f"id={self.id}") - print(f"type={self.type}") - print(f"product={self.product}") - print(f"product_yield={self.product_yield}") + print(f'id={self.id}') + print(f'type={self.type}') + print(f'product={self.product}') + print(f'product_yield={self.product_yield}') reactants = self.get_reactant_amount_pairs() - print(f"reactants={reactants}") + print(f'reactants={reactants}') - print(f"price_estimate={self.price_estimate}") + print(f'price_estimate={self.price_estimate}') if draw: self.draw() @@ -245,8 +245,8 @@ def draw(self) -> None: mols = [r.mol for r in reactants] mols.append(product.mol) - labels = [f"+ {r}" if i > 0 else f"{r}" for i, r in enumerate(reactants)] - labels.append(f"-> {product}") + labels = [f'+ {r}' if i > 0 else f'{r}' for i, r in enumerate(reactants)] + labels.append(f'-> {product}') drawing = draw_grid(mols, labels=labels, highlightAtomLists=None) display(drawing) @@ -276,24 +276,22 @@ def check_reactant_availability( """ if debug: - mrich.var("reaction", self.id) - mrich.var("reactants", self.reactant_ids) - mrich.var("supplier", supplier) + mrich.var('reaction', self.id) + mrich.var('reactants', self.reactant_ids) + mrich.var('supplier', supplier) if supplier is None: - triples = self.db.execute( f""" - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant + SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}quote ON quote_compound = reactant_compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound WHERE reactant_reaction = {self.id} GROUP BY reactant_compound """ ).fetchall() else: - triples = self.db.execute( f""" WITH filtered_quotes AS @@ -301,33 +299,32 @@ def check_reactant_availability( SELECT * FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_supplier = "{supplier}" ) - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant + SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant LEFT JOIN filtered_quotes ON quote_compound = reactant_compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound WHERE reactant_reaction = {self.id} GROUP BY reactant_compound """ ).fetchall() for reactant_compound, has_quote, has_reaction in triples: - if debug: mrich.debug( - f"{reactant_compound=}, {bool(has_quote)=}, {bool(has_reaction)=}" + f'{reactant_compound=}, {bool(has_quote)=}, {bool(has_reaction)=}' ) if has_quote: if debug: - mrich.debug(f"reactant={reactant_compound} has quote") + mrich.debug(f'reactant={reactant_compound} has quote') continue if has_reaction: if debug: - mrich.debug(f"reactant={reactant_compound} has reaction") + mrich.debug(f'reactant={reactant_compound} has reaction') continue if debug: - mrich.warning(f"No quote or reaction for reactant={reactant_compound}") + mrich.warning(f'No quote or reaction for reactant={reactant_compound}') return False @@ -345,19 +342,19 @@ def get_dict( """ - serialisable_fields = ["id", "type", "product_id", "reactant_ids"] + serialisable_fields = ['id', 'type', 'product_id', 'reactant_ids'] data = {} for key in serialisable_fields: data[key] = getattr(self, key) if smiles: - data["product_smiles"] = self.product_smiles - data["reactant_smiles"] = self.reactant_smiles + data['product_smiles'] = self.product_smiles + data['reactant_smiles'] = self.reactant_smiles if mols: - data["product_mol"] = self.product_mol - data["reactant_mols"] = self.reactant_mols + data['product_mol'] = self.product_mol + data['reactant_mols'] = self.reactant_mols return data @@ -365,42 +362,42 @@ def _delete(self) -> None: """Delete this reaction and any related reactants, routes, and components""" route_ids = self.db.select_where( - query="component_route", - table="component", - key=f"component_ref = {self.id} AND component_type = 1", + query='component_route', + table='component', + key=f'component_ref = {self.id} AND component_type = 1', multiple=True, ) - route_ids = [r for r, in route_ids] - route_str_ids = str(tuple(route_ids)).replace(",)", ")") + route_ids = [r for (r,) in route_ids] + route_str_ids = str(tuple(route_ids)).replace(',)', ')') self.db.delete_where( - table="component", key=f"component_route IN {route_str_ids}" + table='component', key=f'component_route IN {route_str_ids}' ) - self.db.delete_where(table="route", key=f"route_id IN {route_str_ids}") + self.db.delete_where(table='route', key=f'route_id IN {route_str_ids}') - self.db.delete_where(table="reactant", key="reaction", value=self.id) + self.db.delete_where(table='reactant', key='reaction', value=self.id) - self.db.delete_where(table="reaction", key="id", value=self.id) + self.db.delete_where(table='reaction', key='id', value=self.id) ### DUNDERS def __str__(self) -> str: """Unformatted string representation""" - return f"R{self.id}" + return f'R{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self.plain_repr}" + return f'[bold underline]{self.plain_repr}' def __eq__( self, - other: "int | Reaction", + other: 'int | Reaction', ) -> bool: """compare this reaction to a :class:`.Reaction` object or ID""" @@ -409,7 +406,6 @@ def __eq__( return self.id == other case Reaction(): - if self.type != other.type: return False diff --git a/hippo/recipe.py b/hippo/recipe.py index 88118d3..5999e8c 100644 --- a/hippo/recipe.py +++ b/hippo/recipe.py @@ -3,8 +3,6 @@ import mcol import mrich -from dataclasses import dataclass, field - from .compound import Ingredient @@ -15,13 +13,13 @@ class Recipe: def __init__( self, - db: "Database", + db: 'Database', *, - products: "IngredientSet | None" = None, - reactants: "IngredientSet | None" = None, - intermediates: "IngredientSet | None" = None, - reactions: "ReactionSet | None" = None, - compounds: "IngredientSet | None" = None, + products: 'IngredientSet | None' = None, + reactants: 'IngredientSet | None' = None, + intermediates: 'IngredientSet | None' = None, + reactions: 'ReactionSet | None' = None, + compounds: 'IngredientSet | None' = None, ) -> None: """Recipe initialisation""" @@ -76,15 +74,15 @@ def from_reaction( *, debug: bool = False, pick_cheapest: bool = True, - permitted_reactions: "ReactionSet | None" = None, + permitted_reactions: 'ReactionSet | None' = None, quoted_only: bool = False, supplier: None | str = None, - unavailable_reaction: str = "error", + unavailable_reaction: str = 'error', reaction_checking_cache: dict[int, bool] = None, reaction_reactant_cache: dict[int, bool] = None, inner: bool = False, get_ingredient_quotes: bool = True, - ) -> "Recipe | list[Recipe]": + ) -> 'Recipe | list[Recipe]': """Create a :class:`.Recipe` from a :class:`.Reaction` and its upstream dependencies :param reaction: reaction to create recipe from @@ -109,10 +107,10 @@ def from_reaction( if debug: mrich.debug( - f"Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})" + f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' ) - mrich.debug(f"{reaction.product.id=}") - mrich.debug(f"{reaction.reactants.ids=}") + mrich.debug(f'{reaction.product.id=}') + mrich.debug(f'{reaction.reactants.ids=}') if permitted_reactions: assert reaction in permitted_reactions @@ -140,27 +138,27 @@ def from_reaction( if quoted_only or supplier: if debug: - mrich.debug(f"Checking reactant_availability: {reaction=}") + mrich.debug(f'Checking reactant_availability: {reaction=}') if reaction_checking_cache and reaction.id in reaction_checking_cache: ok = reaction_checking_cache[reaction.id] - print("reaction_checking_cache used") + print('reaction_checking_cache used') else: ok = reaction.check_reactant_availability(supplier=supplier) # print('cache not used') if reaction_checking_cache is not None: reaction_checking_cache[reaction.id] = ok if not ok: - if unavailable_reaction == "error": - mrich.error(f"Reactants not available for {reaction=}") + if unavailable_reaction == 'error': + mrich.error(f'Reactants not available for {reaction=}') if pick_cheapest: return None else: return [] - def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: + def get_reactant_amount_pairs(reaction: 'Reaction') -> list[tuple[int, float]]: """Get pairs of reactant ID and float amounts""" if reaction_reactant_cache and reaction.id in reaction_reactant_cache: - print("reaction_reactant_cache used") + print('reaction_reactant_cache used') return reaction_reactant_cache[reaction.id] else: pairs = reaction.get_reactant_amount_pairs(compound_object=False) @@ -169,31 +167,29 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: return pairs if debug: - mrich.debug(f"get_reactant_amount_pairs({reaction.id})") + mrich.debug(f'get_reactant_amount_pairs({reaction.id})') pairs = get_reactant_amount_pairs(reaction) for reactant, reactant_amount in pairs: - reactant = db.get_compound(id=reactant) if debug: - mrich.debug(f"{reactant.id=}, {reactant_amount=}") + mrich.debug(f'{reactant.id=}, {reactant_amount=}') # scale amount reactant_amount *= amount reactant_amount /= reaction.product_yield inner_reactions = reactant.get_reactions( - none="quiet", permitted_reactions=permitted_reactions + none='quiet', permitted_reactions=permitted_reactions ) if inner_reactions: - if debug: if len(inner_reactions) == 1: - mrich.debug(f"Reactant has ONE inner reaction") + mrich.debug('Reactant has ONE inner reaction') else: - mrich.warning(f"{reactant=} has MULTIPLE inner reactions") + mrich.warning(f'{reactant=} has MULTIPLE inner reactions') new_recipes = [] @@ -214,9 +210,7 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: inner_recipes += reaction_recipes for recipe in recipes: - for inner_recipe in inner_recipes: - combined_recipe = recipe.copy() combined_recipe.reactants += inner_recipe.reactants @@ -231,7 +225,6 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: recipes = new_recipes else: - ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) for recipe in recipes: recipe.reactants.add(ingredient) @@ -243,7 +236,7 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: if pick_cheapest: if debug: - mrich.debug("Picking cheapest") + mrich.debug('Picking cheapest') priced = [r for r in recipes if r.get_price(supplier=supplier)] # priced = [r for r in recipes if r.price] if not priced: @@ -255,7 +248,7 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: if debug: for recipe in recipes: - mrich.debug(f"{recipe}, {recipe.price}") + mrich.debug(f'{recipe}, {recipe.price}') return sorted_recipes[0] # return sorted(priced, key=lambda r: r.price)[0] @@ -265,17 +258,17 @@ def get_reactant_amount_pairs(reaction: "Reaction") -> list[tuple[int, float]]: @classmethod def from_reactions( cls, - reactions: "ReactionSet", + reactions: 'ReactionSet', amount: float = 1, pick_cheapest: bool = True, - permitted_reactions: "ReactionSet | None" = None, + permitted_reactions: 'ReactionSet | None' = None, final_products_only: bool = True, return_products: bool = False, supplier: str | None = None, use_routes: bool = False, debug: bool = False, **kwargs, - ) -> "Recipe | list[Recipe] | CompoundSet": + ) -> 'Recipe | list[Recipe] | CompoundSet': """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies :param reactions: reactions to create recipe from @@ -288,32 +281,31 @@ def from_reactions( """ + from .cset import CompoundSet from .rset import ReactionSet - from .cset import IngredientSet, CompoundSet assert isinstance(reactions, ReactionSet) db = reactions.db if debug: - mrich.debug("Recipe.from_reactions()") - mrich.var("reactions", reactions) - mrich.var("amount", amount) - mrich.var("final_products_only", final_products_only) - mrich.var("permitted_reactions", permitted_reactions) + mrich.debug('Recipe.from_reactions()') + mrich.var('reactions', reactions) + mrich.var('amount', amount) + mrich.var('final_products_only', final_products_only) + mrich.var('permitted_reactions', permitted_reactions) # get all the products products = reactions.products if debug: - mrich.var("products", products) + mrich.var('products', products) # return products if final_products_only: - if debug: - mrich.var("products.str_ids", products.str_ids) + mrich.var('products.str_ids', products.str_ids) # raise NotImplementedError ids = reactions.db.execute( @@ -325,11 +317,11 @@ def from_reactions( """ ).fetchall() - ids = [i for i, in ids] + ids = [i for (i,) in ids] products = CompoundSet(db, ids) if debug: - mrich.var("final products", products) + mrich.var('final products', products) # return ids @@ -351,7 +343,7 @@ def from_reactions( @classmethod def from_compounds( cls, - compounds: "CompoundSet", + compounds: 'CompoundSet', amount: float = 1, debug: bool = False, pick_cheapest: bool = True, @@ -362,7 +354,7 @@ def from_compounds( pick_first: bool = False, warn_multiple_solutions: bool = True, pick_cheapest_inner_routes: bool = False, - unavailable_reaction: str = "error", + unavailable_reaction: str = 'error', reaction_checking_cache: dict[int, bool] | None = None, reaction_reactant_cache: dict[int, bool] | None = None, use_routes: bool = False, @@ -396,7 +388,7 @@ def from_compounds( assert n_comps - if not hasattr(amount, "__iter__"): + if not hasattr(amount, '__iter__'): amount = [amount] * n_comps if use_routes: @@ -409,19 +401,18 @@ def from_compounds( options = [] ok = 0 - mrich.var("#compounds", n_comps) + mrich.var('#compounds', n_comps) for comp, a in mrich.track( - zip(compounds, amount), - prefix="Solving individual compound recipes...", + zip(compounds, amount, strict=False), + prefix='Solving individual compound recipes...', total=n_comps, ): comp_options = [] if use_routes: - if comp.id not in route_lookup: - mrich.error("No routes to", comp) + mrich.error('No routes to', comp) continue comp_options = [] @@ -430,9 +421,7 @@ def from_compounds( comp_options.append(route) else: - for reaction in comp.reactions: - if permitted_reactions and reaction not in permitted_reactions: continue @@ -459,30 +448,30 @@ def from_compounds( if not comp_options: mrich.error( - f"No solutions for compound={comp} ({comp.reactions.ids=})" + f'No solutions for compound={comp} ({comp.reactions.ids=})' ) continue if pick_cheapest and len(comp_options) > 1: if warn_multiple_solutions: mrich.warning( - f"Multiple solutions for", comp, "(", len(comp_options), ")" + 'Multiple solutions for', comp, '(', len(comp_options), ')' ) if debug: - mrich.debug("Picking cheapest...") + mrich.debug('Picking cheapest...') priced = [r for r in comp_options if r.price] comp_options = sorted(priced, key=lambda r: r.price)[:1] if warn_multiple_solutions and len(comp_options) > 1: - mrich.warning(f"Multiple solutions for compound={comp}") + mrich.warning(f'Multiple solutions for compound={comp}') if debug: - mrich.debug(f"{comp_options=}") + mrich.debug(f'{comp_options=}') else: if n_comps <= 200: - mrich.success(f"Found solution for compound={comp}") + mrich.success(f'Found solution for compound={comp}') ok += 1 - mrich.set_progress_field("ok", ok) - mrich.set_progress_field("n", n_comps) + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', n_comps) options.append(comp_options) @@ -490,7 +479,7 @@ def from_compounds( from itertools import product - mrich.print("Solving recipe combinations...") + mrich.print('Solving recipe combinations...') combinations = list(product(*options)) if not solve_combinations: @@ -500,16 +489,15 @@ def from_compounds( if n_comps > 1: generator = mrich.track( - combinations, prefix="Combining recipes...", total=len(combinations) + combinations, prefix='Combining recipes...', total=len(combinations) ) else: generator = combinations ok = 0 for combo in generator: - if debug: - mrich.debug(f"Combination of {len(combo)} recipes") + mrich.debug(f'Combination of {len(combo)} recipes') if not combo: continue @@ -523,20 +511,20 @@ def from_compounds( solutions.append(solution) ok += 1 - mrich.set_progress_field("ok", ok) - mrich.set_progress_field("n", len(combinations)) + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', len(combinations)) if not solutions: - mrich.error("No solutions") + mrich.error('No solutions') return None if pick_first: return solutions[0] if pick_cheapest: - mrich.debug("Calculating prices...") + mrich.debug('Calculating prices...') priced = [r for r in solutions if r.price] - mrich.print("Picking cheapest from", len(priced), "options") + mrich.print('Picking cheapest from', len(priced), 'options') if not priced: mrich.error("0 recipes with prices, can't choose cheapest") return solutions @@ -547,7 +535,7 @@ def from_compounds( @classmethod def from_reactants( cls, - reactants: "CompoundSet | IngredientSet", + reactants: 'CompoundSet | IngredientSet', amount: float = 1, debug: bool = False, return_products: bool = False, @@ -555,7 +543,7 @@ def from_reactants( pick_cheapest: bool = False, use_routes: bool = False, **kwargs, - ) -> "list[Recipe] | Recipe | CompoundSet": + ) -> 'list[Recipe] | Recipe | CompoundSet': """Find the maximal recipe from a given set of reactants :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored @@ -581,7 +569,6 @@ def from_reactants( # recursively search for possible reactions for i in range(300): - if debug: mrich.debug(i) @@ -592,19 +579,19 @@ def from_reactants( break if debug: - mrich.debug(f"Adding {len(reaction_ids)} reactions") + mrich.debug(f'Adding {len(reaction_ids)} reactions') possible_reactions += reaction_ids if debug: - mrich.var("reaction_ids", reaction_ids) + mrich.var('reaction_ids', reaction_ids) product_ids = db.get_possible_reaction_product_ids( reaction_ids=reaction_ids ) if debug: - mrich.var("product_ids", product_ids) + mrich.var('product_ids', product_ids) n_prev = len(all_reactants) @@ -614,12 +601,12 @@ def from_reactants( break else: - raise NotImplementedError("Maximum recursion depth exceeded") + raise NotImplementedError('Maximum recursion depth exceeded') possible_reactions = list(set(possible_reactions)) if debug: - mrich.var("all possible reactions", possible_reactions) + mrich.var('all possible reactions', possible_reactions) from .rset import ReactionSet @@ -641,8 +628,8 @@ def from_reactants( @classmethod def from_json( cls, - db: "Database", - path: "str | Path", + db: 'Database', + path: 'str | Path', debug: bool = True, allow_db_mismatch: bool = False, clear_quotes: bool = False, @@ -662,6 +649,7 @@ def from_json( # imports import json + from .cset import IngredientSet from .rset import ReactionSet @@ -669,55 +657,55 @@ def from_json( if not data: if debug: mrich.reading(path) - data = json.load(open(path, "rt")) + data = json.load(open(path)) # check metadata - if str(db.path.resolve()) != data["database"]: + if str(db.path.resolve()) != data['database']: if db_mismatch_warning: - mrich.var("session", str(db.path.resolve())) - mrich.var("in file", data["database"]) + mrich.var('session', str(db.path.resolve())) + mrich.var('in file', data['database']) if allow_db_mismatch: if db_mismatch_warning: - mrich.warning("Database path mismatch") + mrich.warning('Database path mismatch') else: mrich.error( - "Database path mismatch, set allow_db_mismatch=True to ignore" + 'Database path mismatch, set allow_db_mismatch=True to ignore' ) return None if debug: mrich.print(f'Recipe was generated at: {data["timestamp"]}') - price = data["price"] + price = data['price'] # IngredientSets - products = IngredientSet.from_ingredient_dicts(db, data["products"]) - intermediates = IngredientSet.from_ingredient_dicts(db, data["intermediates"]) + products = IngredientSet.from_ingredient_dicts(db, data['products']) + intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) reactants = IngredientSet.from_ingredient_dicts( - db, data["reactants"], supplier=data["reactant_supplier"] + db, data['reactants'], supplier=data['reactant_supplier'] ) - if "compounds" in data: + if 'compounds' in data: compounds = IngredientSet.from_ingredient_dicts( - db, data["compounds"], supplier=data["compound_supplier"] + db, data['compounds'], supplier=data['compound_supplier'] ) else: compounds = IngredientSet(db) if clear_quotes: - reactants.df["quote_id"] = None - reactants.df["quoted_amount"] = None - compounds.df["quote_id"] = None - compounds.df["quoted_amount"] = None + reactants.df['quote_id'] = None + reactants.df['quoted_amount'] = None + compounds.df['quote_id'] = None + compounds.df['quoted_amount'] = None # ReactionSet - reactions = ReactionSet(db, data["reaction_ids"], sort=False) + reactions = ReactionSet(db, data['reaction_ids'], sort=False) if debug: - mrich.var("reactants", reactants) - mrich.var("intermediates", intermediates) - mrich.var("products", products) - mrich.var("reactions", reactions) - mrich.var("compounds", compounds) + mrich.var('reactants', reactants) + mrich.var('intermediates', intermediates) + mrich.var('products', products) + mrich.var('reactions', reactions) + mrich.var('compounds', compounds) # Create the object self = cls.__new__(cls) @@ -735,40 +723,40 @@ def from_json( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Associated :class:`.Database:""" return self._db @property - def products(self) -> "IngredientSet": + def products(self) -> 'IngredientSet': """Product :class:`.IngredientSet`""" return self._products @property - def compounds(self) -> "IngredientSet": + def compounds(self) -> 'IngredientSet': """Product :class:`.IngredientSet`""" return self._compounds @compounds.setter - def compounds(self, a: "IngredientSet"): + def compounds(self, a: 'IngredientSet'): """Set the compounds""" self._compounds = a self.__flag_modification() @property - def poses(self) -> "PoseSet": + def poses(self) -> 'PoseSet': """Product poses""" if self._poses is None: self._poses = self.combined_compounds.poses - self._poses._name = f"poses of {self}" + self._poses._name = f'poses of {self}' return self._poses @property - def product_compounds(self) -> "CompoundSet": + def product_compounds(self) -> 'CompoundSet': """Product compounds""" if self._product_compounds is None: self._product_compounds = self.products.compounds - self._product_compounds._name = f"products of {self}" + self._product_compounds._name = f'products of {self}' return self._product_compounds @property @@ -777,30 +765,30 @@ def combined_compound_ids(self) -> set[int]: return set(self.product_compounds.ids) | set(self.compounds.ids) @property - def combined_compounds(self) -> "CompoundSet": + def combined_compounds(self) -> 'CompoundSet': """Combined product and no-chem compounds""" if self._combined_compounds is None: from .cset import CompoundSet self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) - self._combined_compounds._name = f"combined compounds of {self}" + self._combined_compounds._name = f'combined compounds of {self}' return self._combined_compounds @property - def interactions(self) -> "InteractionSet": + def interactions(self) -> 'InteractionSet': """Product pose interactions""" if self._interactions is None: self._interactions = self.poses.interactions return self._interactions @property - def product(self) -> "Ingredient": + def product(self) -> 'Ingredient': """Return single product (if there's only one)""" assert len(self.products) == 1 return self.products[0] @products.setter - def products(self, a: "IngredientSet"): + def products(self, a: 'IngredientSet'): """Set the products""" self._products = a self.__flag_modification() @@ -811,35 +799,35 @@ def reactants(self): return self._reactants @reactants.setter - def reactants(self, a: "IngredientSet"): + def reactants(self, a: 'IngredientSet'): """Set the reactants""" self._reactants = a self.__flag_modification() @property - def intermediates(self) -> "IngredientSet": + def intermediates(self) -> 'IngredientSet': """Intermediates :class:`.IngredientSet`""" return self._intermediates @intermediates.setter - def intermediates(self, a: "IngredientSet"): + def intermediates(self, a: 'IngredientSet'): """Set the intermediates""" self._intermediates = a self.__flag_modification() @property - def reactions(self) -> "ReactionSet": + def reactions(self) -> 'ReactionSet': """Intermediates :class:`.IngredientSet`""" return self._reactions @reactions.setter - def reactions(self, a: "ReactionSet"): + def reactions(self, a: 'ReactionSet'): """Set the reactions""" self._reactions = a self.__flag_modification() @property - def price(self) -> "Price": + def price(self) -> 'Price': """Get the price of the reactants""" return self.reactants.get_price() + self.compounds.get_price() @@ -888,19 +876,19 @@ def type(self) -> str: """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" if self.empty: - return "EMPTY" + return 'EMPTY' chem = bool(self.reactions) nochem = bool(self.compounds) if chem and nochem: - return "MIXED" + return 'MIXED' if chem and not nochem: - return "CHEM" + return 'CHEM' if nochem and not chem: - return "NOCHEM" + return 'NOCHEM' @property def empty(self) -> bool: @@ -925,7 +913,7 @@ def empty(self) -> bool: ### METHODS - def get_price(self, supplier: str | None = None) -> "Price": + def get_price(self, supplier: str | None = None) -> 'Price': """get the reactants price. See :meth:`.IngredientSet.get_price` :param supplier: restrict quotes to this supplier @@ -1009,8 +997,6 @@ def draw(self, color_mapper=None, node_size=300, graph_only=False): if graph_only: return graph else: - import matplotlib as plt - # return nx.draw(graph, pos, with_labels=True, font_weight='bold') # pos = nx.spring_layout(graph, iterations=200, k=30) pos = nx.spring_layout(graph) @@ -1018,12 +1004,12 @@ def draw(self, color_mapper=None, node_size=300, graph_only=False): graph, pos=pos, with_labels=True, - font_weight="bold", + font_weight='bold', node_color=list(colors.values()), node_size=sizes, ) - def sankey(self, title: str | None = None) -> "graph_objects.Figure": + def sankey(self, title: str | None = None) -> 'graph_objects.Figure': """draw a plotly Sankey diagram :param title: (Default value = None) @@ -1037,7 +1023,6 @@ def sankey(self, title: str | None = None) -> "graph_objects.Figure": nodes = {} for edge in graph.edges: - c = edge[0] if c not in nodes: nodes[c] = len(nodes) @@ -1062,12 +1047,12 @@ def sankey(self, title: str | None = None) -> "graph_objects.Figure": hoverkeys = list(n.keys()) if not n: - mrich.error(f"problem w/ node {key=}") + mrich.error(f'problem w/ node {key=}') compound_id = int(key[1:]) customdata.append((compound_id, None)) else: - d = tuple(v if v is not None else "N/A" for v in n.values()) + d = tuple(v if v is not None else 'N/A' for v in n.values()) customdata.append(d) hoverkeys_edges = None @@ -1081,23 +1066,23 @@ def sankey(self, title: str | None = None) -> "graph_objects.Figure": hoverkeys_edges = list(edge.keys()) if not n: - mrich.error(f"problem w/ edge {s=} {t=}") + mrich.error(f'problem w/ edge {s=} {t=}') customdata_edges.append((None, None, None)) else: - d = tuple(v if v is not None else "N/A" for v in edge.values()) + d = tuple(v if v is not None else 'N/A' for v in edge.values()) customdata_edges.append(d) hoverlines = [] for i, key in enumerate(hoverkeys): - hoverlines.append(f"{key}=%" "{" f"customdata[{i}]" "}") - hovertemplate = "Compound " + "
".join(hoverlines) + "" + hoverlines.append(f'{key}=%{{customdata[{i}]}}') + hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' hoverlines_edges = [] for i, key in enumerate(hoverkeys_edges): - hoverlines_edges.append(f"{key}=%" "{" f"customdata[{i}]" "}") + hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') hovertemplate_edges = ( - "Reaction " + "
".join(hoverlines_edges) + "" + 'Reaction ' + '
'.join(hoverlines_edges) + '' ) fig = go.Figure( @@ -1128,9 +1113,9 @@ def sankey(self, title: str | None = None) -> "graph_objects.Figure": if not title: try: - title = f"Recipe
price={self.price}" + title = f'Recipe
price={self.price}' except AssertionError: - title = f"Recipe" + title = 'Recipe' fig.update_layout(title=title) @@ -1143,57 +1128,54 @@ def summary(self, price: bool = True) -> None: """ - import mcol - mrich.h1(str(self)) if price: price = self.price if price: - mrich.var("\nprice", price.amount, price.currency) + mrich.var('\nprice', price.amount, price.currency) # mrich.var('lead-time', self.lead_time, 'working days)) if self.products: - mrich.h3(f"{len(self.products)} products") + mrich.h3(f'{len(self.products)} products') if len(self.products) < 100: for product in self.products: - mrich.var(str(product.compound), f"{product.amount:.2f}", "mg") + mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') if self.intermediates: - mrich.h3(f"{len(self.intermediates)} intermediates") + mrich.h3(f'{len(self.intermediates)} intermediates') if len(self.intermediates) < 100: for intermediate in self.intermediates: mrich.var( str(intermediate.compound), - f"{intermediate.amount:.2f}", - "mg", + f'{intermediate.amount:.2f}', + 'mg', ) if self.reactants: - mrich.h3(f"{len(self.reactants)} reactants") + mrich.h3(f'{len(self.reactants)} reactants') if len(self.reactants) < 100: for reactant in self.reactants: - mrich.var(str(reactant.compound), f"{reactant.amount:.2f}", "mg") + mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') if self.reactions: - mrich.h3(f"{len(self.reactions)} reactions") + mrich.h3(f'{len(self.reactions)} reactions') if len(self.reactions) < 100: for reaction in self.reactions: mrich.var(str(reaction), reaction.reaction_str, reaction.type) - if hasattr(self, "_compounds") and self.compounds: - - mrich.h3(f"{len(self.compounds)} compounds") + if hasattr(self, '_compounds') and self.compounds: + mrich.h3(f'{len(self.compounds)} compounds') if len(self.compounds) < 100: for compound in self.compounds: - mrich.var(str(compound.compound), f"{compound.amount:.2f}", "mg") + mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') - def get_ingredient(self, id) -> "Ingredient": + def get_ingredient(self, id) -> 'Ingredient': """Get an ingredient by its compound ID :param id: compound ID @@ -1214,14 +1196,14 @@ def add_to_all_reactants(self, amount: float = 20) -> None: :param amount: amount in ``mg`` (Default value = 20) """ - self.reactants.df["amount"] += amount + self.reactants.df['amount'] += amount def write_json( self, - file: "str | Path", + file: 'str | Path', *, extra: dict | None = None, - indent: str = "\t", + indent: str = '\t', **kwargs, ) -> None: """Serialise this recipe object and write it to disk @@ -1236,7 +1218,7 @@ def write_json( file = Path(file).resolve() - assert file.parent.exists(), f"Directory does not exist: {file.parent}" + assert file.parent.exists(), f'Directory does not exist: {file.parent}' data = self.get_dict(serialise_price=True, **kwargs) @@ -1244,7 +1226,7 @@ def write_json( data.update(extra) mrich.writing(file) - json.dump(data, open(file, "wt"), indent=indent) + json.dump(data, open(file, 'w'), indent=indent) def get_dict( self, @@ -1282,61 +1264,60 @@ def get_dict( """ - import json from datetime import datetime data = {} # Database if database: - data["database"] = str(self.db.path.resolve()) + data['database'] = str(self.db.path.resolve()) if timestamp: - data["timestamp"] = str(datetime.now()) + data['timestamp'] = str(datetime.now()) # Recipe properties try: if price and serialise_price: - data["price"] = self.price.get_dict() + data['price'] = self.price.get_dict() elif price: - data["price"] = self.price + data['price'] = self.price except AssertionError as e: - mrich.warning(f"Could not get price: {e}") - data["price"] = None + mrich.warning(f'Could not get price: {e}') + data['price'] = None if reactant_supplier: - data["reactant_supplier"] = self.reactants.supplier + data['reactant_supplier'] = self.reactants.supplier if compound_supplier: - data["compound_supplier"] = self.compounds.supplier + data['compound_supplier'] = self.compounds.supplier # IngredientSets if compound_ids_only: - data["reactant_ids"] = self.reactants.compound_ids - data["intermediate_ids"] = self.intermediates.compound_ids + data['reactant_ids'] = self.reactants.compound_ids + data['intermediate_ids'] = self.intermediates.compound_ids if products: - data["products_ids"] = self.products.compound_ids - data["compound_ids"] = self.compounds.compound_ids + data['products_ids'] = self.products.compound_ids + data['compound_ids'] = self.compounds.compound_ids else: - data["reactants"] = self.reactants.df.to_dict(orient="list") - data["intermediates"] = self.intermediates.df.to_dict(orient="list") + data['reactants'] = self.reactants.df.to_dict(orient='list') + data['intermediates'] = self.intermediates.df.to_dict(orient='list') if products: - data["products"] = self.products.df.to_dict(orient="list") - data["compounds"] = self.compounds.df.to_dict(orient="list") + data['products'] = self.products.df.to_dict(orient='list') + data['compounds'] = self.compounds.df.to_dict(orient='list') # ReactionSet - data["reaction_ids"] = self.reactions.ids + data['reaction_ids'] = self.reactions.ids return data - def get_routes(self, return_ids: bool = False) -> "RouteSet": + def get_routes(self, return_ids: bool = False) -> 'RouteSet': """Get routes""" return self.products.get_routes( permitted_reactions=self.reactions, return_ids=return_ids ) def register_missing_routes( - self, missing_only: bool = True, supplier: str = "Enamine" + self, missing_only: bool = True, supplier: str = 'Enamine' ) -> None: """Calculate missing routes to products of this Recipe""" @@ -1348,19 +1329,18 @@ def register_missing_routes( from .cset import CompoundSet records = self.db.select_where( - table="route", - key=f"route_product IN {products.str_ids}", - query="route_product", + table='route', + key=f'route_product IN {products.str_ids}', + query='route_product', multiple=True, ) - existing = set(i for i, in records) + existing = set(i for (i,) in records) missing = set(products.ids) - existing products = CompoundSet(self.db, missing) - mrich.var("#products", len(products)) + mrich.var('#products', len(products)) for i, c in mrich.track(enumerate(products), total=len(products)): - try: reactions = c.reactions except Exception as e: @@ -1368,7 +1348,6 @@ def register_missing_routes( continue for reaction in reactions: - try: recipes = reaction.get_recipes(supplier=supplier) except Exception as e: @@ -1376,16 +1355,15 @@ def register_missing_routes( continue for recipe in recipes: - route = self.db.register_route(recipe=recipe) - mrich.print(f"registered {route=}") + mrich.print(f'registered {route=}') self.db.prune_duplicate_routes() def write_CAR_csv( - self, file: "str | Path", return_df: bool = False - ) -> "DataFrame | None": + self, file: 'str | Path', return_df: bool = False + ) -> 'DataFrame | None': """Prepares CSVs for use with CAR. .. attention:: @@ -1414,10 +1392,10 @@ def write_CAR_csv( """ - from .cset import CompoundSet - from pandas import DataFrame from pathlib import Path + from pandas import DataFrame + # solve each product's reaction file = str(Path(file).resolve()) @@ -1427,39 +1405,37 @@ def write_CAR_csv( routes = self.get_routes() for sub_recipe in routes: - product = sub_recipe.product row = { - "target-names": str(product.compound), - "no-steps": 0, - "concentration-required-mM": None, - "amount-required-uL": None, - "batch-tag": None, + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, } for i, reaction in enumerate(sub_recipe.reactions): - i = i + 1 - row["no-steps"] += 1 + row['no-steps'] += 1 match len(reaction.reactants): case 1: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = None + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None case 2: - row[f"reactant-1-{i}"] = reaction.reactants[0].smiles - row[f"reactant-2-{i}"] = reaction.reactants[1].smiles + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles case _: # mrich.warning(f"More than two reactants for {reaction=}") for j, r in enumerate(reaction.reactants): - row[f"reactant-{j+1}-{i}"] = reaction.reactants[j].smiles + row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles - row[f"reaction-product-smiles-{i}"] = reaction.product.smiles - row[f"reaction-name-{i}"] = reaction.type - row[f"reaction-recipe-{i}"] = None - row[f"reaction-groupby-column-{i}"] = None + row[f'reaction-product-smiles-{i}'] = reaction.product.smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None # row[f'reaction-id-{i}'] = int(reaction.id) rows.append(row) @@ -1467,14 +1443,14 @@ def write_CAR_csv( df = DataFrame(rows) if len(df[df.duplicated()]): - mrich.warning("Removing duplicates from CAR DataFrame") + mrich.warning('Removing duplicates from CAR DataFrame') df = df.drop_duplicates() df = df.convert_dtypes() - for n_steps in set(df["no-steps"]): - subset = df[df["no-steps"] == n_steps] - this_file = file.replace(".csv", f"_{n_steps}steps.csv") + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') mrich.writing(this_file) subset.to_csv(this_file, index=False) @@ -1485,10 +1461,10 @@ def write_CAR_csv( def write_reactant_csv( self, - file: "str | Path", + file: 'str | Path', reaction_type_counts: bool = True, return_df: bool = False, - ) -> "DataFrame | None": + ) -> 'DataFrame | None': """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use Reactant @@ -1521,11 +1497,7 @@ def write_reactant_csv( """ # - remove_with - from pandas import DataFrame - # from rich import print - from .cset import CompoundSet - from .rset import ReactionSet data = [] @@ -1538,7 +1510,7 @@ def write_reactant_csv( INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route WHERE component_type = 2 AND component_ref IN {self.reactants.compounds.str_ids} - AND component_route IN {str(tuple(route_ids)).replace(',)',')')} + AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} """ product_lookup = {} for reactant_id, product_id in self.db.execute(sql): @@ -1565,11 +1537,11 @@ def write_reactant_csv( reaction_lookup = {} for reactant_id, reaction_id, reaction_type in self.db.execute(sql): reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) - reaction_lookup[reactant_id]["ids"].add(reaction_id) - reaction_lookup[reactant_id]["types"].add(reaction_type) - reaction_lookup[reactant_id].setdefault("counts", {}) - reaction_lookup[reactant_id]["counts"].setdefault(reaction_type, 0) - reaction_lookup[reactant_id]["counts"][reaction_type] += 1 + reaction_lookup[reactant_id]['ids'].add(reaction_id) + reaction_lookup[reactant_id]['types'].add(reaction_type) + reaction_lookup[reactant_id].setdefault('counts', {}) + reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) + reaction_lookup[reactant_id]['counts'][reaction_type] += 1 smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) @@ -1581,9 +1553,9 @@ def write_reactant_csv( df = self.reactants.df - df["smiles"] = df["compound_id"].apply(lambda x: smiles_lookup[x]) - df["inchikey"] = df["compound_id"].apply(lambda x: inchikey_lookup[x]) - df = df.drop(columns=["supplier", "max_lead_time", "quoted_amount"]) + df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) + df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) + df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) ### Quote DataFrame @@ -1591,120 +1563,117 @@ def write_reactant_csv( qdf = qdf.rename( columns={ - "id": "quote_id", - "smiles": "quoted_smiles", - "purity": "quoted_purity", - "date": "quote_date", - "lead_time": "quote_lead_time_days", - "price": "quote_price", - "currency": "quote_currency", - "catalogue": "quote_catalogue", - "supplier": "quote_supplier", - "entry": "quote_entry", - "amount": "quoted_amount_mg", + 'id': 'quote_id', + 'smiles': 'quoted_smiles', + 'purity': 'quoted_purity', + 'date': 'quote_date', + 'lead_time': 'quote_lead_time_days', + 'price': 'quote_price', + 'currency': 'quote_currency', + 'catalogue': 'quote_catalogue', + 'supplier': 'quote_supplier', + 'entry': 'quote_entry', + 'amount': 'quoted_amount_mg', } ) - qdf = qdf.drop(columns=["compound"]) + qdf = qdf.drop(columns=['compound']) ### Downstream info try: - df["downstream_product_ids"] = df["compound_id"].apply( + df['downstream_product_ids'] = df['compound_id'].apply( lambda x: product_lookup.get(x, set()) ) - df["downstream_reaction_ids"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["ids"] + df['downstream_reaction_ids'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['ids'] ) - df["downstream_reaction_types"] = df["compound_id"].apply( - lambda x: reaction_lookup[x]["types"] + df['downstream_reaction_types'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['types'] ) except KeyError as e: - mrich.error(f"Reactant C{e} is missing downstream reaction") + mrich.error(f'Reactant C{e} is missing downstream reaction') mrich.error( - "Are all routes enumerated? Try running calculate_missing_routes()" + 'Are all routes enumerated? Try running calculate_missing_routes()' ) return None - df["num_downstream_reactions"] = df["downstream_reaction_ids"].apply(len) - df["num_downstream_reaction_types"] = df["downstream_reaction_types"].apply(len) - df["num_downstream_products"] = df["downstream_product_ids"].apply(len) + df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) + df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) + df['num_downstream_products'] = df['downstream_product_ids'].apply(len) ### Join and reformat - df = df.merge(qdf, on="quote_id", how="left") + df = df.merge(qdf, on='quote_id', how='left') df = df.rename( columns={ - "amount": "required_amount_mg", + 'amount': 'required_amount_mg', } ) cols = [ - "compound_id", - "smiles", - "inchikey", - "required_amount_mg", - "quoted_amount_mg", - "quote_id", - "quote_supplier", - "quote_catalogue", - "quote_entry", - "quote_price", - "quote_currency", - "quote_lead_time_days", - "quoted_purity", - "quoted_smiles", - "quote_date", - "num_downstream_products", - "num_downstream_reaction_types", - "num_downstream_reactions", + 'compound_id', + 'smiles', + 'inchikey', + 'required_amount_mg', + 'quoted_amount_mg', + 'quote_id', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_price', + 'quote_currency', + 'quote_lead_time_days', + 'quoted_purity', + 'quoted_smiles', + 'quote_date', + 'num_downstream_products', + 'num_downstream_reaction_types', + 'num_downstream_reactions', ] if reaction_type_counts: for i, row in df.iterrows(): - - counts = reaction_lookup[row["compound_id"]]["counts"] + counts = reaction_lookup[row['compound_id']]['counts'] for reaction_type, count in counts.items(): - key = f"num_downstream ({reaction_type})" + key = f'num_downstream ({reaction_type})' df.loc[i, key] = count if key not in cols: cols.append(key) cols += [ - "downstream_product_ids", - "downstream_reaction_types", - "downstream_reaction_ids", + 'downstream_product_ids', + 'downstream_reaction_types', + 'downstream_reaction_ids', ] df = df[[c for c in cols if c in df.columns]] ### Add estimated quotes - unquoted = df[df["quote_id"].isna()] + unquoted = df[df['quote_id'].isna()] if len(unquoted): - for i, row in unquoted.iterrows(): - - compound = self.db.get_compound(id=row["compound_id"]) + compound = self.db.get_compound(id=row['compound_id']) ingredient = compound.as_ingredient( - amount=row["required_amount_mg"], get_quote=False + amount=row['required_amount_mg'], get_quote=False ) quote = ingredient.quote - df.loc[i, "quoted_amount_mg"] = quote.amount - df.loc[i, "quote_supplier"] = quote.supplier - df.loc[i, "quote_catalogue"] = quote.catalogue - df.loc[i, "quote_entry"] = quote.entry - df.loc[i, "quote_price"] = quote.price.amount - df.loc[i, "quote_currency"] = quote.price.currency - df.loc[i, "quote_lead_time_days"] = quote.lead_time - df.loc[i, "quoted_purity"] = quote.purity - df.loc[i, "quoted_smiles"] = quote.smiles - df.loc[i, "quote_date"] = quote.date + df.loc[i, 'quoted_amount_mg'] = quote.amount + df.loc[i, 'quote_supplier'] = quote.supplier + df.loc[i, 'quote_catalogue'] = quote.catalogue + df.loc[i, 'quote_entry'] = quote.entry + df.loc[i, 'quote_price'] = quote.price.amount + df.loc[i, 'quote_currency'] = quote.price.currency + df.loc[i, 'quote_lead_time_days'] = quote.lead_time + df.loc[i, 'quoted_purity'] = quote.purity + df.loc[i, 'quoted_smiles'] = quote.smiles + df.loc[i, 'quote_date'] = quote.date ### N.B. scaffold series no longer output @@ -1717,15 +1686,14 @@ def write_reactant_csv( return None def write_product_csv( - self, file: "str | Path", return_df: bool = False - ) -> "pd.DataFrame | None": + self, file: 'str | Path', return_df: bool = False + ) -> 'pd.DataFrame | None': """Detailed CSV output including product information for selection and synthesis""" from pandas import DataFrame # from rich import print from .pset import PoseSet - from .cset import CompoundSet from .rset import ReactionSet data = [] @@ -1737,9 +1705,8 @@ def write_product_csv( inspiration_map = self.db.get_compound_id_inspiration_ids_dict() for product in mrich.track( - self.products, prefix="Constructing product DataFrame" + self.products, prefix='Constructing product DataFrame' ): - d = dict( hippo_id=product.compound_id, smiles=product.smiles, @@ -1762,11 +1729,11 @@ def write_product_csv( ) if not upstream_routes: - mrich.error("No upstream routes for", product) + mrich.error('No upstream routes for', product) continue if not upstream_reactions: - mrich.error("No upstream reactions for", product) + mrich.error('No upstream reactions for', product) continue def get_scaffold_series() -> tuple[list[int], bool]: @@ -1780,22 +1747,22 @@ def get_scaffold_series() -> tuple[list[int], bool]: poses = pose_map.get(product.id, set()) - d["num_poses"] = len(poses) - d["poses"] = poses - d["tags"] = product.tags - d["num_routes"] = len(upstream_routes) - d["num_reaction_steps"] = set( + d['num_poses'] = len(poses) + d['poses'] = poses + d['tags'] = product.tags + d['num_routes'] = len(upstream_routes) + d['num_reaction_steps'] = set( len(route.reactions) for route in upstream_routes ) - d["reaction_dependencies"] = upstream_reactions.ids - d["reactant_dependencies"] = set( + d['reaction_dependencies'] = upstream_reactions.ids + d['reactant_dependencies'] = set( sum([route.reactants.ids for route in upstream_routes], []) ) - d["route_ids"] = [route.id for route in upstream_routes] - d["chemistry_types"] = ", ".join(upstream_reactions.types) + d['route_ids'] = [route.id for route in upstream_routes] + d['chemistry_types'] = ', '.join(upstream_reactions.types) series, is_scaffold = get_scaffold_series() - d["is_scaffold"] = is_scaffold - d["scaffold_series"] = series + d['is_scaffold'] = is_scaffold + d['scaffold_series'] = series inspirations = inspiration_map.get(product.id, None) @@ -1803,21 +1770,21 @@ def get_scaffold_series() -> tuple[list[int], bool]: scaffold = product.scaffolds[0] inspirations = inspiration_map.get(scaffold.id, None) - if not inspirations and "inspiration_pose_ids" in scaffold.metadata: - inspirations = scaffold.metadata["inspiration_pose_ids"] + if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: + inspirations = scaffold.metadata['inspiration_pose_ids'] if ( not inspirations and is_scaffold - and "inspiration_pose_ids" in product.metadata + and 'inspiration_pose_ids' in product.metadata ): - inspirations = product.metadata["inspiration_pose_ids"] + inspirations = product.metadata['inspiration_pose_ids'] if inspirations: inspirations = PoseSet(self.db, inspirations) - d["inspirations"] = ", ".join(n for n in inspirations.names) + d['inspirations'] = ', '.join(n for n in inspirations.names) else: - d["inspirations"] = "" + d['inspirations'] = '' data.append(d) @@ -1831,15 +1798,13 @@ def get_scaffold_series() -> tuple[list[int], bool]: return None def write_chemistry_csv( - self, file: "str | Path", return_df: bool = True - ) -> "pd.DataFrame | None": + self, file: 'str | Path', return_df: bool = True + ) -> 'pd.DataFrame | None': """Detailed CSV output synthetis information for chemistry types in this set""" from pandas import DataFrame - from rich import print from .cset import CompoundSet - from .rset import ReactionSet data = [] @@ -1848,7 +1813,6 @@ def write_chemistry_csv( scaffolds = CompoundSet(self.db) for product in self.products: - if scaffolds := product.scaffolds: scaffolds += scaffolds else: @@ -1859,9 +1823,8 @@ def write_chemistry_csv( route_types = {} for compound in scaffolds: - elabs = ( - self.products.compounds.get_by_scaffold(scaffold=compound, none="quiet") + self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') or [] ) @@ -1880,37 +1843,36 @@ def write_chemistry_csv( upstream_routes.append(route) if not upstream_routes: - mrich.warning(f"No routes to scaffold={compound}") + mrich.warning(f'No routes to scaffold={compound}') continue - d["num_routes"] = len(upstream_routes) + d['num_routes'] = len(upstream_routes) for j, route in enumerate(upstream_routes): - d[f"route_{j+1}_num_steps"] = len(route.reactions) + d[f'route_{j + 1}_num_steps'] = len(route.reactions) group = route_types.setdefault(compound.id, set()) group.add(tuple([r.type for r in route.reactions])) for k, reaction in enumerate(route.reactions): - key = f"route_{j+1}_reaction_{k+1}" + key = f'route_{j + 1}_reaction_{k + 1}' product = reaction.product - d[f"{key}_type"] = reaction.type - d[f"{key}_product_smiles"] = product.smiles - d[f"{key}_product_id"] = product.id - d[f"{key}_product_yield"] = reaction.product_yield + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = product.smiles + d[f'{key}_product_id'] = product.id + d[f'{key}_product_yield'] = reaction.product_yield for i, reactant in enumerate(reaction.reactants): - d[f"{key}_reactant_{i+1}_smiles"] = reactant.smiles - d[f"{key}_reactant_{i+1}_id"] = reactant.id + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id data.append(d) missing_scaffolds = {} for compound in self.products.compounds: - if compound in scaffolds: continue @@ -1922,7 +1884,6 @@ def write_chemistry_csv( scaffolds = compound.scaffolds for scaffold in scaffolds: - if scaffold.id not in route_types: group = missing_scaffolds.setdefault(scaffold.id, []) group.append(compound.id) @@ -1936,11 +1897,10 @@ def write_chemistry_csv( mrich.success(scaffold) mrich.success(chem_types) raise ValueError( - "Scaffold has route not present in dataframe" + 'Scaffold has route not present in dataframe' ) for scaffold_id, elab_ids in missing_scaffolds.items(): - compound = self.db.get_compound(id=sorted(elab_ids)[0]) d = dict( @@ -1958,30 +1918,30 @@ def write_chemistry_csv( upstream_routes.append(route) if not upstream_routes: - mrich.error(f"No routes to elab {compound}") - raise ValueError(f"No routes to elab {compound}") + mrich.error(f'No routes to elab {compound}') + raise ValueError(f'No routes to elab {compound}') - d["num_routes"] = len(upstream_routes) + d['num_routes'] = len(upstream_routes) for j, route in enumerate(upstream_routes): - d[f"route_{j+1}_num_steps"] = len(route.reactions) + d[f'route_{j + 1}_num_steps'] = len(route.reactions) group = route_types.setdefault(compound.id, set()) group.add(tuple([r.type for r in route.reactions])) for k, reaction in enumerate(route.reactions): - key = f"route_{j+1}_reaction_{k+1}" + key = f'route_{j + 1}_reaction_{k + 1}' product = reaction.product - d[f"{key}_type"] = reaction.type - d[f"{key}_product_smiles"] = product.smiles - d[f"{key}_product_id"] = product.id - d[f"{key}_product_yield"] = reaction.product_yield + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = product.smiles + d[f'{key}_product_id'] = product.id + d[f'{key}_product_yield'] = reaction.product_yield for i, reactant in enumerate(reaction.reactants): - d[f"{key}_reactant_{i+1}_smiles"] = reactant.smiles - d[f"{key}_reactant_{i+1}_id"] = reactant.id + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id data.append(d) @@ -1996,28 +1956,28 @@ def write_chemistry_csv( def to_syndirella( self, - out_key: "str | Path", - poses: "PoseSet", + out_key: 'str | Path', + poses: 'PoseSet', *, separate: bool = False, - ) -> "DataFrame": + ) -> 'DataFrame': """Generate inputs for running syndirella elaboration""" import shutil from pathlib import Path - out_key = Path(".") / out_key + out_key = Path('.') / out_key out_dir = out_key.parent out_key = out_key.name - mrich.var("out_key", out_key) - mrich.var("out_dir", out_dir) + mrich.var('out_key', out_key) + mrich.var('out_dir', out_dir) if not out_dir.exists(): mrich.writing(out_dir) out_dir.mkdir(parents=True, exist_ok=True) - template_dir = out_dir / "templates" + template_dir = out_dir / 'templates' if not template_dir.exists(): mrich.writing(template_dir) template_dir.mkdir(parents=True, exist_ok=True) @@ -2042,12 +2002,12 @@ def to_syndirella( """ pose_compounds = poses.compounds - assert set(self.products.compound_ids) == set( - pose_compounds.ids - ), "supplied poses have different compounds to Recipe products" - assert len(poses) == len( - self.products - ), "some duplicate compounds in supplied poses" + assert set(self.products.compound_ids) == set(pose_compounds.ids), ( + 'supplied poses have different compounds to Recipe products' + ) + assert len(poses) == len(self.products), ( + 'some duplicate compounds in supplied poses' + ) df = poses.get_df( inchikey=False, @@ -2059,34 +2019,33 @@ def to_syndirella( ) df = df.reset_index() - df = df.rename(columns={"id": "pose_id"}) - df["compound_set"] = df["compound_id"].apply(lambda x: f"C{x}") - df = df.set_index(["compound_id", "pose_id"]) + df = df.rename(columns={'id': 'pose_id'}) + df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') + df = df.set_index(['compound_id', 'pose_id']) ## CHECKS - no_refs = df[df["reference_id"].isna()] + no_refs = df[df['reference_id'].isna()] if len(no_refs): - mrich.error(len(no_refs), "poses without reference!") - ids = set(no_refs.index.get_level_values("pose_id")) + mrich.error(len(no_refs), 'poses without reference!') + ids = set(no_refs.index.get_level_values('pose_id')) mrich.print(ids) - from .pset import PoseSet - no_insps = bool([1 for i in df["inspiration_aliases"].values if not len(i)]) + no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) if no_insps: - mrich.error(len(no_insps), "poses without inspirations!") + mrich.error(len(no_insps), 'poses without inspirations!') return None ## TEMPLATES references = poses.references ref_lookup = self.db.get_pose_id_alias_dict(references) - df["template"] = df["reference_id"].apply(lambda x: ref_lookup[x]) + df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) for ref_pose in references: - assert ref_pose.apo_path, f"Reference {ref_pose} has no apo_path" + assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' template = template_dir / ref_pose.apo_path.name @@ -2097,122 +2056,119 @@ def to_syndirella( ## INSPIRATIONS for i, row in df.iterrows(): - for j, alias in enumerate(row["inspiration_aliases"]): - df.loc[i, f"hit{j+1}"] = alias + for j, alias in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = alias inspirations = poses.inspirations - sdf_name = out_dir / f"{out_key}_syndirella_inspiration_hits.sdf" + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' inspirations.write_sdf( sdf_name, tags=False, metadata=False, - name_col="name", + name_col='name', ) ## ADD ROUTE INFO routes = self.get_routes() - for sub_recipe in mrich.track(routes, prefix="Adding chemistry info..."): - + for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): product = sub_recipe.product product_id = product.compound_id - matches = df.xs(product_id, level="compound_id") + matches = df.xs(product_id, level='compound_id') if len(matches) > 1: - mrich.warning("Multiple rows for compound", product_id) + mrich.warning('Multiple rows for compound', product_id) for i, row in matches.iterrows(): - key = (product_id, i) for j, reaction in enumerate(sub_recipe.reactions): - j = j + 1 match len(reaction.reactants): case 1: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ 0 ].smiles - df.loc[key, f"reactant2_step{j}"] = None + df.loc[key, f'reactant2_step{j}'] = None case 2: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ 0 ].smiles - df.loc[key, f"reactant2_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ 1 ].smiles case 3: - df.loc[key, f"reactant_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ 0 ].smiles - df.loc[key, f"reactant2_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ 1 ].smiles - df.loc[key, f"reactant3_step{j}"] = reaction.reactants[ + df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ 2 ].smiles case _: - raise NotImplementedError("Too many reactants") + raise NotImplementedError('Too many reactants') - df.loc[key, f"product_step{j}"] = reaction.product.smiles - df.loc[key, f"reaction_name_step{j}"] = reaction.type + df.loc[key, f'product_step{j}'] = reaction.product.smiles + df.loc[key, f'reaction_name_step{j}'] = reaction.type break ## REMOVE UNECESSARY COLS - df = df.drop(columns=["reference_id", "inspiration_aliases"]) + df = df.drop(columns=['reference_id', 'inspiration_aliases']) ## REORDER COLUMNS cols = [ - "smiles", - "reaction_name_step1", - "reactant_step1", - "reactant2_step1", - "reactant3_step1", - "product_step11", - "hit1", - "hit2", - "hit3", - "hit4", - "hit5", - "hit6", - "hit7", - "hit8", - "hit9", - "template", - "compound_set", + 'smiles', + 'reaction_name_step1', + 'reactant_step1', + 'reactant2_step1', + 'reactant3_step1', + 'product_step11', + 'hit1', + 'hit2', + 'hit3', + 'hit4', + 'hit5', + 'hit6', + 'hit7', + 'hit8', + 'hit9', + 'template', + 'compound_set', ] if not any([c not in cols for c in df.columns]): df = df[[c for c in cols if c in df.columns]] if not separate: - out_path = out_dir / f"{out_key}_syndirella_input.csv" + out_path = out_dir / f'{out_key}_syndirella_input.csv' mrich.writing(out_path) df.to_csv(out_path) return df for idx, row in df.iterrows(): - out_path = out_dir / f"{out_key}_{row['compound_set']}_syndirella_input.csv" + out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' mrich.writing(out_path) single_df = row.to_frame().T - single_df = single_df.dropna(axis=1, how="all") + single_df = single_df.dropna(axis=1, how='all') single_df.to_csv(out_path, index=False) return df - def copy(self) -> "Recipe": + def copy(self) -> 'Recipe': """Copy this recipe""" - if hasattr(self, "compounds"): + if hasattr(self, 'compounds'): compounds = self.compounds.copy() else: compounds = None @@ -2240,8 +2196,8 @@ def check_integrity(self, debug: bool = False) -> bool: # no duplicate ingredients if debug: - mrich.debug("Checking integrity:", self) - mrich.debug("Checking for duplicate compounds") + mrich.debug('Checking integrity:', self) + mrich.debug('Checking for duplicate compounds') if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): mrich.error("Reactant compound ID's are not unique") @@ -2258,31 +2214,31 @@ def check_integrity(self, debug: bool = False) -> bool: # all references should exist if debug: - mrich.debug("Checking for missing references") + mrich.debug('Checking for missing references') if self.db.count_where( - table="reaction", key=f"reaction_id IN {self.reactions.str_ids}" + table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' ) < len(self.reactions): - mrich.error("Not all Reactions in Database") + mrich.error('Not all Reactions in Database') return False if self.db.count_where( - table="compound", key=f"compound_id IN {self.product_compounds.str_ids}" + table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' ) < len(self.products): - mrich.error("Not all product Compounds in Database") + mrich.error('Not all product Compounds in Database') return False if self.db.count_where( - table="compound", key=f"compound_id IN {self.reactants.compounds.str_ids}" + table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' ) < len(self.reactants): - mrich.error("Not all reactant Compounds in Database") + mrich.error('Not all reactant Compounds in Database') return False if self.db.count_where( - table="compound", - key=f"compound_id IN {self.intermediates.compounds.str_ids}", + table='compound', + key=f'compound_id IN {self.intermediates.compounds.str_ids}', ) < len(self.intermediates): - mrich.error("Not all intermediate Compounds in Database") + mrich.error('Not all intermediate Compounds in Database') return False reaction_intermediates = self.reactions.intermediates @@ -2290,45 +2246,43 @@ def check_integrity(self, debug: bool = False) -> bool: reaction_reactants = self.reactions.reactants if debug: - mrich.debug("Checking for missing reactions") + mrich.debug('Checking for missing reactions') # all products should have a reaction for product in self.products: if product not in reaction_products: - mrich.error(f"Product: {product} does not have associated reaction") + mrich.error(f'Product: {product} does not have associated reaction') return False # intermediates for intermediate in self.intermediates: if intermediate not in reaction_intermediates: mrich.error( - f"Intermediate: {intermediate} is not in self.reactions.intermediates" + f'Intermediate: {intermediate} is not in self.reactions.intermediates' ) return False # reactants for reactant in self.reactants: if reactant not in reaction_reactants: - mrich.error(f"Reactant: {reactant} is not in self.reactions.reactants") + mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') return False # all reactions should have enough reactant if debug: - mrich.debug("Checking reactant quantities") + mrich.debug('Checking reactant quantities') for reaction in self.reactions: - product_ingredient = self.products(compound_id=reaction.product_id) if product_ingredient is None: product_ingredient = self.intermediates(compound_id=reaction.product_id) if debug and reaction.product_yield < 1.0: - mrich.debug(f"{reaction}.product_yield={reaction.product_yield}") + mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') for reactant in reaction.reactants: - reactant_ingredient = self.intermediates(compound_id=reactant.id) if reactant_ingredient is None: @@ -2338,16 +2292,16 @@ def check_integrity(self, debug: bool = False) -> bool: if reactant_ingredient.amount < required_amount: mrich.error( - f"Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}" + f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' ) return False if debug: - mrich.success(self, "OK") + mrich.success(self, 'OK') return True - def add_ingredient(self, ingredient: "Ingredient", amount: float = 1): + def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" self.compounds.add(ingredient) @@ -2357,61 +2311,59 @@ def __str__(self) -> str: """Unformatted string representation""" if self.score: - s = f"(score={self.score:.3f})" + s = f'(score={self.score:.3f})' else: - s = "" + s = '' if self.hash: - return f"Recipe_{self.hash}{s}" + return f'Recipe_{self.hash}{s}' - return f"Recipe{s}" + return f'Recipe{s}' def __longstr(self) -> str: """Unformatted string representation""" if self.empty: - return f"Empty Recipe()" + return 'Empty Recipe()' if self.reactions: - if self.intermediates: - s = f"{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}" + s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' else: - s = f"{self.reactants} --> {self.products} via {self.reactions}" + s = f'{self.reactants} --> {self.products} via {self.reactions}' if self.score: - s += f", score={self.score:.3f}" + s += f', score={self.score:.3f}' if self.hash: - return f"Recipe_{self.hash}({s})" + return f'Recipe_{self.hash}({s})' - return f"Recipe({s})" + return f'Recipe({s})' else: - - s = f"{self.compounds}" + s = f'{self.compounds}' if self.hash: - return f"Recipe_{self.hash}({s})" + return f'Recipe_{self.hash}({s})' - return f"Recipe(#compounds={self.num_compounds} [no-chem])" + return f'Recipe(#compounds={self.num_compounds} [no-chem])' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self.__longstr()}" + return f'[bold underline]{self.__longstr()}' - def __add__(self, other: "Recipe"): + def __add__(self, other: 'Recipe'): """Add another :class:`.Recipe` to this one""" result = self.copy() result.reactants += other.reactants result.intermediates += other.intermediates result.reactions += other.reactions result.products += other.products - if hasattr(other, "compounds"): + if hasattr(other, 'compounds'): result.compounds += other.compounds return result @@ -2424,10 +2376,10 @@ def __init__( db, *, route_id: int, - product: "IngredientSet", - reactants: "IngredientSet", - intermediates: "IngredientSet", - reactions: "ReactionSet", + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', ) -> None: """Route initialisation""" @@ -2456,8 +2408,8 @@ def __init__( @classmethod def from_json( - cls, db: "Database", path: "str | Path", data: dict = None - ) -> "Route": + cls, db: 'Database', path: 'str | Path', data: dict = None + ) -> 'Route': """Load a serialised route from a JSON file :param db: database to link @@ -2467,18 +2419,19 @@ def from_json( """ import json + from .cset import IngredientSet from .rset import ReactionSet if data is None: - data = json.load(open(path, "rt")) + data = json.load(open(path)) self = cls.__new__(cls) self._db = db - self._id = data["id"] + self._id = data['id'] - self._product_id = data["product_id"] + self._product_id = data['product_id'] self._products = IngredientSet.from_compounds( compounds=None, ids=[self._product_id], db=db ) # IngredientSet @@ -2486,17 +2439,17 @@ def from_json( self._reactants = IngredientSet.from_json( db=db, path=None, - data=data["reactants"]["data"], - supplier=data["reactants"]["supplier"], + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], ) self._intermediates = IngredientSet.from_json( db=db, path=None, - data=data["intermediates"]["data"], - supplier=data["intermediates"]["supplier"], + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], ) self._reactions = ReactionSet( - db=db, indices=data["reactions"]["indices"] + db=db, indices=data['reactions']['indices'] ) # ReactionSet return self @@ -2504,12 +2457,12 @@ def from_json( ### PROPERTIES @property - def product(self) -> "Ingredient": + def product(self) -> 'Ingredient': """Product ingredient""" return self._products[0] @property - def product_compound(self) -> "Compound": + def product_compound(self) -> 'Compound': """Product compound""" return self.product.compound @@ -2519,7 +2472,7 @@ def id(self) -> int: return self._id @property - def price(self) -> "Price": + def price(self) -> 'Price': """Get the price of the reactants""" return self.reactants.price @@ -2529,11 +2482,11 @@ def get_dict(self) -> dict: """Serialisable dictionary""" data = {} - data["id"] = self.id - data["product_id"] = self.product.id - data["reactants"] = self.reactants.get_dict() - data["intermediates"] = self.intermediates.get_dict() - data["reactions"] = self.reactions.get_dict() + data['id'] = self.id + data['product_id'] = self.product.id + data['reactants'] = self.reactants.get_dict() + data['intermediates'] = self.intermediates.get_dict() + data['reactions'] = self.reactions.get_dict() return data @@ -2541,21 +2494,21 @@ def get_dict(self) -> dict: def __str__(self) -> str: """Unformatted string representation""" - return f"Route #{self.id}: {self.product_compound}" + return f'Route #{self.id}: {self.product_compound}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class RouteSet: """A set of Route objects""" - def __init__(self, db: "Database", routes: "list[Route]") -> None: + def __init__(self, db: 'Database', routes: 'list[Route]') -> None: """RouteSet initialisation""" data = {} @@ -2572,7 +2525,7 @@ def __init__(self, db: "Database", routes: "list[Route]") -> None: ### FACTORIES @classmethod - def from_ids(cls, db: "Database", ids: list | set, progress: bool = True): + def from_ids(cls, db: 'Database', ids: list | set, progress: bool = True): """Generate a routeset from a set of :class:`.Route` IDs :param db: database to link @@ -2581,7 +2534,7 @@ def from_ids(cls, db: "Database", ids: list | set, progress: bool = True): """ if progress: - ids = mrich.track(ids, prefix="Getting routes") + ids = mrich.track(ids, prefix='Getting routes') routes = [db.get_route(id=route_id) for route_id in ids] @@ -2589,30 +2542,30 @@ def from_ids(cls, db: "Database", ids: list | set, progress: bool = True): return RouteSet(db, routes) @classmethod - def from_product_ids(cls, db: "Database", ids: list | set, progress: bool = True): + def from_product_ids(cls, db: 'Database', ids: list | set, progress: bool = True): """Generate a routeset from a set of product :class:`.Compound` IDs :param db: database to link :param ids: :class:`.Compound` database IDs """ - str_ids = str(tuple(ids)).replace(",)", ")") + str_ids = str(tuple(ids)).replace(',)', ')') records = db.select_where( - table="route", - query="route_id", - key=f"route_product IN {str_ids}", + table='route', + query='route_id', + key=f'route_product IN {str_ids}', multiple=True, ) - route_ids = [i for i, in records] + route_ids = [i for (i,) in records] return cls.from_ids(db, route_ids, progress=progress) @classmethod def from_json( - cls, db: "Database", path: "str | Path", data: dict = None - ) -> "RouteSet": + cls, db: 'Database', path: 'str | Path', data: dict = None + ) -> 'RouteSet': """Load a serialised routeset from a JSON file :param db: database to link @@ -2626,11 +2579,11 @@ def from_json( if data is None: import json - data = json.load(open(path, "rt")) + data = json.load(open(path)) new_data = {} - for d in mrich.track(data["routes"].values(), prefix="Loading Routes..."): - route_id = d["id"] + for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): + route_id = d['id'] new_data[route_id] = Route.from_json(db=db, path=None, data=d) self._data = new_data @@ -2644,7 +2597,7 @@ def from_json( ### PROPERTIES @property - def data(self) -> "dict[int, Route]": + def data(self) -> 'dict[int, Route]': """Get internal data dictionary""" return self._data @@ -2654,7 +2607,7 @@ def db(self): return self._db @property - def routes(self) -> "list[Route]": + def routes(self) -> 'list[Route]': """Get route objects""" return self.data.values() @@ -2662,12 +2615,12 @@ def routes(self) -> "list[Route]": def product_ids(self) -> list[int]: """Get the :class:`.Compound` ID's of the products""" ids = self.db.select_where( - table="route", - query="DISTINCT route_product", - key=f"route_id IN {self.str_ids}", + table='route', + query='DISTINCT route_product', + key=f'route_id IN {self.str_ids}', multiple=True, ) - return [i for i, in ids] + return [i for (i,) in ids] @property def reactant_ids(self) -> list[int]: @@ -2680,17 +2633,17 @@ def reactant_ids(self) -> list[int]: """ c = self.db.execute(sql) - return [i for i, in c] + return [i for (i,) in c] @property - def products(self) -> "CompoundSet": + def products(self) -> 'CompoundSet': """Return a :class:`.CompoundSet` of all the route products""" from .cset import CompoundSet return CompoundSet(self.db, self.product_ids) @property - def reactants(self) -> "CompoundSet": + def reactants(self) -> 'CompoundSet': """Return a :class:`.CompoundSet` of all the route reactants""" from .cset import CompoundSet @@ -2699,7 +2652,7 @@ def reactants(self) -> "CompoundSet": @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Route` ID's""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property def ids(self) -> list[int]: @@ -2714,12 +2667,11 @@ def cluster_map(self) -> dict[tuple, set]: """ if self._cluster_map is None: - # get route mapping pairs = self.db.select_where( - query="route_product, route_id", - key=f"route_id IN {self.str_ids}", - table="route", + query='route_product, route_id', + key=f'route_id IN {self.str_ids}', + table='route', multiple=True, ) @@ -2745,11 +2697,11 @@ def cluster_map(self) -> dict[tuple, set]: ### METHODS - def copy(self) -> "RouteSet": + def copy(self) -> 'RouteSet': """Copy this RouteSet""" return RouteSet(self.db, self.data.values()) - def set_db_pointers(self, db: "Database") -> None: + def set_db_pointers(self, db: 'Database') -> None: """ :param db: @@ -2772,32 +2724,32 @@ def get_dict(self): # populate with routes for route_id, route in self.data.items(): - data["routes"][route_id] = route.get_dict() + data['routes'][route_id] = route.get_dict() return data def prune_unavailable(self, suppliers: list[str]): """Remove routes that don't have all reactants available from given suppliers""" - suppliers_str = str(tuple(suppliers)).replace(",)", ")") + suppliers_str = str(tuple(suppliers)).replace(',)', ')') sql = f""" WITH possible_reactants AS ( SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] + CASE + WHEN quote_supplier IN {suppliers_str} THEN 1 + END) AS [count_valid] FROM {self.db.SQL_SCHEMA_PREFIX}quote GROUP BY quote_compound ), route_reactants AS ( - SELECT route_id, route_product, + SELECT route_id, route_product, COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) + CASE + WHEN count_valid = 0 THEN 1 + WHEN count_valid IS NULL THEN 1 + END) AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id LEFT JOIN possible_reactants ON quote_compound = component_ref @@ -2812,10 +2764,10 @@ def prune_unavailable(self, suppliers: list[str]): route_ids = self.db.execute(sql).fetchall() - route_ids = [i for i, in route_ids] + route_ids = [i for (i,) in route_ids] - mrich.var("#routes before pruning", len(self)) - mrich.var("#routes after pruning", len(route_ids)) + mrich.var('#routes before pruning', len(self)) + mrich.var('#routes after pruning', len(route_ids)) return RouteSet.from_ids(self.db, route_ids) @@ -2824,18 +2776,18 @@ def pop_id(self) -> int: route_id, route = self.data.popitem() return route_id - def pop(self) -> "Route": + def pop(self) -> 'Route': """Pop the last route from the set and return it's object""" route_id, route = self.data.popitem() return route def balanced_pop( self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> "Route": + ) -> 'Route': """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" if not self._data: - mrich.print("RouteSet depleted") + mrich.print('RouteSet depleted') return None if not self.cluster_map: @@ -2855,7 +2807,7 @@ def balanced_pop( for cluster in permitted_clusters: if cluster not in self.cluster_map: mrich.warning( - cluster, "in permitted_clusters but not cluster_map" + cluster, 'in permitted_clusters but not cluster_map' ) else: self._permitted_clusters.append(cluster) @@ -2869,7 +2821,7 @@ def balanced_pop( ### pop a Route if debug: - mrich.debug(f"Would pop Route from {self._current_cluster=}") + mrich.debug(f'Would pop Route from {self._current_cluster=}') cluster = self._current_cluster @@ -2886,15 +2838,15 @@ def balanced_pop( mrich.print(self.cluster_map) raise except KeyError: - mrich.print("cluster", cluster) - mrich.print("self._permitted_clusters", self._permitted_clusters) - mrich.print("self.cluster_map.keys()", self.cluster_map.keys()) + mrich.print('cluster', cluster) + mrich.print('self._permitted_clusters', self._permitted_clusters) + mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) raise # clean up empty clusters if debug: - mrich.debug("Popped route", route_id) + mrich.debug('Popped route', route_id) # get the Route object @@ -2903,7 +2855,7 @@ def balanced_pop( del self._data[route_id] else: # if debug: - mrich.debug("Route not present") + mrich.debug('Route not present') return self.balanced_pop() ### increment cluster @@ -2919,28 +2871,28 @@ def balanced_pop( self._current_cluster = self._permitted_clusters[i + 1] break else: - raise IndexError("This should never be reached...") + raise IndexError('This should never be reached...') # increment_cluster() if not self.cluster_map[cluster]: del self.cluster_map[cluster] if not self.cluster_map: - mrich.debug("RouteSet.cluster_map depleted") + mrich.debug('RouteSet.cluster_map depleted') self._permitted_clusters = [ c for c in self._permitted_clusters if c != cluster ] # if debug: - mrich.debug("Depleted cluster", cluster) + mrich.debug('Depleted cluster', cluster) if not self._permitted_clusters: - mrich.debug("Depleted all permitted clusters", cluster) - mrich.debug("Removing cluster restriction", cluster) + mrich.debug('Depleted all permitted clusters', cluster) + mrich.debug('Removing cluster restriction', cluster) self._permitted_clusters = list(self.cluster_map.keys()) self._current_cluster = None if debug: - mrich.debug("#Routes in set", len(self._data)) + mrich.debug('#Routes in set', len(self._data)) return route @@ -2966,15 +2918,15 @@ def __len__(self) -> int: def __str__(self) -> str: """Unformatted string representation""" - return "{" f"Route × {len(self)}" "}" + return f'{{Route × {len(self)}}}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __iter__(self): """Iterate over routes in this set""" @@ -2989,12 +2941,12 @@ class RecipeSet: """A set of recipes stored on disk""" def __init__( - self, db: "Database", directory: "str | Path", pattern: str = "*.json" + self, db: 'Database', directory: 'str | Path', pattern: str = '*.json' ): """RecipeSet initialisation""" - from pathlib import Path from json import JSONDecodeError + from pathlib import Path self._db = db self._json_directory = Path(directory) @@ -3003,14 +2955,14 @@ def __init__( self._json_paths = {} for path in self._json_directory.glob(self._json_pattern): self._json_paths[ - path.name.removeprefix("Recipe_").removesuffix(".json") + path.name.removeprefix('Recipe_').removesuffix('.json') ] = path.resolve() - mrich.reading(f"{directory}/{pattern}") + mrich.reading(f'{directory}/{pattern}') self._recipes = {} for key, path in mrich.track( - self._json_paths.items(), prefix="Loading recipes" + self._json_paths.items(), prefix='Loading recipes' ): try: recipe = Recipe.from_json( @@ -3021,19 +2973,19 @@ def __init__( db_mismatch_warning=False, ) except JSONDecodeError: - mrich.error(f"Bad JSON in {path}") + mrich.error(f'Bad JSON in {path}') continue recipe._hash = key self._recipes[key] = recipe - mrich.success("Loaded", len(self), "Recipes") + mrich.success('Loaded', len(self), 'Recipes') ### FACTORIES ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Associated database""" return self._db @@ -3057,23 +3009,22 @@ def get_values( recipes = self._recipes.values() if progress: - recipes = mrich.track(recipes, prefix=f"Calculating {self} values...") + recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') for recipe in recipes: value = getattr(recipe, key) - if serialise_price and key == "price": + if serialise_price and key == 'price': value = value.amount values.append(value) return values - def get_df(self, **kwargs) -> "pandas.DataFrame": + def get_df(self, **kwargs) -> 'pandas.DataFrame': """Get dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`""" data = [] for recipe in self: - d = recipe.get_dict( # reactant_supplier=False, database=False, @@ -3088,7 +3039,7 @@ def get_df(self, **kwargs) -> "pandas.DataFrame": return DataFrame(data) - def items(self) -> "list[tuple[str, Recipe]]": + def items(self) -> 'list[tuple[str, Recipe]]': """Get data dictionary items""" return self._recipes.items() @@ -3109,7 +3060,6 @@ def __getitem__( """Get a :class:`.Recipe` in this set by it's index or key/hash""" match key: - case int(): return list(self._recipes.values())[key] @@ -3118,7 +3068,7 @@ def __getitem__( case _: mrich.error( - f"Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}" + f'Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}' ) return None @@ -3134,12 +3084,12 @@ def __contains__(self, key: str): def __str__(self) -> str: """Unformatted string representation""" - return "{" f"Recipe × {len(self)}" "}" + return f'{{Recipe × {len(self)}}}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' diff --git a/hippo/rgen.py b/hippo/rgen.py index a2ca948..a4f95c9 100644 --- a/hippo/rgen.py +++ b/hippo/rgen.py @@ -1,20 +1,20 @@ """Classes for generating random recipes/selections""" -import mrich - import json from pathlib import Path -from .tools import dt_hash -from .recipe import Recipe +import mrich + from .cset import CompoundSet, IngredientSet +from .recipe import Recipe +from .tools import dt_hash class RRGMixin: """Mixin class for shared properties""" @property - def db(self) -> "Database": + def db(self) -> 'Database': """Get the linked HIPPO Database object""" return self._db @@ -31,7 +31,7 @@ def starting_recipe(self): @property def suppliers_str(self) -> str: """SQL formatted tuple of suppliers""" - return str(tuple(self.suppliers)).replace(",)", ")") + return str(tuple(self.suppliers)).replace(',)', ')') @property def suppliers(self) -> list[str]: @@ -57,15 +57,15 @@ def __repr__(self) -> str: """ANSI Formatted string representation""" import mcol - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - def __call__(self, *args, **kwargs) -> "Recipe": + def __call__(self, *args, **kwargs) -> 'Recipe': """Generate Recipe""" return self.generate(*args, **kwargs) def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class RandomRecipeGenerator(RRGMixin): @@ -78,12 +78,12 @@ def __init__( max_lead_time=None, suppliers: list | None = None, start_with: Recipe | CompoundSet | IngredientSet | None = None, - route_pool: "RouteSet | None" = None, + route_pool: 'RouteSet | None' = None, out_key: str | None = None, ): """RandomRecipeGenerator initialisation""" - mrich.debug("RandomRecipeGenerator.__init__()") + mrich.debug('RandomRecipeGenerator.__init__()') if not start_with: start_with = Recipe(db) @@ -94,30 +94,30 @@ def __init__( self._suppliers = suppliers self._starting_recipe = start_with - mrich.var("database", self.db_path) - mrich.var("max_lead_time", self.max_lead_time) - mrich.var("suppliers", self.suppliers) + mrich.var('database', self.db_path) + mrich.var('max_lead_time', self.max_lead_time) + mrich.var('suppliers', self.suppliers) # Database set up self._db = db if not out_key: - out_key = str(self.db_path.name).removesuffix(".sqlite") - mrich.var("out_key", out_key) + out_key = str(self.db_path.name).removesuffix('.sqlite') + mrich.var('out_key', out_key) parent_dir = Path(out_key).parent if not parent_dir.exists(): parent_dir.mkdir(parents=True) # JSON I/O set up - self._data_path = Path(f"{out_key}_rgen.json") + self._data_path = Path(f'{out_key}_rgen.json') if self.data_path.exists(): - mrich.warning(f"Will overwrite existing rgen data file: {self.data_path}") + mrich.warning(f'Will overwrite existing rgen data file: {self.data_path}') # Recipe I/O set up - path = Path(f"{out_key}_recipes") + path = Path(f'{out_key}_recipes') if not path.exists(): - mrich.writing(f"{path}/") + mrich.writing(f'{path}/') path.mkdir() self._recipe_dir = path @@ -126,10 +126,10 @@ def __init__( route_pool = route_pool.prune_unavailable(suppliers=suppliers) self._route_pool = route_pool else: - mrich.debug("Solving route pool...") + mrich.debug('Solving route pool...') self._route_pool = self.get_route_pool() - assert len(self._route_pool), "Route pool is empty!" + assert len(self._route_pool), 'Route pool is empty!' # dump data self.dump_data() @@ -137,28 +137,28 @@ def __init__( ### FACTORIES @classmethod - def from_json(cls, db: "Database", path: "Path | str"): + def from_json(cls, db: 'Database', path: 'Path | str'): """Construct the RandomRecipeGenerator from a JSON file""" - data = json.load(open(path, "rt")) + data = json.load(open(path)) self = cls.__new__(cls) - self._db_path = Path(data["db_path"]) - self._recipe_dir = Path(data["recipe_dir"]) - self._max_lead_time = data["max_lead_time"] - self._suppliers = data["suppliers"] + self._db_path = Path(data['db_path']) + self._recipe_dir = Path(data['recipe_dir']) + self._max_lead_time = data['max_lead_time'] + self._suppliers = data['suppliers'] self._starting_recipe = Recipe.from_json( db=db, path=None, - data=data["starting_recipe"], + data=data['starting_recipe'], allow_db_mismatch=True, ) - mrich.var("database", self.db_path) - mrich.var("max_lead_time", self.max_lead_time) - mrich.var("suppliers", self.suppliers) + mrich.var('database', self.db_path) + mrich.var('max_lead_time', self.max_lead_time) + mrich.var('suppliers', self.suppliers) self._db = db @@ -168,7 +168,7 @@ def from_json(cls, db: "Database", path: "Path | str"): # Route pool from .recipe import RouteSet - self._route_pool = RouteSet.from_json(path=None, data=data["route_pool"], db=db) + self._route_pool = RouteSet.from_json(path=None, data=data['route_pool'], db=db) return self @@ -197,8 +197,8 @@ def get_route_pool(self, mini_test=False): """ - if "route" not in self.db.table_names: - mrich.error("route table not in Database") + if 'route' not in self.db.table_names: + mrich.error('route table not in Database') raise NotImplementedError assert self.suppliers_str @@ -209,18 +209,18 @@ def get_route_pool(self, mini_test=False): sql = f""" WITH possible_reactants AS ( - SELECT quote_compound, COUNT(CASE WHEN quote_supplier IN {self.suppliers_str} THEN 1 END) AS [count_valid] + SELECT quote_compound, COUNT(CASE WHEN quote_supplier IN {self.suppliers_str} THEN 1 END) AS [count_valid] FROM {self.db.SQL_SCHEMA_PREFIX}quote GROUP BY quote_compound ), route_reactants AS ( - SELECT route_id, route_product, + SELECT route_id, route_product, COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) + CASE + WHEN count_valid = 0 THEN 1 + WHEN count_valid IS NULL THEN 1 + END) AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id LEFT JOIN possible_reactants ON quote_compound = component_ref @@ -234,7 +234,7 @@ def get_route_pool(self, mini_test=False): route_ids = self.db.execute(sql).fetchall() - route_ids = [i for i, in route_ids] + route_ids = [i for (i,) in route_ids] if mini_test: route_ids = route_ids[:100] @@ -250,20 +250,20 @@ def dump_data(self): data = {} - data["db_path"] = str(self.db_path.resolve()) - data["recipe_dir"] = str(self.recipe_dir.resolve()) - data["max_lead_time"] = self.max_lead_time - data["suppliers"] = self.suppliers - data["starting_recipe"] = self.starting_recipe.get_dict(serialise_price=True) - data["route_pool"] = self.route_pool.get_dict() + data['db_path'] = str(self.db_path.resolve()) + data['recipe_dir'] = str(self.recipe_dir.resolve()) + data['max_lead_time'] = self.max_lead_time + data['suppliers'] = self.suppliers + data['starting_recipe'] = self.starting_recipe.get_dict(serialise_price=True) + data['route_pool'] = self.route_pool.get_dict() mrich.writing(self.data_path) - json.dump(data, open(self.data_path, "wt"), indent=4) + json.dump(data, open(self.data_path, 'w'), indent=4) def generate( self, budget: float = 10000, - currency: str = "EUR", + currency: str = 'EUR', max_products: int = 1000, max_reactions: int = 1000, debug: bool = False, @@ -287,7 +287,7 @@ def generate( # construct filename - out_file = self.recipe_dir / f"Recipe_{dt_hash()}.json" + out_file = self.recipe_dir / f'Recipe_{dt_hash()}.json' from .price import Price @@ -305,28 +305,27 @@ def generate( # get the RouteSet pool = self.route_pool.copy() - assert len(pool), "Route pool is empty!" + assert len(pool), 'Route pool is empty!' if shuffle: - mrich.debug("Shuffling Route pool") + mrich.debug('Shuffling Route pool') pool.shuffle() old_recipe = recipe.copy() - mrich.var("route pool", len(pool)) - mrich.var("max_iter", max_iter) - - for i in mrich.track(range(max_iter), prefix="Generating Recipe..."): + mrich.var('route pool', len(pool)) + mrich.var('max_iter', max_iter) + for i in mrich.track(range(max_iter), prefix='Generating Recipe...'): if debug: - mrich.title(f"Iteration {i}") + mrich.title(f'Iteration {i}') price = recipe.price - mrich.set_progress_field("price", str(price)) - mrich.set_progress_field("#products", len(recipe.products)) + mrich.set_progress_field('price', str(price)) + mrich.set_progress_field('#products', len(recipe.products)) if debug: - mrich.var("price", price) + mrich.var('price', price) # pop a route if balance_clusters: @@ -337,35 +336,35 @@ def generate( candidate_route = pool.pop() if debug: - mrich.var("candidate_route", candidate_route) + mrich.var('candidate_route', candidate_route) if debug: - mrich.var("candidate_route.reactants", candidate_route.reactants.ids) + mrich.var('candidate_route.reactants', candidate_route.reactants.ids) if candidate_route.product in recipe.products: continue # add the route to the recipe if debug: - mrich.var("#recipe.reactants", len(recipe.reactants)) + mrich.var('#recipe.reactants', len(recipe.reactants)) recipe += candidate_route if debug: - mrich.var("#recipe.reactants", len(recipe.reactants)) + mrich.var('#recipe.reactants', len(recipe.reactants)) # calculate the new price try: new_price = recipe.price except AssertionError: mrich.error( - f"Something went wrong while calculating the price after adding {candidate_route=} to recipe" + f'Something went wrong while calculating the price after adding {candidate_route=} to recipe' ) raise if debug: - mrich.var("new price", new_price) + mrich.var('new price', new_price) # Break if product pool depleted if not len(pool): - stop_reason = "Product pool depleted" + stop_reason = 'Product pool depleted' mrich.success(stop_reason) break @@ -375,12 +374,12 @@ def generate( continue if len(recipe.reactions) > max_reactions: - stop_reason = "Max #reactions exceeded" + stop_reason = 'Max #reactions exceeded' mrich.success(stop_reason) break if len(recipe.products) > max_products: - stop_reason = "Max #products exceeded" + stop_reason = 'Max #products exceeded' mrich.success(stop_reason) break @@ -388,28 +387,28 @@ def generate( old_recipe = recipe.copy() else: - stop_reason = "Max #iterations reached" + stop_reason = 'Max #iterations reached' mrich.warning(stop_reason) ### recalculate the products to see if any extra can be had for free? - mrich.success(f"Completed after {i} iterations") + mrich.success(f'Completed after {i} iterations') metadict = { - "rgen_data_path": str(self.data_path.resolve()), - "rgen_db_path": str(self.db_path.resolve()), - "rgen_recipe_dir": str(self.recipe_dir.resolve()), - "rgen_max_lead_time": self.max_lead_time, - "rgen_suppliers": self.suppliers, - "gen_budget": budget.amount, - "gen_currency": budget.currency, - "gen_max_products": max_products, - "gen_max_reactions": max_reactions, - "gen_max_iter": max_iter, - "gen_shuffle": shuffle, - "gen_iterations": i, - "gen_stop_reason": stop_reason, - "gen_recipe_path": str(out_file.resolve()), + 'rgen_data_path': str(self.data_path.resolve()), + 'rgen_db_path': str(self.db_path.resolve()), + 'rgen_recipe_dir': str(self.recipe_dir.resolve()), + 'rgen_max_lead_time': self.max_lead_time, + 'rgen_suppliers': self.suppliers, + 'gen_budget': budget.amount, + 'gen_currency': budget.currency, + 'gen_max_products': max_products, + 'gen_max_reactions': max_reactions, + 'gen_max_iter': max_iter, + 'gen_shuffle': shuffle, + 'gen_iterations': i, + 'gen_stop_reason': stop_reason, + 'gen_recipe_path': str(out_file.resolve()), } # write the Recipe JSON @@ -421,7 +420,7 @@ def generate( def __str__(self) -> str: """Unformatted string representation""" - return f"RandomRecipeGenerator(recipe_dir={self.recipe_dir})" + return f'RandomRecipeGenerator(recipe_dir={self.recipe_dir})' class RandomSelectionGenerator(RRGMixin): @@ -440,7 +439,7 @@ def __init__( ): """RandomSelectionGenerator initialisation""" - mrich.debug("RandomRecipeGenerator.__init__()") + mrich.debug('RandomRecipeGenerator.__init__()') # Static parameters self._db_path = db.path @@ -449,28 +448,28 @@ def __init__( self._quoted_only = quoted_only self._db = db - mrich.var("database", self.db_path) - mrich.var("suppliers", self.suppliers) - mrich.var("amount per compound", self.amount, unit="mg") - mrich.var("quoted_only", self.quoted_only) + mrich.var('database', self.db_path) + mrich.var('suppliers', self.suppliers) + mrich.var('amount per compound', self.amount, unit='mg') + mrich.var('quoted_only', self.quoted_only) self.get_starting_recipe(start_with) - mrich.var("starting recipe", self.starting_recipe) + mrich.var('starting recipe', self.starting_recipe) # JSON I/O set up - self._data_path = Path(str(self.db_path.name).replace(".sqlite", "_sgen.json")) + self._data_path = Path(str(self.db_path.name).replace('.sqlite', '_sgen.json')) if self.data_path.exists(): - mrich.warning(f"Will overwrite existing rgen data file: {self.data_path}") + mrich.warning(f'Will overwrite existing rgen data file: {self.data_path}') # Recipe I/O set up - path = Path(str(self.db_path.name).replace(".sqlite", "_selections")) - mrich.writing(f"{path}/") + path = Path(str(self.db_path.name).replace('.sqlite', '_selections')) + mrich.writing(f'{path}/') path.mkdir(exist_ok=True) self._recipe_dir = path - with mrich.spinner("Getting compound pool"): + with mrich.spinner('Getting compound pool'): self.get_compound_pool(compounds) - mrich.var("compound pool", self.compound_pool) + mrich.var('compound pool', self.compound_pool) # dump data self.dump_data() @@ -479,31 +478,31 @@ def __init__( @classmethod def from_json( - cls, db: "Database", path: "Path | str" - ) -> "RandomSelectionGenerator": + cls, db: 'Database', path: 'Path | str' + ) -> 'RandomSelectionGenerator': """Construct the RandomRecipeGenerator from a JSON file""" - data = json.load(open(path, "rt")) + data = json.load(open(path)) self = cls.__new__(cls) - self._db_path = Path(data["db_path"]) - self._recipe_dir = Path(data["recipe_dir"]) + self._db_path = Path(data['db_path']) + self._recipe_dir = Path(data['recipe_dir']) # self._max_lead_time = data["max_lead_time"] - self._suppliers = data["suppliers"] - self._amount = data["amount"] + self._suppliers = data['suppliers'] + self._amount = data['amount'] self._starting_recipe = Recipe.from_json( db=db, path=None, - data=data["starting_recipe"], + data=data['starting_recipe'], allow_db_mismatch=True, ) - mrich.var("database", self.db_path) - mrich.var("suppliers", self.suppliers) - mrich.var("amount", self.amount) - mrich.var("starting_recipe", self.starting_recipe) + mrich.var('database', self.db_path) + mrich.var('suppliers', self.suppliers) + mrich.var('amount', self.amount) + mrich.var('starting_recipe', self.starting_recipe) self._db = db @@ -512,9 +511,9 @@ def from_json( # Route pool self._compound_pool = IngredientSet.from_json( - path=None, data=data["compound_pool"]["data"], db=db + path=None, data=data['compound_pool']['data'], db=db ) - mrich.var("compound_pool", self.compound_pool) + mrich.var('compound_pool', self.compound_pool) return self @@ -531,20 +530,20 @@ def quoted_only(self) -> bool: return self._quoted_only @property - def compound_pool(self) -> "CompoundTable | CompoundSet": + def compound_pool(self) -> 'CompoundTable | CompoundSet': """The pool of compounds that will be chosen from""" return self._compound_pool ### METHODS def get_starting_recipe( - self, start_with: "Recipe | CompoundSet | IngredientSet" + self, start_with: 'Recipe | CompoundSet | IngredientSet' ) -> Recipe: """Process start_with into Recipe object""" if isinstance(start_with, Recipe): - if start_with.type != "NOCHEM": - raise NotImplementedError("Only NOCHEM recipes are supported") + if start_with.type != 'NOCHEM': + raise NotImplementedError('Only NOCHEM recipes are supported') self._starting_recipe = start_with return self._starting_recipe @@ -563,28 +562,27 @@ def get_starting_recipe( def get_compound_pool( self, compounds: CompoundSet | None - ) -> "CompoundTable | CompoundSet": + ) -> 'CompoundTable | CompoundSet': """Get pool of compounds to select from""" if self.suppliers: raise NotImplementedError if compounds is None: - # all compounds if not self.quoted_only: ids = self.db.select( - table="compound", query="compound_id", multiple=True + table='compound', query='compound_id', multiple=True ) self._compound_pool = IngredientSet.from_compounds( - db=self.db, ids=[i for i, in ids], amount=self.amount + db=self.db, ids=[i for (i,) in ids], amount=self.amount ) return self._compound_pool # get all compounds that have a quote sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) + SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_amount >= {self.amount} GROUP BY quote_compound @@ -609,7 +607,6 @@ def get_compound_pool( ) else: - # ignore quoting if not self.quoted_only: self._compound_pool = IngredientSet.from_compounds( @@ -620,7 +617,7 @@ def get_compound_pool( # get all compounds that have a quote sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) + SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote WHERE quote_amount >= {self.amount} AND quote_compound IN {compounds.str_ids} @@ -650,21 +647,21 @@ def dump_data(self): data = {} - data["db_path"] = str(self.db_path.resolve()) - data["recipe_dir"] = str(self.recipe_dir.resolve()) + data['db_path'] = str(self.db_path.resolve()) + data['recipe_dir'] = str(self.recipe_dir.resolve()) # data["max_lead_time"] = self.max_lead_time - data["amount"] = self.amount - data["suppliers"] = self.suppliers - data["starting_recipe"] = self.starting_recipe.get_dict(serialise_price=True) - data["compound_pool"] = self.compound_pool.get_dict() + data['amount'] = self.amount + data['suppliers'] = self.suppliers + data['starting_recipe'] = self.starting_recipe.get_dict(serialise_price=True) + data['compound_pool'] = self.compound_pool.get_dict() mrich.writing(self.data_path) - json.dump(data, open(self.data_path, "wt"), indent=4) + json.dump(data, open(self.data_path, 'w'), indent=4) def generate( self, budget: float = 10000, - currency: str = "EUR", + currency: str = 'EUR', max_iter: int | None = None, max_compounds: int = 1000, debug: bool = False, @@ -682,7 +679,7 @@ def generate( # construct filename - out_file = self.recipe_dir / f"Recipe_{dt_hash()}.json" + out_file = self.recipe_dir / f'Recipe_{dt_hash()}.json' from .price import Price @@ -695,10 +692,10 @@ def generate( # get the RouteSet pool = self.compound_pool.copy() - assert len(pool), "Route pool is empty!" + assert len(pool), 'Route pool is empty!' if shuffle: - mrich.debug("Shuffling Route pool") + mrich.debug('Shuffling Route pool') pool.shuffle() old_recipe = recipe.copy() @@ -706,21 +703,20 @@ def generate( if not max_iter: max_iter = max_compounds * 3 - mrich.var("compound pool", pool) - mrich.var("max_compounds", max_compounds) - mrich.var("max_iter", max_iter) - - for i in mrich.track(range(max_iter), prefix="Generating Recipe..."): + mrich.var('compound pool', pool) + mrich.var('max_compounds', max_compounds) + mrich.var('max_iter', max_iter) + for i in mrich.track(range(max_iter), prefix='Generating Recipe...'): if debug: - mrich.title(f"Iteration {i}") + mrich.title(f'Iteration {i}') price = recipe.price - mrich.set_progress_field("price", str(price)) - mrich.set_progress_field("#compounds", len(recipe.compounds)) + mrich.set_progress_field('price', str(price)) + mrich.set_progress_field('#compounds', len(recipe.compounds)) if debug: - mrich.var("price", price) + mrich.var('price', price) # # pop a route # if balance_clusters: @@ -731,7 +727,7 @@ def generate( candidate = pool.pop() if debug: - mrich.var("candidate", candidate) + mrich.var('candidate', candidate) if candidate in recipe.compounds: continue @@ -744,17 +740,17 @@ def generate( new_price = recipe.price except AssertionError: mrich.error( - f"Something went wrong while calculating the price after adding {candidate_route=} to recipe" + f'Something went wrong while calculating the price after adding {candidate_route=} to recipe' ) raise if debug: - mrich.var("#compounds", recipe.num_compounds) - mrich.var("new price", new_price) + mrich.var('#compounds', recipe.num_compounds) + mrich.var('new price', new_price) # Break if product pool depleted if not len(pool): - stop_reason = "Compound pool depleted" + stop_reason = 'Compound pool depleted' mrich.success(stop_reason) break @@ -764,7 +760,7 @@ def generate( continue if len(recipe.compounds) > max_compounds: - stop_reason = "Max #compounds exceeded" + stop_reason = 'Max #compounds exceeded' mrich.success(stop_reason) break @@ -772,26 +768,26 @@ def generate( old_recipe = recipe.copy() else: - stop_reason = "Max #iterations reached" + stop_reason = 'Max #iterations reached' mrich.warning(stop_reason) ### recalculate the products to see if any extra can be had for free? - mrich.success(f"Completed after {i} iterations") + mrich.success(f'Completed after {i} iterations') metadict = { - "rgen_data_path": str(self.data_path.resolve()), - "rgen_db_path": str(self.db_path.resolve()), - "rgen_recipe_dir": str(self.recipe_dir.resolve()), - "rgen_suppliers": self.suppliers, - "rgen_amount": self.amount, - "gen_budget": budget.amount, - "gen_currency": budget.currency, - "gen_max_compounds": max_compounds, - "gen_shuffle": shuffle, - "gen_iterations": i, - "gen_stop_reason": stop_reason, - "gen_recipe_path": str(out_file.resolve()), + 'rgen_data_path': str(self.data_path.resolve()), + 'rgen_db_path': str(self.db_path.resolve()), + 'rgen_recipe_dir': str(self.recipe_dir.resolve()), + 'rgen_suppliers': self.suppliers, + 'rgen_amount': self.amount, + 'gen_budget': budget.amount, + 'gen_currency': budget.currency, + 'gen_max_compounds': max_compounds, + 'gen_shuffle': shuffle, + 'gen_iterations': i, + 'gen_stop_reason': stop_reason, + 'gen_recipe_path': str(out_file.resolve()), } # write the Recipe JSON @@ -803,4 +799,4 @@ def generate( def __str__(self) -> str: """Unformatted string representation""" - return f"RandomSelectionGenerator(recipe_dir={self.recipe_dir})" + return f'RandomSelectionGenerator(recipe_dir={self.recipe_dir})' diff --git a/hippo/rset.py b/hippo/rset.py index 23447ca..3385c33 100644 --- a/hippo/rset.py +++ b/hippo/rset.py @@ -2,8 +2,6 @@ import mcol import mrich - -import os from numpy import int64 from .db import Database @@ -48,12 +46,12 @@ class ReactionTable: """ - _name = "all reactions" + _name = 'all reactions' def __init__( self, db: Database, - table: str = "reaction", + table: str = 'reaction', ) -> None: """ReactionTable initialisation""" @@ -63,7 +61,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns the associated :class:`.Database`""" return self._db @@ -81,15 +79,15 @@ def name(self) -> str | None: def types(self) -> list[str]: """Returns a list of the unique reaction types present in the table""" result = self.db.select( - table=self.table, query="DISTINCT reaction_type", multiple=True + table=self.table, query='DISTINCT reaction_type', multiple=True ) - return [q for q, in result] + return [q for (q,) in result] @property def ids(self) -> list[int]: """Returns the IDs of child reactions""" - result = self.db.select(table=self.table, query="reaction_id", multiple=True) - return [q for q, in result] + result = self.db.select(table=self.table, query='reaction_id', multiple=True) + return [q for (q,) in result] ### METHODS @@ -103,7 +101,7 @@ def interactive(self) -> None: """ return self[self.ids].interactive() - def get_by_type(self, reaction_type: str) -> "ReactionSet": + def get_by_type(self, reaction_type: str) -> 'ReactionSet': """Get all child reactions of the given type :param reaction_type: reaction type to filter by @@ -111,16 +109,16 @@ def get_by_type(self, reaction_type: str) -> "ReactionSet": """ result = self.db.select_where( table=self.table, - query="reaction_id", - key="type", + query='reaction_id', + key='type', value=reaction_type, multiple=True, ) - rset = self[[q for q, in result]] - rset._name = f"all {reaction_type} reactions" + rset = self[[q for (q,) in result]] + rset._name = f'all {reaction_type} reactions' return rset - def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame": + def get_df(self, *, smiles: bool = True, mols: bool = True) -> 'pandas.DataFrame': """Construct a pandas.DataFrame of all reactions in the database :param smiles: Include smiles column (Default value = True) @@ -128,19 +126,18 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame """ - from rdkit.Chem import Mol from pandas import DataFrame + from rdkit.Chem import Mol ### SQL QUERY data = {} if not smiles and not mols: - sql = f""" - SELECT reaction_id, reaction_type, reaction_product, reactant_compound - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + SELECT reaction_id, reaction_type, reaction_product, reactant_compound + FROM {self.db.SQL_SCHEMA_PREFIX}reaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON reaction.reaction_id = reactant.reactant_reaction """ @@ -150,17 +147,16 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame if reaction_id not in data: data[reaction_id] = dict(product_id=product_id, reactant_ids=[]) else: - assert data[reaction_id]["product_id"] == product_id + assert data[reaction_id]['product_id'] == product_id - data[reaction_id]["reactant_ids"].append(reactant_id) + data[reaction_id]['reactant_ids'].append(reactant_id) else: - sql = f""" SELECT {query} - FROM {self.db.SQL_SCHEMA_PREFIX}reaction + FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON reaction.reaction_id = reactant.reactant_reaction INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound c_r @@ -172,18 +168,17 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame if not mols: sql = sql.format( - query="reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles" + query='reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles' ) else: sql = sql.format( - query="reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles, mol_to_binary_mol(c_p.compound_mol), mol_to_binary_mol(c_r.compound_mol)" + query='reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles, mol_to_binary_mol(c_p.compound_mol), mol_to_binary_mol(c_r.compound_mol)' ) results = self.db.execute(sql).fetchall() for result in results: - ( reaction_id, reaction_type, @@ -206,15 +201,15 @@ def get_df(self, *, smiles: bool = True, mols: bool = True) -> "pandas.DataFrame reactant_smiles=set(), ) if mols: - data[reaction_id]["product_mol"] = Mol(product_mol) - data[reaction_id]["reactant_mols"] = set() + data[reaction_id]['product_mol'] = Mol(product_mol) + data[reaction_id]['reactant_mols'] = set() else: - assert data[reaction_id]["product_id"] == product_id + assert data[reaction_id]['product_id'] == product_id - data[reaction_id]["reactant_ids"].add(reactant_id) - data[reaction_id]["reactant_smiles"].add(reactant_smiles) + data[reaction_id]['reactant_ids'].add(reactant_id) + data[reaction_id]['reactant_smiles'].add(reactant_smiles) if mols: - data[reaction_id]["reactant_mols"].add(Mol(reactant_mol)) + data[reaction_id]['reactant_mols'].add(Mol(reactant_mol)) data = data.values() return DataFrame(data) @@ -252,7 +247,7 @@ def set_product_yields( ### DUNDERS - def __getitem__(self, key) -> "Reaction | ReactionSet | None": + def __getitem__(self, key) -> 'Reaction | ReactionSet | None': """Get a member :class:`.Reaction` object or subset :class:`.ReactionSet` thereof. :param key: Can be an integer ID, negative integer index, list/set/tuple of IDs, or slice of IDs @@ -260,9 +255,7 @@ def __getitem__(self, key) -> "Reaction | ReactionSet | None": """ match key: - case int(): - if key == 0: return self.__getitem__(key=1) @@ -286,7 +279,7 @@ def __getitem__(self, key) -> "Reaction | ReactionSet | None": case _: mrich.error( - f"Unsupported type for ReactionTable.__getitem__(): {key=} {type(key)}" + f'Unsupported type for ReactionTable.__getitem__(): {key=} {type(key)}' ) return None @@ -295,21 +288,21 @@ def __str__(self) -> str: """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"R × {len(self)}" "}" + s += f'{{R × {len(self)}}}' return s def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """Number of reactions in this set""" @@ -323,7 +316,7 @@ def __call__( self, *, type: str = None, - ) -> "ReactionSet": + ) -> 'ReactionSet': """Filter reactions by a given type :param type: reaction type to filter by @@ -334,7 +327,7 @@ def __call__( if type: return self.get_by_type(type) else: - mrich.error("Must provide type argument") + mrich.error('Must provide type argument') return None @@ -385,7 +378,7 @@ class ReactionSet: """ - _table = "reaction" + _table = 'reaction' def __init__( self, @@ -443,30 +436,30 @@ def ids(self) -> list[int]: def types(self) -> list[str]: """Returns the types of reactions in this set""" records = self.db.select_where( - table="reaction", - key=f"reaction_id IN {self.str_ids}", - query="DISTINCT reaction_type", + table='reaction', + key=f'reaction_id IN {self.str_ids}', + query='DISTINCT reaction_type', multiple=True, ) - return [t for t, in records] + return [t for (t,) in records] @property def num_types(self) -> int: """Returns the number of reaction types in this set""" (count,) = self.db.select_where( - table="reaction", - key=f"reaction_id IN {self.str_ids}", - query="COUNT(DISTINCT reaction_type)", + table='reaction', + key=f'reaction_id IN {self.str_ids}', + query='COUNT(DISTINCT reaction_type)', ) return count @property def str_ids(self) -> str: """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(",)", ")") + return str(tuple(self.ids)).replace(',)', ')') @property - def products(self) -> "CompoundSet": + def products(self) -> 'CompoundSet': """Get all product compounds that can be synthesised with these reactions (no intermediates)""" from .cset import CompoundSet @@ -479,13 +472,13 @@ def products(self) -> "CompoundSet": AND compound_id NOT IN {intermediates.str_ids} """ ).fetchall() - cset = CompoundSet(self.db, [i for i, in product_ids]) + cset = CompoundSet(self.db, [i for (i,) in product_ids]) if self.name: - cset._name = f"products of {self}" + cset._name = f'products of {self}' return cset @property - def intermediates(self) -> "CompoundSet": + def intermediates(self) -> 'CompoundSet': """Get all intermediate compounds that can be synthesised with these reactions""" from .cset import CompoundSet @@ -496,13 +489,13 @@ def intermediates(self) -> "CompoundSet": WHERE reactant_reaction IN {self.str_ids} """ intermediate_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for i, in intermediate_ids]) + cset = CompoundSet(self.db, [i for (i,) in intermediate_ids]) if self.name: - cset._name = f"intermediates of {self}" + cset._name = f'intermediates of {self}' return cset @property - def reactants(self) -> "CompoundSet": + def reactants(self) -> 'CompoundSet': """Get all reactant compounds that are used by these reactions""" from .cset import CompoundSet @@ -511,9 +504,9 @@ def reactants(self) -> "CompoundSet": WHERE reactant_reaction IN {self.str_ids} """ reactant_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for i, in reactant_ids]) + cset = CompoundSet(self.db, [i for (i,) in reactant_ids]) if self.name: - cset._name = f"reactants of {self}" + cset._name = f'reactants of {self}' return cset ### METHODS @@ -531,38 +524,35 @@ def add(self, r: Reaction) -> None: def interactive(self): """Creates a ipywidget to interactively navigate this PoseSet.""" + from IPython.display import display from ipywidgets import ( - interactive, BoundedIntText, Checkbox, - interactive_output, - HBox, GridBox, Layout, VBox, + interactive_output, ) - from IPython.display import display - from pprint import pprint a = BoundedIntText( value=0, min=0, max=len(self) - 1, step=1, - description=f"Rs (/{len(self)}):", + description=f'Rs (/{len(self)}):', disabled=False, ) - b = Checkbox(description="Name", value=True) - c = Checkbox(description="Summary", value=False) - d = Checkbox(description="Draw", value=True) - e = Checkbox(description="Check chemistry", value=False) - f = Checkbox(description="Reactant Quotes", value=False) + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='Draw', value=True) + e = Checkbox(description='Check chemistry', value=False) + f = Checkbox(description='Reactant Quotes', value=False) ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns="repeat(5, 100px)") + [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') ) - ui2 = GridBox([e, f], layout=Layout(grid_template_columns="repeat(2, 150px)")) + ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) ui = VBox([a, ui1, ui2]) def widget( @@ -605,18 +595,18 @@ def widget( out = interactive_output( widget, { - "i": a, - "name": b, - "summary": c, - "draw": d, - "check_chemistry": e, - "reactants": f, + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'check_chemistry': e, + 'reactants': f, }, ) display(ui, out) - def get_df(self, smiles=True, mols=True, **kwargs) -> "pandas.DataFrame": + def get_df(self, smiles=True, mols=True, **kwargs) -> 'pandas.DataFrame': """Construct a pandas.DataFrame of this ReactionSet :param smiles: Include smiles column (Default value = True) @@ -626,23 +616,22 @@ def get_df(self, smiles=True, mols=True, **kwargs) -> "pandas.DataFrame": """ from pandas import DataFrame - from rdkit.Chem import Mol - mrich.debug("Using slower Reaction.dict rather than direct SQL query...") + mrich.debug('Using slower Reaction.dict rather than direct SQL query...') data = [] - for r in mrich.track(self, prefix="ReactionSet --> DataFrame"): + for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) return DataFrame(data) - def copy(self) -> "ReactionSet": + def copy(self) -> 'ReactionSet': """Return a copy of this set""" return ReactionSet(self.db, self.ids, sort=False, name=self.name) def get_recipes( self, amounts: float | list[float] = 1.0, **kwargs - ) -> "Recipe | list[Recipe]": + ) -> 'Recipe | list[Recipe]': """Get the :class:`.Recipe` object(s) from this set of recipes :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) @@ -674,21 +663,21 @@ def __str__(self) -> str: """Unformatted string representation""" if self.name: - s = f"{self.name}: " + s = f'{self.name}: ' else: - s = "" + s = '' - s += "{" f"R × {len(self)}" "}" + s += f'{{R × {len(self)}}}' return s def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """Number of member :class:`.Reaction` objects""" @@ -698,7 +687,7 @@ def __iter__(self): """Iterate through member :class:`.Reaction` objects""" return iter(self.db.get_reaction(id=i) for i in self.indices) - def __getitem__(self, key) -> "Reaction | ReactionSet": + def __getitem__(self, key) -> 'Reaction | ReactionSet': """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" match key: @@ -706,7 +695,7 @@ def __getitem__(self, key) -> "Reaction | ReactionSet": try: index = self.indices[key] except IndexError: - mrich.error(f"list index out of range: {key=} for {self}") + mrich.error(f'list index out of range: {key=} for {self}') raise return self.db.get_reaction(id=index) case slice(): @@ -719,12 +708,12 @@ def __getitem__(self, key) -> "Reaction | ReactionSet": return ReactionSet(self.db, ids) case _: mrich.error( - f"Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}" + f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' ) return None - def __add__(self, other: "ReactionSet") -> "ReactionSet": + def __add__(self, other: 'ReactionSet') -> 'ReactionSet': """Add a :class:`.ReactionSet` to this one""" if other: for reaction in other: @@ -734,8 +723,8 @@ def __add__(self, other: "ReactionSet") -> "ReactionSet": def __sub__( self, - other: "ReactionSet", - ) -> "ReactionSet": + other: 'ReactionSet', + ) -> 'ReactionSet': """Substract a :class:`.ReactionSet` from this set""" match other: case ReactionSet(): diff --git a/hippo/scoring.py b/hippo/scoring.py index b34eda5..e735a5c 100644 --- a/hippo/scoring.py +++ b/hippo/scoring.py @@ -1,18 +1,17 @@ """Classes for scoring Recipes""" import mrich - import numpy as np import pandas as pd from scipy.interpolate import interp1d DATA_COLUMNS = [ - "score", - "price", - "compound_ids", - "pose_ids", - "interaction_ids", - "pose_metadata", + 'score', + 'price', + 'compound_ids', + 'pose_ids', + 'interaction_ids', + 'pose_metadata', ] @@ -30,14 +29,14 @@ class Scorer: def __init__( self, - db: "Database", - directory: "Path | str", - pattern: str = "*.json", + db: 'Database', + directory: 'Path | str', + pattern: str = '*.json', attributes: list[str] = None, populate: bool = True, load_cache: bool = True, - allowed_poses: "PoseSet | list[int] | None" = None, - out_key: str = "scorer", + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', ) -> None: """Scorer initialisation""" @@ -71,7 +70,7 @@ def __init__( columns=DATA_COLUMNS + self.attribute_keys, ) - self._data.replace({np.nan: None}, inplace=True), + (self._data.replace({np.nan: None}, inplace=True),) if populate: if load_cache and self.json_path.exists(): @@ -88,23 +87,21 @@ def __init__( @classmethod def default( cls, - db: "Database", - directory: "Path | str", - pattern: str = "*.json", + db: 'Database', + directory: 'Path | str', + pattern: str = '*.json', skip: list[str] | None = None, load_cache: bool = True, subsites: bool = True, - allowed_poses: "PoseSet | list[int] | None" = None, - out_key: str = "scorer", - ) -> "Scorer": + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', + ) -> 'Scorer': """Create a Scorer instance with Default attributes""" - from .recipe import RecipeSet - self = cls.__new__(cls) attributes = [ - k for k, v in DEFAULT_ATTRIBUTES.items() if v["type"] == "standard" + k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard' ] self.__init__( @@ -119,37 +116,36 @@ def default( skip = skip or [] - if not db.count("interaction"): - mrich.warning("No interactions in DB, skipping related metrics") - skip.append("interaction_count") - skip.append("interaction_balance") + if not db.count('interaction'): + mrich.warning('No interactions in DB, skipping related metrics') + skip.append('interaction_count') + skip.append('interaction_balance') - if not db.count("pose"): - mrich.warning("No poses in DB, skipping related metrics") - skip.append("num_inspirations") - skip.append("num_inspiration_sets") - skip.append("avg_energy_score") - skip.append("avg_distance_score") + if not db.count('pose'): + mrich.warning('No poses in DB, skipping related metrics') + skip.append('num_inspirations') + skip.append('num_inspiration_sets') + skip.append('avg_energy_score') + skip.append('avg_distance_score') - if not db.count("scaffold"): - mrich.warning("No scaffold entries in DB, skipping related metrics") - skip.append("num_scaffolds") - skip.append("num_scaffolds_elaborated") - skip.append("elaboration_balance") + if not db.count('scaffold'): + mrich.warning('No scaffold entries in DB, skipping related metrics') + skip.append('num_scaffolds') + skip.append('num_scaffolds_elaborated') + skip.append('elaboration_balance') # custom attributes for key, attribute in [ - (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v["type"] == "custom" + (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'custom' ]: - if skip and key in skip: continue - if not subsites and "subsite" in key: + if not subsites and 'subsite' in key: continue self.add_custom_attribute( - key, attribute["function"], weight_reset_warning=False + key, attribute['function'], weight_reset_warning=False ) if load_cache and self.json_path.exists(): @@ -160,10 +156,10 @@ def default( self._populate_recipe_child_sets() # weights - wsum = sum(abs(d["weight"]) for d in DEFAULT_ATTRIBUTES.values()) + wsum = sum(abs(d['weight']) for d in DEFAULT_ATTRIBUTES.values()) for attribute in self.attributes: d = DEFAULT_ATTRIBUTES[attribute.key] - attribute.weight = d["weight"] / wsum + attribute.weight = d['weight'] / wsum return self @@ -175,7 +171,7 @@ def num_recipes(self) -> int: return len(self._recipes) @property - def attributes(self) -> "list[Attribute | CustomAttribute]": + def attributes(self) -> 'list[Attribute | CustomAttribute]': """Return list of :class:`.Attribute` / :class:`.CustomAttribute` objects""" return list(self._attributes.values()) @@ -185,7 +181,7 @@ def attribute_keys(self) -> list[str]: return list(self._attributes.keys()) @property - def recipes(self) -> "RecipeSet": + def recipes(self) -> 'RecipeSet': """Return :class:`.RecipeSet` of recipes being scored""" return self._recipes @@ -211,23 +207,23 @@ def weights(self, ws) -> None: ws = [w for w in ws] wsum = sum([abs(w) for w in ws]) - for a, w in zip(self.attributes, ws): + for a, w in zip(self.attributes, ws, strict=False): a.weight = w / wsum @property def score_dict(self) -> dict[str, float]: """Dictionary of scores keyed by :meth:`.Recipe.hash`""" - col = self._data["score"] + col = self._data['score'] null = col.isnull() if null.sum(): - mrich.debug("Calculating scores...") + mrich.debug('Calculating scores...') for key in col[null].index.values: recipe = self.recipes[key] score = self.score(recipe) - self._data.at[key, "score"] = score + self._data.at[key, 'score'] = score self._dump_json() @@ -239,28 +235,28 @@ def scores(self) -> list[float]: return list(self.score_dict.values()) @property - def best(self) -> "Recipe": + def best(self) -> 'Recipe': """Return highest scoring :class:`.Recipe`""" return self.top(1) @property - def db(self) -> "Database": + def db(self) -> 'Database': """:class:`.Database`""" return self._db @property - def json_path(self) -> "Path": + def json_path(self) -> 'Path': """Path where cache will be written""" from pathlib import Path - return Path(self.db.path.name.replace(".sqlite", f"_{self._out_key}.json")) + return Path(self.db.path.name.replace('.sqlite', f'_{self._out_key}.json')) @property - def poses(self) -> "PoseSet": + def poses(self) -> 'PoseSet': """Return all associated poses as :class:`.PoseSet`""" from .pset import PoseSet - ids = set().union(*self._data["pose_ids"]) + ids = set().union(*self._data['pose_ids']) return PoseSet(self.db, ids) ### METHODS @@ -268,9 +264,9 @@ def poses(self) -> "PoseSet": def add_custom_attribute( self, key: str, - function: "Callable", + function: 'Callable', weight_reset_warning: bool = True, - ) -> "CustomAttribute": + ) -> 'CustomAttribute': """Add a custom scoring attribute :param key: name/key for the attribute @@ -281,23 +277,22 @@ def add_custom_attribute( ca = CustomAttribute(self, key, function) if key not in self._attributes: - # self._flag_weight_modification() self._attributes[key] = ca if weight_reset_warning: - mrich.warning("Attribute weights have been reset") + mrich.warning('Attribute weights have been reset') self.weights = 1.0 self._data[key] = None else: - mrich.warning("Existing attribute with {key=}") + mrich.warning('Existing attribute with {key=}') return self._attributes[key] - def add_recipes(self, json_paths: "list", debug: bool = False) -> None: + def add_recipes(self, json_paths: 'list', debug: bool = False) -> None: """Add more serialised :class:`.Recipe` objects to be scored :param json_paths: list of JSON paths @@ -305,16 +300,16 @@ def add_recipes(self, json_paths: "list", debug: bool = False) -> None: """ from pathlib import Path + from .recipe import Recipe for json_path in json_paths: - path = Path(json_path) - key = path.name.removeprefix("Recipe_").removesuffix(".json") + key = path.name.removeprefix('Recipe_').removesuffix('.json') if key in self.recipes: - mrich.warning(f"Skipping duplicate {path}") + mrich.warning(f'Skipping duplicate {path}') continue recipe = Recipe.from_json(self._db, path, allow_db_mismatch=True) @@ -325,23 +320,23 @@ def add_recipes(self, json_paths: "list", debug: bool = False) -> None: mrich.debug(recipe) if debug: - mrich.debug("Updating Scorer.recipes._json_paths") + mrich.debug('Updating Scorer.recipes._json_paths') self.recipes._json_paths[key] = path.resolve() if debug: - mrich.debug("Updating Scorer.recipes._recipes") + mrich.debug('Updating Scorer.recipes._recipes') self.recipes._recipes[key] = recipe self._data.loc[key] = None - self._data.replace({np.nan: None}, inplace=True), + (self._data.replace({np.nan: None}, inplace=True),) self._populate_query_cache() self._populate_recipe_child_sets() self._flag_weight_modification() def score( self, - recipe: "Recipe", + recipe: 'Recipe', *, debug: bool = False, ) -> float: @@ -364,22 +359,22 @@ def score( print_data.append( dict( key=attribute.key, - weight=f"{attribute.weight:.2f}", - value=f"{attribute.get_value(recipe):.2f}", - unweighted=f"{attribute.unweighted(recipe):.2%}", - weighted=f"{attribute(recipe):.2%}", + weight=f'{attribute.weight:.2f}', + value=f'{attribute.get_value(recipe):.2f}', + unweighted=f'{attribute.unweighted(recipe):.2%}', + weighted=f'{attribute(recipe):.2%}', ) ) - df = pd.DataFrame(print_data).set_index("key") + df = pd.DataFrame(print_data).set_index('key') mrich.print(df) - mrich.var("score", score) + mrich.var('score', score) recipe._score = score return score - def compare(self, recipes: "list[Recipe] | list[str]") -> None: + def compare(self, recipes: 'list[Recipe] | list[str]') -> None: """Compare attribute values and scores for recipes :param recipes: list of :class:`.Recipe` objects or hashes @@ -393,30 +388,30 @@ def compare(self, recipes: "list[Recipe] | list[str]") -> None: print_data = [] for attribute in self.attributes: - d = {"attribute (weight)": f"{attribute.key} ({attribute.weight:.2%})"} + d = {'attribute (weight)': f'{attribute.key} ({attribute.weight:.2%})'} for recipe in recipes: # d = dict(hash=recipe.hash) d[recipe.hash] = ( - f"{attribute.get_value(recipe):.2f} ({attribute.unweighted(recipe):.2%})" + f'{attribute.get_value(recipe):.2f} ({attribute.unweighted(recipe):.2%})' ) print_data.append(d) - df = pd.DataFrame(print_data).set_index("attribute (weight)") + df = pd.DataFrame(print_data).set_index('attribute (weight)') mrich.print(df) - def get_sorted_df(self) -> "pd.DataFrame": + def get_sorted_df(self) -> 'pd.DataFrame': """Get DataFrame sorted by descending score""" # compute scores self.scores - return self._data.sort_values(by="score", ascending=False) + return self._data.sort_values(by='score', ascending=False) def plot( self, keys: list[str], budget: float | None = None, - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Plot any two attributes as a scatter plot :param keys: list two attribute keys to plot @@ -427,7 +422,7 @@ def plot( import plotly.express as px if len(keys) != 2: - mrich.error("Only two keys supported") + mrich.error('Only two keys supported') return None # calculate scores @@ -435,13 +430,13 @@ def plot( df = self._data.drop( columns=[ - "compound_ids", - "pose_ids", - "interaction_ids", + 'compound_ids', + 'pose_ids', + 'interaction_ids', ] ) - df["score"] = pd.to_numeric(df["score"]) + df['score'] = pd.to_numeric(df['score']) if isinstance(keys, str): assert keys in df.columns @@ -453,18 +448,18 @@ def plot( raise KeyError(f'no attribute/column named "{key}"') if budget: - df = df[df["price"] < budget] + df = df[df['price'] < budget] - df["hash"] = df.index.values + df['hash'] = df.index.values hover_data = [ - "hash", + 'hash', ] hover_data += [c for c in df.columns] return px.scatter( - df, x=keys[0], y=keys[1], color="score", hover_data=hover_data + df, x=keys[0], y=keys[1], color='score', hover_data=hover_data ) def top_keys(self, n: int, budget: float | None = None) -> list[str]: @@ -477,7 +472,7 @@ def top_keys(self, n: int, budget: float | None = None) -> list[str]: keys = self.get_sorted_df(budget=budget).index[:n] return list(keys) - def top(self, n: int, budget: float | None = None) -> "list[Recipe]": + def top(self, n: int, budget: float | None = None) -> 'list[Recipe]': """Return top `n` scoring :class:`.Recipe` :param n: number of :class:`.Recipe` objects to return @@ -494,7 +489,7 @@ def top(self, n: int, budget: float | None = None) -> "list[Recipe]": def _flag_weight_modification(self): """Reset scores due to weight modification""" - self._data["score"] = None + self._data['score'] = None def summary(self) -> None: """Print some summary statistics of the scorer's attributes""" @@ -503,7 +498,7 @@ def summary(self) -> None: for attribute in self.attributes: mrich.print( attribute, - f"min={attribute.min:.3g}, mean={attribute.mean:.3g}, std={attribute.std:.3g}, max={attribute.max:.3g}", + f'min={attribute.min:.3g}, mean={attribute.mean:.3g}, std={attribute.std:.3g}, max={attribute.max:.3g}', ) def __check_integrity(self) -> bool: @@ -532,11 +527,11 @@ def _populate_query_cache(self) -> None: ### Recipe prices for recipe in self.recipes: - self._data.at[recipe.hash, "price"] = recipe.price.amount + self._data.at[recipe.hash, 'price'] = recipe.price.amount ### Compound IDs - col = "compound_ids" + col = 'compound_ids' null = df[col].isnull() # populate missing product compound ids @@ -549,27 +544,26 @@ def _populate_query_cache(self) -> None: ### Pose IDs - col = "pose_ids" + col = 'pose_ids' null = df[col].isnull() # populate missing product pose ids if null.sum(): - compound_ids = set() - for ids in df[null]["compound_ids"]: + for ids in df[null]['compound_ids']: for id in ids: compound_ids.add(id) cset = CompoundSet(self.db, compound_ids, sort=False) - mrich.debug(f"Getting poses for {len(cset)} compounds") + mrich.debug(f'Getting poses for {len(cset)} compounds') pose_map = self.db.get_compound_id_pose_ids_dict(cset) mrich.debug(f'Populating _data["{col}"]...') for key in df[null].index.values: assert len(df[null]) == null.sum() recipe = self.recipes[key] - comp_ids = df["compound_ids"][key] + comp_ids = df['compound_ids'][key] all_pose_ids = set() @@ -587,27 +581,26 @@ def _populate_query_cache(self) -> None: ### Interaction IDs - col = "interaction_ids" + col = 'interaction_ids' null = df[col].isnull() # populate missing product interaction ids if null.sum(): - pose_ids = set() - for ids in df[null]["pose_ids"]: + for ids in df[null]['pose_ids']: for id in ids: pose_ids.add(id) pset = PoseSet(self.db, pose_ids, sort=False) - mrich.debug(f"Getting interactions for {len(pset)} poses") + mrich.debug(f'Getting interactions for {len(pset)} poses') interaction_map = self.db.get_pose_id_interaction_ids_dict(pset) mrich.debug(f'Populating _data["{col}"]...') for key in df[null].index.values: assert len(df[null]) == null.sum() recipe = self.recipes[key] - pose_ids = df["pose_ids"][key] + pose_ids = df['pose_ids'][key] all_interaction_ids = set() @@ -619,27 +612,26 @@ def _populate_query_cache(self) -> None: ### Metadata Dictionaries - col = "pose_metadata" + col = 'pose_metadata' null = df[col].isnull() # populate missing product interaction ids if null.sum(): - pose_ids = set() - for ids in df[null]["pose_ids"]: + for ids in df[null]['pose_ids']: for id in ids: pose_ids.add(id) # pset = PoseSet(self.db, pose_ids, sort=False) - mrich.debug(f"Getting metadata for {len(pose_ids)} poses") - metadata_lookup = self.db.get_id_metadata_dict(table="pose", ids=pose_ids) + mrich.debug(f'Getting metadata for {len(pose_ids)} poses') + metadata_lookup = self.db.get_id_metadata_dict(table='pose', ids=pose_ids) mrich.debug(f'Populating _data["{col}"]...') for key in df[null].index.values: assert len(df[null]) == null.sum() recipe = self.recipes[key] - pose_ids = df["pose_ids"][key] + pose_ids = df['pose_ids'][key] row = df.loc[key] @@ -653,34 +645,33 @@ def _populate_recipe_child_sets(self) -> None: """Populate internal cache of recipe child compound/pose/interaction sets""" from .cset import CompoundSet - from .pset import PoseSet from .iset import InteractionSet + from .pset import PoseSet - mrich.debug("Populating recipe caches") + mrich.debug('Populating recipe caches') for key, recipe in self.recipes.items(): - row = self._data.loc[key] if recipe._combined_compounds is None: - ids = row["compound_ids"] + ids = row['compound_ids'] cache = CompoundSet(self.db, ids) - cache._name = f"Recipe_{key} products" + cache._name = f'Recipe_{key} products' recipe._combined_compounds = cache if recipe._poses is None: - ids = row["pose_ids"] + ids = row['pose_ids'] cache = PoseSet(self.db, ids) - cache._name = f"Recipe_{key} poses" + cache._name = f'Recipe_{key} poses' recipe._poses = cache if recipe._interactions is None: - ids = row["interaction_ids"] + ids = row['interaction_ids'] cache = InteractionSet(self.db, ids) - cache._name = f"Recipe_{key} product interactions" + cache._name = f'Recipe_{key} product interactions' recipe._interactions = cache if recipe._poses._metadata_dict is None: - cache = row["pose_metadata"] + cache = row['pose_metadata'] recipe._poses._metadata_dict = cache def _dump_json(self) -> None: @@ -694,17 +685,16 @@ def _load_json(self): path = self.json_path mrich.reading(path) - cached = pd.read_json(path, orient="columns") + cached = pd.read_json(path, orient='columns') if (cached_columns := set(cached.columns)) != ( self_columns := set(self._data.columns) ): - for col in cached_columns - self_columns: - mrich.error(f"JSON has unexpected {col}") + mrich.error(f'JSON has unexpected {col}') for col in self_columns - cached_columns: - mrich.error(f"JSON is missing {col}") + mrich.error(f'JSON is missing {col}') display(cached.head()) display(self._data.head()) @@ -715,15 +705,15 @@ def _load_json(self): self_keys = set(self._data.index.values) if difference := cached_keys - self_keys: - mrich.warning("JSON has extra Recipes:") + mrich.warning('JSON has extra Recipes:') mrich.warning(difference) if difference := self_keys - cached_keys: - mrich.error("JSON is missing Recipes:") + mrich.error('JSON is missing Recipes:') mrich.error(difference) - raise ValueError("JSON is missing Recipes") + raise ValueError('JSON is missing Recipes') - cached.replace({np.nan: None}, inplace=True), + (cached.replace({np.nan: None}, inplace=True),) self._data = cached @@ -731,17 +721,17 @@ def _load_json(self): def __str__(self) -> str: """Unformatted string representation""" - return f"Scorer(#recipes={self.num_recipes})" + return f'Scorer(#recipes={self.num_recipes})' def __repr__(self) -> str: """ANSI Formatted string representation""" import mcol - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class Attribute: @@ -754,13 +744,13 @@ class Attribute: :param bins: number of scoring bins """ - _type = "Attribute" + _type = 'Attribute' ### DUNDERS def __init__( self, - scorer: "Scorer", + scorer: 'Scorer', key: str, *, inverse: bool = False, @@ -784,7 +774,7 @@ def __init__( ### PROPERTIES @property - def scorer(self) -> "Scorer": + def scorer(self) -> 'Scorer': """Get associated :class:`.Scorer`""" return self._scorer @@ -811,7 +801,7 @@ def value_dict(self) -> dict[str, float]: null = df.isnull() if null.sum(): - with mrich.loading(f"Constructing value dictionary for {self}"): + with mrich.loading(f'Constructing value dictionary for {self}'): for key in df[null].index.values: recipe = self.scorer.recipes[key] self.get_value(recipe, force=True) @@ -860,13 +850,12 @@ def weight(self, w): def percentile_interpolator(self): """Interpolator function""" if self._percentile_interpolator is None: - count, bins_count = np.histogram(self.values, bins=self.bins) pdf = count / sum(count) cdf = np.cumsum(pdf) self._percentile_interpolator = interp1d( - bins_count[1:], cdf, kind="linear", fill_value="extrapolate" + bins_count[1:], cdf, kind='linear', fill_value='extrapolate' ) return self._percentile_interpolator @@ -875,7 +864,7 @@ def percentile_interpolator(self): def get_value( self, - recipe: "Recipe", + recipe: 'Recipe', serialise_price: bool = True, force: bool = False, ) -> float: @@ -890,7 +879,7 @@ def get_value( if force or cached is None: value = getattr(recipe, self.key) - if serialise_price and self.key == "price": + if serialise_price and self.key == 'price': value = value.amount self.scorer._data.at[recipe.hash, self.key] = value else: @@ -898,19 +887,19 @@ def get_value( return value - def histogram(self) -> "plotly.graph_objects.Figure": + def histogram(self) -> 'plotly.graph_objects.Figure': """Plot histogram of attribute values""" import plotly.graph_objects as go fig = go.Figure(go.Histogram(x=self.values)) - fig.update_layout(xaxis_title=self.key, yaxis_title="count") + fig.update_layout(xaxis_title=self.key, yaxis_title='count') return fig def unweighted( self, - recipe: "Recipe", + recipe: 'Recipe', ) -> float: """Return unweighted percentile score for a given :class:`.Recipe`""" @@ -927,7 +916,7 @@ def unweighted( def __call__( self, - recipe: "Recipe", + recipe: 'Recipe', ) -> float: """return the weighted score of a given :class:`.Recipe`""" @@ -949,28 +938,28 @@ def __repr__(self) -> str: """ANSI Formatted string representation""" import mcol - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class CustomAttribute(Attribute): """Scoring attribute with a custom function""" - _type = "CustomAttribute" + _type = 'CustomAttribute' - def __init__(self, scorer: "Scorer", key: str, function: "Callable") -> None: + def __init__(self, scorer: 'Scorer', key: str, function: 'Callable') -> None: """CustomAttribute initialisation""" self._function = function - super(CustomAttribute, self).__init__(scorer=scorer, key=key) + super().__init__(scorer=scorer, key=key) ### METHODS def get_value( self, - recipe: "Recipe", + recipe: 'Recipe', serialise_price: bool = True, force: bool = False, ) -> float: @@ -984,10 +973,9 @@ def get_value( cached = self.scorer._data[self.key][recipe.hash] if force or cached is None: - value = self._function(recipe) - if serialise_price and self.key == "price": + if serialise_price and self.key == 'price': value = value.amount self.scorer._data.at[recipe.hash, self.key] = value else: @@ -997,40 +985,40 @@ def get_value( DEFAULT_ATTRIBUTES = { - "num_scaffolds": dict( - type="custom", + 'num_scaffolds': dict( + type='custom', weight=1.0, - function=lambda r: r.combined_compounds.count_by_tag(tag="Syndirella scaffold"), - description="The number of Syndirella scaffold compounds in this selection. Higher is better.", + function=lambda r: r.combined_compounds.count_by_tag(tag='Syndirella scaffold'), + description='The number of Syndirella scaffold compounds in this selection. Higher is better.', ), - "num_compounds": dict( - type="standard", + 'num_compounds': dict( + type='standard', weight=1.0, - description="The number of product compounds in this selection. Higher is better.", + description='The number of product compounds in this selection. Higher is better.', ), - "num_scaffolds_elaborated": dict( - type="custom", + 'num_scaffolds_elaborated': dict( + type='custom', weight=1.0, function=lambda r: r.combined_compounds.num_scaffolds_elaborated, - description="The number of Syndirella scaffold compounds that have at least one elaboration in this selection. Higher is better.", + description='The number of Syndirella scaffold compounds that have at least one elaboration in this selection. Higher is better.', ), - "elaboration_balance": dict( - type="custom", + 'elaboration_balance': dict( + type='custom', weight=1.0, function=lambda r: r.combined_compounds.elaboration_balance, - description="A measure for how evenly scaffold compounds have been elaborated using an h-index. Higher is better.", + description='A measure for how evenly scaffold compounds have been elaborated using an h-index. Higher is better.', ), ### REALLY UNPERFORMANT? - "num_inspirations": dict( - type="custom", + 'num_inspirations': dict( + type='custom', weight=1.0, function=lambda r: r.poses.num_inspirations, - description="The number of unique fragment compounds that inspired poses for product compounds in this selection. Higher is better.", + description='The number of unique fragment compounds that inspired poses for product compounds in this selection. Higher is better.', ), - "num_inspiration_sets": dict( - type="custom", + 'num_inspiration_sets': dict( + type='custom', weight=1.0, function=lambda r: r.poses.num_inspiration_sets, - description="The number of unique fragment combinations that inspired poses for product compounds in this selection. Higher is better.", + description='The number of unique fragment combinations that inspired poses for product compounds in this selection. Higher is better.', ), # "risk_diversity": dict( # type="custom", @@ -1038,41 +1026,41 @@ def get_value( # function=lambda r: r.combined_compounds.risk_diversity, # description="A measure of how evenly spread the risk of elaborations are for each scaffold compound. Risk in this case refers to the number of atoms added. Higher is better", # ), # REMOVED BECAUSE IT DOES NOT NECESSARILY IMPROVE AS PRODUCTS ARE ADDED - "interaction_count": dict( - type="custom", + 'interaction_count': dict( + type='custom', weight=1.0, function=lambda r: r.interactions.num_features, - description="The number of protein features that are being interecated with in this selection. Higher is better.", + description='The number of protein features that are being interecated with in this selection. Higher is better.', ), - "interaction_balance": dict( - type="custom", + 'interaction_balance': dict( + type='custom', weight=0.0, function=lambda r: r.interactions.per_feature_count_hirsch, - description="A measure for how evenly protein features are being interacted with in this selection using an h-index. Higher is better", + description='A measure for how evenly protein features are being interacted with in this selection using an h-index. Higher is better', ), - "num_subsites": dict( - type="custom", + 'num_subsites': dict( + type='custom', weight=1.0, function=lambda r: r.poses.num_subsites, - description="Count the number of subsites that poses in this set come into contact with. Higher is better.", + description='Count the number of subsites that poses in this set come into contact with. Higher is better.', ), - "subsite_balance": dict( - type="custom", + 'subsite_balance': dict( + type='custom', weight=0.0, function=lambda r: r.poses.subsite_balance, - description="Count the number of subsites that poses in this set come into contact with", + description='Count the number of subsites that poses in this set come into contact with', ), - "avg_distance_score": dict( - type="custom", + 'avg_distance_score': dict( + type='custom', weight=-0.0, function=lambda r: r.poses.avg_distance_score, - description="Average distance score (e.g. RMSD to fragment inspirations) for poses in this set. Lower is better.", + description='Average distance score (e.g. RMSD to fragment inspirations) for poses in this set. Lower is better.', ), - "avg_energy_score": dict( - type="custom", + 'avg_energy_score': dict( + type='custom', weight=-0.0, function=lambda r: r.poses.avg_energy_score, - description="Average energy score (e.g. binding ddG) for poses in this set. Lower is better.", + description='Average energy score (e.g. binding ddG) for poses in this set. Lower is better.', ), # "reaction_risk": dict(type='custom', weight=1.0, function=None), # "pockets?": dict(type='custom', weight=1.0, function=None), diff --git a/hippo/subsite.py b/hippo/subsite.py index 8911b05..1f73329 100644 --- a/hippo/subsite.py +++ b/hippo/subsite.py @@ -13,9 +13,9 @@ class Subsite: """ - _table = "subsite" + _table = 'subsite' - def __init__(self, db: "Database", id: int, target_id: int, name: str) -> None: + def __init__(self, db: 'Database', id: int, target_id: int, name: str) -> None: """Subsite initialisation""" self._db = db @@ -28,7 +28,7 @@ def __init__(self, db: "Database", id: int, target_id: int, name: str) -> None: ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -43,7 +43,7 @@ def table(self): return self._table @property - def target(self) -> "Target": + def target(self) -> 'Target': """Returns the associated protein :class:`.Target`""" if self._target is None: self._target = self.db.get_target(id=self.target_id) @@ -60,28 +60,28 @@ def name(self) -> str: return self._name @property - def metadata(self) -> "MetaData": + def metadata(self) -> 'MetaData': """Returns the SubsiteTag's metadata""" if self._metadata is None: - self._metadata = self.db.get_metadata(table="subsite", id=self.id) + self._metadata = self.db.get_metadata(table='subsite', id=self.id) return self._metadata @property - def poses(self) -> "PoseSet | None": + def poses(self) -> 'PoseSet | None': """Return all poses in this subsite""" from .pset import PoseSet indices = self.db.select_where( - table="subsite_tag", - query="subsite_tag_pose", + table='subsite_tag', + query='subsite_tag_pose', multiple=True, - key="ref", + key='ref', value=self.id, ) - indices = [i for i, in indices] + indices = [i for (i,) in indices] if not indices: return None - return PoseSet(self.db, indices, name=f"poses in {self}") + return PoseSet(self.db, indices, name=f'poses in {self}') ### METHODS @@ -89,15 +89,15 @@ def poses(self) -> "PoseSet | None": def __str__(self): """Unformatted string representation""" - return f"S{self.id}: {self.target.name}->{self.name}" + return f'S{self.id}: {self.target.name}->{self.name}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class SubsiteTag: @@ -110,9 +110,9 @@ class SubsiteTag: """ - _table = "subsite_tag" + _table = 'subsite_tag' - def __init__(self, db: "Database", id: int, subsite_id: int, pose_id: int): + def __init__(self, db: 'Database', id: int, subsite_id: int, pose_id: int): """SubsiteTag initialisation""" self._db = db @@ -128,7 +128,7 @@ def __init__(self, db: "Database", id: int, subsite_id: int, pose_id: int): ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -158,10 +158,10 @@ def name(self): return self._name @property - def metadata(self) -> "MetaData": + def metadata(self) -> 'MetaData': """Returns the SubsiteTag's metadata""" if self._metadata is None: - self._metadata = self.db.get_metadata(table="subsite_tag", id=self.id) + self._metadata = self.db.get_metadata(table='subsite_tag', id=self.id) return self._metadata ### DUNDERS diff --git a/hippo/syndirella.py b/hippo/syndirella.py index c1637b3..0114cec 100644 --- a/hippo/syndirella.py +++ b/hippo/syndirella.py @@ -2,29 +2,28 @@ def reactions_from_row( - *, animal: "HIPPO", row: "pandas.Series", num_steps: int -) -> "ReactionSet": + *, animal: 'HIPPO', row: 'pandas.Series', num_steps: int +) -> 'ReactionSet': """Get :class:`.ReactionSet` from a syndirella *to-hippo* DataFrame row""" reaction_ids = set() for step in range(num_steps): - step += 1 # get relevant fields - reaction_name = row[f"{step}_reaction"] + reaction_name = row[f'{step}_reaction'] product = None reactants = [] - if reactant1_smiles := row[f"{step}_r1_smiles"]: + if reactant1_smiles := row[f'{step}_r1_smiles']: reactants.append(animal.register_compound(smiles=reactant1_smiles)) - if reactant2_smiles := row[f"{step}_r2_smiles"]: + if reactant2_smiles := row[f'{step}_r2_smiles']: reactants.append(animal.register_compound(smiles=reactant2_smiles)) - if product_smiles := row[f"{step}_product_smiles"]: + if product_smiles := row[f'{step}_product_smiles']: product = animal.register_compound(smiles=product_smiles) assert product diff --git a/hippo/tags.py b/hippo/tags.py index 07720a3..fa72662 100644 --- a/hippo/tags.py +++ b/hippo/tags.py @@ -1,10 +1,10 @@ """Classes for managing compound/pose tags""" +from collections.abc import MutableSet + import mcol import mrich -from collections.abc import MutableSet - class TagTable: """Object representing the 'tag' table in the :class:`.Database`. @@ -15,11 +15,11 @@ class TagTable: """ - _table = "tag" + _table = 'tag' def __init__( self, - db: "Database", + db: 'Database', ) -> None: """TagTable initialisation""" @@ -28,7 +28,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -41,13 +41,13 @@ def table(self) -> str: def unique(self) -> set[str]: """Returns a set of unique tag names contained in the table""" values = self.db.select( - table=self.table, query="DISTINCT tag_name", multiple=True + table=self.table, query='DISTINCT tag_name', multiple=True ) - return list(sorted(set(v for v, in values))) + return list(sorted(set(v for (v,) in values))) ### METHODS - def summary(self, return_df: bool = False) -> "pd.DataFrame": + def summary(self, return_df: bool = False) -> 'pd.DataFrame': """Print a summary table of tags with compound and pose counts""" from pandas import DataFrame @@ -68,12 +68,12 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": ] df = DataFrame(data) - df = df.set_index("tag") + df = df.set_index('tag') # compounds with poses sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) + SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON tag_pose = pose_id @@ -84,7 +84,7 @@ def summary(self, return_df: bool = False) -> "pd.DataFrame": cursor = self.db.execute(sql) for tag, count in cursor.fetchall(): - df.loc[tag, "num_posed_compounds"] = count + df.loc[tag, 'num_posed_compounds'] = count df = df.fillna(0) df = df.astype(int) @@ -98,13 +98,13 @@ def rename(self, old: str, new: str) -> None: """Rename all instances of a tag across the database""" match self.db.engine: - case "sqlite3": + case 'sqlite3': sql = """ UPDATE OR IGNORE tag SET tag_name = ? WHERE tag_name = ?; """ - case "psycopg": + case 'psycopg': sql = """ UPDATE hippo.tag SET tag_name = %s @@ -120,21 +120,21 @@ def rename(self, old: str, new: str) -> None: def delete(self, tag: str) -> None: """Delete all assignments for the given tag""" - self.db.delete_where(table="tag", key="name", value=tag) + self.db.delete_where(table='tag', key='name', value=tag) ### DUNDERS def __str__(self) -> str: """Unformatted representation of this object""" - return f"Tags {self.unique}" + return f'Tags {self.unique}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' class TagSet(MutableSet): @@ -148,7 +148,7 @@ class TagSet(MutableSet): def __init__( self, - parent: "Compound | Pose", + parent: 'Compound | Pose', tags: list | tuple | None = None, immutable: bool = False, commit: bool = True, @@ -190,7 +190,7 @@ def parent(self): return self._parent @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self.parent.db @@ -206,8 +206,8 @@ def _remove_tag_from_db( """ sql = f""" - DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_name="{tag}" + DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_name="{tag}" AND tag_{self.parent.table} = {self.parent.id} """ @@ -223,7 +223,7 @@ def _clear_tags_from_db( """ sql = f""" - DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag + DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag WHERE tag_{self.parent.table} = {self.parent.id} """ @@ -240,7 +240,7 @@ def _add_tag_to_db( :param commit: commit the changes? (Default value = True) """ - payload = {"name": tag, self.parent.table: self.parent.id} + payload = {'name': tag, self.parent.table: self.parent.id} self.db.insert_tag(**payload, commit=commit) ### METHODS @@ -281,7 +281,7 @@ def remove(self, tag: str) -> None: del self._elements[i] self._remove_tag_from_db(tag) else: - raise ValueError(f"{tag} not in {self}") + raise ValueError(f'{tag} not in {self}') def add( self, @@ -324,11 +324,11 @@ def __str__(self) -> str: def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' def __len__(self) -> int: """Number of tags in this set""" diff --git a/hippo/target.py b/hippo/target.py index 86a4505..5ef5dd1 100644 --- a/hippo/target.py +++ b/hippo/target.py @@ -17,7 +17,7 @@ class Target: def __init__( self, - db: "Database", + db: 'Database', id: int, name: str, ) -> None: @@ -30,7 +30,7 @@ def __init__( ### PROPERTIES @property - def db(self) -> "Database": + def db(self) -> 'Database': """Returns a pointer to the parent database""" return self._db @@ -49,31 +49,30 @@ def feature_ids(self) -> list[int]: """Returns the target's feature ID's""" records = self.db.select_where( - query="feature_id", - table="feature", - key="target", + query='feature_id', + table='feature', + key='target', value=self.id, none=False, multiple=True, - sort="feature_chain_name, feature_residue_number", + sort='feature_chain_name, feature_residue_number', ) if not records: return None - return [v for v, in records] + return [v for (v,) in records] @property - def features(self) -> list["Feature"]: + def features(self) -> list['Feature']: """Returns the target's features""" if feature_ids := self.feature_ids: - from .feature import Feature - feature_ids = str(tuple(feature_ids)).replace(",)", ")") + feature_ids = str(tuple(feature_ids)).replace(',)', ')') records = self.db.select_all_where( - table="feature", key=f"feature_id IN {feature_ids}", multiple=True + table='feature', key=f'feature_id IN {feature_ids}', multiple=True ) return [Feature(*record) for record in records] @@ -81,17 +80,17 @@ def features(self) -> list["Feature"]: return None @property - def subsites(self) -> "list[Subsite]": + def subsites(self) -> 'list[Subsite]': """List of :class:`.Subsite` objects on this target""" from .subsite import Subsite records = self.db.select_where( - table="subsite", - key="target", + table='subsite', + key='target', value=self.id, multiple=True, - query="subsite_id, subsite_name", + query='subsite_id, subsite_name', ) if not records: @@ -109,11 +108,11 @@ def subsites(self) -> "list[Subsite]": def calculate_features( self, - protein: "mp.System", + protein: 'mp.System', reference_id: int | None = None, force: bool = False, debug: bool = False, - ) -> list["Feature"]: + ) -> list['Feature']: """Calculate features from a protein system :param protein: `molparse.System` object, likely from :meth:`.Pose.protein_system` @@ -125,14 +124,13 @@ def calculate_features( return self._feature_cache[reference_id] else: - if debug: - mrich.debug("protein.get_protein_features()") + mrich.debug('protein.get_protein_features()') features = protein.get_protein_features() if debug: - mrich.debug("inserting features...") + mrich.debug('inserting features...') records = [ dict( @@ -163,8 +161,8 @@ def __str__(self) -> str: def __repr__(self) -> str: """ANSI Formatted string representation""" - return f"{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' def __rich__(self) -> str: """Rich Formatted string representation""" - return f"[bold underline]{self}" + return f'[bold underline]{self}' diff --git a/hippo/tools.py b/hippo/tools.py index c2d7bad..2f91108 100644 --- a/hippo/tools.py +++ b/hippo/tools.py @@ -1,20 +1,20 @@ """Generic tools for use in the HIPPO package""" import re -import numpy as np -from molparse.rdkit import mol_from_smiles -from rdkit.Chem.inchi import MolToInchiKey -from rdkit.Chem import MolFromSmiles, MolToSmiles, AddHs, RemoveHs -import mcol from datetime import datetime from string import ascii_uppercase +import mcol import mrich +import numpy as np +from molparse.rdkit import mol_from_smiles +from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RemoveHs +from rdkit.Chem.inchi import MolToInchiKey def strip_sql(sql) -> str: """Reduce unecessary whitespace in SQL""" - return re.sub(r"\s+", " ", sql).strip() + return re.sub(r'\s+', ' ', sql).strip() def df_row_to_dict(df_row) -> dict: @@ -23,13 +23,12 @@ def df_row_to_dict(df_row) -> dict: :param df_row: pandas dataframe row / series """ - assert len(df_row) == 1, f"{len(df_row)=}" + assert len(df_row) == 1, f'{len(df_row)=}' data = {} for col in df_row.columns: - - if col == "Unnamed: 0": + if col == 'Unnamed: 0': continue value = df_row[col].values[0] @@ -43,24 +42,24 @@ def df_row_to_dict(df_row) -> dict: def remove_other_ligands( - sys: "molparse.System", residue_number: int, chain: str -) -> "molparse.System": + sys: 'molparse.System', residue_number: int, chain: str +) -> 'molparse.System': """Remove ligands other than the specified one""" - ligand_residues = [r.number for r in sys["rLIG"] if r.number != residue_number] + ligand_residues = [r.number for r in sys['rLIG'] if r.number != residue_number] # if ligand_residues: for c in sys.chains: if c.name != chain: - c.remove_residues(names=["LIG"], verbosity=0) + c.remove_residues(names=['LIG'], verbosity=0) elif ligand_residues: c.remove_residues(numbers=ligand_residues, verbosity=0) # print([r.name_number_str for r in sys['rLIG']]) - assert ( - len([r.name_number_str for r in sys["rLIG"]]) == 1 - ), f"{sys.name} {[r.name_number_str for r in sys['rLIG']]}" + assert len([r.name_number_str for r in sys['rLIG']]) == 1, ( + f'{sys.name} {[r.name_number_str for r in sys["rLIG"]]}' + ) return sys @@ -94,22 +93,22 @@ def remove_isotopes_from_smiles(smiles: str) -> str: def smiles_has_isotope(smiles: str, regex=True) -> bool: """Does provided smiles string contain isotopes?""" if regex: - return re.search(r"([\[][0-9]+[A-Z]+\])", smiles) + return re.search(r'([\[][0-9]+[A-Z]+\])', smiles) else: mol = MolFromSmiles(smiles) return any(atom.GetIsotope() for atom in mol.GetAtoms()) REPLACE = { - "[STB]": "[S]", + '[STB]': '[S]', } def sanitise_smiles( s: str, verbosity: bool = False, - sanitisation_failed: str = "error", - radical: str = "error", + sanitisation_failed: str = 'error', + radical: str = 'error', ) -> str: """Sanitise smiles by: @@ -126,23 +125,23 @@ def sanitise_smiles( :returns: SMILES string """ - assert isinstance(s, str), f"non-string smiles={s}" + assert isinstance(s, str), f'non-string smiles={s}' orig_smiles = s # if multiple molecules take the largest - if "." in s: - s = sorted(s.split("."), key=lambda x: len(x))[-1] + if '.' in s: + s = sorted(s.split('.'), key=lambda x: len(x))[-1] # flatten the smiles stereo_smiles = s - smiles = s.replace("@", "") - smiles = smiles.replace("/", "") - smiles = smiles.replace("\\", "") + smiles = s.replace('@', '') + smiles = smiles.replace('/', '') + smiles = smiles.replace('\\', '') # remove isotopic stuff if smiles_has_isotope(smiles): - mrich.warning(f"Isotope(s) in SMILES: {smiles}") + mrich.warning(f'Isotope(s) in SMILES: {smiles}') smiles = remove_isotopes_from_smiles(smiles) # replace specific sequences @@ -154,10 +153,10 @@ def sanitise_smiles( mol = MolFromSmiles(smiles) if mol: smiles = MolToSmiles(mol, True) - elif sanitisation_failed == "error": + elif sanitisation_failed == 'error': raise SanitisationError - elif sanitisation_failed == "warning": - mrich.warning(f"sanitisation failed for {smiles=}") + elif sanitisation_failed == 'warning': + mrich.warning(f'sanitisation failed for {smiles=}') # check radicals reconstruct = False @@ -165,53 +164,51 @@ def sanitise_smiles( if not atom.GetNumRadicalElectrons(): continue - if radical == "warning": - mrich.warning(f"Radical atom in {smiles=}") - elif radical == "error": - raise SanitisationError(f"Radical atom in {smiles=}") - elif radical == "remove": - mrich.warning(f"Removed radical atom") + if radical == 'warning': + mrich.warning(f'Radical atom in {smiles=}') + elif radical == 'error': + raise SanitisationError(f'Radical atom in {smiles=}') + elif radical == 'remove': + mrich.warning('Removed radical atom') atom.SetNumRadicalElectrons(0) smiles = MolToSmiles(mol, True) reconstruct = True # atom.SetFormalCharge(0) else: - raise NotImplementedError(f"Unknown option {radical=}") + raise NotImplementedError(f'Unknown option {radical=}') if reconstruct: mol = AddHs(mol) mol = RemoveHs(mol, implicitOnly=True) smiles = MolToSmiles(mol, True) - mrich.warning(f"New {smiles=}") + mrich.warning(f'New {smiles=}') if verbosity: - if smiles != orig_smiles: - annotated_smiles_str = orig_smiles.replace( - ".", f"{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}" + '.', f'{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}' ) annotated_smiles_str = annotated_smiles_str.replace( - "@", f"{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}" + '@', f'{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}' ) - mrich.warning(f"SMILES was changed: {annotated_smiles_str} --> {smiles}") + mrich.warning(f'SMILES was changed: {annotated_smiles_str} --> {smiles}') return smiles -def sanitise_mol(m: "rdkit.Chem.Mol") -> "rdkit.Chem.Mol": +def sanitise_mol(m: 'rdkit.Chem.Mol') -> 'rdkit.Chem.Mol': """Sanitise by RDKit round-trip""" - from rdkit.Chem import MolToMolBlock, MolFromMolBlock + from rdkit.Chem import MolFromMolBlock, MolToMolBlock return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: "Pose", b: "Pose") -> float: +def pose_gap(a: 'Pose', b: 'Pose') -> float: """Calculate minimum distance between two :class:`.Pose` objects""" - from numpy.linalg import norm from molparse.rdkit import mol_to_AtomGroup + from numpy.linalg import norm min_dist = None @@ -227,7 +224,7 @@ def pose_gap(a: "Pose", b: "Pose") -> float: return min_dist -ALPHANUMERIC_CHARS = "0123456789" + ascii_uppercase +ALPHANUMERIC_CHARS = '0123456789' + ascii_uppercase def number_to_base(n: int, b: int) -> int: @@ -252,8 +249,8 @@ def dt_hash() -> str: + dt.second * 10 + dt.microsecond / 10000 ) - timehash = "".join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) - return f"{timehash:>07}" + timehash = ''.join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) + return f'{timehash:>07}' class SanitisationError(Exception): diff --git a/hippo/web.py b/hippo/web.py index 30582ba..656e1a8 100644 --- a/hippo/web.py +++ b/hippo/web.py @@ -1,11 +1,10 @@ """Classes for web (static HTML) output""" -from pathlib import Path -import shutil - import logging +import shutil +from pathlib import Path -logging.getLogger("PIL").setLevel(logging.WARNING) +logging.getLogger('PIL').setLevel(logging.WARNING) import mrich @@ -20,15 +19,15 @@ def __init__( self, output_dir: str | Path, *, - animal: "HIPPO", - scaffolds: "CompoundSet | None" = None, + animal: 'HIPPO', + scaffolds: 'CompoundSet | None' = None, suppliers: list[str] | None = None, - starting_recipe: "Recipe | None" = None, - rgen: "RandomRecipeGenerator | None" = None, - scorer: "Scorer | None" = None, - proposals: "list[Recipe] | None" = None, + starting_recipe: 'Recipe | None' = None, + rgen: 'RandomRecipeGenerator | None' = None, + scorer: 'Scorer | None' = None, + proposals: 'list[Recipe] | None' = None, title: str | None = None, - scaffold_tag: str = "Syndirella scaffold", + scaffold_tag: str = 'Syndirella scaffold', extra_recipe_dir: str | Path = None, skip_existing: bool = True, ) -> None: @@ -50,7 +49,7 @@ def __init__( self._all_scaffolds = self.animal.compounds(tag=scaffold_tag) - mrich.debug(f"{len(self.all_scaffolds)=}") + mrich.debug(f'{len(self.all_scaffolds)=}') self._all_scaffold_poses = None self._all_elabs = None @@ -69,32 +68,32 @@ def __init__( ### PROPERTIES @property - def animal(self) -> "HIPPO": + def animal(self) -> 'HIPPO': """associated :class:`.HIPPO` object""" return self._animal @property - def db(self) -> "Database": + def db(self) -> 'Database': """associated :class:`.Database` object""" return self.animal.db @property - def doc(self) -> "yattag.Doc": + def doc(self) -> 'yattag.Doc': """yattag.Doc""" return self._doc @property - def tag(self) -> "yattag.tag": + def tag(self) -> 'yattag.tag': """yattag.tag""" return self._tag @property - def text(self) -> "yattag.text": + def text(self) -> 'yattag.text': """yattag.text""" return self._text @property - def line(self) -> "yattag.line": + def line(self) -> 'yattag.line': """yattag.line""" return self._line @@ -104,37 +103,37 @@ def title(self) -> str: return self._title @property - def output_dir(self) -> "Path": + def output_dir(self) -> 'Path': """Output directory""" return self._output_dir @property - def resource_dir(self) -> "Path": + def resource_dir(self) -> 'Path': """Output directory""" - return self.output_dir / "web_resources" + return self.output_dir / 'web_resources' @property - def mol_image_dir(self) -> "Path": + def mol_image_dir(self) -> 'Path': """Output directory""" - return self.resource_dir / "mol_images" + return self.resource_dir / 'mol_images' @property - def pose_sdf_dir(self) -> "Path": + def pose_sdf_dir(self) -> 'Path': """Output directory""" - return self.resource_dir / "pose_sdfs" + return self.resource_dir / 'pose_sdfs' @property - def index_path(self) -> "Path": + def index_path(self) -> 'Path': """index.html Path""" - return self.output_dir / "index.html" + return self.output_dir / 'index.html' @property - def proposals(self) -> "list[Recipe]": + def proposals(self) -> 'list[Recipe]': """List of proposal :class:`.Recipe` objects""" return self._proposals @property - def scaffolds(self) -> "CompoundSet": + def scaffolds(self) -> 'CompoundSet': """Scaffold :class:`.CompoundSet`""" return self._scaffolds @@ -144,62 +143,62 @@ def suppliers(self) -> list[str]: return self._suppliers @property - def starting_recipe(self) -> "Recipe": + def starting_recipe(self) -> 'Recipe': """Starting :class:`.Recipe`""" return self._starting_recipe @property - def rgen(self) -> "RandomRecipeGenerator": + def rgen(self) -> 'RandomRecipeGenerator': """:class:`.RandomRecipeGenerator`""" return self._rgen @property - def scorer(self) -> "Scorer": + def scorer(self) -> 'Scorer': """:class:`.Scorer`""" return self._scorer @property - def all_scaffolds(self) -> "CompoundSet": + def all_scaffolds(self) -> 'CompoundSet': """All scaffold compounds""" return self._all_scaffolds @property - def all_elabs(self) -> "CompoundSet": + def all_elabs(self) -> 'CompoundSet': """All elaborations""" if self._all_elabs is None: self._all_elabs = self.all_scaffolds.elabs return self._all_elabs @property - def all_elab_poses(self) -> "PoseSet": + def all_elab_poses(self) -> 'PoseSet': """All elaboration poses""" if self._all_elab_poses is None: self._all_elab_poses = self.all_elabs.poses return self._all_elab_poses @property - def scaffold_poses(self) -> "PoseSet": + def scaffold_poses(self) -> 'PoseSet': """Scaffold poses""" if self._scaffold_poses is None: self._scaffold_poses = self.scaffolds.poses return self._scaffold_poses @property - def all_scaffold_poses(self) -> "PoseSet": + def all_scaffold_poses(self) -> 'PoseSet': """All scaffold poses""" if self._all_scaffold_poses is None: self._all_scaffold_poses = self.all_scaffolds.poses return self._all_scaffold_poses @property - def proposal(self) -> "Recipe": + def proposal(self) -> 'Recipe': """Return single :class:`.Recipe` proposal""" if len(self.proposals) != 1: - mrich.warning(f"{len(self.proposals)=}") + mrich.warning(f'{len(self.proposals)=}') return self._proposals[0] @property - def extra_recipe_dir(self) -> "Path": + def extra_recipe_dir(self) -> 'Path': """Optional extra recipe directory""" return self._extra_recipe_dir @@ -217,7 +216,7 @@ def write_html(self) -> None: path = self.index_path - with open(path, "wt") as f: + with open(path, 'w') as f: mrich.writing(path) f.writelines(indent(self.doc.getvalue())) @@ -251,26 +250,24 @@ def setup_page(self) -> None: self._text = text self._line = line - self.doc.asis("") - - with self.tag("html"): + self.doc.asis('') + with self.tag('html'): self.header() - with self.tag("body", klass="w3-content", style="max-width:none"): - - with self.tag("div", klass="w3-bar w3-teal"): - with self.tag("div", klass="w3-bar-item"): - src = "https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_assets-02.png?raw=true" + with self.tag('body', klass='w3-content', style='max-width:none'): + with self.tag('div', klass='w3-bar w3-teal'): + with self.tag('div', klass='w3-bar-item'): + src = 'https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_assets-02.png?raw=true' self.doc.stag( - "img", src=src, style="max-height:75px" + 'img', src=src, style='max-height:75px' ) # , klass="w3-image") - with self.tag("div", klass="w3-bar-item"): - with self.tag("h1"): + with self.tag('div', klass='w3-bar-item'): + with self.tag('h1'): self.text(self.title) - with self.tag("div", klass="w3-container w3-dark-gray w3-padding"): + with self.tag('div', klass='w3-container w3-dark-gray w3-padding'): self.section(self.sec_targets) self.section(self.sec_hits) @@ -289,51 +286,50 @@ def setup_page(self) -> None: if self.proposals: self.section(self.sec_proposals) - with self.tag("div", klass="w3-container w3-teal w3-padding"): - with self.tag("div", klass="w3-center"): - src = "https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_logo_tightcrop.png?raw=true" - self.doc.stag("img", src=src, style="max-height:150px") + with self.tag('div', klass='w3-container w3-teal w3-padding'): + with self.tag('div', klass='w3-center'): + src = 'https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_logo_tightcrop.png?raw=true' + self.doc.stag('img', src=src, style='max-height:150px') def header(self) -> None: """Create the page header""" - with self.tag("head"): - - with self.tag("title"): + with self.tag('head'): + with self.tag('title'): self.text(self.title) - self.doc.stag("meta", charset="UTF-8") + self.doc.stag('meta', charset='UTF-8') self.doc.stag( - "meta", name="viewport", content="width=device-width, initial-scale=1" + 'meta', name='viewport', content='width=device-width, initial-scale=1' ) self.doc.stag( - "link", - rel="stylesheet", - href="https://www.w3schools.com/w3css/4/w3.css", + 'link', + rel='stylesheet', + href='https://www.w3schools.com/w3css/4/w3.css', ) self.doc.stag( - "link", - rel="stylesheet", - href="https://fonts.googleapis.com/css?family=Oswald", + 'link', + rel='stylesheet', + href='https://fonts.googleapis.com/css?family=Oswald', ) self.doc.stag( - "link", - rel="stylesheet", - href="https://fonts.googleapis.com/css?family=Open Sans", + 'link', + rel='stylesheet', + href='https://fonts.googleapis.com/css?family=Open Sans', ) self.doc.stag( - "link", - rel="stylesheet", - href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css", + 'link', + rel='stylesheet', + href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css', ) - with self.tag("script", src="https://cdn.plot.ly/plotly-latest.min.js"): + with self.tag('script', src='https://cdn.plot.ly/plotly-latest.min.js'): ... - with self.tag("script", src="https://3Dmol.org/build/3Dmol-min.js"): + with self.tag('script', src='https://3Dmol.org/build/3Dmol-min.js'): ... - with self.tag("script", src="https://3Dmol.org/build/3Dmol.ui-min.js"): + with self.tag('script', src='https://3Dmol.org/build/3Dmol.ui-min.js'): ... self.style() @@ -343,12 +339,12 @@ def style(self) -> None: # change to a .css file and use doc.stag("link", rel="stylesheet", href="style.css") - with self.tag("style"): + with self.tag('style'): self.doc.asis( """h1,h2,h3,h4,h5,h6 {font-family: "Oswald"}body {font-family: "Open Sans"}""" ) - def section_header(self, title: str, tag: str = "h2") -> None: + def section_header(self, title: str, tag: str = 'h2') -> None: """section header""" with self.tag(tag): self.text(str(title)) @@ -362,12 +358,12 @@ def sidebar(self) -> None: """https://www.w3schools.com/w3css/w3css_sidebar.asp""" raise NotImplementedError - def var(self, key, value, tag=None, separator=": ") -> None: + def var(self, key, value, tag=None, separator=': ') -> None: """sub-content accordion""" - text = f"{key}{separator}{value}" + text = f'{key}{separator}{value}' if not tag: - with self.tag("b"): + with self.tag('b'): self.text(key) self.text(separator) self.text(str(value)) @@ -377,7 +373,7 @@ def var(self, key, value, tag=None, separator=": ") -> None: def section(self, function) -> None: """create section div""" - with self.tag("div", klass="w3-panel w3-border w3-white"): + with self.tag('div', klass='w3-panel w3-border w3-white'): function() def plotly_graph(self, figure, filename, write: bool = True): @@ -386,7 +382,7 @@ def plotly_graph(self, figure, filename, write: bool = True): # from plotly.offline import plot from hippo_plot import write_html - f"""
+ """
""" @@ -397,24 +393,24 @@ def plotly_graph(self, figure, filename, write: bool = True): write_html(path, figure) # embed the graph - with self.tag("div"): + with self.tag('div'): with self.tag( - "iframe", + 'iframe', src=str(rel_path), - width="100%", - height="500", - style="border:none", + width='100%', + height='500', + style='border:none', ): ... - def table(self, data, style: str = "w3-table-all w3-responsive", **kwargs): + def table(self, data, style: str = 'w3-table-all w3-responsive', **kwargs): """Embed some data as a table""" from pandas import DataFrame df = DataFrame(data) html = df.to_html(**kwargs, classes=style, index=False, escape=False) self.doc.asis(html) - self.doc.asis("
") + self.doc.asis('
') # def mol_grid_svg(self, cset, **kwargs): @@ -438,31 +434,31 @@ def save_compound_image(self, compound): from rdkit.Chem.Draw import MolToImage image = MolToImage(compound.mol) - path = self.mol_image_dir / f"C{compound.id}.png" + path = self.mol_image_dir / f'C{compound.id}.png' mrich.writing(path) image.save(path) def save_pose_sdf(self, pose): """Export pose SDF""" - path = self.pose_sdf_dir / f"P{pose.id}.sdf" + path = self.pose_sdf_dir / f'P{pose.id}.sdf' self.animal.poses([pose.id]).write_sdf(path, inspirations=False) def save_pset_sdf(self, name, pset): """Save poseset as SDF""" - path = self.pose_sdf_dir / f"{name}.sdf" + path = self.pose_sdf_dir / f'{name}.sdf' pset.write_sdf(path, inspirations=False) - def compound_image(self, compound, max_height="250px"): + def compound_image(self, compound, max_height='250px'): """Compound image stag""" self.save_compound_image(compound) src = str( Path(self.resource_dir.name) / Path(self.mol_image_dir.name) - / f"C{compound.id}.png" + / f'C{compound.id}.png' ) - self.doc.stag("img", src=src, style=f"max-height:{max_height}") + self.doc.stag('img', src=src, style=f'max-height:{max_height}') # def pose_3d_view(self, pose): # """Compound image stag""" @@ -477,122 +473,120 @@ def compound_image(self, compound, max_height="250px"): # self.doc.stag("img", src=src, style=f"max-height:{max_height}") - def compound_grid(self, compounds, style="w3-center", pose_modal: bool = False): + def compound_grid(self, compounds, style='w3-center', pose_modal: bool = False): """Compound grid component""" id_num_poses_dict = compounds.id_num_poses_dict inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - with self.tag("div", klass="w3-row"): + with self.tag('div', klass='w3-row'): for compound in compounds: - with self.tag( - "div", - klass=f"w3-col s12 m6 l4 {style} w3-hover-border-black", - style="border:8px solid white", + 'div', + klass=f'w3-col s12 m6 l4 {style} w3-hover-border-black', + style='border:8px solid white', ): - with self.tag("p"): - with self.tag("b"): - self.text(f"{compound}") + with self.tag('p'): + with self.tag('b'): + self.text(f'{compound}') self.compound_image(compound) - with self.tag("p", klass="w3-small w3-monospace"): - self.text(f"{compound.inchikey}") - self.doc.asis("
") - self.text(f"{compound.smiles}") - self.doc.asis("
") + with self.tag('p', klass='w3-small w3-monospace'): + self.text(f'{compound.inchikey}') + self.doc.asis('
') + self.text(f'{compound.smiles}') + self.doc.asis('
') inspirations = inspiration_map.get(compound.id, None) if ( not inspirations - and "inspiration_pose_ids" in compound.metadata + and 'inspiration_pose_ids' in compound.metadata ): - inspirations = compound.metadata["inspiration_pose_ids"] + inspirations = compound.metadata['inspiration_pose_ids'] if inspirations: inspirations = self.animal.poses[inspirations] - self.text(f"inspirations: {inspirations.names}") + self.text(f'inspirations: {inspirations.names}') else: - self.text(f"inspirations: ?") + self.text('inspirations: ?') num_poses = id_num_poses_dict[compound.id] - self.button(f"{num_poses} poses", disable=num_poses == 0) + self.button(f'{num_poses} poses', disable=num_poses == 0) if pose_modal: poses = compound.poses - modal_name = f"modal_c{compound.id}_poses" + modal_name = f'modal_c{compound.id}_poses' # MOLECULE MODAL self.modal_button( - f"view {len(poses)} poses", + f'view {len(poses)} poses', modal_name, disable=len(poses) == 0, ) if poses: - self.save_pset_sdf(modal_name, poses) def modal_content(): """modal content""" - with self.tag("p"): - self.text("TEXT TEXT TEXT") + with self.tag('p'): + self.text('TEXT TEXT TEXT') self.modal(modal_name, modal_content) def modal_button( - self, text, modal_name, disable: bool = False, style: str = "w3-teal" + self, text, modal_name, disable: bool = False, style: str = 'w3-teal' ): """Modal opening button""" onclick = f"document.getElementById('{modal_name}').style.display='block'" self.button(text, style=style, onclick=onclick, disable=disable) def button( - self, text: str, onclick: str = "", style="w3-teal", disable: bool = False + self, text: str, onclick: str = '', style='w3-teal', disable: bool = False ): """Generic button component""" assert text - klass = f"w3-btn {style}" + klass = f'w3-btn {style}' if disable: - klass += " w3-disabled" - onclick = "" + klass += ' w3-disabled' + onclick = '' - with self.tag("button", klass=klass, onclick=onclick): + with self.tag('button', klass=klass, onclick=onclick): self.text(text) def modal(self, modal_name, content_function): """Generic modal""" - with self.tag("div", id=modal_name, klass="w3-modal"): - with self.tag("div", klass="w3-modal-content"): - with self.tag("div", klass="w3-container"): + with self.tag('div', id=modal_name, klass='w3-modal'): + with self.tag('div', klass='w3-modal-content'): + with self.tag('div', klass='w3-container'): with self.tag( - "span", + 'span', onclick=f"document.getElementById('{modal_name}').style.display='none'", - klass="w3-button w3-display-topright", + klass='w3-button w3-display-topright', ): - self.doc.asis("×") + self.doc.asis('×') content_function() def recipe_subsection( - self, recipe, title, sankey: bool = False, title_style="h3", show_title=True + self, recipe, title, sankey: bool = False, title_style='h3', show_title=True ): """recipe subsection""" if show_title: self.section_header(title, title_style) - recipe_name = title.lower().replace(" ", "_") + recipe_name = title.lower().replace(' ', '_') if sankey: fig = recipe.sankey() - self.plotly_graph(fig, f"{recipe_name}.html") + self.plotly_graph(fig, f'{recipe_name}.html') # self.section_header("Products", "h4") @@ -600,10 +594,10 @@ def recipe_subsection( def modal_content(): """modal content""" - self.table(df, style="w3-table-all w3-small") + self.table(df, style='w3-table-all w3-small') - modal_name = f"{recipe_name}_products" - self.modal_button("products", modal_name) + modal_name = f'{recipe_name}_products' + self.modal_button('products', modal_name) self.modal(modal_name, modal_content) if intermediates := recipe.intermediates: @@ -612,10 +606,10 @@ def modal_content(): def modal_content(): """modal content""" - self.table(df, style="w3-table-all w3-small") + self.table(df, style='w3-table-all w3-small') - modal_name = f"{recipe_name}_intermediates" - self.modal_button("intermediates", modal_name) + modal_name = f'{recipe_name}_intermediates' + self.modal_button('intermediates', modal_name) self.modal(modal_name, modal_content) # self.section_header("Reactants", "h4") @@ -624,10 +618,10 @@ def modal_content(): def modal_content(): """modal content""" - self.table(df, style="w3-table-all w3-small") + self.table(df, style='w3-table-all w3-small') - modal_name = f"{recipe_name}_reactants" - self.modal_button("reactants", modal_name) + modal_name = f'{recipe_name}_reactants' + self.modal_button('reactants', modal_name) self.modal(modal_name, modal_content) # self.section_header("Reactions", "h4") @@ -636,10 +630,10 @@ def modal_content(): def modal_content(): """modal content""" - self.table(df, style="w3-table-all w3-small") + self.table(df, style='w3-table-all w3-small') - modal_name = f"{recipe_name}_reactions" - self.modal_button("reactions", modal_name) + modal_name = f'{recipe_name}_reactions' + self.modal_button('reactions', modal_name) self.modal(modal_name, modal_content) def scorer_attribute(self, attribute, histogram: bool = True): @@ -651,72 +645,71 @@ def scorer_attribute(self, attribute, histogram: bool = True): self.section_header(f'{attribute._type}: "{key}"') - with self.tag("ul"): - self.var("weight", f"{attribute.weight:.2f}", tag="li") - self.var("inverse", f"{attribute.inverse}", tag="li") - self.var("min", f"{attribute.min:.2f}", tag="li") - self.var("max", f"{attribute.max:.2f}", tag="li") - self.var("mean", f"{attribute.mean:.2f}", tag="li") - self.var("std", f"{attribute.std:.2f}", tag="li") + with self.tag('ul'): + self.var('weight', f'{attribute.weight:.2f}', tag='li') + self.var('inverse', f'{attribute.inverse}', tag='li') + self.var('min', f'{attribute.min:.2f}', tag='li') + self.var('max', f'{attribute.max:.2f}', tag='li') + self.var('mean', f'{attribute.mean:.2f}', tag='li') + self.var('std', f'{attribute.std:.2f}', tag='li') if key in DEFAULT_ATTRIBUTES: - description = DEFAULT_ATTRIBUTES[key]["description"] + description = DEFAULT_ATTRIBUTES[key]['description'] if attribute.inverse: - description += "(Lower is better)" + description += '(Lower is better)' else: - description += "(Lower is better)" + description += '(Lower is better)' - self.var("Description", description, tag="li") + self.var('Description', description, tag='li') if histogram: fig = attribute.histogram(progress=True) - self.plotly_graph(fig, f"attribute_{key}_hist.html") + self.plotly_graph(fig, f'attribute_{key}_hist.html') ### SECTION CONTENT def sec_targets(self) -> None: """Section on targets""" - title = "Protein Target" + title = 'Protein Target' targets = self.animal.targets if len(targets) > 1: - title += "s" + title += 's' self.section_header(title) for target in targets: - self.section_header(target.name, "h3") + self.section_header(target.name, 'h3') - self.var("name", target.name) + self.var('name', target.name) subsites = target.subsites if subsites: - - self.section_header("Subsites", "h4") - with self.tag("ul"): + self.section_header('Subsites', 'h4') + with self.tag('ul'): for subsite in subsites: - self.var(f"Site {subsite.id}", subsite.name, tag="li") + self.var(f'Site {subsite.id}', subsite.name, tag='li') # try: fig = self.funnel() - self.plotly_graph(fig, "project_funnel.html") + self.plotly_graph(fig, 'project_funnel.html') # except Exception as e: # mrich.error(e) def sec_hits(self) -> None: """Section on experimental hits""" - title = "Experimental hits" - hit_compounds = self.animal.compounds(tag="hits") - hit_poses = self.animal.poses(tag="hits") + title = 'Experimental hits' + hit_compounds = self.animal.compounds(tag='hits') + hit_poses = self.animal.poses(tag='hits') self.section_header(title) - with self.tag("ul"): - self.var("#compounds", len(hit_compounds), tag="li") - self.var("#observations", len(hit_poses), tag="li") + with self.tag('ul'): + self.var('#compounds', len(hit_compounds), tag='li') + self.var('#observations', len(hit_poses), tag='li') from .animal import GENERATED_TAG_COLS @@ -725,21 +718,21 @@ def sec_hits(self) -> None: show_compounds=False, poses=hit_poses, logo=None, - title="Tags", - skip=["Pose", "hits"] + GENERATED_TAG_COLS, + title='Tags', + skip=['Pose', 'hits'] + GENERATED_TAG_COLS, ) - self.plotly_graph(fig, "hit_tags.html") + self.plotly_graph(fig, 'hit_tags.html') # files - self.section_header("Downloads", "h3") - path = self.resource_dir / "hit_poses.sdf" - rel_path = Path(self.resource_dir.name) / "hit_poses.sdf" + self.section_header('Downloads', 'h3') + path = self.resource_dir / 'hit_poses.sdf' + rel_path = Path(self.resource_dir.name) / 'hit_poses.sdf' hit_poses.write_sdf(path, inspirations=False) table_data = [ dict( - Name="hit_poses.sdf", - Description="SDF of the experimental hits", + Name='hit_poses.sdf', + Description='SDF of the experimental hits', Download=f'SDF', ) ] @@ -748,54 +741,54 @@ def sec_hits(self) -> None: def sec_scaffolds(self) -> None: """Section on scaffolds""" - title = "Scaffolds" + title = 'Scaffolds' self.section_header(title) - self.section_header("All scaffolds", "h3") + self.section_header('All scaffolds', 'h3') - with self.tag("ul"): - self.var("#compounds", len(self.all_scaffolds), tag="li") + with self.tag('ul'): + self.var('#compounds', len(self.all_scaffolds), tag='li') # route dict? df = self.all_scaffolds.get_df(mol=False, num_poses=True) # , routes=True) def modal_content(): """modal content""" - self.table(df, style="w3-table-all w3-small") + self.table(df, style='w3-table-all w3-small') - modal_name = "all_scaffolds_modal" - self.modal_button(f"all scaffolds table", modal_name) + modal_name = 'all_scaffolds_modal' + self.modal_button('all scaffolds table', modal_name) self.modal(modal_name, modal_content) # quoting? - self.section_header("Selected scaffolds", "h3") + self.section_header('Selected scaffolds', 'h3') self.compound_grid(self.scaffolds, pose_modal=False) # files - self.section_header("Downloads", "h3") + self.section_header('Downloads', 'h3') table_data = [] - path = self.resource_dir / "all_scaffold_smiles.csv" + path = self.resource_dir / 'all_scaffold_smiles.csv' rel_path = Path(self.resource_dir.name) / path.name self.scaffolds.write_smiles_csv(path) table_data.append( dict( - Name="All scaffolds", - Description="CSV of scaffold SMILES", + Name='All scaffolds', + Description='CSV of scaffold SMILES', Download=f'CSV', ) ) - path = self.resource_dir / "selected_scaffold_smiles.csv" + path = self.resource_dir / 'selected_scaffold_smiles.csv' rel_path = Path(self.resource_dir.name) / path.name self.scaffolds.write_smiles_csv(path) table_data.append( dict( - Name="Selected scaffolds", - Description="CSV of scaffold SMILES", + Name='Selected scaffolds', + Description='CSV of scaffold SMILES', Download=f'CSV', ) ) @@ -808,66 +801,66 @@ def sec_elaborations(self) -> None: elabs = self.scaffolds.elabs if not elabs: - mrich.warning("No elaborations") + mrich.warning('No elaborations') return None self._elaborations = elabs - title = "Elaborations" + title = 'Elaborations' self.section_header(title) fig = self.animal.plot_reaction_funnel( - title="Syndirella elaboration space", logo=False + title='Syndirella elaboration space', logo=False ) - self.plotly_graph(fig, "reaction_funnel.html") + self.plotly_graph(fig, 'reaction_funnel.html') def sec_quoting(self) -> None: """Section on quoting""" - title = "Quoting" + title = 'Quoting' self.section_header(title) def sec_product_pool(self) -> None: """Section on product_pool""" - title = "product_pool" + title = 'product_pool' self.section_header(title) def sec_route_pool(self) -> None: """Section on route_pool""" - title = "route_pool" + title = 'route_pool' self.section_header(title) def sec_rgen(self) -> None: """Section on rgen""" - title = "Random Recipe Generation" + title = 'Random Recipe Generation' self.section_header(title) rgen = self.rgen - with self.tag("ul"): - self.var("suppliers", rgen.suppliers, tag="li") - self.var("max_lead_time", rgen.max_lead_time, tag="li") - self.var("route_pool", len(rgen.route_pool), tag="li") + with self.tag('ul'): + self.var('suppliers', rgen.suppliers, tag='li') + self.var('max_lead_time', rgen.max_lead_time, tag='li') + self.var('route_pool', len(rgen.route_pool), tag='li') - self.recipe_subsection(rgen.starting_recipe, "Starting Recipe", sankey=False) + self.recipe_subsection(rgen.starting_recipe, 'Starting Recipe', sankey=False) def sec_scorer(self) -> None: """Section on scorer""" - title = "Recipe Selection" + title = 'Recipe Selection' self.section_header(title) scorer = self.scorer - with self.tag("ul"): - self.var("#recipes", len(scorer.recipes), tag="li") - self.var("#attributes", len(scorer.attributes), tag="li") + with self.tag('ul'): + self.var('#recipes', len(scorer.recipes), tag='li') + self.var('#attributes', len(scorer.attributes), tag='li') - fig = scorer.plot(["price", "score"]) - self.plotly_graph(fig, "scorer_scatter.html") + fig = scorer.plot(['price', 'score']) + self.plotly_graph(fig, 'scorer_scatter.html') for attribute in scorer.attributes: self.scorer_attribute(attribute) @@ -875,22 +868,21 @@ def sec_scorer(self) -> None: def sec_proposals(self) -> None: """Section on proposals""" - title = "Proposal Recipes" + title = 'Proposal Recipes' self.section_header(title) table_data = [] for proposal in self.proposals: - d = {} - d["hash"] = str(proposal.hash) - d["price"] = str(proposal.price) - d["price/compound"] = str(proposal.price / proposal.num_products) + d['hash'] = str(proposal.hash) + d['price'] = str(proposal.price) + d['price/compound'] = str(proposal.price / proposal.num_products) for attribute in self.scorer.attributes: - d[f"{attribute.key} w={attribute.weight}"] = ( - f"{attribute.get_value(proposal):.1f} ({attribute.unweighted(proposal):.0%})" + d[f'{attribute.key} w={attribute.weight}'] = ( + f'{attribute.get_value(proposal):.1f} ({attribute.unweighted(proposal):.0%})' ) table_data.append(d) @@ -898,42 +890,38 @@ def sec_proposals(self) -> None: self.table(table_data) for proposal in self.proposals: - - filename = f"proposal_{proposal.hash}.html" + filename = f'proposal_{proposal.hash}.html' path = self.resource_dir / filename - self.section_header(str(proposal), "h3") + self.section_header(str(proposal), 'h3') from .plotting import plot_compound_tsnee if path.exists() and self.skip_existing: - self.plotly_graph(None, filename, write=False) else: - fig = plot_compound_tsnee( proposal.products.compounds, logo=False, legend=False, - title="Product Clustering", + title='Product Clustering', ) self.plotly_graph(fig, filename) self.recipe_subsection( - proposal, f"Recipe {proposal.hash}", sankey=False, show_title=False + proposal, f'Recipe {proposal.hash}', sankey=False, show_title=False ) # files - self.section_header("Downloads", "h3") + self.section_header('Downloads', 'h3') table_data = [] for proposal in self.proposals: - # JSON - filename = f"Recipe_{proposal.hash}.json" + filename = f'Recipe_{proposal.hash}.json' original = self.rgen.recipe_dir / filename if not original.exists() and self.extra_recipe_dir: original = Path(self.extra_recipe_dir) / filename @@ -945,13 +933,13 @@ def sec_proposals(self) -> None: table_data.append( dict( Name=str(proposal), - Description="Recipe JSON", + Description='Recipe JSON', Download=f'JSON', ) ) # SDF - filename = f"Recipe_{proposal.hash}_poses.sdf" + filename = f'Recipe_{proposal.hash}_poses.sdf' try: original = self.rgen.recipe_dir / filename @@ -965,36 +953,35 @@ def sec_proposals(self) -> None: table_data.append( dict( Name=str(proposal), - Description="Recipe product poses (Fragalysis compatible)", + Description='Recipe product poses (Fragalysis compatible)', Download=f'SDF', ) ) except FileNotFoundError: # hit_poses.write_sdf(path, inspirations=False) - mrich.error(f"Could not find pose SDF: {original}") + mrich.error(f'Could not find pose SDF: {original}') # CAR CSVs - filename = f"Recipe_{proposal.hash}_CAR" - path = self.resource_dir / f"{filename}.csv" + filename = f'Recipe_{proposal.hash}_CAR' + path = self.resource_dir / f'{filename}.csv' proposal.write_CAR_csv(path) - for file in Path(self.resource_dir).glob(f"{filename}*.csv"): - + for file in Path(self.resource_dir).glob(f'{filename}*.csv'): rel_path = Path(self.resource_dir.name) / file.name table_data.append( dict( Name=str(proposal), - Description=f"CAR input file [{file.name}]", + Description=f'CAR input file [{file.name}]', Download=f'CSV', ) ) # Reactant CSV - filename = f"Recipe_{proposal.hash}_reactants" - path = self.resource_dir / f"{filename}.csv" + filename = f'Recipe_{proposal.hash}_reactants' + path = self.resource_dir / f'{filename}.csv' if not path.exists() or not self.skip_existing: proposal.write_reactant_csv(path) @@ -1004,15 +991,15 @@ def sec_proposals(self) -> None: table_data.append( dict( Name=str(proposal), - Description=f"Reactant data file", + Description='Reactant data file', Download=f'CSV', ) ) # Product CSV - filename = f"Recipe_{proposal.hash}_products" - path = self.resource_dir / f"{filename}.csv" + filename = f'Recipe_{proposal.hash}_products' + path = self.resource_dir / f'{filename}.csv' if not path.exists() or not self.skip_existing: proposal.write_product_csv(path) @@ -1022,15 +1009,15 @@ def sec_proposals(self) -> None: table_data.append( dict( Name=str(proposal), - Description=f"Product data file", + Description='Product data file', Download=f'CSV', ) ) # Scaffold/chemistry CSV - filename = f"Recipe_{proposal.hash}_chemistry" - path = self.resource_dir / f"{filename}.csv" + filename = f'Recipe_{proposal.hash}_chemistry' + path = self.resource_dir / f'{filename}.csv' if not path.exists() or not self.skip_existing: proposal.write_chemistry_csv(path) @@ -1040,7 +1027,7 @@ def sec_proposals(self) -> None: table_data.append( dict( Name=str(proposal), - Description=f"Chemistry review file", + Description='Chemistry review file', Download=f'CSV', ) ) @@ -1052,10 +1039,10 @@ def sec_proposals(self) -> None: def funnel( self, log_y: bool = True, - scaffolds: "CompoundSet | None" = None, + scaffolds: 'CompoundSet | None' = None, num_inspirations: int | None = None, num_inspiration_sets: int | None = None, - ) -> "plotly.graph_objects.Figure": + ) -> 'plotly.graph_objects.Figure': """Funnel plot""" if scaffolds is None: @@ -1080,31 +1067,31 @@ def funnel( len(self.proposal.products), ], category=[ - "Fragments", - "Fragment Sets", - "Scaffolds", - "Elaborations", - "Accessible Products", - "Selected Products", + 'Fragments', + 'Fragment Sets', + 'Scaffolds', + 'Elaborations', + 'Accessible Products', + 'Selected Products', ], ) df = DataFrame(data) if log_y: - y = "log_y" - df["log_y"] = df.apply(lambda x: np_log(x["number"]), axis=1) + y = 'log_y' + df['log_y'] = df.apply(lambda x: np_log(x['number']), axis=1) else: - y = "number" + y = 'number' - fig = px.funnel(df, x="category", y=y, text="number", log_y=False) + fig = px.funnel(df, x='category', y=y, text='number', log_y=False) - fig.data[0].texttemplate = "%{text}" + fig.data[0].texttemplate = '%{text}' fig.update_layout( - xaxis={"side": "top"}, + xaxis={'side': 'top'}, ) - fig.layout.xaxis.title.text = "" + fig.layout.xaxis.title.text = '' # title = title or f"{animal.name}: Reaction statistics" diff --git a/hippo/xca.py b/hippo/xca.py index 76dd4b1..4bb8a56 100644 --- a/hippo/xca.py +++ b/hippo/xca.py @@ -1,8 +1,5 @@ """Functions for interfacing with XChemAlign data""" -import mrich -from mrich import print - import re @@ -19,7 +16,7 @@ def parse_observation_longcode(longcode: str) -> dict[str]: """ match = re.search( - r"^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$", longcode + r'^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$', longcode ) if not match: diff --git a/images/postgres/docker-entrypoint.sh b/images/postgres/docker-entrypoint.sh old mode 100644 new mode 100755 diff --git a/images/xchem-designdb/01_schema.sql b/images/xchem-designdb/01_schema.sql index 8384231..2f1b536 100644 --- a/images/xchem-designdb/01_schema.sql +++ b/images/xchem-designdb/01_schema.sql @@ -301,7 +301,7 @@ CREATE TABLE IF NOT EXISTS designdb.routes ( CREATE TABLE IF NOT EXISTS designdb.components ( id BIGSERIAL PRIMARY KEY, route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- component_ref INTEGER, -- Insert by codebase/notebook, Synderilla component_amount REAL, -- Insert by codebase/notebook, Synderilla created_on TIMESTAMPTZ DEFAULT now(), diff --git a/images/xchem-designdb/01_schema_OLD.sql b/images/xchem-designdb/01_schema_OLD.sql index d296c7d..93c25cb 100644 --- a/images/xchem-designdb/01_schema_OLD.sql +++ b/images/xchem-designdb/01_schema_OLD.sql @@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS designdb.pose_method ( CREATE TABLE IF NOT EXISTS designdb.compound ( compound_pk BIGSERIAL PRIMARY KEY, - compound_inchikey TEXT, -- Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? + compound_inchikey TEXT, -- Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? compound_alias TEXT, -- Maybe insert by the codebase. compound_smiles TEXT, -- Inseret by the codebase. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? compound_base BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Not populated by code @@ -164,7 +164,7 @@ CREATE TABLE IF NOT EXISTS designdb.subsite ( CREATE TABLE IF NOT EXISTS designdb.component ( component_pk BIGSERIAL PRIMARY KEY, component_route BIGINT REFERENCES designdb.route (route_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- component_ref INTEGER, -- Insert by codebase/notebook, Synderilla component_amount REAL, -- Insert by codebase/notebook, Synderilla created_on TIMESTAMPTZ DEFAULT now(), diff --git a/images/xchem-designdb/env.template b/images/xchem-designdb/env.template index 3eb0d5d..791d64e 100644 --- a/images/xchem-designdb/env.template +++ b/images/xchem-designdb/env.template @@ -19,4 +19,4 @@ RDKIT_VERSION=Release_2025_09_5 BOOST_VERSION=1.90.0 BOOST_VER_US=1_90_0 PG_MAJOR=18 -PG_BASE=postgres:18.2-bookworm \ No newline at end of file +PG_BASE=postgres:18.2-bookworm diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index 101ca6e..a308b13 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -84,7 +84,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( inchi_version TEXT, -- Must be done by codebase created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now() - -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed + -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed -- CONSTRAINT uc_pose_path UNIQUE (pose_path) -- Removed ); @@ -305,7 +305,7 @@ CREATE TABLE IF NOT EXISTS designdb.routes ( CREATE TABLE IF NOT EXISTS designdb.components ( id BIGSERIAL PRIMARY KEY, route_id BIGINT NOT NULL REFERENCES designdb.routes (id) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- + component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- component_ref INTEGER, -- Insert by codebase/notebook, Synderilla component_amount REAL, -- Insert by codebase/notebook, Synderilla created_on TIMESTAMPTZ DEFAULT now(), diff --git a/pyproject.toml b/pyproject.toml index fbd9804..3ac1651 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,13 +3,15 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "xchem-hippo" -version = "0.0.0" +version = "1.0.0" authors = [ { name = "Max Winokan", email = "max@winokan.com" }, + { name = "Kalev Takkis", email = "ktakkis@informaticsmatters.com" }, ] description = "Hit Interaction Profiling and Procurement Optimisation" readme = "README.md" -requires-python = ">=3.10" +# python version limit dictated by pandas +requires-python = ">=3.10,<3.13" requires = [] classifiers = [ "Programming Language :: Python :: 3", @@ -17,24 +19,99 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ - "rdkit >= 2024.9.6", - "molparse >= 0.0.41", - "mpytools >= 0.0.21", - "jupyterlab", - "chardet", - "pandas", - "yattag", - "hirsch", - "hippo_plot", - "scikit-learn", - "openpyxl", - "ipywidgets", - "networkx", - "openmm", - "apsw", - "python-louvain", - "psycopg[binary]", + "rdkit==2025.9.5", + "molparse>=0.0.41", + "mpytools>=0.0.21", + "jupyterlab>=4.5", + "chardet>=7", + "pandas>=2.3", + "yattag>=1.16", # html widget rendering, possibly to be removed + "hirsch>=0.1", # used? + "hippo_plot>=0.0.1", + "scikit-learn>=1.7", + "openpyxl>=3.1", + "ipywidgets>=8.1", + "networkx>=3.4", + "openmm>=8.4", + "apsw>=3.52", + "python-louvain>=0.16", + "psycopg[binary]>=3.3", + "django>=5.2.12", + "syndirella>=5.0.7a0", + # "syndirella==4.0.1a0",# have to pin that apparently + "typer>=0.24.1", # used? + "neo4j>=6.1.0", + "gemmi>=0.7.5", + "mrich>=1.0", + "pdbfixer>=1.12.0", + # Max's hippo depends on this. cannot install with uv, comes from conda + # "chemicalite==2024.5.1", + # options: + # - this is probably present in diamond environments, + # - can use conda install in container + # - but.. probs don't even need it, it's only used for expressions and I got that covered ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2,<10", + "ruff>=0.15.2", + "ty>=0.0.19", + "mypy>=1.19", + "commitizen>=4.13.5,<5", + "pre-commit>=4.5.1", +] + +[tool.commitizen] +name = "cz_customize" +version_provider = "scm" +version_scheme = "semver2" +annotated_tag = true +update_changelog_on_bump = true +changelog_merge_prerelease = true +prerelease_offset = 1 +pre_bump_hooks = ["./add-issue-links-to-changelog.sh"] + +[tool.commitizen.customize] +bump_pattern = '^(feat|fix|ci|build|perf|refactor|chore|remove|style|test)' +bump_map = {feat = "MINOR", fix = "PATCH", ci = "PATCH", build = "PATCH", perf = "PATCH", refactor = "PATCH", chore = "PATCH", remove = "PATCH", style = "PATCH", test = "PATCH" } +schema_pattern = "(build|bump|chore|ci|dev|docs|feat|fix|perf|refactor|remove|style|test):(\\s.*)" +commit_parser = "^(?Pbuild|bump|chore|ci|dev|docs|feat|fix|perf|refactor|remove|style|test):\\s(?P.*)?" +# By excluding 'bump', 'ci', 'dev', and 'docs' from +# 'change_type_map', 'change_type_order' and 'changelog_pattern' +# we omit the corresponding commit comments from the CHANGELOG. +# The order of types in the CHANGELOG is dictated by the type_order +change_type_map = {"feat" = "New Features", "fix" = "Bug Fixes", "perf" = "Performance Improvements", "refactor" = "Refactoring", "chore" = "General Improvements", "remove" = "Removed", "style" = "Stylistic Changes", "test" = "Testing", "build" = "Build"} +change_type_order = ["BREAKING CHANGE", "New Features", "Bug Fixes", "Performance Improvements", "Refactoring", "General Improvements", "Removed", "Stylistic Changes", "Testing", "Build"] +changelog_pattern = "^(build|chore|feat|fix|perf|refactor|remove|style|test)" + +[tool.ruff] +line-length = 88 +target-version = "py313" +exclude = [ + "tests", + "migrations", +] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # pyflakes + "I", # import sort + "UP", # pyupgrade + "B", # bugbear +] + +[tool.ruff.format] +quote-style = "single" + +[tool.ty.src] +exclude = [ + "migrations", + "tests", +] + + [tool.hatch.build] include = [ "hippo/*.py", diff --git a/tests/config.py b/tests/config.py index 7f95b7b..52a4b12 100644 --- a/tests/config.py +++ b/tests/config.py @@ -1,12 +1,12 @@ ## CONFIGURE TESTING DATA -TARGET = "SARS2_Nprot" -PROPOSAL = "lb32627-93" -STACK = "production" +TARGET = 'SARS2_Nprot' +PROPOSAL = 'lb32627-93' +STACK = 'production' ## CONFIGURE CLEANUP CLEANUP_FILES = [ - f"{TARGET}.tar.gz", + f'{TARGET}.tar.gz', ] CLEANUP_DIRS = [ @@ -26,7 +26,7 @@ ### SQLITE -DB = "db_test.sqlite" +DB = 'db_test.sqlite' CLEANUP_FILES.append(DB) diff --git a/tests/test_00_cleanup.py b/tests/test_00_cleanup.py index 648a194..8a37697 100644 --- a/tests/test_00_cleanup.py +++ b/tests/test_00_cleanup.py @@ -22,5 +22,5 @@ def test_cleanup(): pass -if __name__ == "__main__": +if __name__ == '__main__': test_cleanup() diff --git a/tests/test_01_fragalysis_download.py b/tests/test_01_fragalysis_download.py index a488217..6d9fbb4 100644 --- a/tests/test_01_fragalysis_download.py +++ b/tests/test_01_fragalysis_download.py @@ -6,15 +6,14 @@ def test_fragalysis_download(): if not DOWNLOAD: return - from pathlib import Path from fragalysis.requests import download_target path = download_target(name=TARGET, tas=PROPOSAL) assert path.exists() - assert (path / "metadata.csv").exists() - assert (path / "aligned_files").exists() + assert (path / 'metadata.csv').exists() + assert (path / 'aligned_files').exists() -if __name__ == "__main__": +if __name__ == '__main__': test_fragalysis_download() diff --git a/tests/test_02_setup_animal.py b/tests/test_02_setup_animal.py index 1dfa367..942953d 100644 --- a/tests/test_02_setup_animal.py +++ b/tests/test_02_setup_animal.py @@ -7,9 +7,10 @@ def test_setup_animal(): return from pathlib import Path + import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.summary() if isinstance(DB, str): @@ -18,5 +19,5 @@ def test_setup_animal(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_setup_animal() diff --git a/tests/test_03_add_hits.py b/tests/test_03_add_hits.py index 526ccc2..7cc1ab0 100644 --- a/tests/test_03_add_hits.py +++ b/tests/test_03_add_hits.py @@ -10,16 +10,16 @@ def test_add_hits(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.add_hits( target_name=TARGET, - aligned_directory=Path(TARGET) / "aligned_files", - metadata_csv=Path(TARGET) / "metadata.csv", + aligned_directory=Path(TARGET) / 'aligned_files', + metadata_csv=Path(TARGET) / 'metadata.csv', ) animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_add_hits() diff --git a/tests/test_04_interactions.py b/tests/test_04_interactions.py index eb8d2bb..407825b 100644 --- a/tests/test_04_interactions.py +++ b/tests/test_04_interactions.py @@ -5,7 +5,7 @@ def test_calculate_interactions(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) for pose in animal.poses: pose.calculate_interactions() @@ -13,5 +13,5 @@ def test_calculate_interactions(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_calculate_interactions() diff --git a/tests/test_05_scaffolds.py b/tests/test_05_scaffolds.py index 5d82919..7d6a547 100644 --- a/tests/test_05_scaffolds.py +++ b/tests/test_05_scaffolds.py @@ -1,21 +1,22 @@ from config import * + import hippo def test_calculate_all_scaffolds(): if SCAFFOLDS: - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.db.calculate_all_scaffolds() animal.db.close() def test_calculate_all_murcko_scaffolds(): if SCAFFOLDS: - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) animal.db.calculate_all_murcko_scaffolds() animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_calculate_all_scaffolds() test_calculate_all_murcko_scaffolds() diff --git a/tests/test_06_subsites.py b/tests/test_06_subsites.py index 2e93dbf..97ee004 100644 --- a/tests/test_06_subsites.py +++ b/tests/test_06_subsites.py @@ -8,13 +8,13 @@ def test_set_subsites(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) - hits = animal.poses(tag="hits") + hits = animal.poses(tag='hits') hits.set_subsites_from_metadata_field() animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_set_subsites() diff --git a/tests/test_compound.py b/tests/test_compound.py index 0512d64..fe1194a 100644 --- a/tests/test_compound.py +++ b/tests/test_compound.py @@ -1,39 +1,39 @@ from config import * NOT_NULL_PROPERTIES = [ - "id", - "inchikey", - "name", - "smiles", - "mol", - "num_heavy_atoms", - "molecular_weight", - "num_rings", - "formula", - "atomtype_dict", - "metadata", - "db", - "tags", - "poses", - "best_placed_pose", - "num_poses", - "num_reactions", - "num_reactant", - "num_scaffolds", - "dict", - "is_scaffold", - "is_elab", - "is_product", - "table", + 'id', + 'inchikey', + 'name', + 'smiles', + 'mol', + 'num_heavy_atoms', + 'molecular_weight', + 'num_rings', + 'formula', + 'atomtype_dict', + 'metadata', + 'db', + 'tags', + 'poses', + 'best_placed_pose', + 'num_poses', + 'num_reactions', + 'num_reactant', + 'num_scaffolds', + 'dict', + 'is_scaffold', + 'is_elab', + 'is_product', + 'table', ] PROPERTIES = [ - "alias", - "elabs", - "reaction", - "reactions", - "scaffolds", - "num_atoms_added", + 'alias', + 'elabs', + 'reaction', + 'reactions', + 'scaffolds', + 'num_atoms_added', ] @@ -41,13 +41,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) compound = animal.C1 for prop in NOT_NULL_PROPERTIES: value = getattr(compound, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(compound, prop) @@ -56,5 +56,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_feature.py b/tests/test_feature.py index 9df33ca..afdeb25 100644 --- a/tests/test_feature.py +++ b/tests/test_feature.py @@ -1,13 +1,13 @@ from config import * NOT_NULL_PROPERTIES = [ - "id", - "family", - "target", - "chain_name", - "residue_name", - "residue_number", - "atom_names", + 'id', + 'family', + 'target', + 'chain_name', + 'residue_name', + 'residue_number', + 'atom_names', ] PROPERTIES = [] @@ -17,16 +17,16 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) - animal.db.print_table("feature") + animal.db.print_table('feature') feature = animal.F1 for prop in NOT_NULL_PROPERTIES: value = getattr(feature, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(feature, prop) @@ -35,5 +35,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 415c4c2..1afc923 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,28 +1,28 @@ from config import * NOT_NULL_PROPERTIES = [ - "id", - "table", - "db", - "family", - "pose_id", - "pose", - "feature_id", - "feature", - "residue_name", - "residue_number", - "atom_ids", - "prot_coord", - "lig_coord", - "distance", - "family_str", - "type", - "description", + 'id', + 'table', + 'db', + 'family', + 'pose_id', + 'pose', + 'feature_id', + 'feature', + 'residue_name', + 'residue_number', + 'atom_ids', + 'prot_coord', + 'lig_coord', + 'distance', + 'family_str', + 'type', + 'description', ] PROPERTIES = [ - "angle", - "energy", + 'angle', + 'energy', ] @@ -30,13 +30,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) interaction = animal.I1 for prop in NOT_NULL_PROPERTIES: value = getattr(interaction, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(interaction, prop) @@ -45,5 +45,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_pose.py b/tests/test_pose.py index 2720cfc..850b467 100644 --- a/tests/test_pose.py +++ b/tests/test_pose.py @@ -1,48 +1,48 @@ from config import * NOT_NULL_PROPERTIES = [ - "db", - "id", - "inchikey", - "alias", - "name", - "smiles", - "target", - "compound_id", - "compound", - "path", - "mol", - "protonated_mol", - "protein_system", - "complex_system", - "has_complex_pdb_path", - "metadata", - "has_fingerprint", - "tags", - "features", - "dict", - "table", - "num_heavy_atoms", - "num_scaffolds", - "scaffold_ids", - "interactions", - "classic_fingerprint", - "mol_path", - "apo_path", + 'db', + 'id', + 'inchikey', + 'alias', + 'name', + 'smiles', + 'target', + 'compound_id', + 'compound', + 'path', + 'mol', + 'protonated_mol', + 'protein_system', + 'complex_system', + 'has_complex_pdb_path', + 'metadata', + 'has_fingerprint', + 'tags', + 'features', + 'dict', + 'table', + 'num_heavy_atoms', + 'num_scaffolds', + 'scaffold_ids', + 'interactions', + 'classic_fingerprint', + 'mol_path', + 'apo_path', ] PROPERTIES = [ - "reference", - "reference_id", - "inspirations", - "derivatives", - "num_atoms_added", - "num_atoms_added_wrt_scaffolds", - "num_atoms_added_wrt_inspirations", - "energy_score", - "distance_score", - "inspiration_score", - "subsites", + 'reference', + 'reference_id', + 'inspirations', + 'derivatives', + 'num_atoms_added', + 'num_atoms_added_wrt_scaffolds', + 'num_atoms_added_wrt_inspirations', + 'energy_score', + 'distance_score', + 'inspiration_score', + 'subsites', ] @@ -50,13 +50,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) pose = animal.P1 for prop in NOT_NULL_PROPERTIES: value = getattr(pose, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(pose, prop) @@ -64,5 +64,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_subsite.py b/tests/test_subsite.py index c1a7071..e05a6bb 100644 --- a/tests/test_subsite.py +++ b/tests/test_subsite.py @@ -1,14 +1,14 @@ from config import * NOT_NULL_PROPERTIES = [ - "db", - "id", - "table", - "target", - "target_id", - "name", - "metadata", - "poses", + 'db', + 'id', + 'table', + 'target', + 'target_id', + 'name', + 'metadata', + 'poses', ] PROPERTIES = [] @@ -18,13 +18,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) subsite = animal.S1 for prop in NOT_NULL_PROPERTIES: value = getattr(subsite, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(subsite, prop) @@ -33,5 +33,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/tests/test_tags.py b/tests/test_tags.py index 0b991fc..4195d3e 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -1,7 +1,7 @@ from config import * NOT_NULL_PROPERTIES = [ - "unique", + 'unique', ] PROPERTIES = [] @@ -11,13 +11,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) tag_table = animal.tags for prop in NOT_NULL_PROPERTIES: value = getattr(tag_table, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(tag_table, prop) @@ -30,11 +30,11 @@ def test_summary(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) tag_table = animal.tags tag_table.summary() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() test_summary() diff --git a/tests/test_target.py b/tests/test_target.py index 9321770..fef8d0b 100644 --- a/tests/test_target.py +++ b/tests/test_target.py @@ -1,11 +1,11 @@ from config import * NOT_NULL_PROPERTIES = [ - "id", - "name", - "feature_ids", - "features", - "subsites", + 'id', + 'name', + 'feature_ids', + 'features', + 'subsites', ] PROPERTIES = [] @@ -15,13 +15,13 @@ def test_properties(): import hippo - animal = hippo.HIPPO("test", DB) + animal = hippo.HIPPO('test', DB) target = animal.T1 for prop in NOT_NULL_PROPERTIES: value = getattr(target, prop) print(prop, value) - assert value is not None, f"{prop} is None" + assert value is not None, f'{prop} is None' for prop in PROPERTIES: value = getattr(target, prop) @@ -30,5 +30,5 @@ def test_properties(): animal.db.close() -if __name__ == "__main__": +if __name__ == '__main__': test_properties() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..648e59c --- /dev/null +++ b/uv.lock @@ -0,0 +1,3871 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "apsw" +version = "3.52.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/c9/7435800e496f12e2b7b45525a87be1aa0cc66e4adaa634d970d034e09ca4/apsw-3.52.0.0.tar.gz", hash = "sha256:2244ba3a341f4278bb579c8a918ef926683c3569e4faa07608346ea1f61f35b4", size = 1230500, upload-time = "2026-03-09T18:31:25.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/17/058f41256b046e3bdfd6c50508b611b3bf0719c9612902990856e0613132/apsw-3.52.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad48710e21095eca756bede03bb654fbe6bdca46172f07d456f297510cca5f7", size = 3654675, upload-time = "2026-03-09T18:28:17.158Z" }, + { url = "https://files.pythonhosted.org/packages/7f/32/e0354373ddcce7955774287739070192ebbc62980a950c13543b517a595e/apsw-3.52.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:97b5c1ecd0e81c504d65b98781291c7a1783a876dd04512d7de2d8bfe039525f", size = 3460901, upload-time = "2026-03-09T18:28:19.151Z" }, + { url = "https://files.pythonhosted.org/packages/fa/03/9f07bf9f90eb3ea0b9ce959e0508d32d4ea21fd82a2a8b18f109ad9253ac/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:91beeaf8cf58f52d94488322c57841246018cdcf9c35cf19d9c11de74456c5ec", size = 12250587, upload-time = "2026-03-09T18:28:21.557Z" }, + { url = "https://files.pythonhosted.org/packages/b5/34/b9b2daecea6627a9b1738cf543922780862a0b220a2b591628f238b33142/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d169caa3639f2e77cd61d1f18cce695d81f1560fbc26372d73eb5ef689e60869", size = 11552784, upload-time = "2026-03-09T18:28:24.507Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5f/6d16a02f2f0b52e507d37d97ac70aeaaafae12e26c86359935cd527441de/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:e440f9b11255a8ec9bb88ce7805f324e9605f87b6aaaf168f39571744f82641d", size = 12224819, upload-time = "2026-03-09T18:28:26.834Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ca8b7f557e531b7c8afe0185e7e07d56c55aa83042f8d604ee3700d71c1f/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e9c8238f0ca227dfa4b8498c0bcf67384ec780a81c9ba4064b12e9a4cfa4264d", size = 12393927, upload-time = "2026-03-09T18:28:29.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/a7/bdc6374e9c9b5e97eb61a79a2f3a7a6ff471083570569fe88e88292e59cd/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5301b3ddef13981cd251c0dd57af6f13b2147b1b4c1985615ff4259512b66705", size = 12458777, upload-time = "2026-03-09T18:28:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/eb/67/af6d634c8dc36697cd3441d7c95697cf7e0d92db412f3840c32d99ed8472/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:55c0cd753fc20a4c5287ae19657b7f41750d177fd3d81cd8340c6b7747636db7", size = 12131406, upload-time = "2026-03-09T18:28:35.019Z" }, + { url = "https://files.pythonhosted.org/packages/55/39/8cd1fd89b90fe20d18304fa41edc8bb3d975256a17150e9fd3d28a20222f/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:144546b6cde6fc6c22187fb1c6f22c941f74fe91a365c36ada3eece7c2dfa01f", size = 12697413, upload-time = "2026-03-09T18:28:37.543Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bf/557bf98c69e1537c53547bf99f46c2edd767b5be29609e83744ac6ecd0e5/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:957436d0b8b371683d1fe55f241aa7b16d39d13ac7769f84e7210a9401a2226e", size = 12700466, upload-time = "2026-03-09T18:28:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/782b7f7f349ca31ae5f57fbbfd4cd8995a4f2ddb00575d0903eae99a56df/apsw-3.52.0.0-cp310-cp310-win32.whl", hash = "sha256:b22de74249725b820ceee24852eefa6fba08e9b99f5254c68ba3f0407fae021d", size = 3104358, upload-time = "2026-03-09T18:28:42.551Z" }, + { url = "https://files.pythonhosted.org/packages/f9/12/3535c53b967d9c88c717294b12d32c567dd20ec852829531fd671907813e/apsw-3.52.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:5273ae9f882101021097494daa00dd1a760d86cc88d47d9881ec88b80bfdaca9", size = 3528965, upload-time = "2026-03-09T18:28:44.583Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/63f916a6853e3ee6f241195cb4d01acb6717729b64c3810acd197f93c827/apsw-3.52.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:bcf323955572061446eb87f6382493dfb073d2b2b1bdcfc4c4d791b3bb0d1f80", size = 3088639, upload-time = "2026-03-09T18:28:46.504Z" }, + { url = "https://files.pythonhosted.org/packages/02/d0/3af38ba8bdaad1aae578825b367f52cf5b4415d6f6a5321c8de577c4533c/apsw-3.52.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f75bcfe25c2766cea56ac00b77a61753dcaeaf6a3b6b6a08596cd39a0cea9c82", size = 3659947, upload-time = "2026-03-09T18:28:48.104Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/4333dbd238f01c9e541fae0e73846585bc93f496fc89e0cb052817951d6e/apsw-3.52.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:10dd7128205613e43f42b850d64e7f1a8c396ddc746f40b856caa1b1e298c437", size = 3467435, upload-time = "2026-03-09T18:28:49.866Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/1ebff106af61ec06953d3cd621d8447cf3f5de80947dff14b2a13a9faa74/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:52d5504e838b3b14cb580c82c2f2dff97c2f967ff66b1c9b318f56bb80d283fa", size = 12464127, upload-time = "2026-03-09T18:28:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/bc/a8/8c41532975de9cfba86b8d5919ea79ce5663e222d8f1b8f9e68e5d598f06/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bbc978ca3062fc1b9776227a17256182e3d9e4d9490d5435754b9f4d91035e87", size = 11751497, upload-time = "2026-03-09T18:28:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/3a/83/61a2fa7bba9a4c621938ffbb4165cc6ae6312747181bfae46b8de858a991/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:4b0624502b4b2564bd787de6e584d2bb271d1bf4fb3f53f9e55eac713bbbc7c9", size = 12372950, upload-time = "2026-03-09T18:28:58.071Z" }, + { url = "https://files.pythonhosted.org/packages/60/f0/460162f8486231c3f253622f6c4567551059a08d8508e14930fb2530b861/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bbb812364085073172bbf9d977b5e0a917c6292f03859374db5577429772f8b2", size = 12556201, upload-time = "2026-03-09T18:29:00.664Z" }, + { url = "https://files.pythonhosted.org/packages/18/03/2a96bed534b5620807bfb10df955de091fc090a0e17459ab60f25d8684b5/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:019c7bd7d140dff8e02ba07bb06541ddf31dce7465e889ea09f8579d9d7e09b9", size = 12657976, upload-time = "2026-03-09T18:29:03.146Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/046153fa2481113a9c5b0f61c25192269207ed7c7adffcf8edd6c168e85e/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa10d38e9e7732b439483f23da1ad5ee4721808d9b2fead574f4a670266ced20", size = 12324781, upload-time = "2026-03-09T18:29:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/50/db/a981360071398cede27a452209b44d663e242726f98535022f1c7c02dc96/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:44a9a277a4777f30546a525d82b956a4c4617484bdb6347bec621ee155dcf9af", size = 12847104, upload-time = "2026-03-09T18:29:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7d/4f501c820b3b8418f8cffee1332204a390207850737b6964f9916512b8e7/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c754b188ace178a163453f49a749069647b56a79873b608bca1dfdf1a680bdd", size = 12855448, upload-time = "2026-03-09T18:29:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/e8/07/5afdb8025e4f88ee3c2998467fa8a7fac8cee4718ad88b6d9c0c9181fabe/apsw-3.52.0.0-cp311-cp311-win32.whl", hash = "sha256:824e502a34fcb5cbf5f23586a5474dc0cfdd35dd0070e1aa42e2e857e7c10aed", size = 3099025, upload-time = "2026-03-09T18:29:14.086Z" }, + { url = "https://files.pythonhosted.org/packages/28/68/e84a6e721f0252a72a8ef40058c3dfb9d25aa15e91ac40ad19db61806e63/apsw-3.52.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:db497d7b325695f04b1cabac2cfec6abdf72ca86b693bc09ec44a316f49bb375", size = 3528161, upload-time = "2026-03-09T18:29:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ac/996b71426d2442b5ee57363055e7d7408ccc00b407a506c5a3c912153095/apsw-3.52.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:9c72b0408047d09129d74cad4cf4be28d3d18e31aae955a3173b8b128ef21d95", size = 3088591, upload-time = "2026-03-09T18:29:18.067Z" }, + { url = "https://files.pythonhosted.org/packages/32/92/27fa38cf5f6892169a02ccce0f1c985e3bfaec555760d5b6e9675cc4176d/apsw-3.52.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5e3403d1cd586c0c4b20604d3d0111c70551fbc1f12a5a5f613971903be6f5f", size = 3658693, upload-time = "2026-03-09T18:29:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5a/8def063527c2f400e7559f5020d7ef6b58b11887f29043d7c12ff7e957ac/apsw-3.52.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a7cdc27f717f2056964950d8a14a1ee36c4c1da59aebfb9e88c5a5223199dec", size = 3467021, upload-time = "2026-03-09T18:29:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/02/64/b75e4e5eeca78ec8d646eac7ab30aca25dcb50c76acceb4e2ce8ea7df2ad/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:87fc1c98a4c884eff54e9cb34c2efad42ce0d797d127b2d10c09bd8874f4500b", size = 12451880, upload-time = "2026-03-09T18:29:23.763Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bb/3aa093ba70eaf00cfd40658472485f92118b5ff76065f5e168aee3cf11cb/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d935a826afd089c4c11e8fd3b61aed1b78a78b5ace9fa3956097c0a2b0bbd8f6", size = 11737316, upload-time = "2026-03-09T18:29:26.172Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/a95b135824ab779668ebf2936af23ec7bc3ed67894b5ace3d7eeb9b5afcd/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:4693db12d33ba1d3a2ed68c06b17421413d946d4385fcce3da455734c735343d", size = 12350147, upload-time = "2026-03-09T18:29:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/5cf6154c7be30cb79d1ef839d07480c6ed4dc074dc9567d62aaca6bb5c14/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8ae28f739c0dc3c067c5e979fce3656820f48a1e2a770a33eb0c3184910ec717", size = 12540380, upload-time = "2026-03-09T18:29:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/0b/04/4d057e928f6b978b76970f3725e00b3c01c997190248845be11cfd2e7227/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3213024b1e3555e1ebec779edd08edef57dad23f49c6e1d95774e47bc0045be", size = 12647874, upload-time = "2026-03-09T18:29:34.109Z" }, + { url = "https://files.pythonhosted.org/packages/c5/97/61254cee874af7a03babdf10f8669a94ee7c2e8e8acb2a62a60097a389e5/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9ceebe709c21bf4917654d7216637b9af0065b4b494eb15fc67e8b7d014890cc", size = 12329765, upload-time = "2026-03-09T18:29:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/b8/02/b26f7aa597634c96c3a7d580fbf5b9722ffd966aa11bad7000f0775acac5/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1c19c5524eb473078371574e4745af2be5738292871e24225c90ad0783095730", size = 12820199, upload-time = "2026-03-09T18:29:39.504Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b9/74511e2a1ffd894533e56a3453d636d851bd8e92a83937ac0a84af624d9c/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:313e7670de146aa65e6de9a86fe571661da4bf49b1db20f7c3f0cd8bf8cdf115", size = 12843160, upload-time = "2026-03-09T18:29:42.948Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/4fb99804fd64e03fdb919bf4a8dc505edbece2cd5b53a9be77d87e934b17/apsw-3.52.0.0-cp312-cp312-win32.whl", hash = "sha256:9519559366bb737bcba0795db4307da4931406b4b68829f27f7fbeb821142e5c", size = 3098702, upload-time = "2026-03-09T18:29:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e0/6377ca5bd8b9b9aa4785fa4ebfa7bd3065ab518064ab425a0463cd40c5dd/apsw-3.52.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:63c4fc72f79e707a64d1cba06983e6548ddd806e210f804cdc7fdb62a0fb7b02", size = 3525558, upload-time = "2026-03-09T18:29:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/c6/91/28ecad6169fdd268e46e4c803277300d155c1a984fcb8d0457a11f4f4bc1/apsw-3.52.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:277ac389768f1fc301c1566c9eeb2426b9116705339f552f14affa763e90b58f", size = 3088702, upload-time = "2026-03-09T18:29:48.49Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "ase" +version = "3.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/d38b39abd24110deb13bee0dc14404eca1f2113c01bc9bbf075dc3e1c2dd/ase-3.27.0.tar.gz", hash = "sha256:92ada752d6866a61d2d27e0e6a4fd5b8cd86f59ca79a58f1d2fe29d7099153dc", size = 2363050, upload-time = "2025-12-28T15:41:22.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/9b/9b55b4d4855743de61ba91566d03b2560285ed8fc0387b9cf914795d4abf/ase-3.27.0-py3-none-any.whl", hash = "sha256:058c48ea504fe7fbbe7c932f778415243ef2df45b1ab869866f24efcc17f0538", size = 2885170, upload-time = "2025-12-28T15:41:20.257Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "async-lru" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/8a/ca724066c32a53fa75f59e0f21aa822fdaa8a0dffa112d223634e3caabf9/async_lru-2.2.0.tar.gz", hash = "sha256:80abae2a237dbc6c60861d621619af39f0d920aea306de34cb992c879e01370c", size = 14654, upload-time = "2026-02-20T19:11:43.848Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/5c/af990f019b8dd11c5492a6371fe74a5b0276357370030b67254a87329944/async_lru-2.2.0-py3-none-any.whl", hash = "sha256:e2c1cf731eba202b59c5feedaef14ffd9d02ad0037fcda64938699f2c380eafe", size = 7890, upload-time = "2026-02-20T19:11:42.273Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "biopython" +version = "1.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/61/c59a849bd457c8a1b408ae828dbcc15e674962b5a29705e869e15b32bf25/biopython-1.86.tar.gz", hash = "sha256:93a50b586a4d2cec68ab2f99d03ef583c5761d8fba5535cb8e81da781d0d92ff", size = 19835323, upload-time = "2025-10-28T21:18:31.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/9f/5c95732ad98a6d40f4be58978be801cc87b50c71d79a7aee46c4a085114d/biopython-1.86-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02aef2e31cc92544f574ff837cabaaaf53733f3a6b5a433f781c59e5424a7576", size = 2691935, upload-time = "2025-10-28T21:27:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/ca/15/9902cbc901073ba2de397f5c9c84e72147e02aaca1755fa650d26bb715a2/biopython-1.86-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e1b12819a78242b529f54e5d2d00ad90023710a5846ca0f2011ac989fd17d4b", size = 2669420, upload-time = "2025-10-28T21:26:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5d/9cb775106361f8ef7ab459b89ff6d725e81dae7abd382309d3bbf82ced6d/biopython-1.86-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e62504faac6e62fe26e40d6905a69519d8b7b5b0506a426d641b218fde788b5", size = 3183554, upload-time = "2025-10-29T00:35:24.074Z" }, + { url = "https://files.pythonhosted.org/packages/08/47/87c8db55746099b38baf6c597688807bad2e59074cec37169168fe98e69e/biopython-1.86-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4530060aadc6af060a9a049da91a582738837e187fcea80486c71eca74ae59", size = 3205260, upload-time = "2025-10-29T00:35:33.89Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/7822f6b7521073ccc80eb18736b664efe1d59edeb6a3f72c362f542ec352/biopython-1.86-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:186e2065c0d1a6c2afc85b9c21a2911a931949668bd73b4c03f429a31b3589f8", size = 3154783, upload-time = "2025-10-28T23:52:52.157Z" }, + { url = "https://files.pythonhosted.org/packages/98/7a/6759f99b481432969a4979f03b4c4966716d4cffd2154749ae5af0d8904a/biopython-1.86-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6915a09859159598b9421e7240561692e7bb4084e5340c4dbb2435c5c38805a2", size = 3173920, upload-time = "2025-10-28T23:52:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/17/c2/fe2a223b3fdd718099055679b68f9f15b2006d83a5a1c1593246d5e5fe81/biopython-1.86-cp310-cp310-win32.whl", hash = "sha256:be3d83152fe3232e2d197896a506902b84ad60d40b3f1d1fc934914d138c6dc1", size = 2697949, upload-time = "2025-10-28T21:32:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/1f/11/44fb3975df1070966ffc213f52e9fff63fb48b43bc3d403584ca3f4f9a42/biopython-1.86-cp310-cp310-win_amd64.whl", hash = "sha256:6fbbfe19e12170754adb9632155b7e3be0d4c247f0a2e09d3917bec859282de1", size = 2734235, upload-time = "2025-10-28T21:32:03.014Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f5/37d6bb3a1245ec5f5f1c66d5cd790b06cdb54a75b36849893405c17f3612/biopython-1.86-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba88b0754ad53c93eba11d910364cfc773686933c89a886522309ba903151e50", size = 2691944, upload-time = "2025-10-28T21:27:24.053Z" }, + { url = "https://files.pythonhosted.org/packages/14/12/44d71f333b7302b30788df80705f2207c47b54c17d0935a378dfc709507d/biopython-1.86-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6cceb32b9036bbdc59962e31bd1605ece24edc226c0d50f99839948b5b5c9dda", size = 2669434, upload-time = "2025-10-28T21:26:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1b/731060090ed29b5ac2484865255f1f363a50afb7275717ceb2c6f20d3ea4/biopython-1.86-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0f040ff85bd7d0ee06574bc6d032bc666802f2fe781b0c316b936237eb3d17e", size = 3196718, upload-time = "2025-10-29T00:35:47.806Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8d/8409535c341061b9c78faf151e73b484b456b3c3bdf59b27cf3984f16fbc/biopython-1.86-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ac858fd71f1093380d8b0a16acf060e7c228ad65f9ecacdb9f5760cfb9f59b1", size = 3218383, upload-time = "2025-10-29T00:35:53.523Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bc/5e93a11f70732122679747a728509d03a6a066b178cc1d7ca30ed2f1ebee/biopython-1.86-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da4bcf5a48ee647624e2d0bedac7fb1c24ef0facd514519cca074593b8a6a40e", size = 3168368, upload-time = "2025-10-28T23:53:16.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/e187940571a3a24d20f407f1d7514ab1fe0dc9fa49e01790c4bd56ced0bc/biopython-1.86-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d4dd9090caaf364a08ab54cd561f37c5f4ea5bcc8f0189d332dcd36d6df5767", size = 3186451, upload-time = "2025-10-28T23:53:22.463Z" }, + { url = "https://files.pythonhosted.org/packages/2a/88/1e8ffb0db6a03888768613d682a79043e9975067b9095e644a6872905c88/biopython-1.86-cp311-cp311-win32.whl", hash = "sha256:90591f4554c09d311193e7774b5143442c67e178a5b7d929aaa2a054048b22a7", size = 2697756, upload-time = "2025-10-28T21:32:23.017Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b2/e34e45d6cb46c96486a2ed5f07874b6c9493dec68b9d6262ae05f4fe909b/biopython-1.86-cp311-cp311-win_amd64.whl", hash = "sha256:0a95321ca929c04c934e62252c9e2cc5c4fd13ce575798d98af2d79512334b9b", size = 2733781, upload-time = "2025-10-28T21:32:18.409Z" }, + { url = "https://files.pythonhosted.org/packages/98/e2/199b8ccbd4b9bf234157db0668177b5b7784d62f29d9096fd0d3a70e3b86/biopython-1.86-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f8d372aae21d79b11613751c6ae23c88db0e94d25b7567b1f67aa0304fb61667", size = 2693171, upload-time = "2025-10-29T00:26:59.028Z" }, + { url = "https://files.pythonhosted.org/packages/d8/2f/1a7da2a55212b3d0a03866d22213f91273fee3722b5364575419fbe574a5/biopython-1.86-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:baf19d9237aaaa387a68f8f055f978af5c80338d7e037ab028e8d768928f1250", size = 2692543, upload-time = "2025-10-28T21:27:31.855Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e9/4057d4c2aa22ca25c180ecbed2ce9e7d65bf787999778bc63b41df0d03b5/biopython-1.86-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04f9abdf6cbf0087850de5f8148da0d420c4cb87905bf4de3145ad24a8d55dcd", size = 2669975, upload-time = "2025-10-28T21:26:54.181Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/3e6862720d7c51f0fbe7d6d25be72a95486779d9d98122283b4e8032fb40/biopython-1.86-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:187c3c24dd2255e7328f3e0523ab5d6350b73ff562517de0c1922385617101d2", size = 3209367, upload-time = "2025-10-29T00:36:06.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cb/61877367bf08670573d62513b239dc65cf2b7488dc74322cc6051da2e55e/biopython-1.86-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1859830b8262785c6b59dfe0c82cddb643974f63b9d2779bb9f3e2c47c0a95da", size = 3235466, upload-time = "2025-10-29T00:36:11.516Z" }, + { url = "https://files.pythonhosted.org/packages/84/1a/3182a77776b76f3f5c64825ee1acf9355f665bed72ee9e8ff49e48f25d98/biopython-1.86-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfd906c47b6fb38e3abb9f52e0c06822e6e82a043d38c2000773692c29db1ed8", size = 3178776, upload-time = "2025-10-28T23:53:41.487Z" }, + { url = "https://files.pythonhosted.org/packages/1a/22/828b08fac8dbc8c1dbc1ad03815137cebc9c78303ec7d21b568544028119/biopython-1.86-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a6ab2c60742f1c8494cfbbe3b7a8b45f0400c8f2b36b686b895d5e4d625f04e", size = 3197586, upload-time = "2025-10-28T23:53:47.136Z" }, + { url = "https://files.pythonhosted.org/packages/36/7a/122aea7653fa93d7eb72978928e80759082efffa70afe0c25a17e18521da/biopython-1.86-cp312-cp312-win32.whl", hash = "sha256:192c61bc3d782c171b7d50bb7d8189d84790d6e3c4b24fd41d1d7ffc7d303efe", size = 2698043, upload-time = "2025-10-28T21:32:39.452Z" }, + { url = "https://files.pythonhosted.org/packages/a9/13/00db03b01e54070d5b0ec9c71eef86e61afa733d9af76e5b9b09f5dc9165/biopython-1.86-cp312-cp312-win_amd64.whl", hash = "sha256:35a6b9c5dcdfb5c2631a313a007f3f41a7d72573ba2b68c962e10ea92096ff3b", size = 2733610, upload-time = "2025-10-28T21:32:34.99Z" }, +] + +[[package]] +name = "bleach" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "chardet" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/80/4684035f1a2a3096506bc377276a815ccf0be3c3316eab35d589e82d9f3c/chardet-7.0.1.tar.gz", hash = "sha256:6fce895c12c5495bb598e59ae3cd89306969b4464ec7b6dd609b9c86e3397fe3", size = 490240, upload-time = "2026-03-04T21:25:26.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/97/07c01ad079ede646f241fe34de7686f2385e0deae4feb36ca2041a9ed059/chardet-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8a8d87853c7f191029933307094a8896b087c2c436703281cb289a22aa4ae8bd", size = 542016, upload-time = "2026-03-04T21:24:41.685Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/5f9afb10c47852de7bd2399e25dd72fe3884b16b79a195c230e9e4affd4f/chardet-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fb14755377d8de845c69378bbaedc0e35109c21a43824450524fd9c3178792d5", size = 535149, upload-time = "2026-03-04T21:24:43.585Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e4/47a9306a1c5757e86309f558d0e206d71842efb7b5109ab8e5991a63e926/chardet-7.0.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4af34cf0652a9da44720540c97f11e30781a77900c89547b311984a7272b33f7", size = 554683, upload-time = "2026-03-04T21:24:45.376Z" }, + { url = "https://files.pythonhosted.org/packages/23/b3/7494df94d362bc5602fdb7bd3df20afc9d6005c6e781030c1415c40e812f/chardet-7.0.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54e448fab0c11b27bb908ea0218e2094578c583d05faa5f65b91fa6ccfa45570", size = 557300, upload-time = "2026-03-04T21:24:47.126Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bb/388f15997240ea245087e66a258ed301247f84cd34328dd8f73a6bba9184/chardet-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:69708a504a43464b60ea16d031250b58206969c9bbd6851266e2f39afef53168", size = 524154, upload-time = "2026-03-04T21:24:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/00/fb/a90b4510aa9080966c65321db2084bcfa184518ee1ed15570d351649ecb2/chardet-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c3f59dc3e148b54813ec5c7b4b2e025d37f5dc221ee28a06d1a62f169cfaedf5", size = 540100, upload-time = "2026-03-04T21:24:50.883Z" }, + { url = "https://files.pythonhosted.org/packages/24/fa/3ad0b454a55376b7971fe64c2f225dfe56a491d8d8728fbfba63f8ff416d/chardet-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3355a3c8453d673e7c1664fdd24a0c6ef39964c3d41befc4849250f7eb1de3b5", size = 533202, upload-time = "2026-03-04T21:24:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/ad/53/a57a8a6be34379e55c8bdbf2b988c145d3b7675577bd152e73bff7c4ba3c/chardet-7.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5333f9967863ea7d8642df0e00cf4d33e8ed7e99fe7b6464b40ba969a2808544", size = 552994, upload-time = "2026-03-04T21:24:53.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9f/3d4ba1650e3eb3e7431a054e3bf1b5eaea25b84c72afabf5ef6fc33305d1/chardet-7.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:265cb3b5dafc0411c0949800a0692f07e986fb663b6ae1ecfba32ad193a55a03", size = 555605, upload-time = "2026-03-04T21:24:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/73/64/9c5c450ba18359a8e8ab2943e6c3a0b100bd394799bc73a844e3c5cd9c7c/chardet-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:26186f0ea03c4c1f9be20c088b127c71b0e9d487676930fab77625ddec2a4ef2", size = 524098, upload-time = "2026-03-04T21:24:57.3Z" }, + { url = "https://files.pythonhosted.org/packages/f6/88/4c6fe7dcd5d36a2cfd7030084fbd79264083f329faaf96038c23888a8e05/chardet-7.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f661edbfa77b8683a503043ddc9b9fe9036cf28af13064200e11fa1844ded79c", size = 541828, upload-time = "2026-03-04T21:24:58.726Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fb/3b92a2433eadef83ae131fa720a17857cfbf7687c5f188bfb2f9eee2d3dd/chardet-7.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:169951fa88d449e72e0c6194cec1c5e405fd36a6cfbe74c7dab5494cc35f1700", size = 533571, upload-time = "2026-03-04T21:25:00.703Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/37bee6900183ea08a3a0ae04b9f018f9e64c6b10716e1f7b423db0c4356c/chardet-7.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd6db7505556ae8f9e2a3bf6d689c2b86aa6b459cf39552645d2c4d3fdbf489c", size = 554182, upload-time = "2026-03-04T21:25:02.168Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f907962b18df78d5ca87a7484e4034354408d2c97cec6f53634b0ea0424c594", size = 557933, upload-time = "2026-03-04T21:25:03.694Z" }, + { url = "https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:302798e1e62008ca34a216dd04ecc5e240993b2090628e2a35d4c0754313ea9a", size = 524256, upload-time = "2026-03-04T21:25:05.581Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1f/c1a089db6333b1283409cad3714b8935e7e56722c9c60f9299726a1e57c2/chardet-7.0.1-py3-none-any.whl", hash = "sha256:e51e1ff2c51b2d622d97c9737bd5ee9d9b9038f05b7dd8f9ea10b9e2d9674c24", size = 408292, upload-time = "2026-03-04T21:25:25.214Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/21/a2b1505639008ba2e6ef03733a81fc6cfd6a07ea6139a2b76421230b8dad/charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765", size = 283319, upload-time = "2026-03-06T06:00:26.433Z" }, + { url = "https://files.pythonhosted.org/packages/70/67/df234c29b68f4e1e095885c9db1cb4b69b8aba49cf94fac041db4aaf1267/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990", size = 189974, upload-time = "2026-03-06T06:00:28.222Z" }, + { url = "https://files.pythonhosted.org/packages/df/7f/fc66af802961c6be42e2c7b69c58f95cbd1f39b0e81b3365d8efe2a02a04/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:568e3c34b58422075a1b49575a6abc616d9751b4d61b23f712e12ebb78fe47b2", size = 207866, upload-time = "2026-03-06T06:00:29.769Z" }, + { url = "https://files.pythonhosted.org/packages/c9/23/404eb36fac4e95b833c50e305bba9a241086d427bb2167a42eac7c4f7da4/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:036c079aa08a6a592b82487f97c60b439428320ed1b2ea0b3912e99d30c77765", size = 203239, upload-time = "2026-03-06T06:00:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2f/8a1d989bfadd120c90114ab33e0d2a0cbde05278c1fc15e83e62d570f50a/charset_normalizer-3.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340810d34ef83af92148e96e3e44cb2d3f910d2bf95e5618a5c467d9f102231d", size = 196529, upload-time = "2026-03-06T06:00:32.608Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0c/c75f85ff7ca1f051958bb518cd43922d86f576c03947a050fbedfdfb4f15/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cd2d0f0ec9aa977a27731a3209ebbcacebebaf41f902bd453a928bfd281cf7f8", size = 184152, upload-time = "2026-03-06T06:00:33.93Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/4ed37f6199af5dde94d4aeaf577f3813a5ec6635834cda1d957013a09c76/charset_normalizer-3.4.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b362bcd27819f9c07cbf23db4e0e8cd4b44c5ecd900c2ff907b2b92274a7412", size = 195226, upload-time = "2026-03-06T06:00:35.469Z" }, + { url = "https://files.pythonhosted.org/packages/28/31/7ba1102178cba7c34dcc050f43d427172f389729e356038f0726253dd914/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:77be992288f720306ab4108fe5c74797de327f3248368dfc7e1a916d6ed9e5a2", size = 192933, upload-time = "2026-03-06T06:00:36.83Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/f86443ab3921e6a60b33b93f4a1161222231f6c69bc24fb18f3bee7b8518/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:8b78d8a609a4b82c273257ee9d631ded7fac0d875bdcdccc109f3ee8328cfcb1", size = 185647, upload-time = "2026-03-06T06:00:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/08b8be891760f1f5a6d23ce11d6d50c92981603e6eb740b4f72eea9424e2/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ba20bdf69bd127f66d0174d6f2a93e69045e0b4036dc1ca78e091bcc765830c4", size = 209533, upload-time = "2026-03-06T06:00:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/df114f23406199f8af711ddccfbf409ffbc5b7cdc18fa19644997ff0c9bb/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:76a9d0de4d0eab387822e7b35d8f89367dd237c72e82ab42b9f7bf5e15ada00f", size = 195901, upload-time = "2026-03-06T06:00:43.978Z" }, + { url = "https://files.pythonhosted.org/packages/07/83/71ef34a76fe8aa05ff8f840244bda2d61e043c2ef6f30d200450b9f6a1be/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8fff79bf5978c693c9b1a4d71e4a94fddfb5fe744eb062a318e15f4a2f63a550", size = 204950, upload-time = "2026-03-06T06:00:45.202Z" }, + { url = "https://files.pythonhosted.org/packages/58/40/0253be623995365137d7dc68e45245036207ab2227251e69a3d93ce43183/charset_normalizer-3.4.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c7e84e0c0005e3bdc1a9211cd4e62c78ba80bc37b2365ef4410cd2007a9047f2", size = 198546, upload-time = "2026-03-06T06:00:46.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5c/5f3cb5b259a130895ef5ae16b38eaf141430fa3f7af50cd06c5d67e4f7b2/charset_normalizer-3.4.5-cp310-cp310-win32.whl", hash = "sha256:58ad8270cfa5d4bef1bc85bd387217e14ff154d6630e976c6f56f9a040757475", size = 132516, upload-time = "2026-03-06T06:00:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/a5/c3/84fb174e7770f2df2e1a2115090771bfbc2227fb39a765c6d00568d1aab4/charset_normalizer-3.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:02a9d1b01c1e12c27883b0c9349e0bcd9ae92e727ff1a277207e1a262b1cbf05", size = 142906, upload-time = "2026-03-06T06:00:49.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b2/6f852f8b969f2cbd0d4092d2e60139ab1af95af9bb651337cae89ec0f684/charset_normalizer-3.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:039215608ac7b358c4da0191d10fc76868567fbf276d54c14721bdedeb6de064", size = 133258, upload-time = "2026-03-06T06:00:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "choreographer" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "logistro" }, + { name = "simplejson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399, upload-time = "2025-11-09T23:04:44.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338, upload-time = "2025-11-09T23:04:43.154Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "commitizen" +version = "4.13.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/44/10f95e8178ab5a584298726a4a94ceb83a7f77e00741fec4680df05fedd5/commitizen-4.13.9.tar.gz", hash = "sha256:2b4567ed50555e10920e5bd804a6a4e2c42ec70bb74f14a83f2680fe9eaf9727", size = 64145, upload-time = "2026-02-25T02:40:05.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/22/9b14ee0f17f0aad219a2fb37a293a57b8324d9d195c6ef6807bcd0bf2055/commitizen-4.13.9-py3-none-any.whl", hash = "sha256:d2af3d6a83cacec9d5200e17768942c5de6266f93d932c955986c60c4285f2db", size = 85373, upload-time = "2026-02-25T02:40:03.83Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "dash" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/dd/3aed9bfd81dfd8f44b3a5db0583080ac9470d5e92ee134982bd5c69e286e/dash-4.0.0.tar.gz", hash = "sha256:c5f2bca497af288f552aea3ae208f6a0cca472559003dac84ac21187a1c3a142", size = 6943263, upload-time = "2026-02-03T19:42:27.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8c/dd63d210b28a7589f4bc1e84880525368147425c717d12834ab562f52d14/dash-4.0.0-py3-none-any.whl", hash = "sha256:e36b4b4eae9e1fa4136bf4f1450ed14ef76063bc5da0b10f8ab07bd57a7cb1ab", size = 7247521, upload-time = "2026-02-03T19:42:25.01Z" }, +] + +[[package]] +name = "dash-dangerously-set-inner-html" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/b1/5102060b9b6836409db84265f4f934475c2707cce87e75f3f8a04493e0dc/dash_dangerously_set_inner_html-0.0.2.tar.gz", hash = "sha256:d7fe990755851fc4d2e22c8f10b7aea055cabf380bbceefba589779b269fea64", size = 4956, upload-time = "2018-12-19T21:12:47.258Z" } + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/be/8bd693a0b9d53d48c8978fa5d889e06f3b5b03e45fd1ea1e78267b4887cb/debugpy-1.8.20-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:157e96ffb7f80b3ad36d808646198c90acb46fdcfd8bb1999838f0b6f2b59c64", size = 2099192, upload-time = "2026-01-29T23:03:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/77/1b/85326d07432086a06361d493d2743edd0c4fc2ef62162be7f8618441ac37/debugpy-1.8.20-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:c1178ae571aff42e61801a38b007af504ec8e05fde1c5c12e5a7efef21009642", size = 3088568, upload-time = "2026-01-29T23:03:31.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/60/3e08462ee3eccd10998853eb35947c416e446bfe2bc37dbb886b9044586c/debugpy-1.8.20-cp310-cp310-win32.whl", hash = "sha256:c29dd9d656c0fbd77906a6e6a82ae4881514aa3294b94c903ff99303e789b4a2", size = 5284399, upload-time = "2026-01-29T23:03:33.678Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/09d49106e770fe558ced5e80df2e3c2ebee10e576eda155dcc5670473663/debugpy-1.8.20-cp310-cp310-win_amd64.whl", hash = "sha256:3ca85463f63b5dd0aa7aaa933d97cbc47c174896dcae8431695872969f981893", size = 5316388, upload-time = "2026-01-29T23:03:35.095Z" }, + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "django" +version = "5.2.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version < '3.12'" }, + { name = "sqlparse", marker = "python_full_version < '3.12'" }, + { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, +] + +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "asgiref", marker = "python_full_version >= '3.12'" }, + { name = "sqlparse", marker = "python_full_version >= '3.12'" }, + { name = "tzdata", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8b/4c32ecde6bea6486a2a5d05340e695174351ff6b06cf651a74c005f9df00/filelock-3.25.1.tar.gz", hash = "sha256:b9a2e977f794ef94d77cdf7d27129ac648a61f585bff3ca24630c1629f701aa9", size = 40319, upload-time = "2026-03-09T19:38:47.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/b8/2f664b56a3b4b32d28d3d106c71783073f712ba43ff6d34b9ea0ce36dc7b/filelock-3.25.1-py3-none-any.whl", hash = "sha256:18972df45473c4aa2c7921b609ee9ca4925910cc3a0fb226c96b92fc224ef7bf", size = 26720, upload-time = "2026-03-09T19:38:45.718Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/96/686339e0fda8142b7ebed39af53f4a5694602a729662f42a6209e3be91d0/fonttools-4.62.0.tar.gz", hash = "sha256:0dc477c12b8076b4eb9af2e440421b0433ffa9e1dcb39e0640a6c94665ed1098", size = 3579521, upload-time = "2026-03-09T16:50:06.217Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/e0/9db48ec7f6b95bae7b20667ded54f18dba8e759ef66232c8683822ae26fc/fonttools-4.62.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:62b6a3d0028e458e9b59501cf7124a84cd69681c433570e4861aff4fb54a236c", size = 2873527, upload-time = "2026-03-09T16:48:12.416Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/86eccfdc922cb9fafc63189a9793fa9f6dd60e68a07be42e454ef2c0deae/fonttools-4.62.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:966557078b55e697f65300b18025c54e872d7908d1899b7314d7c16e64868cb2", size = 2417427, upload-time = "2026-03-09T16:48:15.122Z" }, + { url = "https://files.pythonhosted.org/packages/d3/98/f547a1fceeae81a9a5c6461bde2badac8bf50bda7122a8012b32b1e65396/fonttools-4.62.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf34861145b516cddd19b07ae6f4a61ea1c6326031b960ec9ddce8ee815e888", size = 4934993, upload-time = "2026-03-09T16:48:18.186Z" }, + { url = "https://files.pythonhosted.org/packages/5c/57/a23a051fcff998fdfabdd33c6721b5bad499da08b586d3676993410071f0/fonttools-4.62.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e2ff573de2775508c8a366351fb901c4ced5dc6cf2d87dd15c973bedcdd5216", size = 4892154, upload-time = "2026-03-09T16:48:20.736Z" }, + { url = "https://files.pythonhosted.org/packages/e2/62/e27644b433dc6db1d47bc6028a27d772eec5cc8338e24a9a1fce5d7120aa/fonttools-4.62.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:55b189a1b3033860a38e4e5bd0626c5aa25c7ce9caee7bc784a8caec7a675401", size = 4911635, upload-time = "2026-03-09T16:48:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e2/1bf141911a5616bacfe9cf237c80ccd69d0d92482c38c0f7f6a55d063ad9/fonttools-4.62.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:825f98cd14907c74a4d0a3f7db8570886ffce9c6369fed1385020febf919abf6", size = 5031492, upload-time = "2026-03-09T16:48:25.095Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/790c292f4347ecfa77d9c7e0d1d91e04ab227f6e4a337ed4fe37ca388048/fonttools-4.62.0-cp310-cp310-win32.whl", hash = "sha256:c858030560f92a054444c6e46745227bfd3bb4e55383c80d79462cd47289e4b5", size = 1507656, upload-time = "2026-03-09T16:48:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ee/08c0b7f8bac6e44638de6fe9a3e710a623932f60eccd58912c4d4743516d/fonttools-4.62.0-cp310-cp310-win_amd64.whl", hash = "sha256:9bf75eb69330e34ad2a096fac67887102c8537991eb6cac1507fc835bbb70e0a", size = 1556540, upload-time = "2026-03-09T16:48:30.359Z" }, + { url = "https://files.pythonhosted.org/packages/e4/33/63d79ca41020dd460b51f1e0f58ad1ff0a36b7bcbdf8f3971d52836581e9/fonttools-4.62.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:196cafef9aeec5258425bd31a4e9a414b2ee0d1557bca184d7923d3d3bcd90f9", size = 2870816, upload-time = "2026-03-09T16:48:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7a/9aeec114bc9fc00d757a41f092f7107863d372e684a5b5724c043654477c/fonttools-4.62.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:153afc3012ff8761b1733e8fbe5d98623409774c44ffd88fbcb780e240c11d13", size = 2416127, upload-time = "2026-03-09T16:48:34.627Z" }, + { url = "https://files.pythonhosted.org/packages/5a/71/12cfd8ae0478b7158ffa8850786781f67e73c00fd897ef9d053415c5f88b/fonttools-4.62.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13b663fb197334de84db790353d59da2a7288fd14e9be329f5debc63ec0500a5", size = 5100678, upload-time = "2026-03-09T16:48:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d7/8e4845993ee233c2023d11babe9b3dae7d30333da1d792eeccebcb77baab/fonttools-4.62.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:591220d5333264b1df0d3285adbdfe2af4f6a45bbf9ca2b485f97c9f577c49ff", size = 5070859, upload-time = "2026-03-09T16:48:38.786Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a0/287ae04cd883a52e7bb1d92dfc4997dcffb54173761c751106845fa9e316/fonttools-4.62.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:579f35c121528a50c96bf6fcb6a393e81e7f896d4326bf40e379f1c971603db9", size = 5076689, upload-time = "2026-03-09T16:48:41.886Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4e/a2377ad26c36fcd3e671a1c316ea5ed83107de1588e2d897a98349363bc7/fonttools-4.62.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:44956b003151d5a289eba6c71fe590d63509267c37e26de1766ba15d9c589582", size = 5202053, upload-time = "2026-03-09T16:48:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/2e/ad0472e69b02f83dc88983a9910d122178461606404be5b4838af6d1744a/fonttools-4.62.0-cp311-cp311-win32.whl", hash = "sha256:42c7848fa8836ab92c23b1617c407a905642521ff2d7897fe2bf8381530172f1", size = 2292852, upload-time = "2026-03-09T16:48:46.962Z" }, + { url = "https://files.pythonhosted.org/packages/77/ce/f5a4c42c117f8113ce04048053c128d17426751a508f26398110c993a074/fonttools-4.62.0-cp311-cp311-win_amd64.whl", hash = "sha256:4da779e8f342a32856075ddb193b2a024ad900bc04ecb744014c32409ae871ed", size = 2344367, upload-time = "2026-03-09T16:48:48.818Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9d/7ad1ffc080619f67d0b1e0fa6a0578f0be077404f13fd8e448d1616a94a3/fonttools-4.62.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22bde4dc12a9e09b5ced77f3b5053d96cf10c4976c6ac0dee293418ef289d221", size = 2870004, upload-time = "2026-03-09T16:48:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8b/ba59069a490f61b737e064c3129453dbd28ee38e81d56af0d04d7e6b4de4/fonttools-4.62.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7199c73b326bad892f1cb53ffdd002128bfd58a89b8f662204fbf1daf8d62e85", size = 2414662, upload-time = "2026-03-09T16:48:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8c/c52a4310de58deeac7e9ea800892aec09b00bb3eb0c53265b31ec02be115/fonttools-4.62.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d732938633681d6e2324e601b79e93f7f72395ec8681f9cdae5a8c08bc167e72", size = 5032975, upload-time = "2026-03-09T16:48:55.718Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a1/d16318232964d786907b9b3613b8409f74cf0be2da400854509d3a864e43/fonttools-4.62.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31a804c16d76038cc4e3826e07678efb0a02dc4f15396ea8e07088adbfb2578e", size = 4988544, upload-time = "2026-03-09T16:48:57.715Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8d/7e745ca3e65852adc5e52a83dc213fe1b07d61cb5b394970fcd4b1199d1e/fonttools-4.62.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:090e74ac86e68c20150e665ef8e7e0c20cb9f8b395302c9419fa2e4d332c3b51", size = 4971296, upload-time = "2026-03-09T16:48:59.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" }, + { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" }, + { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "fragmenstein" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "molecular-rectifier" }, + { name = "numpy" }, + { name = "pandarallel" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandera" }, + { name = "pebble" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "rdkit-to-params" }, + { name = "requests" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "singledispatchmethod" }, + { name = "smallworld-api" }, + { name = "sqlitedict" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/04/607e375df3e1c97be61410d66ef318ad1c44eb384c0a7d517f168f307f1a/fragmenstein-1.1.2.tar.gz", hash = "sha256:e8e64e16e374f90c81162d20a6fbb2bd7d32338aa7f2346e42fb1844a8d50885", size = 611303, upload-time = "2026-02-23T22:29:54.742Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/04/058a9a6e8fdfa31d7533b11750b641334b968182d14f1b06dfb29932d9df/fragmenstein-1.1.2-py3-none-any.whl", hash = "sha256:0a7af54fce80806cea256324b6704705e8d8d0a27050083ceabfafa4b39daf23", size = 826074, upload-time = "2026-02-23T22:29:52.326Z" }, +] + +[[package]] +name = "gemmi" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/38/a79d9e672b837ceefa7da1921c33b10479362603c4c6370bc004d69558d6/gemmi-0.7.5.tar.gz", hash = "sha256:3328f26c8a8a0ef6a7fc8bb28e167818e324e4239dd4197d6b6066ae2b6315fe", size = 1523171, upload-time = "2026-03-02T08:27:31.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/4a/85b0a6eb5b885f9304eb33e6998a3c3ae5b5f0387eeb22272c3b7d1824b4/gemmi-0.7.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:dfa43fa2f02aada6427ebe4cba0665a5884a8a97fcf41f799bd3ea5fae2af241", size = 2834338, upload-time = "2026-03-02T08:31:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/a1/c8/79d9ae367b4a101ace15466c5d5454f8e4ae92c286ebc827ac6aef2a4e48/gemmi-0.7.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1b0a66dc42b898cf81c3951ebb7f9c4d96a12f80a7b166032d4b47bce01e9cd", size = 2716758, upload-time = "2026-03-02T08:31:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/fd/fd/2388f03cb11558ec7b459f6ee4ab415a65fbdbeccff40115252f06ba86fb/gemmi-0.7.5-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:336074d483f5a7945eff61db18ed1a10475104367b99936dc52eec66551d3cdf", size = 2637256, upload-time = "2026-03-02T08:31:08.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/b77a22012e8198327648b5502cb204392b9da945abfe0e1b73d7d955c7b9/gemmi-0.7.5-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37d7cfd00a6d717baaba72bb6c1cd392d4f2a01913261e17bbbc6c394a0f031d", size = 2991344, upload-time = "2026-03-02T08:31:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/52/93/f8b6d7e0533cbc015690e9f730ceaa7004c33dc9adfdd7166ed7d242060d/gemmi-0.7.5-cp310-cp310-win_amd64.whl", hash = "sha256:b682ce2f67e46fc609dcd780d12bbec8d7769a3816506f24d7c2a761ccdb73c9", size = 2268178, upload-time = "2026-03-02T08:31:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/7f/79/b13830a65bf9fc85474a984604f094cc18817dc93a784f4c567a2dc05169/gemmi-0.7.5-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:e134fd33f34bf9f2ffacd9e0207aeac6329dde818f62340e7390217a25ee8e2d", size = 2834430, upload-time = "2026-03-02T08:31:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/42/15/26cac702cdf6281ddeb185d5912ce14e555e277c6e4caeb1d36966e43822/gemmi-0.7.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4db34eaa3d3fc102afea7a156330862cbeb82f557444c079403d4412e326c527", size = 2716532, upload-time = "2026-03-02T08:31:18.321Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/a60fd259b99785f04fe5c7fdbda1ec9c10aa9641d4efe9186c019a1689d9/gemmi-0.7.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcd6b82ce6b33049aa43d2aaed167090a77eaa1370f51f5422a683edfe2eec97", size = 2638468, upload-time = "2026-03-02T08:31:20.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/46e443fc70b4aabe6e775521ff476aefb051db9acabb16a5cb51f04e3e2b/gemmi-0.7.5-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:895c63c7bcf30cffba97cf12c89dc3905f4645f838c17009b4534459a6c53a1e", size = 2991765, upload-time = "2026-03-02T08:31:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/32/7e/27b2313a644b42e02ed875ebeff73a1e88d7f564f15c1bf88c9557bbda0b/gemmi-0.7.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7f9524061282ceb114d5316af333667fae850896141ddbadfd2d275d9d6ac5ad", size = 3161503, upload-time = "2026-03-02T08:31:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/8f/05/ee808eb8ece89c612d1bf6dd071ee870e129a69331383b95e482bbd4b692/gemmi-0.7.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:264726ed818aee8907dd8e6007f4a14c28fbf1f1b8ded3761e793e5a5f3284c6", size = 3514467, upload-time = "2026-03-02T08:31:26.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5e/62402bf021183bc6122cb01b8f1be17cac67545713fb30f888f59357a782/gemmi-0.7.5-cp311-cp311-win_amd64.whl", hash = "sha256:06cb44f4e3657b7e3a2b23cd40b67a8e7b5d00bfb92ea94cb4060bd47ba50df6", size = 2268231, upload-time = "2026-03-02T08:31:29.469Z" }, + { url = "https://files.pythonhosted.org/packages/0b/72/7e33f0c1871d648088e3dd67ce47366ed942d625300f6d966730da92a7d7/gemmi-0.7.5-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:2da5d5c1d31fc8c3bffe7530c697c97f8389edff57e8b12898218c588c4f0dac", size = 2845071, upload-time = "2026-03-02T08:31:31.413Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/a6497e1c2c9bc6ed2b79e0f2d31a4ce509fd2a9eed4e4f7ac63eda8113cb/gemmi-0.7.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5682920985109c6a08616ae9aae080f8b46a9714534dc864b535e3e6d203d5b8", size = 2720607, upload-time = "2026-03-02T08:31:33.778Z" }, + { url = "https://files.pythonhosted.org/packages/df/31/704e6c7ffc251d1dc1a19a8cd2e30881a83978c6df8668ba052523fa1720/gemmi-0.7.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:255ca0b0a7f6fb0bf4322f2d69c5f94edf6e95fb801bc1d120ca8dd93b646065", size = 2624986, upload-time = "2026-03-02T08:31:35.902Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/5a431cd1ea7587408a66947384b39beb2ab2bcc1c87b7c4082f05036719f/gemmi-0.7.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:217bb9ac9da7c90704026dacfc0a0652a38f4df1e318225d8f35c75f1f8c7ebf", size = 2982717, upload-time = "2026-03-02T08:31:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/36/e0/ca646b4e22b3d6129ce56a087a9031f7a7843d47425f0adc38a7ab789b24/gemmi-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74e1b5177b626aadb819fd8168f5d6064c04a2a1e45c87f357a96d30ddafc749", size = 3146031, upload-time = "2026-03-02T08:31:39.744Z" }, + { url = "https://files.pythonhosted.org/packages/d5/cc/47e6039859393175a9b38f9a72732c018a3052d838fecf1ff635d8b84d95/gemmi-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6d30fa7ae889149c22dbb58899e77117e6548edc6e8ccfae3b4b2a259464d2ee", size = 3505196, upload-time = "2026-03-02T08:31:41.651Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f2/53be7a4ba5816e13c39be0f728facac4bcb39cf4903ceeec54b006511c8f/gemmi-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:a1fdb6f72006495b5119e3a8bb5c3185efa708b785bd4a5ce4397ef7abb3fec7", size = 2270488, upload-time = "2026-03-02T08:31:43.898Z" }, +] + +[[package]] +name = "glob2" +version = "0.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/a5/bbbc3b74a94fbdbd7915e7ad030f16539bfdc1362f7e9003b594f0537950/glob2-0.7.tar.gz", hash = "sha256:85c3dbd07c8aa26d63d7aacee34fa86e9a91a3873bc30bf62ec46e531f92ab8c", size = 10697, upload-time = "2019-06-10T23:33:48.308Z" } + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "haggis" +version = "0.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/c8/832f5820c4967182ab341b8b868465b550a94bd9a16267025b24f938040f/haggis-0.14.1.tar.gz", hash = "sha256:16f1f9352107e398bbd87475f267ff7f8d7648f407014770b35862e8d14ff3c1", size = 178957, upload-time = "2024-10-17T21:49:33.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/11/4d86aca86b85493ef50c029f9aa155cb8013234b8eee124ff9d7877877dd/haggis-0.14.1-py3-none-any.whl", hash = "sha256:7962ce71bb0b40c3d81342f8083e256895c122c96cf166b7d120317a107f290a", size = 221221, upload-time = "2024-10-17T21:49:31.186Z" }, +] + +[[package]] +name = "hippo-plot" +version = "0.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, + { name = "dash-dangerously-set-inner-html" }, + { name = "mpytools" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "plotly" }, + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/98/ef0347cd015a813ba35c7e6007967c2b01bb7b86e03a5be469f8512d66b8/hippo_plot-0.0.7.tar.gz", hash = "sha256:ce13eed683772fd009c54384e4f3214978d98785292e65fc1f58078e2b22ef5d", size = 4731, upload-time = "2024-05-03T13:09:57.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/da/ab6152212987c563c74d369bd1cf03b945ac10f51bd9e6147943c5396097/hippo_plot-0.0.7-py3-none-any.whl", hash = "sha256:1c1ca1726a0da9d5977d64fd0c8ed7b68fdcef90d14a43324d230625ca6863a8", size = 4761, upload-time = "2024-05-03T13:09:56.822Z" }, +] + +[[package]] +name = "hirsch" +version = "0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/c2/d4897bd5aa06a2170d085be3aa25ad7ff5c299b61c0ea3af4e1fc248d382/hirsch-0.1.tar.gz", hash = "sha256:e82d9c56580a846a4d7d8e16d21b77a63537dc7c34e76c0d83054d93bd967578", size = 15892, upload-time = "2024-10-31T15:26:53.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/13/7af74c270c485607fcf3d2049544cf6d1e79966f641e6bf375b1fdbc8668/hirsch-0.1-py3-none-any.whl", hash = "sha256:4890897ffc7178ba5aab66dcf74ee77ab11c786b5ec4dc45081d0faa23a235b8", size = 15106, upload-time = "2024-10-31T15:26:51.683Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "8.38.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, +] + +[[package]] +name = "ipython" +version = "9.10.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version == '3.11.*'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, + { name = "jedi", marker = "python_full_version == '3.11.*'" }, + { name = "matplotlib-inline", marker = "python_full_version == '3.11.*'" }, + { name = "pexpect", marker = "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version == '3.11.*'" }, + { name = "pygments", marker = "python_full_version == '3.11.*'" }, + { name = "stack-data", marker = "python_full_version == '3.11.*'" }, + { name = "traitlets", marker = "python_full_version == '3.11.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" }, +] + +[[package]] +name = "ipython" +version = "9.11.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.12'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, + { name = "jedi", marker = "python_full_version >= '3.12'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, + { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "stack-data", marker = "python_full_version >= '3.12'" }, + { name = "traitlets", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/28/a4698eda5a8928a45d6b693578b135b753e14fa1c2b36ee9441e69a45576/ipython-9.11.0.tar.gz", hash = "sha256:2a94bc4406b22ecc7e4cb95b98450f3ea493a76bec8896cda11b78d7752a6667", size = 4427354, upload-time = "2026-03-05T08:57:30.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/90/45c72becc57158facc6a6404f663b77bbcea2519ca57f760e2879ae1315d/ipython-9.11.0-py3-none-any.whl", hash = "sha256:6922d5bcf944c6e525a76a0a304451b60a2b6f875e86656d8bc2dfda5d710e19", size = 624222, upload-time = "2026-03-05T08:57:28.94Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "json5" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-console" }, + { name = "jupyterlab" }, + { name = "nbconvert" }, + { name = "notebook" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/f3/af28ea964ab8bc1e472dba2e82627d36d470c51f5cd38c37502eeffaa25e/jupyter-1.1.1.tar.gz", hash = "sha256:d55467bceabdea49d7e3624af7e33d59c37fff53ed3a350e1ac957bed731de7a", size = 5714959, upload-time = "2024-08-30T07:15:48.299Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-console" +version = "6.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "pyzmq" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/2d/e2fd31e2fc41c14e2bcb6c976ab732597e907523f6b2420305f9fc7fdbdb/jupyter_console-6.6.3.tar.gz", hash = "sha256:566a4bf31c87adbfadf22cdf846e3069b59a71ed5da71d6ba4d8aaad14a53539", size = 34363, upload-time = "2023-03-06T14:13:31.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/77/71d78d58f15c22db16328a476426f7ac4a60d3a5a7ba3b9627ee2f7903d4/jupyter_console-6.6.3-py3-none-any.whl", hash = "sha256:309d33409fcc92ffdad25f0bcdf9a4a9daa61b6f341177570fdac03de5352485", size = 24510, upload-time = "2023-03-06T14:13:28.229Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/5a/9066c9f8e94ee517133cd98dba393459a16cd48bba71a82f16a65415206c/jupyter_lsp-2.3.0.tar.gz", hash = "sha256:458aa59339dc868fb784d73364f17dbce8836e906cd75fd471a325cba02e0245", size = 54823, upload-time = "2025-08-27T17:47:34.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/60/1f6cee0c46263de1173894f0fafcb3475ded276c472c14d25e0280c18d6d/jupyter_lsp-2.3.0-py3-none-any.whl", hash = "sha256:e914a3cb2addf48b1c7710914771aaf1819d46b2e5a79b0f917b5478ec93f34f", size = 76687, upload-time = "2025-08-27T17:47:33.15Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/ac/e040ec363d7b6b1f11304cc9f209dac4517ece5d5e01821366b924a64a50/jupyter_server-2.17.0.tar.gz", hash = "sha256:c38ea898566964c888b4772ae1ed58eca84592e88251d2cfc4d171f81f7e99d5", size = 731949, upload-time = "2025-08-21T14:42:54.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/80/a24767e6ca280f5a49525d987bf3e4d7552bf67c8be07e8ccf20271f8568/jupyter_server-2.17.0-py3-none-any.whl", hash = "sha256:e8cb9c7db4251f51ed307e329b81b72ccf2056ff82d50524debde1ee1870e13f", size = 388221, upload-time = "2025-08-21T14:42:52.034Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/2d/953a5612a34a3c799a62566a548e711d103f631672fd49650e0f2de80870/jupyterlab-4.5.5.tar.gz", hash = "sha256:eac620698c59eb810e1729909be418d9373d18137cac66637141abba613b3fda", size = 23968441, upload-time = "2026-02-23T18:57:34.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/52/372d3494766d690dfdd286871bf5f7fb9a6c61f7566ccaa7153a163dd1df/jupyterlab-4.5.5-py3-none-any.whl", hash = "sha256:a35694a40a8e7f2e82f387472af24e61b22adcce87b5a8ab97a5d9c486202a6d", size = 12446824, upload-time = "2026-02-23T18:57:30.398Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "kaleido" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "choreographer" }, + { name = "logistro" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pytest-timeout" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/5f/63f5fa395c7a8a93558c0904ba8f1c8d1b997ca6a3de61bc7659970d66bf/librt-0.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81fd938344fecb9373ba1b155968c8a329491d2ce38e7ddb76f30ffb938f12dc", size = 65697, upload-time = "2026-02-17T16:11:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/0472cf37267b5920eff2f292ccfaede1886288ce35b7f3203d8de00abfe6/librt-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5db05697c82b3a2ec53f6e72b2ed373132b0c2e05135f0696784e97d7f5d48e7", size = 68376, upload-time = "2026-02-17T16:11:08.395Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8bd1359fdcd27ab897cd5963294fa4a7c83b20a8564678e4fd12157e56a5/librt-0.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d56bc4011975f7460bea7b33e1ff425d2f1adf419935ff6707273c77f8a4ada6", size = 197084, upload-time = "2026-02-17T16:11:09.774Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fe/163e33fdd091d0c2b102f8a60cc0a61fd730ad44e32617cd161e7cd67a01/librt-0.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdc0f588ff4b663ea96c26d2a230c525c6fc62b28314edaaaca8ed5af931ad0", size = 207337, upload-time = "2026-02-17T16:11:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/f85130582f05dcf0c8902f3d629270231d2f4afdfc567f8305a952ac7f14/librt-0.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97c2b54ff6717a7a563b72627990bec60d8029df17df423f0ed37d56a17a176b", size = 219980, upload-time = "2026-02-17T16:11:12.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/cb5e4d03659e043a26c74e08206412ac9a3742f0477d96f9761a55313b5f/librt-0.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f1125e6bbf2f1657d9a2f3ccc4a2c9b0c8b176965bb565dd4d86be67eddb4b6", size = 212921, upload-time = "2026-02-17T16:11:14.484Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/a3a01e4240579c30f3487f6fed01eb4bc8ef0616da5b4ebac27ca19775f3/librt-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8f4bb453f408137d7581be309b2fbc6868a80e7ef60c88e689078ee3a296ae71", size = 221381, upload-time = "2026-02-17T16:11:17.459Z" }, + { url = "https://files.pythonhosted.org/packages/08/b0/fc2d54b4b1c6fb81e77288ff31ff25a2c1e62eaef4424a984f228839717b/librt-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c336d61d2fe74a3195edc1646d53ff1cddd3a9600b09fa6ab75e5514ba4862a7", size = 216714, upload-time = "2026-02-17T16:11:19.197Z" }, + { url = "https://files.pythonhosted.org/packages/96/96/85daa73ffbd87e1fb287d7af6553ada66bf25a2a6b0de4764344a05469f6/librt-0.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:eb5656019db7c4deacf0c1a55a898c5bb8f989be904597fcb5232a2f4828fa05", size = 214777, upload-time = "2026-02-17T16:11:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/12/9c/c3aa7a2360383f4bf4f04d98195f2739a579128720c603f4807f006a4225/librt-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c25d9e338d5bed46c1632f851babf3d13c78f49a225462017cf5e11e845c5891", size = 237398, upload-time = "2026-02-17T16:11:22.083Z" }, + { url = "https://files.pythonhosted.org/packages/61/19/d350ea89e5274665185dabc4bbb9c3536c3411f862881d316c8b8e00eb66/librt-0.8.1-cp310-cp310-win32.whl", hash = "sha256:aaab0e307e344cb28d800957ef3ec16605146ef0e59e059a60a176d19543d1b7", size = 54285, upload-time = "2026-02-17T16:11:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/45d587d3d41c112e9543a0093d883eb57a24a03e41561c127818aa2a6bcc/librt-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:56e04c14b696300d47b3bc5f1d10a00e86ae978886d0cee14e5714fafb5df5d2", size = 61352, upload-time = "2026-02-17T16:11:24.207Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/0e748af5e4fee180cf7cd12bd12b0513ad23b045dccb2a83191bde82d168/librt-0.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:681dc2451d6d846794a828c16c22dc452d924e9f700a485b7ecb887a30aad1fd", size = 65315, upload-time = "2026-02-17T16:11:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/7184806efda571887c798d573ca4134c80ac8642dcdd32f12c31b939c595/librt-0.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3b4350b13cc0e6f5bec8fa7caf29a8fb8cdc051a3bae45cfbfd7ce64f009965", size = 68021, upload-time = "2026-02-17T16:11:26.129Z" }, + { url = "https://files.pythonhosted.org/packages/ae/88/c3c52d2a5d5101f28d3dc89298444626e7874aa904eed498464c2af17627/librt-0.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac1e7817fd0ed3d14fd7c5df91daed84c48e4c2a11ee99c0547f9f62fdae13da", size = 194500, upload-time = "2026-02-17T16:11:27.177Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5d/6fb0a25b6a8906e85b2c3b87bee1d6ed31510be7605b06772f9374ca5cb3/librt-0.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:747328be0c5b7075cde86a0e09d7a9196029800ba75a1689332348e998fb85c0", size = 205622, upload-time = "2026-02-17T16:11:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/8006ae81227105476a45691f5831499e4d936b1c049b0c1feb17c11b02d1/librt-0.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0af2bd2bc204fa27f3d6711d0f360e6b8c684a035206257a81673ab924aa11e", size = 218304, upload-time = "2026-02-17T16:11:29.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/19/60e07886ad16670aae57ef44dada41912c90906a6fe9f2b9abac21374748/librt-0.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d480de377f5b687b6b1bc0c0407426da556e2a757633cc7e4d2e1a057aa688f3", size = 211493, upload-time = "2026-02-17T16:11:30.445Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cf/f666c89d0e861d05600438213feeb818c7514d3315bae3648b1fc145d2b6/librt-0.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d0ee06b5b5291f609ddb37b9750985b27bc567791bc87c76a569b3feed8481ac", size = 219129, upload-time = "2026-02-17T16:11:32.021Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ef/f1bea01e40b4a879364c031476c82a0dc69ce068daad67ab96302fed2d45/librt-0.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9e2c6f77b9ad48ce5603b83b7da9ee3e36b3ab425353f695cba13200c5d96596", size = 213113, upload-time = "2026-02-17T16:11:33.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/cdab544370cc6bc1b72ea369525f547a59e6938ef6863a11ab3cd24759af/librt-0.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:439352ba9373f11cb8e1933da194dcc6206daf779ff8df0ed69c5e39113e6a99", size = 212269, upload-time = "2026-02-17T16:11:34.373Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/48d6ed8dac595654f15eceab2035131c136d1ae9a1e3548e777bb6dbb95d/librt-0.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:82210adabbc331dbb65d7868b105185464ef13f56f7f76688565ad79f648b0fe", size = 234673, upload-time = "2026-02-17T16:11:36.063Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/35b68b1db517f27a01be4467593292eb5315def8900afad29fabf56304ba/librt-0.8.1-cp311-cp311-win32.whl", hash = "sha256:52c224e14614b750c0a6d97368e16804a98c684657c7518752c356834fff83bb", size = 54597, upload-time = "2026-02-17T16:11:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/796fe8f02822235966693f257bf2c79f40e11337337a657a8cfebba5febc/librt-0.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:c00e5c884f528c9932d278d5c9cbbea38a6b81eb62c02e06ae53751a83a4d52b", size = 61733, upload-time = "2026-02-17T16:11:38.691Z" }, + { url = "https://files.pythonhosted.org/packages/28/ad/232e13d61f879a42a4e7117d65e4984bb28371a34bb6fb9ca54ec2c8f54e/librt-0.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:f7cdf7f26c2286ffb02e46d7bac56c94655540b26347673bea15fa52a6af17e9", size = 52273, upload-time = "2026-02-17T16:11:40.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, +] + +[[package]] +name = "logistro" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, + { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mistune" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/55/d01f0c4b45ade6536c51170b9043db8b2ec6ddf4a35c7ea3f5f559ac935b/mistune-3.2.0.tar.gz", hash = "sha256:708487c8a8cdd99c9d90eb3ed4c3ed961246ff78ac82f03418f5183ab70e398a", size = 95467, upload-time = "2025-12-23T11:36:34.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/f7/4a5e785ec9fbd65146a27b6b70b6cdc161a66f2024e4b04ac06a67f5578b/mistune-3.2.0-py3-none-any.whl", hash = "sha256:febdc629a3c78616b94393c6580551e0e34cc289987ec6c35ed3f4be42d0eee1", size = 53598, upload-time = "2025-12-23T11:36:33.211Z" }, +] + +[[package]] +name = "molecular-rectifier" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/99/00a2d3d4646d7a470944bb3b356d47f8355b6556c2def6d086a01a440447/molecular_rectifier-1.0.1.tar.gz", hash = "sha256:76ac7cdbee8ade1e7acb89b373ba55ccc9b8b07bb8e1fadc5239f59ef77d941f", size = 17015, upload-time = "2024-04-05T10:22:19.168Z" } + +[[package]] +name = "molparse" +version = "0.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ase" }, + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "kaleido" }, + { name = "matplotlib" }, + { name = "mpytools" }, + { name = "numpy" }, + { name = "plotly" }, + { name = "py3dmol" }, + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/81/41df14bc6cf8e93aeba1a69a85f102c12d99cf0bea1ed1e0934a2377449a/molparse-0.0.41.tar.gz", hash = "sha256:c0c15e243a7cb29f010ae6bdf37f1d870bf0bb7dc97fcae1d223b74513c38440", size = 145086, upload-time = "2025-11-06T16:06:19.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/09/318ff29d72034c7a11068f7d86ec825e0774219a65537e9ccf4d6c92031e/molparse-0.0.41-py3-none-any.whl", hash = "sha256:bb128cdc8124570fa174c6124a9d1de81a9e7b9e46db3d7083720a8beec3a96d", size = 176391, upload-time = "2025-11-06T16:06:16.937Z" }, +] + +[[package]] +name = "mpytools" +version = "0.0.28" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cycler" }, + { name = "emoji" }, + { name = "haggis" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "plotly" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/d9/dfdd9bcac8e28807a0b68c18dffb7e44bd37244d531c41adeffe78319ebf/mpytools-0.0.28.tar.gz", hash = "sha256:88669ee9847d6db57327a0cb9df9d1f7e05688a3239a2255ff122986c7658fcf", size = 24153, upload-time = "2025-08-05T10:44:09.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/c0/07db92c4cd7d1453b5080435afc691c3338ab3aed2c6f3ae267de5737fb9/mpytools-0.0.28-py3-none-any.whl", hash = "sha256:59bbda5678ebce73ddefa0dbdd86ecc9198fcbd50c1c005b854fb2d962197d6d", size = 33465, upload-time = "2025-08-05T10:44:08.121Z" }, +] + +[[package]] +name = "mrich" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/79/afc99575469cf5729b707887ba75b85865d9feb89924b633725c4227c9a5/mrich-1.0.tar.gz", hash = "sha256:3b448227708e5df291c07dd04898e58fdac084770f6ab4e9caf8de97689fbe37", size = 6752, upload-time = "2026-01-27T14:51:39.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/30/85fb178959f80ef5745b7cf9549a1b18f87e844dff53944e5bc240cb2bbe/mrich-1.0-py3-none-any.whl", hash = "sha256:1f2b09837a239236dcf50d5982b2ddf84b9941605ab9137d0702d3191dbae00c", size = 5966, upload-time = "2026-01-27T14:51:37.526Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, + { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, + { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/47/81f886b699450d0569f7bc551df2b1673d18df7ff25cc0c21ca36ed8a5ff/nbconvert-7.17.0.tar.gz", hash = "sha256:1b2696f1b5be12309f6c7d707c24af604b87dfaf6d950794c7b07acab96dda78", size = 862855, upload-time = "2026-01-29T16:37:48.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/4b/8d5f796a792f8a25f6925a96032f098789f448571eb92011df1ae59e8ea8/nbconvert-7.17.0-py3-none-any.whl", hash = "sha256:4f99a63b337b9a23504347afdab24a11faa7d86b405e5c8f9881cd313336d518", size = 261510, upload-time = "2026-01-29T16:37:46.322Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "notebook" +version = "7.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, + { name = "jupyterlab" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/08/9d446fbb49f95de316ea6d7f25d0a4bc95117dd574e35f405895ac706f29/notebook-7.5.4.tar.gz", hash = "sha256:b928b2ba22cb63aa83df2e0e76fe3697950a0c1c4a41b84ebccf1972b1bb5771", size = 14167892, upload-time = "2026-02-24T14:13:56.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/01/05e5387b53e0f549212d5eff58845886f3827617b5c9409c966ddc07cb6d/notebook-7.5.4-py3-none-any.whl", hash = "sha256:860e31782b3d3a25ca0819ff039f5cf77845d1bf30c78ef9528b88b25e0a9850", size = 14578014, upload-time = "2026-02-24T14:13:52.274Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554, upload-time = "2024-02-05T23:51:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127, upload-time = "2024-02-05T23:52:15.314Z" }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994, upload-time = "2024-02-05T23:52:47.569Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005, upload-time = "2024-02-05T23:53:15.637Z" }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297, upload-time = "2024-02-05T23:53:42.16Z" }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567, upload-time = "2024-02-05T23:54:11.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812, upload-time = "2024-02-05T23:54:26.453Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913, upload-time = "2024-02-05T23:54:53.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, +] + +[[package]] +name = "openmm" +version = "8.4.0.post2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/75/8aa4a2f989d35da193b1674be86a13504f1b368c9a419b80095fe04f6fad/openmm-8.4.0.post2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f5466e34b1201ac26a0bd41c8239a7a1849735bcba3c953e4524c1dd0e0015b6", size = 13228640, upload-time = "2025-11-24T21:40:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/aa/02/8ac8f2949fcb8122c9afbfba2b6a57db4f6bf8b8fd180edf4a18522e7891/openmm-8.4.0.post2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4b918707cbdcf517a18d1122e45e5721e9d6ad57005dba5183334cb18765803a", size = 12742263, upload-time = "2025-11-24T21:40:53.807Z" }, + { url = "https://files.pythonhosted.org/packages/6b/39/275dcf2099d6f28a6a9331aea9491b545dff9fc54984dea5c4bf28ee4a09/openmm-8.4.0.post2-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:eaadcafd6839e7f61153808de66ef47b56c2f909193d86e2b107d77c64017494", size = 14248895, upload-time = "2025-11-24T21:41:00.416Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9f/4c486ef74c5dcfce2f4fd797bb7e663d591ea6e19897deeb3b945b3c576e/openmm-8.4.0.post2-cp310-cp310-win_amd64.whl", hash = "sha256:2eed655113d0e78ae9fb539e63b15dd38270c116e96bf1785037fef35e3acc3c", size = 13095815, upload-time = "2025-11-24T21:41:06.351Z" }, + { url = "https://files.pythonhosted.org/packages/56/3e/091c18ae7efb9eb0ceacd31cb516510fd8b7b31927f57b1da46f094ff781/openmm-8.4.0.post2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9399dd46cf7bf345ad28e77e3457a11298874d2e2e407860cc0e46cceb6d3d11", size = 13228102, upload-time = "2025-11-24T21:41:14.647Z" }, + { url = "https://files.pythonhosted.org/packages/42/ac/e19f750374532e70fde81f01132e0537b48719ccc2ff2483c5b30b0e7d99/openmm-8.4.0.post2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49d0e5a0c41ff471f5a24cb73de71ee517b59cc34beaa17aad429892f57e3962", size = 12741400, upload-time = "2025-11-24T21:41:23.031Z" }, + { url = "https://files.pythonhosted.org/packages/f9/01/8fea59390d19ef600a7af46a9edb48d05f7f28ba04cf02c6b1f5d8411402/openmm-8.4.0.post2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:12ffcd82d596bded1382e30af55907754ee481aafc0fc4a921a97de2ea7a8c55", size = 14248574, upload-time = "2025-11-24T21:41:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/b5/82/573cf4b24e9ad17bbb1e0582e3848631bfd34c2b14b0aa217273adee0f0a/openmm-8.4.0.post2-cp311-cp311-win_amd64.whl", hash = "sha256:b18fb1fb3128df8f2cedb23af33c484701a6fa51da8204824a445f800581be13", size = 13096083, upload-time = "2025-11-24T21:41:35.644Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c8/1344fa71c59e891f8dbb107aae192f661a72073ed84064c7828b1a26d9ee/openmm-8.4.0.post2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3c8a012810928b1c0ae91a755d3197e81e7d03d2058d92dd4924d283edeae44", size = 13224672, upload-time = "2025-11-24T21:41:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/a7/0a/e9d1080eb107349ef090cbe0bd8335f3920708f1435b943df8c1c5496f50/openmm-8.4.0.post2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bde30736f7b4b595041caf083bb4e4e79a794ab164faedd0664eda0348a299f", size = 12739310, upload-time = "2025-11-24T21:41:49.437Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/ab3937509f5dcde71fe7ac300f24a8d0684448d9b4820470360202bb95e4/openmm-8.4.0.post2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:168544c0b388ae71cc3f85e27c36c4f393854a313c4203f9e9957d6214836c13", size = 14253728, upload-time = "2025-11-24T21:41:55.662Z" }, + { url = "https://files.pythonhosted.org/packages/06/d4/a6022476db6cd0baf0218fa33ed70182f1447e0b6bf5ded124a846c4dab9/openmm-8.4.0.post2-cp312-cp312-win_amd64.whl", hash = "sha256:6e9fd826aedf34b4c27a4dcda83da93e90f3e81305c58bcc07dafee22460a469", size = 13098413, upload-time = "2025-11-24T21:42:00.409Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandarallel" +version = "1.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/c5/787365399cc7262e20d1d9f42ba202018c2191e6cd5b1a2a10f9161dae35/pandarallel-1.6.5.tar.gz", hash = "sha256:1c2df98ff6441e8ae13ff428ceebaa7ec42d731f7f972c41ce4fdef1d3adf640", size = 14201, upload-time = "2023-05-02T20:43:15.214Z" } + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, +] + +[[package]] +name = "pandera" +version = "0.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pydantic" }, + { name = "typeguard" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/ee/8e0d40dad2c0947b933fc9c0959b2c17cc3419ccdf50df683216f37a3f96/pandera-0.29.0.tar.gz", hash = "sha256:06bc4fc1e4ff02534dd44482a9bc704fb2e58fe3fbb11be906aa714f7f5ec801", size = 575324, upload-time = "2026-01-29T02:49:36.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7b/03299e4ccc5e3cfb0f9e234207ac43ef08b3ba6c4c2882c890e550ceadba/pandera-0.29.0-py3-none-any.whl", hash = "sha256:b3b25d6c00d7c100fbab96aff0e81e52d3dae543a880d24135cca705fa97c516", size = 295876, upload-time = "2026-01-29T02:49:34.812Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "parso" +version = "0.8.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "pdbfixer" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "openmm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/bd/394c3185e4b9fbacf803651a374e99e597d3411d316784f8ea6d8fc17af5/pdbfixer-1.12.0.tar.gz", hash = "sha256:82e54074b33eb00270e9a439c8633fcf82cd3ab7a36561b01425a260c0fc4143", size = 665893, upload-time = "2026-02-06T19:11:22.323Z" } + +[[package]] +name = "pebble" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3b/7debef984e227a70798963cf2e5ea90882f62bca659b33cbd421a453abd1/pebble-5.2.0.tar.gz", hash = "sha256:8e0a5f6a1cfdd0ac1bfc4a789e20d2b4b895de976e547d23b7de23b71ef39b34", size = 39811, upload-time = "2026-01-25T12:05:11.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/de/1cce5274efcb921484998864820f2ba41679ea472daef748a7bc03fc0bb7/pebble-5.2.0-py3-none-any.whl", hash = "sha256:6237a792a78524648857ec6d2dae069c91a45bdef18daf957078a56e2dd8e0a8", size = 34881, upload-time = "2026-01-25T12:05:09.714Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'emscripten' and sys_platform != 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "plotly" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py3dmol" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/7a/d7be826cf33a917f4ff78fd07237368dfdf21e09ab463b9de9a33c61ec0c/py3dmol-2.5.4.tar.gz", hash = "sha256:6142e1605f51e0fd8ec000db5ab045a90a08bc6147176735631a20be301dd19d", size = 7851, upload-time = "2026-01-22T13:33:49.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.py3-none-any.whl", hash = "sha256:32806726b5310524a2b5bfee320737f7feef635cafc945c991062806daa9e43a", size = 7154, upload-time = "2026-01-22T13:33:48.659Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyrosetta-installer" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/e5/f65f56754c5ce31fc63cc8d29cc6b148f1730488f80e3dade1d10fd2ae17/pyrosetta_installer-0.1.2.tar.gz", hash = "sha256:cc442000a9470f9fbacad286b5e4620739321f39e3e7bb5f66a76a5e97c3359c", size = 3597, upload-time = "2024-10-22T20:12:53.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/5e/186bbc2fc9b66acbee19d21f30df611dca3bac7e76eb5da51aa664ea1c43/pyrosetta_installer-0.1.2-py3-none-any.whl", hash = "sha256:5fc2addb4083f1fc29b3e2b816df93610367a00b70d94030026447d6786c0305", size = 3906, upload-time = "2024-10-22T20:12:52.317Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-louvain" +version = "0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0d/8787b021d52eb8764c0bb18ab95f720cf554902044c6a5cb1865daf45763/python-louvain-0.16.tar.gz", hash = "sha256:b7ba2df5002fd28d3ee789a49532baad11fe648e4f2117cf0798e7520a1da56b", size = 204641, upload-time = "2022-01-29T15:53:03.532Z" } + +[[package]] +name = "pytz" +version = "2026.1.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/54/37c7370ba91f579235049dc26cd2c5e657d2a943e01820844ffc81f32176/pywinpty-3.0.3.tar.gz", hash = "sha256:523441dc34d231fb361b4b00f8c99d3f16de02f5005fd544a0183112bcc22412", size = 31309, upload-time = "2026-02-04T21:51:09.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/a652709bd76ca7533cd1c443b03add9f5051fdf71bc6bdb8801dddd4e7a3/pywinpty-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:ff05f12d775b142b11c6fe085129bdd759b61cf7d41da6c745e78e3a1ef5bf40", size = 2114320, upload-time = "2026-02-04T21:53:50.972Z" }, + { url = "https://files.pythonhosted.org/packages/b2/13/a0181cc5c2d5635d3dbc3802b97bc8e3ad4fa7502ccef576651a5e08e54c/pywinpty-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:340ccacb4d74278a631923794ccd758471cfc8eeeeee4610b280420a17ad1e82", size = 235670, upload-time = "2026-02-04T21:50:20.324Z" }, + { url = "https://files.pythonhosted.org/packages/79/c3/3e75075c7f71735f22b66fab0481f2c98e3a4d58cba55cb50ba29114bcf6/pywinpty-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:dff25a9a6435f527d7c65608a7e62783fc12076e7d44487a4911ee91be5a8ac8", size = 2114430, upload-time = "2026-02-04T21:54:19.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1e/8a54166a8c5e4f5cb516514bdf4090be4d51a71e8d9f6d98c0aa00fe45d4/pywinpty-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc1e230e5b193eef4431cba3f39996a288f9958f9c9f092c8a961d930ee8f68", size = 236191, upload-time = "2026-02-04T21:50:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d4/aeb5e1784d2c5bff6e189138a9ca91a090117459cea0c30378e1f2db3d54/pywinpty-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0", size = 2113098, upload-time = "2026-02-04T21:54:37.711Z" }, + { url = "https://files.pythonhosted.org/packages/b9/53/7278223c493ccfe4883239cf06c823c56460a8010e0fc778eef67858dc14/pywinpty-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9", size = 234901, upload-time = "2026-02-04T21:53:31.35Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "rdkit" +version = "2025.9.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/f0/4637604614dc11abe1a57c4491033e57e96a09400f6b60b64dccd055bc78/rdkit-2025.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:129ab527eb4a12d7cc82707a78b5558ba33074894ee86138ad382d486bf3cd72", size = 29515255, upload-time = "2026-02-16T08:48:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/a7/82/fec87c89c816d66bd6dfa1765dc479f8383135ce9b55664eb52351f72004/rdkit-2025.9.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6ec55e650967cc41f90207f2fa240458e8a689739be97045ebd1ac0cf4bf2985", size = 35242460, upload-time = "2026-02-16T08:48:23.124Z" }, + { url = "https://files.pythonhosted.org/packages/21/67/aeb84b2da4da12c7ba56cc29e3302259dee1630ff580bee6d04d68669f81/rdkit-2025.9.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b1dd28003a601a65ef5a89013b4a48589edc6c4e94bd98a2752116c6bed6d138", size = 36729523, upload-time = "2026-02-16T08:48:26.893Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/f6ab4d69f5797c30b50aa0959e0eb257aeedc73fdb29acad432b434bc95f/rdkit-2025.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:c3281d5ba71781409f63f3e08d501a69fe977fcb19742cb2a3905646307aff11", size = 24281514, upload-time = "2026-02-16T08:48:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/47/e5/aafa2eea493035edc6995b6b99ad5776ca735613344c3468386b72ad4e58/rdkit-2025.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37e33ac1437ae60f8e97901ad0246a69b5ea0c68ac74605cbea85c3913721840", size = 29515614, upload-time = "2026-02-16T08:48:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c5/79a67fd3fce6e8d087a120c74f49cd0a68ceb441819f09e948d6ab38f579/rdkit-2025.9.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7cea7687aaf73ae6950e730203d7e0eab769e56aa24289aace64ba7ae5b457d1", size = 35237379, upload-time = "2026-02-16T08:48:38.18Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2c/f949c5b8fbc760906bc6555beefbebd505894d50e38fbaa159e5c8301bbc/rdkit-2025.9.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7318bc4fb8a1755999aeed43222cd4c95e64795555b84db15ae0cbb7686a254c", size = 36728364, upload-time = "2026-02-16T08:48:42.689Z" }, + { url = "https://files.pythonhosted.org/packages/73/cf/e2c0d68f2586f1a24e256d6132f73db4b30af3a548a3ffd6a24142414fb9/rdkit-2025.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:14413636c51c46a238ca6d8b652b13d6824f10e4951b6d995cbc7f2e763170b9", size = 24281655, upload-time = "2026-02-16T08:48:46.029Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6c/55aa888c54cf3aa8c8fb525642c51769e37fd2bcdd924cd35c3cb3678fc1/rdkit-2025.9.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64f313a8e2993bd7de8c1d72dc268e25c27bcaab31d4217a58528fbcc0ca5195", size = 29560510, upload-time = "2026-02-16T08:48:49.538Z" }, + { url = "https://files.pythonhosted.org/packages/21/6f/d6420b5343c9b113b41235f3497e4d9fbff9ac5ee7df3be5c6f09f25723e/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24ab792c9eae49bc0cffe619bcac4ccc91ce61eb09ea68be5a74f29d7671681d", size = 35118879, upload-time = "2026-02-16T08:48:53.36Z" }, + { url = "https://files.pythonhosted.org/packages/6e/86/5609ddde91431190739919e6c41821b4fcd2a3fcc05dcc4094a666b34796/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:07705d64bf33b832eff1ea8819c0ffe38e079b31ebb194a73e5ae47554910c8b", size = 36663662, upload-time = "2026-02-16T08:48:57.757Z" }, + { url = "https://files.pythonhosted.org/packages/03/21/1da115389d2eae82364ac5eb4c95c7c57841f2906239257b17af3b8d48f4/rdkit-2025.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:f99b65d3b52d76532ceecb73befae0180e147120972d4054fcd3c66fa71f3a95", size = 24300316, upload-time = "2026-02-16T08:49:01.634Z" }, +] + +[[package]] +name = "rdkit-to-params" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/70/cc9d755059d428478d2580e346ba83318ba1cd74deff66018790d6108ea6/rdkit_to_params-1.3.4.tar.gz", hash = "sha256:5cc0f773e4ee612586efafc6a1e129a0851b8b688baebe8d33863eeca7e18950", size = 71065, upload-time = "2026-02-28T14:57:04.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/19/a95c16edd252f9ced111a713f5c3b00977626398de7fdb6fad7ad2f8e0d9/rdkit_to_params-1.3.4-py3-none-any.whl", hash = "sha256:ceb7b030b175ff05f44f57a7cb5d480ff747563b73c1e2cd1a8d46789c217c12", size = 59800, upload-time = "2026-02-28T14:57:03.026Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9b/840e0039e65fcf12758adf684d2289024d6140cde9268cc59887dc55189c/ruff-0.15.5.tar.gz", hash = "sha256:7c3601d3b6d76dce18c5c824fc8d06f4eef33d6df0c21ec7799510cde0f159a2", size = 4574214, upload-time = "2026-03-05T20:06:34.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/20/5369c3ce21588c708bcbe517a8fbe1a8dfdb5dfd5137e14790b1da71612c/ruff-0.15.5-py3-none-linux_armv6l.whl", hash = "sha256:4ae44c42281f42e3b06b988e442d344a5b9b72450ff3c892e30d11b29a96a57c", size = 10478185, upload-time = "2026-03-05T20:06:29.093Z" }, + { url = "https://files.pythonhosted.org/packages/44/ed/e81dd668547da281e5dce710cf0bc60193f8d3d43833e8241d006720e42b/ruff-0.15.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6edd3792d408ebcf61adabc01822da687579a1a023f297618ac27a5b51ef0080", size = 10859201, upload-time = "2026-03-05T20:06:32.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8f/533075f00aaf19b07c5cd6aa6e5d89424b06b3b3f4583bfa9c640a079059/ruff-0.15.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:89f463f7c8205a9f8dea9d658d59eff49db05f88f89cc3047fb1a02d9f344010", size = 10184752, upload-time = "2026-03-05T20:06:40.312Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/ba49e2c3fa0395b3152bad634c7432f7edfc509c133b8f4529053ff024fb/ruff-0.15.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba786a8295c6574c1116704cf0b9e6563de3432ac888d8f83685654fe528fd65", size = 10534857, upload-time = "2026-03-05T20:06:19.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/71/39234440f27a226475a0659561adb0d784b4d247dfe7f43ffc12dd02e288/ruff-0.15.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd4b801e57955fe9f02b31d20375ab3a5c4415f2e5105b79fb94cf2642c91440", size = 10309120, upload-time = "2026-03-05T20:06:00.435Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/4140aa86a93df032156982b726f4952aaec4a883bb98cb6ef73c347da253/ruff-0.15.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:391f7c73388f3d8c11b794dbbc2959a5b5afe66642c142a6effa90b45f6f5204", size = 11047428, upload-time = "2026-03-05T20:05:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/4953e7e3287676f78fbe85e3a0ca414c5ca81237b7575bdadc00229ac240/ruff-0.15.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dc18f30302e379fe1e998548b0f5e9f4dff907f52f73ad6da419ea9c19d66c8", size = 11914251, upload-time = "2026-03-05T20:06:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/77/46/0f7c865c10cf896ccf5a939c3e84e1cfaeed608ff5249584799a74d33835/ruff-0.15.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc6e7f90087e2d27f98dc34ed1b3ab7c8f0d273cc5431415454e22c0bd2a681", size = 11333801, upload-time = "2026-03-05T20:05:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/d3/01/a10fe54b653061585e655f5286c2662ebddb68831ed3eaebfb0eb08c0a16/ruff-0.15.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1cb7169f53c1ddb06e71a9aebd7e98fc0fea936b39afb36d8e86d36ecc2636a", size = 11206821, upload-time = "2026-03-05T20:06:03.441Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0d/2132ceaf20c5e8699aa83da2706ecb5c5dcdf78b453f77edca7fb70f8a93/ruff-0.15.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9b037924500a31ee17389b5c8c4d88874cc6ea8e42f12e9c61a3d754ff72f1ca", size = 11133326, upload-time = "2026-03-05T20:06:25.655Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/2e5259a7eb2a0f87c08c0fe5bf5825a1e4b90883a52685524596bfc93072/ruff-0.15.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:65bb414e5b4eadd95a8c1e4804f6772bbe8995889f203a01f77ddf2d790929dd", size = 10510820, upload-time = "2026-03-05T20:06:37.79Z" }, + { url = "https://files.pythonhosted.org/packages/ff/20/b67ce78f9e6c59ffbdb5b4503d0090e749b5f2d31b599b554698a80d861c/ruff-0.15.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d20aa469ae3b57033519c559e9bc9cd9e782842e39be05b50e852c7c981fa01d", size = 10302395, upload-time = "2026-03-05T20:05:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e5/719f1acccd31b720d477751558ed74e9c88134adcc377e5e886af89d3072/ruff-0.15.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:15388dd28c9161cdb8eda68993533acc870aa4e646a0a277aa166de9ad5a8752", size = 10754069, upload-time = "2026-03-05T20:06:06.422Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/d1db14469e32d98f3ca27079dbd30b7b44dbb5317d06ab36718dee3baf03/ruff-0.15.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b30da330cbd03bed0c21420b6b953158f60c74c54c5f4c1dabbdf3a57bf355d2", size = 11304315, upload-time = "2026-03-05T20:06:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/28/3a/950367aee7c69027f4f422059227b290ed780366b6aecee5de5039d50fa8/ruff-0.15.5-py3-none-win32.whl", hash = "sha256:732e5ee1f98ba5b3679029989a06ca39a950cced52143a0ea82a2102cb592b74", size = 10551676, upload-time = "2026-03-05T20:06:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/b8/00/bf077a505b4e649bdd3c47ff8ec967735ce2544c8e4a43aba42ee9bf935d/ruff-0.15.5-py3-none-win_amd64.whl", hash = "sha256:821d41c5fa9e19117616c35eaa3f4b75046ec76c65e7ae20a333e9a8696bc7fe", size = 11678972, upload-time = "2026-03-05T20:06:45.379Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4e/cd76eca6db6115604b7626668e891c9dd03330384082e33662fb0f113614/ruff-0.15.5-py3-none-win_arm64.whl", hash = "sha256:b498d1c60d2fe5c10c45ec3f698901065772730b411f164ae270bb6bfcc4740b", size = 10965572, upload-time = "2026-03-05T20:06:16.984Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/3e/daed796fd69cce768b8788401cc464ea90b306fb196ae1ffed0b98182859/scikit_learn-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b33579c10a3081d076ab403df4a4190da4f4432d443521674637677dc91e61f", size = 9336221, upload-time = "2025-09-09T08:20:19.328Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ce/af9d99533b24c55ff4e18d9b7b4d9919bbc6cd8f22fe7a7be01519a347d5/scikit_learn-1.7.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:36749fb62b3d961b1ce4fedf08fa57a1986cd409eff2d783bca5d4b9b5fce51c", size = 8653834, upload-time = "2025-09-09T08:20:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/58/0e/8c2a03d518fb6bd0b6b0d4b114c63d5f1db01ff0f9925d8eb10960d01c01/scikit_learn-1.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7a58814265dfc52b3295b1900cfb5701589d30a8bb026c7540f1e9d3499d5ec8", size = 9660938, upload-time = "2025-09-09T08:20:24.327Z" }, + { url = "https://files.pythonhosted.org/packages/2b/75/4311605069b5d220e7cf5adabb38535bd96f0079313cdbb04b291479b22a/scikit_learn-1.7.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a847fea807e278f821a0406ca01e387f97653e284ecbd9750e3ee7c90347f18", size = 9477818, upload-time = "2025-09-09T08:20:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/87961813c34adbca21a6b3f6b2bea344c43b30217a6d24cc437c6147f3e8/scikit_learn-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:ca250e6836d10e6f402436d6463d6c0e4d8e0234cfb6a9a47835bd392b852ce5", size = 8886969, upload-time = "2025-09-09T08:20:29.329Z" }, + { url = "https://files.pythonhosted.org/packages/43/83/564e141eef908a5863a54da8ca342a137f45a0bfb71d1d79704c9894c9d1/scikit_learn-1.7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7509693451651cd7361d30ce4e86a1347493554f172b1c72a39300fa2aea79e", size = 9331967, upload-time = "2025-09-09T08:20:32.421Z" }, + { url = "https://files.pythonhosted.org/packages/18/d6/ba863a4171ac9d7314c4d3fc251f015704a2caeee41ced89f321c049ed83/scikit_learn-1.7.2-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0486c8f827c2e7b64837c731c8feff72c0bd2b998067a8a9cbc10643c31f0fe1", size = 8648645, upload-time = "2025-09-09T08:20:34.436Z" }, + { url = "https://files.pythonhosted.org/packages/ef/0e/97dbca66347b8cf0ea8b529e6bb9367e337ba2e8be0ef5c1a545232abfde/scikit_learn-1.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89877e19a80c7b11a2891a27c21c4894fb18e2c2e077815bcade10d34287b20d", size = 9715424, upload-time = "2025-09-09T08:20:36.776Z" }, + { url = "https://files.pythonhosted.org/packages/f7/32/1f3b22e3207e1d2c883a7e09abb956362e7d1bd2f14458c7de258a26ac15/scikit_learn-1.7.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8da8bf89d4d79aaec192d2bda62f9b56ae4e5b4ef93b6a56b5de4977e375c1f1", size = 9509234, upload-time = "2025-09-09T08:20:38.957Z" }, + { url = "https://files.pythonhosted.org/packages/9f/71/34ddbd21f1da67c7a768146968b4d0220ee6831e4bcbad3e03dd3eae88b6/scikit_learn-1.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ed8d58725030568523e937c43e56bc01cadb478fc43c042a9aca1dacb3ba1", size = 8894244, upload-time = "2025-09-09T08:20:41.166Z" }, + { url = "https://files.pythonhosted.org/packages/a7/aa/3996e2196075689afb9fce0410ebdb4a09099d7964d061d7213700204409/scikit_learn-1.7.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8d91a97fa2b706943822398ab943cde71858a50245e31bc71dba62aab1d60a96", size = 9259818, upload-time = "2025-09-09T08:20:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/43/5d/779320063e88af9c4a7c2cf463ff11c21ac9c8bd730c4a294b0000b666c9/scikit_learn-1.7.2-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acbc0f5fd2edd3432a22c69bed78e837c70cf896cd7993d71d51ba6708507476", size = 8636997, upload-time = "2025-09-09T08:20:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, + { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, + { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, +] + +[[package]] +name = "scikit-learn" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "joblib", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "threadpoolctl", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, + { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, + { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, + { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, + { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, +] + +[[package]] +name = "seaborn" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/59/a451d7420a77ab0b98f7affa3a1d78a313d2f7281a57afb1a34bae8ab412/seaborn-0.13.2.tar.gz", hash = "sha256:93e60a40988f4d65e9f4885df477e2fdaff6b73a9ded434c1ab356dd57eefff7", size = 1457696, upload-time = "2024-01-25T13:21:52.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/11/00d3c3dfc25ad54e731d91449895a79e4bf2384dc3ac01809010ba88f6d5/seaborn-0.13.2-py3-none-any.whl", hash = "sha256:636f8336facf092165e27924f223d3c62ca560b1f2bb5dff7ab7fad265361987", size = 294914, upload-time = "2024-01-25T13:21:49.598Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + +[[package]] +name = "simplejson" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/09/2bf3761de89ea2d91bdce6cf107dcd858892d0adc22c995684878826cc6b/simplejson-3.20.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6d7286dc11af60a2f76eafb0c2acde2d997e87890e37e24590bb513bec9f1bc5", size = 94039, upload-time = "2025-09-26T16:27:29.283Z" }, + { url = "https://files.pythonhosted.org/packages/0f/33/c3277db8931f0ae9e54b9292668863365672d90fb0f632f4cf9829cb7d68/simplejson-3.20.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c01379b4861c3b0aa40cba8d44f2b448f5743999aa68aaa5d3ef7049d4a28a2d", size = 75894, upload-time = "2025-09-26T16:27:30.378Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/ae47b04d03c7c8a7b7b1a8b39a6e27c3bd424e52f4988d70aca6293ff5e5/simplejson-3.20.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a16b029ca25645b3bc44e84a4f941efa51bf93c180b31bd704ce6349d1fc77c1", size = 76116, upload-time = "2025-09-26T16:27:31.42Z" }, + { url = "https://files.pythonhosted.org/packages/4b/42/6c9af551e5a8d0f171d6dce3d9d1260068927f7b80f1f09834e07887c8c4/simplejson-3.20.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e22a5fb7b1437ffb057e02e1936a3bfb19084ae9d221ec5e9f4cf85f69946b6", size = 138827, upload-time = "2025-09-26T16:27:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/2b/22/5e268bbcbe9f75577491e406ec0a5536f5b2fa91a3b52031fea51cd83e1d/simplejson-3.20.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8b6ff02fc7b8555c906c24735908854819b0d0dc85883d453e23ca4c0445d01", size = 146772, upload-time = "2025-09-26T16:27:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/b4/800f14728e2ad666f420dfdb57697ca128aeae7f991b35759c09356b829a/simplejson-3.20.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2bfc1c396ad972ba4431130b42307b2321dba14d988580c1ac421ec6a6b7cee3", size = 134497, upload-time = "2025-09-26T16:27:35.211Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/c54eef4226c6ac8e9a389bbe5b21fef116768f97a2dc1a683c716ffe66ef/simplejson-3.20.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a97249ee1aee005d891b5a211faf58092a309f3d9d440bc269043b08f662eda", size = 138172, upload-time = "2025-09-26T16:27:36.44Z" }, + { url = "https://files.pythonhosted.org/packages/09/36/4e282f5211b34620f1b2e4b51d9ddaab5af82219b9b7b78360a33f7e5387/simplejson-3.20.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1036be00b5edaddbddbb89c0f80ed229714a941cfd21e51386dc69c237201c2", size = 140272, upload-time = "2025-09-26T16:27:37.605Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/94ad2cf32f477c449e1f63c863d8a513e2408d651c4e58fe4b6a7434e168/simplejson-3.20.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5d6f5bacb8cdee64946b45f2680afa3f54cd38e62471ceda89f777693aeca4e4", size = 140468, upload-time = "2025-09-26T16:27:39.015Z" }, + { url = "https://files.pythonhosted.org/packages/e5/46/827731e4163be3f987cb8ee90f5d444161db8f540b5e735355faa098d9bc/simplejson-3.20.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8db6841fb796ec5af632f677abf21c6425a1ebea0d9ac3ef1a340b8dc69f52b8", size = 148700, upload-time = "2025-09-26T16:27:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/c32121064b1ec2fb7b5d872d9a1abda62df064d35e0160eddfa907118343/simplejson-3.20.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0a341f7cc2aae82ee2b31f8a827fd2e51d09626f8b3accc441a6907c88aedb7", size = 141323, upload-time = "2025-09-26T16:27:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/46/b6/c897c54326fe86dd12d101981171a49361949f4728294f418c3b86a1af77/simplejson-3.20.2-cp310-cp310-win32.whl", hash = "sha256:27f9c01a6bc581d32ab026f515226864576da05ef322d7fc141cd8a15a95ce53", size = 74377, upload-time = "2025-09-26T16:27:42.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/87/a6e03d4d80cca99c1fee4e960f3440e2f21be9470e537970f960ca5547f1/simplejson-3.20.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0a63ec98a4547ff366871bf832a7367ee43d047bcec0b07b66c794e2137b476", size = 76081, upload-time = "2025-09-26T16:27:43.945Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3e/96898c6c66d9dca3f9bd14d7487bf783b4acc77471b42f979babbb68d4ca/simplejson-3.20.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06190b33cd7849efc413a5738d3da00b90e4a5382fd3d584c841ac20fb828c6f", size = 92633, upload-time = "2025-09-26T16:27:45.028Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a2/cd2e10b880368305d89dd540685b8bdcc136df2b3c76b5ddd72596254539/simplejson-3.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4ad4eac7d858947a30d2c404e61f16b84d16be79eb6fb316341885bdde864fa8", size = 75309, upload-time = "2025-09-26T16:27:46.142Z" }, + { url = "https://files.pythonhosted.org/packages/5d/02/290f7282eaa6ebe945d35c47e6534348af97472446951dce0d144e013f4c/simplejson-3.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b392e11c6165d4a0fde41754a0e13e1d88a5ad782b245a973dd4b2bdb4e5076a", size = 75308, upload-time = "2025-09-26T16:27:47.542Z" }, + { url = "https://files.pythonhosted.org/packages/43/91/43695f17b69e70c4b0b03247aa47fb3989d338a70c4b726bbdc2da184160/simplejson-3.20.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51eccc4e353eed3c50e0ea2326173acdc05e58f0c110405920b989d481287e51", size = 143733, upload-time = "2025-09-26T16:27:48.673Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4b/fdcaf444ac1c3cbf1c52bf00320c499e1cf05d373a58a3731ae627ba5e2d/simplejson-3.20.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306e83d7c331ad833d2d43c76a67f476c4b80c4a13334f6e34bb110e6105b3bd", size = 153397, upload-time = "2025-09-26T16:27:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/21550f81a50cd03599f048a2d588ffb7f4c4d8064ae091511e8e5848eeaa/simplejson-3.20.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f820a6ac2ef0bc338ae4963f4f82ccebdb0824fe9caf6d660670c578abe01013", size = 141654, upload-time = "2025-09-26T16:27:51.168Z" }, + { url = "https://files.pythonhosted.org/packages/cf/54/d76c0e72ad02450a3e723b65b04f49001d0e73218ef6a220b158a64639cb/simplejson-3.20.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21e7a066528a5451433eb3418184f05682ea0493d14e9aae690499b7e1eb6b81", size = 144913, upload-time = "2025-09-26T16:27:52.331Z" }, + { url = "https://files.pythonhosted.org/packages/3f/49/976f59b42a6956d4aeb075ada16ad64448a985704bc69cd427a2245ce835/simplejson-3.20.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:438680ddde57ea87161a4824e8de04387b328ad51cfdf1eaf723623a3014b7aa", size = 144568, upload-time = "2025-09-26T16:27:53.41Z" }, + { url = "https://files.pythonhosted.org/packages/60/c7/30bae30424ace8cd791ca660fed454ed9479233810fe25c3f3eab3d9dc7b/simplejson-3.20.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cac78470ae68b8d8c41b6fca97f5bf8e024ca80d5878c7724e024540f5cdaadb", size = 146239, upload-time = "2025-09-26T16:27:54.502Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/7f3b7b97351c53746e7b996fcd106986cda1954ab556fd665314756618d2/simplejson-3.20.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7524e19c2da5ef281860a3d74668050c6986be15c9dd99966034ba47c68828c2", size = 154497, upload-time = "2025-09-26T16:27:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/7241daa91d0bf19126589f6a8dcbe8287f4ed3d734e76fd4a092708947be/simplejson-3.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e9b6d845a603b2eef3394eb5e21edb8626cd9ae9a8361d14e267eb969dbe413", size = 148069, upload-time = "2025-09-26T16:27:57.039Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f4/ef18d2962fe53e7be5123d3784e623859eec7ed97060c9c8536c69d34836/simplejson-3.20.2-cp311-cp311-win32.whl", hash = "sha256:47d8927e5ac927fdd34c99cc617938abb3624b06ff86e8e219740a86507eb961", size = 74158, upload-time = "2025-09-26T16:27:58.265Z" }, + { url = "https://files.pythonhosted.org/packages/35/fd/3d1158ecdc573fdad81bf3cc78df04522bf3959758bba6597ba4c956c74d/simplejson-3.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:ba4edf3be8e97e4713d06c3d302cba1ff5c49d16e9d24c209884ac1b8455520c", size = 75911, upload-time = "2025-09-26T16:27:59.292Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" }, + { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" }, + { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" }, + { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, +] + +[[package]] +name = "singledispatchmethod" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/e0/20ecc21453d7f052ee04d0d9b3305798e0f6afc619885c9aaee4ba86b25c/singledispatchmethod-1.0.tar.gz", hash = "sha256:183a7fbeab53b9c9d182f8b8f9c2d7e109a7d40afaa30261d81dd8de68cd73bf", size = 6064, upload-time = "2019-08-12T07:28:17.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ae/cb1b0901b332754245ce6ce900beef6e6c6a97b42e1e4eb78ab66379a6ba/singledispatchmethod-1.0-py2.py3-none-any.whl", hash = "sha256:ed4a794e701cbe415f0df32b71dbdb96d3a415701795bcfb5ee694f8c12418db", size = 4674, upload-time = "2021-09-30T13:17:20.27Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smallworld-api" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/b9/2fbbdf844956da26dd8592a206cb74694b66959a77e39c37d8375192cb02/smallworld-api-1.1.4.tar.gz", hash = "sha256:2d01d5b6d1441ded4daf6631592c3bfcc7bf7c0307ccd2417d1e9cf00ee8ae9b", size = 19548, upload-time = "2024-01-17T11:16:26.602Z" } + +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + +[[package]] +name = "sqlitedict" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/9a/7620d1e9dcb02839ed6d4b14064e609cdd7a8ae1e47289aa0456796dd9ca/sqlitedict-2.1.0.tar.gz", hash = "sha256:03d9cfb96d602996f1d4c2db2856f1224b96a9c431bdd16e78032a72940f9e8c", size = 21846, upload-time = "2022-12-03T13:39:13.102Z" } + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "syndirella" +version = "5.0.7a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "biopython" }, + { name = "fragmenstein" }, + { name = "glob2" }, + { name = "jupyter" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pyrosetta-installer" }, + { name = "pyyaml" }, + { name = "rdkit" }, + { name = "requests" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "seaborn" }, + { name = "shortuuid" }, + { name = "xlrd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/bd425f95b94fed78b80ce527dfacd96a34ef990b863156e777443d5aeb90/syndirella-5.0.7a0.tar.gz", hash = "sha256:ddd6938dada2290128abbaf346af54333d5d72c6aaac173bf0e6f46ca750c0cb", size = 13117837, upload-time = "2026-02-27T15:49:26.68Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/c0/b175dad3f227aa6b7134fc307879790d0c828df50746f87c59b4447617b6/syndirella-5.0.7a0-py3-none-any.whl", hash = "sha256:8033f9b12808f50df0a1d47445f3c5f41609ca0db634fe8799dfdd8ef37aa9bc", size = 9577633, upload-time = "2026-02-27T15:49:24.526Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085, upload-time = "2024-10-24T14:58:29.895Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/a9/e94a9d5224107d7ce3cc1fab8d5dc97f5ea351ccc6322ee4fb661da94e35/tornado-6.5.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d6241c1a16b1c9e4cc28148b1cda97dd1c6cb4fb7068ac1bedc610768dff0ba9", size = 443909, upload-time = "2025-12-15T19:20:48.382Z" }, + { url = "https://files.pythonhosted.org/packages/db/7e/f7b8d8c4453f305a51f80dbb49014257bb7d28ccb4bbb8dd328ea995ecad/tornado-6.5.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2d50f63dda1d2cac3ae1fa23d254e16b5e38153758470e9956cbc3d813d40843", size = 442163, upload-time = "2025-12-15T19:20:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b5/206f82d51e1bfa940ba366a8d2f83904b15942c45a78dd978b599870ab44/tornado-6.5.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1cf66105dc6acb5af613c054955b8137e34a03698aa53272dbda4afe252be17", size = 445746, upload-time = "2025-12-15T19:20:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/1a3338e0bd30ada6ad4356c13a0a6c35fbc859063fa7eddb309183364ac1/tornado-6.5.4-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50ff0a58b0dc97939d29da29cd624da010e7f804746621c78d14b80238669335", size = 445083, upload-time = "2025-12-15T19:20:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/50/d4/e51d52047e7eb9a582da59f32125d17c0482d065afd5d3bc435ff2120dc5/tornado-6.5.4-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fb5e04efa54cf0baabdd10061eb4148e0be137166146fff835745f59ab9f7f", size = 445315, upload-time = "2025-12-15T19:20:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/27/07/2273972f69ca63dbc139694a3fc4684edec3ea3f9efabf77ed32483b875c/tornado-6.5.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9c86b1643b33a4cd415f8d0fe53045f913bf07b4a3ef646b735a6a86047dda84", size = 446003, upload-time = "2025-12-15T19:20:56.101Z" }, + { url = "https://files.pythonhosted.org/packages/d1/83/41c52e47502bf7260044413b6770d1a48dda2f0246f95ee1384a3cd9c44a/tornado-6.5.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:6eb82872335a53dd063a4f10917b3efd28270b56a33db69009606a0312660a6f", size = 445412, upload-time = "2025-12-15T19:20:57.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/c7/bc96917f06cbee182d44735d4ecde9c432e25b84f4c2086143013e7b9e52/tornado-6.5.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6076d5dda368c9328ff41ab5d9dd3608e695e8225d1cd0fd1e006f05da3635a8", size = 445392, upload-time = "2025-12-15T19:20:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" }, + { url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, +] + +[[package]] +name = "ty" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/20/2ba8fd9493c89c41dfe9dbb73bc70a28b28028463bc0d2897ba8be36230a/ty-0.0.21.tar.gz", hash = "sha256:a4c2ba5d67d64df8fcdefd8b280ac1149d24a73dbda82fa953a0dff9d21400ed", size = 5297967, upload-time = "2026-03-06T01:57:13.809Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/70/edf38bb37517531681d1c37f5df64744e5ad02673c02eb48447eae4bea08/ty-0.0.21-py3-none-linux_armv6l.whl", hash = "sha256:7bdf2f572378de78e1f388d24691c89db51b7caf07cf90f2bfcc1d6b18b70a76", size = 10299222, upload-time = "2026-03-06T01:57:16.64Z" }, + { url = "https://files.pythonhosted.org/packages/72/62/0047b0bd19afeefbc7286f20a5f78a2aa39f92b4d89853f0d7185ab89edc/ty-0.0.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7e9613994610431ab8625025bd2880dbcb77c5c9fabdd21134cda12d840a529d", size = 10130513, upload-time = "2026-03-06T01:57:29.93Z" }, + { url = "https://files.pythonhosted.org/packages/a2/20/0b93a9e91aaed23155780258cdfdb4726ef68b6985378ac069bc427291a0/ty-0.0.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:56d3b198b64dd0a19b2b66e257deaed2ecea568e722ae5352f3c6fb62027f89d", size = 9605425, upload-time = "2026-03-06T01:57:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fd/9945e2fa2996a1287b1e1d7ce050e97e1f420233b271e770934bfa0880a0/ty-0.0.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d23d2c34f7a77d974bb08f0860ef700addc8a683d81a0319f71c08f87506cfd0", size = 10108298, upload-time = "2026-03-06T01:57:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/52/e7/4ec52fcb15f3200826c9f048472c062549a05b0d1ef0b51f32d527b513c4/ty-0.0.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56b01fd2519637a4ca88344f61c96225f540c98ff18bca321d4eaa7bb0f7aa2f", size = 10121556, upload-time = "2026-03-06T01:57:03.242Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/ad457be2a8abea0f25549598bd098554540ced66229488daa0d558dad3c8/ty-0.0.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9de7e11c63c6afc40f3e9ba716374add171aee7fabc70b5146a510705c6d41b", size = 10603264, upload-time = "2026-03-06T01:56:52.134Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5b/2ecc7a2175243a4bcb72f5298ae41feabbb93b764bb0dc45722f3752c2c2/ty-0.0.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62f7f5b235c4f7876db305c36997aea07b7af29b1a068f373d0e2547e25f32ff", size = 11196428, upload-time = "2026-03-06T01:57:32.94Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/aff507d6a901f328ef96a298032b0c11aaaf950a146ed7dd3b5bf2cd3acf/ty-0.0.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee8399f7c453a425291e6688efe430cfae7ab0ac4ffd50eba9f872bf878b54f6", size = 10866355, upload-time = "2026-03-06T01:56:57.831Z" }, + { url = "https://files.pythonhosted.org/packages/be/30/822bbcb92d55b65989aa7ed06d9585f28ade9c9447369194ed4b0fb3b5b9/ty-0.0.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:210e7568c9f886c4d01308d751949ee714ad7ad9d7d928d2ba90d329dd880367", size = 10738177, upload-time = "2026-03-06T01:57:11.256Z" }, + { url = "https://files.pythonhosted.org/packages/57/cc/46e7991b6469e93ac2c7e533a028983e402485580150ac864c56352a3a82/ty-0.0.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:53508e345b11569f78b21ba8e2b4e61df38a9754947fb3cd9f2ef574367338fb", size = 10079158, upload-time = "2026-03-06T01:57:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/15/c2/0bbdadfbd008240f8f1a87dc877433cb3884436097926107ccf06e618199/ty-0.0.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:553e43571f4a35604c36cfd07d8b61a5eb7a714e3c67f8c4ff2cf674fefbaef9", size = 10150535, upload-time = "2026-03-06T01:57:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/2dbdb7b57b5362200ef0a39738ebd31331726328336def0143ac097ee59d/ty-0.0.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:666f6822e3b9200abfa7e95eb0ddd576460adb8d66b550c0ad2c70abc84a2048", size = 10319803, upload-time = "2026-03-06T01:57:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/72/84/70e52c0b7abc7c2086f9876ef454a73b161d3125315536d8d7e911c94ca4/ty-0.0.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a0854d008347ce4a5fb351af132f660a390ab2a1163444d075251d43e6f74b9b", size = 10826239, upload-time = "2026-03-06T01:57:21.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/8a/1f72480fd013bbc6cd1929002abbbcde9a0b08ead6a15154de9d7f7fa37e/ty-0.0.21-py3-none-win32.whl", hash = "sha256:bef3ab4c7b966bcc276a8ac6c11b63ba222d21355b48d471ea782c4104eee4e0", size = 9693196, upload-time = "2026-03-06T01:57:24.126Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/1104808b875c26c640e536945753a78562d606bef4e241d9dbf3d92477f6/ty-0.0.21-py3-none-win_amd64.whl", hash = "sha256:a709d576e5bea84b745d43058d8b9cd4f27f74a0b24acb4b0cbb7d3d41e0d050", size = 10668660, upload-time = "2026-03-06T01:56:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/25e0adc404bbf986977657b25318991f93097b49f8aea640d93c0b0db68e/ty-0.0.21-py3-none-win_arm64.whl", hash = "sha256:f72047996598ac20553fb7e21ba5741e3c82dee4e9eadf10d954551a5fe09391", size = 10104161, upload-time = "2026-03-06T01:57:06.072Z" }, +] + +[[package]] +name = "typeguard" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/e8/66e25efcc18542d58706ce4e50415710593721aae26e794ab1dec34fb66f/typeguard-4.5.1.tar.gz", hash = "sha256:f6f8ecbbc819c9bc749983cc67c02391e16a9b43b8b27f15dc70ed7c4a007274", size = 80121, upload-time = "2026-02-19T16:09:03.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/88/b55b3117287a8540b76dbdd87733808d4d01c8067a3b339408c250bb3600/typeguard-4.5.1-py3-none-any.whl", hash = "sha256:44d2bf329d49a244110a090b55f5f91aa82d9a9834ebfd30bcc73651e4a8cc40", size = 36745, upload-time = "2026-02-19T16:09:01.6Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "wrapt" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c", size = 60669, upload-time = "2026-03-06T02:52:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f", size = 61603, upload-time = "2026-03-06T02:54:21.032Z" }, + { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb", size = 113632, upload-time = "2026-03-06T02:53:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e", size = 115644, upload-time = "2026-03-06T02:54:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba", size = 112016, upload-time = "2026-03-06T02:54:43.274Z" }, + { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f", size = 114823, upload-time = "2026-03-06T02:54:29.392Z" }, + { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394", size = 111244, upload-time = "2026-03-06T02:54:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45", size = 113307, upload-time = "2026-03-06T02:54:12.428Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", hash = "sha256:d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d", size = 57986, upload-time = "2026-03-06T02:54:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71", size = 60336, upload-time = "2026-03-06T02:54:18Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", hash = "sha256:1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc", size = 58757, upload-time = "2026-03-06T02:53:51.545Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb", size = 60666, upload-time = "2026-03-06T02:52:58.934Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d", size = 61601, upload-time = "2026-03-06T02:53:00.487Z" }, + { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894", size = 114057, upload-time = "2026-03-06T02:52:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842", size = 116099, upload-time = "2026-03-06T02:54:56.74Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8", size = 112457, upload-time = "2026-03-06T02:53:52.842Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6", size = 115351, upload-time = "2026-03-06T02:53:32.684Z" }, + { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9", size = 111748, upload-time = "2026-03-06T02:53:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15", size = 113783, upload-time = "2026-03-06T02:53:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", hash = "sha256:5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b", size = 57977, upload-time = "2026-03-06T02:53:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1", size = 60336, upload-time = "2026-03-06T02:54:28.104Z" }, + { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a", size = 58756, upload-time = "2026-03-06T02:53:16.319Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" }, + { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" }, + { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" }, + { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, +] + +[[package]] +name = "xchem-hippo" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "apsw" }, + { name = "chardet" }, + { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "gemmi" }, + { name = "hippo-plot" }, + { name = "hirsch" }, + { name = "ipywidgets" }, + { name = "jupyterlab" }, + { name = "molparse" }, + { name = "mpytools" }, + { name = "mrich" }, + { name = "neo4j" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "openmm" }, + { name = "openpyxl" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pdbfixer" }, + { name = "psycopg", extra = ["binary"] }, + { name = "python-louvain" }, + { name = "rdkit" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "syndirella" }, + { name = "typer" }, + { name = "yattag" }, +] + +[package.dev-dependencies] +dev = [ + { name = "commitizen" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "apsw", specifier = ">=3.52" }, + { name = "chardet", specifier = ">=7" }, + { name = "django", specifier = ">=5.2.12" }, + { name = "gemmi", specifier = ">=0.7.5" }, + { name = "hippo-plot", specifier = ">=0.0.1" }, + { name = "hirsch", specifier = ">=0.1" }, + { name = "ipywidgets", specifier = ">=8.1" }, + { name = "jupyterlab", specifier = ">=4.5" }, + { name = "molparse", specifier = ">=0.0.41" }, + { name = "mpytools", specifier = ">=0.0.21" }, + { name = "mrich", specifier = ">=1.0" }, + { name = "neo4j", specifier = ">=6.1.0" }, + { name = "networkx", specifier = ">=3.4" }, + { name = "openmm", specifier = ">=8.4" }, + { name = "openpyxl", specifier = ">=3.1" }, + { name = "pandas", specifier = ">=2.3" }, + { name = "pdbfixer", specifier = ">=1.12.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3" }, + { name = "python-louvain", specifier = ">=0.16" }, + { name = "rdkit", specifier = "==2025.9.5" }, + { name = "scikit-learn", specifier = ">=1.7" }, + { name = "syndirella", specifier = ">=5.0.7a0" }, + { name = "typer", specifier = ">=0.24.1" }, + { name = "yattag", specifier = ">=1.16" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "commitizen", specifier = ">=4.13.5,<5" }, + { name = "mypy", specifier = ">=1.19" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.2,<10" }, + { name = "ruff", specifier = ">=0.15.2" }, + { name = "ty", specifier = ">=0.0.19" }, +] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "yattag" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/1a/d3b2a2b8f843f5e7138471c4a5c9172ef62bb41239aa4371784b7448110c/yattag-1.16.1.tar.gz", hash = "sha256:baa8f254e7ea5d3e0618281ad2ff5610e0e5360b3608e695c29bfb3b29d051f4", size = 29069, upload-time = "2024-11-02T22:38:30.443Z" } + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From e29185a0e9b418795cd8c6434b9ae43297d5f8ee Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 11 Mar 2026 15:01:08 +0000 Subject: [PATCH 121/163] fix: added .dockerignore --- .dockerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.dockerignore b/.dockerignore index fb632dc..70d691f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -37,4 +37,3 @@ Thumbs.db #Python cache files __pycache__ - From 3519fb37221d223fdde9a4af68c1842dc65dc8d4 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Sat, 14 Mar 2026 09:39:13 +0000 Subject: [PATCH 122/163] feat: schema working Can initialise sqlite database. Some doubts about schema, field types, null restrictions, etc, but that comes later --- pyproject.toml | 5 + src/designdb/__init__.py | 1 + src/designdb/admin.py | 1 + src/designdb/animal.py | 96 ++++ src/designdb/apps.py | 5 + src/designdb/django_setup.py | 40 ++ src/designdb/models.py | 831 +++++++++++++++++++++++++++++++++++ src/designdb/tests.py | 1 + src/designdb/views.py | 1 + src/manage.py | 23 + src/xchem_hippo/__init__.py | 0 src/xchem_hippo/asgi.py | 16 + src/xchem_hippo/settings.py | 123 ++++++ src/xchem_hippo/urls.py | 23 + src/xchem_hippo/wsgi.py | 16 + uv.lock | 79 ++++ 16 files changed, 1261 insertions(+) create mode 100644 src/designdb/__init__.py create mode 100644 src/designdb/admin.py create mode 100644 src/designdb/animal.py create mode 100644 src/designdb/apps.py create mode 100644 src/designdb/django_setup.py create mode 100644 src/designdb/models.py create mode 100644 src/designdb/tests.py create mode 100644 src/designdb/views.py create mode 100755 src/manage.py create mode 100644 src/xchem_hippo/__init__.py create mode 100644 src/xchem_hippo/asgi.py create mode 100644 src/xchem_hippo/settings.py create mode 100644 src/xchem_hippo/urls.py create mode 100644 src/xchem_hippo/wsgi.py diff --git a/pyproject.toml b/pyproject.toml index 3ac1651..278bd47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,8 @@ dependencies = [ # - this is probably present in diamond environments, # - can use conda install in container # - but.. probs don't even need it, it's only used for expressions and I got that covered + "django-rdkit", + "environs>=14.6.0", ] [dependency-groups] @@ -116,3 +118,6 @@ exclude = [ include = [ "hippo/*.py", ] + +[tool.uv.sources] +django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } diff --git a/src/designdb/__init__.py b/src/designdb/__init__.py new file mode 100644 index 0000000..d4a862e --- /dev/null +++ b/src/designdb/__init__.py @@ -0,0 +1 @@ +from .animal import HIPPO diff --git a/src/designdb/admin.py b/src/designdb/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/src/designdb/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/src/designdb/animal.py b/src/designdb/animal.py new file mode 100644 index 0000000..120ed41 --- /dev/null +++ b/src/designdb/animal.py @@ -0,0 +1,96 @@ +"""Main animal class for HIPPO""" + +from pathlib import Path + +import mrich + +from .django_setup import configure_django + +# can't import modules, because forces loading models before they're ready + + +class HIPPO: + """The :class:`.HIPPO` `animal` class. Instantiating a :class:`.HIPPO` object will create or link a :class:`.HIPPO` :class:`.Database`. + + :: + + from hippo import HIPPO + animal = HIPPO(project_name, db_path) + + .. attention:: + + In addition to this API reference please see the tutorial pages :doc:`getting_started` and :doc:`insert_elaborations`. + + :param project_name: give this :class:`.HIPPO` a name + :param db_path: path where the :class:`.Database` will be stored + :param copy_from: optionally initialise this animal by copying the :class:`.Database` at this given path, defaults to None + :returns: :class:`.HIPPO` object + """ + + def __init__( + self, + name: str, + db: str | Path | dict, + copy_from: str | Path | None = None, + overwrite_existing: bool = False, + update_legacy: bool = False, + ) -> None: + """HIPPO initialisation""" + + mrich.bold('Creating HIPPO animal') + + self._name = name + + mrich.var('name', name, color='arg') + + if isinstance(db, dict): + ### POSTGRES + pass + # from .postgres import PostgresDatabase + + # self._db = PostgresDatabase(animal=self, **db) + # configure_django(db, manage_models=False) + + else: + ### INITIALISE SQLITE DATABASE + + # from .db import Database + + db_path = Path(db) + + mrich.var('db_path', db_path, color='file') + + # if copy_from: + # self._db = Database.copy_from( + # source=copy_from, + # destination=db_path, + # animal=self, + # update_legacy=update_legacy, + # overwrite_existing=overwrite_existing, + # ) + # else: + # self._db = Database(db_path, animal=self, update_legacy=update_legacy) + + configure_django(db_path, manage_models=True) + + from django.apps import apps + from django.db import connection + + with connection.schema_editor() as schema_editor: + for model in apps.get_models(): + if model._meta.managed: + schema_editor.create_model(model) + + # self._compounds = CompoundTable(self.db) + # self._poses = PoseTable(self.db) + # self._tags = TagTable(self.db) + # self._reactions = ReactionTable(self.db) + + # ### in memory subsets + # self._reactants = None + # self._products = None + # self._intermediates = None + # self._scaffolds = None + # self._elabs = None + + mrich.success('Initialised animal', f'[var_name]{self}') diff --git a/src/designdb/apps.py b/src/designdb/apps.py new file mode 100644 index 0000000..f5477dd --- /dev/null +++ b/src/designdb/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DesigndbConfig(AppConfig): + name = 'designdb' diff --git a/src/designdb/django_setup.py b/src/designdb/django_setup.py new file mode 100644 index 0000000..4668498 --- /dev/null +++ b/src/designdb/django_setup.py @@ -0,0 +1,40 @@ +import django +from django.conf import settings + + +def configure_django(db_config, manage_models): + + if settings.configured: + return + + if manage_models: + # sqlite3 db, create and manage models + database = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': db_config, + } + else: + # postgres, existing installation, don't touch + database = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': '...', + 'USER': '...', + 'PASSWORD': '...', + 'HOST': '...', + 'OPTIONS': { + # sets the schema + 'options': '-c search_path=designdb' + }, + } + + settings.configure( + INSTALLED_APPS=[ + 'designdb.apps.DesigndbConfig', + ], + DATABASES={'default': database}, + TIME_ZONE='UTC', + USE_TZ=True, + MANAGE_MODELS=manage_models, + ) + + django.setup() diff --git a/src/designdb/models.py b/src/designdb/models.py new file mode 100644 index 0000000..dd831f9 --- /dev/null +++ b/src/designdb/models.py @@ -0,0 +1,831 @@ +# from django.db.models import indexes +from django.conf import settings +from django.db import models + +_MANAGE_MODELS = settings.MANAGE_MODELS + + +if settings.MANAGE_MODELS: + # sqlite3, rdkit field types not available + from django.db.models import BinaryField as BfpField + + # shouldn't this be binary as well? + from django.db.models import TextField as MolField +else: + from django_rdkit.models import BfpField, MolField + + +class BaseModel(models.Model): + created_on = models.DateTimeField(null=True, blank=True) + updated_on = models.DateTimeField(null=True, blank=True) + + class Meta: + abstract = True + # managed = False + managed = _MANAGE_MODELS + app_label = 'designdb' + default_related_name = '%(class)ss' + + +class Target(BaseModel): + id = models.BigAutoField(primary_key=True) + external_target_id = models.BigIntegerField(null=True, blank=True) + target_name = models.TextField() + target_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'targets' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target_name', + ], + name='uc_target', + ), + ] + indexes = [ + models.Index(fields=['target_name'], name='idx_target_name'), + models.Index(fields=['created_on'], name='idx_target_created'), + ] + + +class Compound(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_inchikey = models.TextField(null=True, blank=True) + compound_alias = models.TextField(null=True, blank=True) + compound_smiles = models.TextField(null=True, blank=True) + + base_compound = models.ForeignKey( + 'self', + null=True, + blank=True, + on_delete=models.SET_NULL, + db_column='base_compound_id', + related_name='+', # add if needed + ) + + # compound_mol = models.TextField(null=True, blank=True) + # compound_pattern_bfp = models.TextField(null=True, blank=True) + # compound_morgan_bfp = models.TextField(null=True, blank=True) + compound_mol = MolField(null=True) + compound_pattern_bfp = BfpField(null=True) + compound_morgan_bfp = BfpField(null=True) + + compound_metadata = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + tags = models.ManyToManyField( + 'CompoundTag', + through='CompoundTagJunction', + related_name='compounds', + ) + + enumeration_methods = models.ManyToManyField( + 'EnumerationMethod', + through='CompoundEnumerationMethodJunction', + related_name='compounds', + ) + + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + scaffolds = models.ManyToManyField( + 'self', + through='Scaffold', + ) + + class Meta(BaseModel.Meta): + db_table = 'compounds' + constraints = [ + # I believe there were supposed to be changes to these + models.UniqueConstraint( + fields=[ + 'compound_alias', + ], + name='uc_compound_alias', + ), + models.UniqueConstraint( + fields=[ + 'compound_inchikey', + ], + name='uc_compound_inchikey', + ), + models.UniqueConstraint( + fields=[ + 'compound_smiles', + ], + name='uc_compound_smiles', + ), + ] + indexes = [ + models.Index(fields=['base_compound'], name='idx_base_compound_id'), + models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), + models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), + models.Index(fields=['created_on'], name='idx_compound_created'), + ] + + +class Subsite(BaseModel): + id = models.BigAutoField(primary_key=True) + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + subsite_name = models.TextField() + subsite_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsites' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target', + 'subsite_name', + ], + name='uc_subsite', + ), + ] + indexes = [ + models.Index(fields=['target'], name='idx_subsite_target_id'), + models.Index(fields=['created_on'], name='idx_subsite_created'), + ] + + +class Pose(BaseModel): + id = models.BigAutoField(primary_key=True) + + pose_inchikey = models.TextField(null=True, blank=True) + pose_alias = models.TextField(null=True, blank=True) + pose_smiles = models.TextField(null=True, blank=True) + + pose_reference = models.IntegerField(null=True, blank=True) + pose_path = models.TextField(null=True, blank=True) + + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + # pose_mol = models.TextField(null=True, blank=True) + pose_mol = MolField(null=True) + # this is integer in the db.. pretty sure this cannot be the case? + pose_fingerprint = models.IntegerField(null=True, blank=True) + + pose_metadata = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + methods = models.ManyToManyField( + 'PoseMethod', + through='PoseMethodJunction', + related_name='poses', + ) + tags = models.ManyToManyField( + 'PoseTag', + through='PoseTagJunction', + related_name='poses', + ) + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + inspirations = models.ManyToManyField( + 'self', + through='Inspiration', + ) + + class Meta(BaseModel.Meta): + db_table = 'poses' + # There are no constraints here, but they need to be unique, + # verified in code (rdkit.align_pose coords from + # file). Investigate adding coords to db and doing the search + # there + + # although.. would alias-target combo work? + indexes = [ + models.Index(fields=['compound'], name='idx_pose_compound_id'), + models.Index(fields=['target'], name='idx_pose_target_id'), + models.Index(fields=['pose_path'], name='idx_pose_path'), + models.Index(fields=['created_on'], name='idx_pose_created'), + ] + + +class SubsiteTag(BaseModel): + id = models.BigAutoField(primary_key=True) + subsite = models.ForeignKey( + Subsite, + on_delete=models.RESTRICT, + db_column='subsite_id', + ) + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + + subsite_tag_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsite_tags' + unique_together = ('subsite', 'pose') + constraints = [ + models.UniqueConstraint( + fields=[ + 'subsite', + 'pose', + ], + name='uc_subsite_tag', + ), + ] + indexes = [ + models.Index(fields=['subsite'], name='idx_subsite_tag_subsite_id'), + models.Index(fields=['pose'], name='idx_subsite_tag_pose_id'), + models.Index(fields=['created_on'], name='idx_subsite_tag_created'), + ] + + +class PoseMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_method_name = models.TextField(null=True, blank=True) + pose_method_description = models.TextField(null=True, blank=True) + pose_method_version = models.TextField(null=True, blank=True) + pose_method_organization = models.TextField(null=True, blank=True) + pose_method_link = models.TextField(null=True, blank=True) + pose_method_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_method_name', + 'pose_method_version', + ], + name='uc_pose_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['pose_method_name'], name='idx_pose_method_name'), + models.Index(fields=['created_on'], name='idx_pose_method_created'), + ] + + +class PoseMethodJunction(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_method_id') + pose = models.ForeignKey( + 'Pose', + on_delete=models.CASCADE, + db_column='pose_id', + ) + + pose_method = models.ForeignKey( + 'PoseMethod', + on_delete=models.CASCADE, + db_column='pose_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_methods' + indexes = [ + models.Index( + fields=['pose_method'], name='idx_has_pose_methods_pose_method_id' + ), + models.Index( + fields=['created_on'], name='idx_idx_has_pose_methods_created' + ), + ] + + +class PoseTag(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_tag_name = models.TextField() + pose_tag_description = models.TextField(null=True, blank=True) + pose_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_tag_name', + ], + name='uc_pose_tag', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_pose_tag_created'), + ] + + +class PoseTagJunction(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_tag_id') + pose = models.ForeignKey( + Pose, + on_delete=models.CASCADE, + db_column='pose_id', + ) + pose_tag = models.ForeignKey( + PoseTag, + on_delete=models.CASCADE, + db_column='pose_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_tags' + indexes = [ + models.Index(fields=['pose_tag'], name='idx_has_pose_tag_pose_tag_id'), + models.Index(fields=['created_on'], name='idx_has_pose_tag_created'), + ] + + +# this was missing.. is this a m2m table as well? really looks like it +class Inspiration(BaseModel): + id = models.BigAutoField(primary_key=True) + # original behaviour described in schema was SET_NULL but I don't + # see how that makes sense. if either original or derivative is + # deleted, you'll have orphaned entries + original_pose = models.ForeignKey( + Pose, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='original_pose_id', + related_name='+', + ) + derivative_pose = models.ForeignKey( + Pose, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='derivative_pose_id', + related_name='+', + ) + + class Meta(BaseModel.Meta): + db_table = 'inspirations' + constraints = [ + models.UniqueConstraint( + fields=[ + 'original_pose', + 'derivative_pose', + ], + name='uc_inspiration', + ) + ] + indexes = [ + models.Index( + fields=['original_pose'], name='idx_inspiration_original_pose_id' + ), + models.Index( + fields=['derivative_pose'], name='idx_inspiration_derivative_pose_id' + ), + models.Index(fields=['created_on'], name='idx_inspiration_created'), + ] + + +class Feature(BaseModel): + id = models.BigAutoField(primary_key=True) + feature_family = models.TextField(null=True, blank=True) + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + feature_chain_name = models.TextField(null=True, blank=True) + feature_residue_name = models.TextField(null=True, blank=True) + feature_residue_number = models.IntegerField(null=True, blank=True) + feature_atom_name = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'features' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature_family', + 'target', + 'feature_chain_name', + 'feature_residue_name', + 'feature_residue_number', + 'feature_atom_name', + ], + name='uc_feature', + ) + ] + indexes = [ + models.Index(fields=['target'], name='idx_feature_target_id'), + models.Index(fields=['created_on'], name='idx_feature_created'), + ] + + +class Interaction(BaseModel): + id = models.BigAutoField(primary_key=True) + feature = models.ForeignKey( + Feature, + on_delete=models.RESTRICT, + db_column='feature_id', + ) + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + + interaction_type = models.TextField() + interaction_family = models.TextField() + interaction_atom_id = models.TextField() + + # could these be vectors? + interaction_prot_coord = models.TextField() + interaction_lig_coord = models.TextField() + + interaction_distance = models.FloatField() + interaction_angle = models.FloatField(null=True, blank=True) + interaction_energy = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'interactions' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature', + 'pose', + 'interaction_type', + 'interaction_family', + 'interaction_atom_id', + ], + name='uc_interaction', + ) + ] + indexes = [ + models.Index(fields=['feature_id'], name='idx_interaction_feature_id'), + models.Index(fields=['pose'], name='idx_interaction_pose_id'), + models.Index(fields=['created_on'], name='idx_interaction_created'), + ] + + +class CompoundTag(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_tag_name = models.TextField() + compound_tag_description = models.TextField(null=True, blank=True) + compound_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'compound_tag_name', + ], + name='uc_compound_tag_name', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_compound_tag_created'), + ] + + +class CompoundTagJunction(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'compound_tag_id') + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + compound_tag = models.ForeignKey( + CompoundTag, + on_delete=models.CASCADE, + db_column='compound_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_compound_tags' + indexes = [ + models.Index( + fields=['compound_tag'], name='idx_has_compound_tag_compound_tag_id' + ), + models.Index(fields=['created_on'], name='idx_has_compound_tag_created'), + ] + + +class EnumerationMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + enum_name = models.TextField(null=True, blank=True) + enum_description = models.TextField(null=True, blank=True) + enum_version = models.TextField(null=True, blank=True) + enum_organization = models.TextField(null=True, blank=True) + enum_link = models.TextField(null=True, blank=True) + enum_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'enumeration_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'enum_name', + 'enum_version', + ], + name='uc_enumeration_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['enum_name'], name='idx_enumeration_method_name'), + models.Index(fields=['created_on'], name='idx_enumeration_method_created'), + ] + + +class CompoundEnumerationMethodJunction(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'enumeration_method_id') + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + enumeration_method = models.ForeignKey( + EnumerationMethod, + on_delete=models.CASCADE, + db_column='enumeration_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_enumeration_methods' + indexes = [ + models.Index( + fields=['enumeration_method'], + name='idx_has_enumeration_methods_enumeration_method_id', + ), + models.Index( + fields=['created_on'], name='idx_has_enumeration_methods_created' + ), + ] + + +class ScoringMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + method_name = models.TextField(null=True, blank=True) + method_description = models.TextField(null=True, blank=True) + method_version = models.TextField(null=True, blank=True) + method_organization = models.TextField(null=True, blank=True) + method_link = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'scoring_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'method_name', + 'method_version', + ], + name='uc_scoring_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['method_name'], name='idx_scoring_method_name'), + models.Index(fields=['created_on'], name='idx_scoring_method_created'), + ] + + +class ScoreValue(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id') + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + related_name='scores', + ) + + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + related_name='scores', + ) + + scoring_method = models.ForeignKey( + ScoringMethod, + on_delete=models.RESTRICT, + db_column='scoring_method_id', + related_name='scores', + ) + + score = models.JSONField() + + class Meta(BaseModel.Meta): + db_table = 'score_values' + indexes = [ + models.Index(fields=['pose'], name='idx_score_values_pose_id'), + models.Index(fields=['compound'], name='idx_score_values_compound_id'), + models.Index( + fields=['scoring_method'], name='idx_score_values_scoring_method_id' + ), + models.Index(fields=['created_on'], name='idx_score_values_created'), + ] + + +class Reaction(BaseModel): + id = models.BigAutoField(primary_key=True) + reaction_type = models.TextField(null=True, blank=True) + product_compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + reaction_product_yield = models.FloatField(null=True, blank=True) + reaction_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'reactions' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_reaction_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_reaction_created'), + ] + + +class Reactant(BaseModel): + id = models.BigAutoField(primary_key=True) + reactant_amount = models.FloatField(null=True, blank=True) + reaction = models.ForeignKey( + Reaction, + on_delete=models.CASCADE, + db_column='reaction_id', + ) + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'reactants' + constraints = [ + models.UniqueConstraint( + fields=[ + 'reaction', + 'compound', + ], + name='uc_reactant', + ) + ] + indexes = [ + models.Index(fields=['reaction'], name='idx_reactant_reaction_id'), + models.Index(fields=['compound'], name='idx_reactant_compound_id'), + models.Index(fields=['created_on'], name='idx_reactant_created'), + ] + + +class Quote(BaseModel): + id = models.BigAutoField(primary_key=True) + quote_smiles = models.TextField(null=True, blank=True) + quote_amount = models.FloatField(null=True, blank=True) + quote_supplier = models.TextField(null=True, blank=True) + quote_catalogue = models.TextField(null=True, blank=True) + quote_entry = models.TextField(null=True, blank=True) + quote_lead_time = models.IntegerField(null=True, blank=True) + quote_price = models.FloatField(null=True, blank=True) + quote_currency = models.TextField(null=True, blank=True) + quote_purity = models.FloatField(null=True, blank=True) + quote_date = models.TextField(null=True, blank=True) + compound = models.ForeignKey( + Compound, + # sql schema speciefies SET_NULL. Doesn't seem right but not sure + null=True, + on_delete=models.SET_NULL, + db_column='compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'quotes' + constraints = [ + models.UniqueConstraint( + fields=[ + 'quote_amount', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + ], + name='uc_quote', + ) + ] + indexes = [ + models.Index(fields=['compound'], name='idx_quote_compound_id'), + models.Index(fields=['created_on'], name='idx_quote_created'), + ] + + +class Scaffold(BaseModel): + id = models.BigAutoField(primary_key=True) + # same comment as with inspiratons. original schema says SET_NULL + # but doesn't seem right + base_compound = models.ForeignKey( + Compound, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='base_compound_id', + related_name='scaffold_bases', + ) + superstructure_compound = models.ForeignKey( + Compound, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='superstructure_compound_id', + related_name='scaffold_superstructures', + ) + + class Meta(BaseModel.Meta): + db_table = 'scaffolds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'base_compound', + 'superstructure_compound', + ], + name='uc_scaffold', + ) + ] + indexes = [ + models.Index( + fields=['base_compound'], name='idx_scaffold_base_compound_id' + ), + models.Index( + fields=['superstructure_compound'], + name='idx_scaffold_superstructure_compound_id', + ), + models.Index(fields=['created_on'], name='idx_scaffold_created'), + ] + + +class Route(BaseModel): + id = models.BigAutoField(primary_key=True) + product_compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'routes' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_route_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_route_created'), + ] + + +class Component(BaseModel): + id = models.BigAutoField(primary_key=True) + route = models.ForeignKey( + Route, + on_delete=models.RESTRICT, + db_column='route_id', + ) + component_type = models.IntegerField(null=True, blank=True) + component_ref = models.IntegerField(null=True, blank=True) + component_amount = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'components' + constraints = [ + models.UniqueConstraint( + fields=[ + 'route', + 'component_ref', + 'component_type', + ], + name='uc_component', + ) + ] + indexes = [ + models.Index(fields=['route'], name='idx_component_route_id'), + models.Index(fields=['created_on'], name='idx_component_created'), + ] + + +# what follows is audit tables, indexes, materialised views (none), +# views, functions and triggers. I'm not sure I need them here, will create if do. + + +# these functions available in db +# CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol +# LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; diff --git a/src/designdb/tests.py b/src/designdb/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/src/designdb/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/src/designdb/views.py b/src/designdb/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/src/designdb/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/src/manage.py b/src/manage.py new file mode 100755 index 0000000..8333a56 --- /dev/null +++ b/src/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/src/xchem_hippo/__init__.py b/src/xchem_hippo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/xchem_hippo/asgi.py b/src/xchem_hippo/asgi.py new file mode 100644 index 0000000..a393821 --- /dev/null +++ b/src/xchem_hippo/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for xchem_hippo project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_asgi_application() diff --git a/src/xchem_hippo/settings.py b/src/xchem_hippo/settings.py new file mode 100644 index 0000000..c0a9282 --- /dev/null +++ b/src/xchem_hippo/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for xchem_hippo project. + +Generated by 'django-admin startproject' using Django 6.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path + +from environs import Env + +env = Env() +env.read_env() + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = env('SECRET_KEY') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = env.bool('DEBUG', default=False) + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'designdb.apps.DesigndbConfig', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'xchem_hippo.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'xchem_hippo.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + + +# database is configured dynamically. when ready for static setup, +# move postgres settings from django_setup.py +DATABASES = [] + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/src/xchem_hippo/urls.py b/src/xchem_hippo/urls.py new file mode 100644 index 0000000..dcf76c7 --- /dev/null +++ b/src/xchem_hippo/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for xchem_hippo project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/src/xchem_hippo/wsgi.py b/src/xchem_hippo/wsgi.py new file mode 100644 index 0000000..bc49854 --- /dev/null +++ b/src/xchem_hippo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for xchem_hippo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_wsgi_application() diff --git a/uv.lock b/uv.lock index 648e59c..46378a1 100644 --- a/uv.lock +++ b/uv.lock @@ -225,6 +225,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, + { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, + { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, + { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, + { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, + { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, + { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, + { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, + { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, + { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -762,6 +796,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, ] +[[package]] +name = "django-rdkit" +version = "0.4.0" +source = { git = "https://github.com/rdkit/django-rdkit#6dcf8f677ced66f1a9c51d086d746c01753aa23f" } + [[package]] name = "emoji" version = "2.15.0" @@ -771,6 +810,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, ] +[[package]] +name = "environs" +version = "14.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "python-dotenv" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/c7/94f97e6e74482a50b5fc798856b6cc06e8d072ab05a0b74cb5d87bd0d065/environs-14.6.0.tar.gz", hash = "sha256:ed2767588deb503209ffe4dd9bb2b39311c2e4e7e27ce2c64bf62ca83328d068", size = 35563, upload-time = "2026-02-20T04:02:08.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" }, +] + [[package]] name = "et-xmlfile" version = "2.0.0" @@ -1704,6 +1757,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, ] +[[package]] +name = "marshmallow" +version = "4.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/03/261af5efb3d3ce0e2db3fd1e11dc5a96b74a4fb76e488da1c845a8f12345/marshmallow-4.2.2.tar.gz", hash = "sha256:ba40340683a2d1c15103647994ff2f6bc2c8c80da01904cbe5d96ee4baa78d9f", size = 221404, upload-time = "2026-02-04T15:47:03.401Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/70/bb89f807a6a6704bdc4d6f850d5d32954f6c1965e3248e31455defdf2f30/marshmallow-4.2.2-py3-none-any.whl", hash = "sha256:084a9466111b7ec7183ca3a65aed758739af919fedc5ebdab60fb39d6b4dc121", size = 48454, upload-time = "2026-02-04T15:47:02.013Z" }, +] + [[package]] name = "matplotlib" version = "3.10.8" @@ -2733,6 +2799,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-json-logger" version = "4.0.0" @@ -3772,6 +3847,8 @@ dependencies = [ { name = "chardet" }, { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "django-rdkit" }, + { name = "environs" }, { name = "gemmi" }, { name = "hippo-plot" }, { name = "hirsch" }, @@ -3813,6 +3890,8 @@ requires-dist = [ { name = "apsw", specifier = ">=3.52" }, { name = "chardet", specifier = ">=7" }, { name = "django", specifier = ">=5.2.12" }, + { name = "django-rdkit", git = "https://github.com/rdkit/django-rdkit" }, + { name = "environs", specifier = ">=14.6.0" }, { name = "gemmi", specifier = ">=0.7.5" }, { name = "hippo-plot", specifier = ">=0.0.1" }, { name = "hirsch", specifier = ">=0.1" }, From 01e6c70c2d826119abe5cb3e03abe00d9beaf65a Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 10 Apr 2026 11:17:46 +0100 Subject: [PATCH 123/163] fix: worflow working (mostly) in both sqlite and postgres Mostly becaues seems data is not 100% correct? Some nan values in dfs and obviously I have no source files --- .pre-commit-config.yaml | 24 +- Dockerfile | 3 + Makefile | 4 + images/xchem-designdb/init-db/01_schema.sql | 894 ++++-- pyproject.toml | 16 +- src/__init__.py | 3 + src/bootstrap.py | 116 + src/designdb/__init__.py | 4 +- src/designdb/animal.py | 430 ++- src/designdb/chem.py | 397 +++ src/designdb/django_setup.py | 40 - src/designdb/ingredient.py | 267 ++ src/designdb/models.py | 265 +- src/designdb/price.py | 249 ++ src/designdb/recipe.py | 3074 +++++++++++++++++++ src/designdb/route.py | 219 ++ src/designdb/services/__init__.py | 0 src/designdb/services/compound.py | 136 + src/designdb/services/ingestion.py | 1065 +++++++ src/designdb/services/pose.py | 208 ++ src/designdb/services/reaction.py | 109 + src/designdb/services/route.py | 80 + src/designdb/services/score.py | 74 + src/designdb/sets/__init__.py | 0 src/designdb/sets/compound.py | 2511 +++++++++++++++ src/designdb/sets/interaction.py | 802 +++++ src/designdb/sets/pose.py | 2243 ++++++++++++++ src/designdb/sets/reaction.py | 362 +++ src/designdb/sets/route.py | 427 +++ src/designdb/utils.py | 341 ++ src/designdb/utils_frag.py | 197 ++ src/designdb/utils_xca.py | 39 + src/xchem_hippo/settings.py | 123 - uv.lock | 743 ++++- 34 files changed, 14722 insertions(+), 743 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/bootstrap.py create mode 100644 src/designdb/chem.py delete mode 100644 src/designdb/django_setup.py create mode 100644 src/designdb/ingredient.py create mode 100644 src/designdb/price.py create mode 100644 src/designdb/recipe.py create mode 100644 src/designdb/route.py create mode 100644 src/designdb/services/__init__.py create mode 100644 src/designdb/services/compound.py create mode 100644 src/designdb/services/ingestion.py create mode 100644 src/designdb/services/pose.py create mode 100644 src/designdb/services/reaction.py create mode 100644 src/designdb/services/route.py create mode 100644 src/designdb/services/score.py create mode 100644 src/designdb/sets/__init__.py create mode 100644 src/designdb/sets/compound.py create mode 100644 src/designdb/sets/interaction.py create mode 100644 src/designdb/sets/pose.py create mode 100644 src/designdb/sets/reaction.py create mode 100644 src/designdb/sets/route.py create mode 100644 src/designdb/utils.py create mode 100644 src/designdb/utils_frag.py create mode 100644 src/designdb/utils_xca.py delete mode 100644 src/xchem_hippo/settings.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 897e11d..86a31f0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,14 +31,20 @@ repos: language: system types: [python] - - id: mypy - name: mypy - entry: uv run mypy + - id: isort + name: isort + entry: uv run isort src language: system - pass_filenames: false + types: [python] - - id: ty-check - name: ty check - entry: uv run ty check - language: system - pass_filenames: false + # - id: mypy + # name: mypy + # entry: uv run mypy + # language: system + # pass_filenames: false + + # - id: ty-check + # name: ty check + # entry: uv run ty check + # language: system + # pass_filenames: false diff --git a/Dockerfile b/Dockerfile index 46e834b..57fa28d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,9 @@ ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" +# NB! force-install numpy because need newer version +RUN pip install numpy --upgrade + # notebooks USER 0 diff --git a/Makefile b/Makefile index f0038ab..bd61834 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ help: @echo " make lint Run ruff lint" @echo " make format Run ruff format" @echo " make typecheck Run mypy" + @echo " make isort Run isort" @echo " make check Run all checks" @echo " make test Run tests" @echo " make ci Simulate CI run" @@ -29,6 +30,9 @@ format: typecheck: uv run pre-commit run mypy --all-files +isort: + uv run pre-commit run isort --all-files + check: uv run pre-commit run --all-files diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index a308b13..c384eed 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -39,19 +39,25 @@ CREATE TABLE IF NOT EXISTS designdb.compounds ( compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? compound_alias TEXT, -- Maybe insert by the codebase. compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + compound_hash TEXT NOT NULL, -- Canonical identity hash from application pipeline (e.g. RDKit RegistrationHash after SuperParent); pair with rdkit_version for reproducibility base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code - compound_mol rdkit.mol, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Originally, maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge - compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. + compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) + -- compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + -- compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + -- compound_mol mol, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) + compound_pattern_bfp bfp, -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bfp, -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken compound_metadata TEXT, -- currently Null note TEXT, -- New column - rdkit_version TEXT, --Can be done by RDkit cartridge - inchi_version TEXT, -- Must be done by codebase + rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) + -- inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) + inchi_version TEXT, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_compound_alias UNIQUE (compound_alias), - CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), - CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) + updated_on TIMESTAMPTZ DEFAULT now() --comma thingy + -- CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + -- CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), -- comma thingy + -- CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) ); CREATE TABLE IF NOT EXISTS designdb.subsites ( @@ -73,7 +79,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( pose_path TEXT, compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, - pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge + pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge, Check with Kalev!!!!! pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. --pose_energy_score REAL, -- LR - redundant; use designdb.score_values --pose_distance_score REAL, -- LR - redundant; use designdb.score_values @@ -268,22 +274,49 @@ CREATE TABLE IF NOT EXISTS designdb.reactants ( CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) ); -CREATE TABLE IF NOT EXISTS designdb.quotes ( +-- Registration identity: one row per distinct catalogue_smiles (unique). catalogue_inchikey is NOT unique — +-- different SMILES strings can map to the same Standard InChIKey after cartridge normalization (trigger from SMILES). +-- catalogue_hash: application / loader pipeline (same algorithm as compounds.compound_hash); NOT unique — multiple +-- rows may share a hash when SuperParent/registration hash collapses stereoisomers differently than stored SMILES and that's the correct behaviour +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds ( + id BIGSERIAL PRIMARY KEY, + catalogue_smiles TEXT NOT NULL, + catalogue_inchikey TEXT NOT NULL, -- Populated by RDKit cartridge trigger from catalogue_smiles + catalogue_hash TEXT NOT NULL, -- Set by Enamine parsing script on insert/update; links via designdb.compound_catalogue_map + rdkit_version TEXT NOT NULL, + inchi_version TEXT NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uq_catalogue_compounds_smiles UNIQUE (catalogue_smiles), + CONSTRAINT ck_catalogue_compounds_hash_nonempty CHECK (length(trim(catalogue_hash)) > 0) +); + +-- Former quotes rows split out. supplier = old quote_catalogue; supplier_id = old quote_entry; vendor = old quote_supplier. +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices ( id BIGSERIAL PRIMARY KEY, - quote_smiles TEXT, - quote_amount REAL, - quote_supplier TEXT, - quote_catalogue TEXT, -- Catalogue (there are null values, plus BB, Full stock etc.) - quote_entry TEXT, -- This the catalogue number (supplier id) - quote_lead_time INTEGER, -- Days, weeks? - quote_price REAL, - quote_currency TEXT, - quote_purity REAL, -- Not percentage (e.g. 0.99) - quote_date TEXT, - compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, --Quote compound originally, mapped with compound_id + catalogue_id BIGINT NOT NULL REFERENCES designdb.catalogue_compounds (id) ON DELETE CASCADE, + vendor TEXT NOT NULL, + supplier TEXT, + supplier_id TEXT NOT NULL, + amount REAL NOT NULL, + price REAL, + currency TEXT, + purity REAL, + lead_time INTEGER, created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) + CONSTRAINT uc_catalogue_price UNIQUE (catalogue_id, vendor, supplier, supplier_id, amount) +); + +-- Many-to-many: compounds - catalogue price lines matched on shared identity hash (compound_hash = catalogue_hash). +CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, + match_hash TEXT NOT NULL, + created_on TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (compound_id, catalogue_price_id), + CONSTRAINT ck_compound_catalogue_map_match_hash_nonempty CHECK (length(trim(match_hash)) > 0) ); CREATE TABLE IF NOT EXISTS designdb.scaffolds ( @@ -332,8 +365,19 @@ CREATE TABLE IF NOT EXISTS designdb.components ( -- AUDIT TABLES -- ========================================================= --- Event audit for quotes (tracks INSERT/UPDATE/DELETE for data load change tracking) -CREATE TABLE IF NOT EXISTS designdb.quotes_event_audit ( +-- Event audit for catalogue_compounds (chemistry / identity rows) +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for catalogue_prices (vendor / pricing lines) +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), @@ -417,6 +461,7 @@ CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(crea CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_compound_hash ON designdb.compounds(compound_hash); CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); @@ -452,20 +497,37 @@ CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(f CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); -CREATE INDEX IF NOT EXISTS idx_quote_compound_id ON designdb.quotes(compound_id); -CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quotes(created_on); +-- UNIQUE(catalogue_smiles) supplies btree on catalogue_smiles; btree on catalogue_inchikey (non-unique) and catalogue_hash for lookups +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_created ON designdb.catalogue_compounds(created_on); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_inchikey ON designdb.catalogue_compounds(catalogue_inchikey); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_hash ON designdb.catalogue_compounds(catalogue_hash); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_catalogue_id ON designdb.catalogue_prices(catalogue_id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_created ON designdb.catalogue_prices(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_match_hash ON designdb.compound_catalogue_map(match_hash); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_catalogue_price_id ON designdb.compound_catalogue_map(catalogue_price_id); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_created ON designdb.compound_catalogue_map(created_on); -- ========================================================= -- AUDIT INDEXES -- ========================================================= -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id ON designdb.quotes_event_audit(id); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_operation ON designdb.quotes_event_audit(operation); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_at ON designdb.quotes_event_audit(changed_at); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_by ON designdb.quotes_event_audit(changed_by); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_old_gin ON designdb.quotes_event_audit USING GIN (old_values); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_new_gin ON designdb.quotes_event_audit USING GIN (new_values); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id_changed ON designdb.quotes_event_audit(id, changed_at DESC); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id ON designdb.catalogue_compounds_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_operation ON designdb.catalogue_compounds_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_at ON designdb.catalogue_compounds_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_by ON designdb.catalogue_compounds_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_old_gin ON designdb.catalogue_compounds_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_new_gin ON designdb.catalogue_compounds_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id_changed ON designdb.catalogue_compounds_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id ON designdb.catalogue_prices_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_operation ON designdb.catalogue_prices_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_at ON designdb.catalogue_prices_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_by ON designdb.catalogue_prices_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_old_gin ON designdb.catalogue_prices_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_new_gin ON designdb.catalogue_prices_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id_changed ON designdb.catalogue_prices_event_audit(id, changed_at DESC); CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); @@ -534,108 +596,108 @@ CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_e CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); --- ========================================================= --- MATERIALIZED VIEWS --- ========================================================= --- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). --- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. - --- ========================================================= --- VIEWS --- ========================================================= - --- Shows quote updates captured via designdb.quotes_event_audit -CREATE OR REPLACE VIEW designdb.quotes_price_changes_v AS -SELECT - a.id AS quote_id, - (NULLIF(COALESCE(a.new_values->>'compound_id', a.old_values->>'compound_id'), ''))::BIGINT AS compound_id, - COALESCE(a.new_values->>'quote_smiles', a.old_values->>'quote_smiles') AS quote_smiles, - (NULLIF(COALESCE(a.new_values->>'quote_amount', a.old_values->>'quote_amount'), ''))::DOUBLE PRECISION AS quote_amount, - COALESCE(a.new_values->>'quote_supplier', a.old_values->>'quote_supplier') AS quote_supplier, - COALESCE(a.new_values->>'quote_catalogue', a.old_values->>'quote_catalogue') AS quote_catalogue, - COALESCE(a.new_values->>'quote_entry', a.old_values->>'quote_entry') AS quote_entry, - (NULLIF(a.old_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_old, - (NULLIF(a.new_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_new, - COALESCE(a.new_values->>'quote_currency', a.old_values->>'quote_currency') AS quote_currency, - (NULLIF(COALESCE(a.new_values->>'quote_purity', a.old_values->>'quote_purity'), ''))::DOUBLE PRECISION AS quote_purity, - a.changed_at -FROM designdb.quotes_event_audit a -WHERE a.operation = 'U'; - --- Pose tags: UPDATE events with old/new name, description, note. -CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS -SELECT - a.id AS pose_tag_id, - a.old_values->>'pose_tag_name' AS pose_tag_name_old, - a.new_values->>'pose_tag_name' AS pose_tag_name_new, - a.old_values->>'pose_tag_description' AS pose_tag_description_old, - a.new_values->>'pose_tag_description' AS pose_tag_description_new, - a.old_values->>'pose_tag_note' AS pose_tag_note_old, - a.new_values->>'pose_tag_note' AS pose_tag_note_new, - a.changed_by, - a.changed_at -FROM designdb.pose_tags_event_audit a -WHERE a.operation = 'U'; - --- Compound tags: UPDATE events with old/new name, description, note. -CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS -SELECT - a.id AS compound_tag_id, - a.old_values->>'compound_tag_name' AS compound_tag_name_old, - a.new_values->>'compound_tag_name' AS compound_tag_name_new, - a.old_values->>'compound_tag_description' AS compound_tag_description_old, - a.new_values->>'compound_tag_description' AS compound_tag_description_new, - a.old_values->>'compound_tag_note' AS compound_tag_note_old, - a.new_values->>'compound_tag_note' AS compound_tag_note_new, - a.changed_by, - a.changed_at -FROM designdb.compound_tags_event_audit a -WHERE a.operation = 'U'; - --- Pose methods: UPDATE events with old/new name, description, version, etc. -CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS -SELECT - a.id AS pose_method_id, - a.old_values->>'pose_method_name' AS pose_method_name_old, - a.new_values->>'pose_method_name' AS pose_method_name_new, - a.old_values->>'pose_method_description' AS pose_method_description_old, - a.new_values->>'pose_method_description' AS pose_method_description_new, - a.old_values->>'pose_method_version' AS pose_method_version_old, - a.new_values->>'pose_method_version' AS pose_method_version_new, - a.changed_by, - a.changed_at -FROM designdb.pose_methods_event_audit a -WHERE a.operation = 'U'; - --- Enumeration methods: UPDATE events with old/new name, description, version, etc. -CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS -SELECT - a.id AS enumeration_method_id, - a.old_values->>'enum_name' AS enum_name_old, - a.new_values->>'enum_name' AS enum_name_new, - a.old_values->>'enum_description' AS enum_description_old, - a.new_values->>'enum_description' AS enum_description_new, - a.old_values->>'enum_version' AS enum_version_old, - a.new_values->>'enum_version' AS enum_version_new, - a.changed_by, - a.changed_at -FROM designdb.enumeration_methods_event_audit a -WHERE a.operation = 'U'; - --- Scoring methods: UPDATE events with old/new name, description, version, etc. -CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS -SELECT - a.id AS scoring_method_id, - a.old_values->>'method_name' AS method_name_old, - a.new_values->>'method_name' AS method_name_new, - a.old_values->>'method_description' AS method_description_old, - a.new_values->>'method_description' AS method_description_new, - a.old_values->>'method_version' AS method_version_old, - a.new_values->>'method_version' AS method_version_new, - a.changed_by, - a.changed_at -FROM designdb.scoring_methods_event_audit a -WHERE a.operation = 'U'; +-- -- ========================================================= +-- -- MATERIALIZED VIEWS +-- -- ========================================================= +-- -- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- -- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. + +-- -- ========================================================= +-- -- VIEWS +-- -- ========================================================= + +-- -- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) +-- CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS +-- SELECT +-- a.id AS catalogue_price_id, +-- (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, +-- COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, +-- COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, +-- COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, +-- (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, +-- (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, +-- (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, +-- COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, +-- (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, +-- (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, +-- a.changed_at +-- FROM designdb.catalogue_prices_event_audit a +-- WHERE a.operation = 'U'; + +-- -- Pose tags: UPDATE events with old/new name, description, note. +-- CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +-- SELECT +-- a.id AS pose_tag_id, +-- a.old_values->>'pose_tag_name' AS pose_tag_name_old, +-- a.new_values->>'pose_tag_name' AS pose_tag_name_new, +-- a.old_values->>'pose_tag_description' AS pose_tag_description_old, +-- a.new_values->>'pose_tag_description' AS pose_tag_description_new, +-- a.old_values->>'pose_tag_note' AS pose_tag_note_old, +-- a.new_values->>'pose_tag_note' AS pose_tag_note_new, +-- a.changed_by, +-- a.changed_at +-- FROM designdb.pose_tags_event_audit a +-- WHERE a.operation = 'U'; + +-- -- Compound tags: UPDATE events with old/new name, description, note. +-- CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +-- SELECT +-- a.id AS compound_tag_id, +-- a.old_values->>'compound_tag_name' AS compound_tag_name_old, +-- a.new_values->>'compound_tag_name' AS compound_tag_name_new, +-- a.old_values->>'compound_tag_description' AS compound_tag_description_old, +-- a.new_values->>'compound_tag_description' AS compound_tag_description_new, +-- a.old_values->>'compound_tag_note' AS compound_tag_note_old, +-- a.new_values->>'compound_tag_note' AS compound_tag_note_new, +-- a.changed_by, +-- a.changed_at +-- FROM designdb.compound_tags_event_audit a +-- WHERE a.operation = 'U'; + +-- -- Pose methods: UPDATE events with old/new name, description, version, etc. +-- CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +-- SELECT +-- a.id AS pose_method_id, +-- a.old_values->>'pose_method_name' AS pose_method_name_old, +-- a.new_values->>'pose_method_name' AS pose_method_name_new, +-- a.old_values->>'pose_method_description' AS pose_method_description_old, +-- a.new_values->>'pose_method_description' AS pose_method_description_new, +-- a.old_values->>'pose_method_version' AS pose_method_version_old, +-- a.new_values->>'pose_method_version' AS pose_method_version_new, +-- a.changed_by, +-- a.changed_at +-- FROM designdb.pose_methods_event_audit a +-- WHERE a.operation = 'U'; + +-- -- Enumeration methods: UPDATE events with old/new name, description, version, etc. +-- CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +-- SELECT +-- a.id AS enumeration_method_id, +-- a.old_values->>'enum_name' AS enum_name_old, +-- a.new_values->>'enum_name' AS enum_name_new, +-- a.old_values->>'enum_description' AS enum_description_old, +-- a.new_values->>'enum_description' AS enum_description_new, +-- a.old_values->>'enum_version' AS enum_version_old, +-- a.new_values->>'enum_version' AS enum_version_new, +-- a.changed_by, +-- a.changed_at +-- FROM designdb.enumeration_methods_event_audit a +-- WHERE a.operation = 'U'; + +-- -- Scoring methods: UPDATE events with old/new name, description, version, etc. +-- CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +-- SELECT +-- a.id AS scoring_method_id, +-- a.old_values->>'method_name' AS method_name_old, +-- a.new_values->>'method_name' AS method_name_new, +-- a.old_values->>'method_description' AS method_description_old, +-- a.new_values->>'method_description' AS method_description_new, +-- a.old_values->>'method_version' AS method_version_old, +-- a.new_values->>'method_version' AS method_version_new, +-- a.changed_by, +-- a.changed_at +-- FROM designdb.scoring_methods_event_audit a +-- WHERE a.operation = 'U'; -- ========================================================= -- FUNCTIONS @@ -644,7 +706,7 @@ WHERE a.operation = 'U'; -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND WRAPPERS -- ========================================================= --- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey (used by compound, pose, and quote triggers). +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (used by compound, pose, and catalogue_compounds triggers). CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; @@ -655,10 +717,13 @@ CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; +CREATE OR REPLACE FUNCTION designdb.mol_to_ctab(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_ctab(m); $$; + -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND TRIGGER -- ========================================================= --- Input: compound_smiles (inserted by application). Populates compound_mol and compound_inchikey. +-- Input: compound_smiles (inserted by application). Populates compound_mol (CTAB) and compound_inchikey. CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() RETURNS trigger @@ -671,7 +736,8 @@ BEGIN BEGIN v_mol := designdb.mol_from_smiles(NEW.compound_smiles); IF v_mol IS NOT NULL THEN - NEW.compound_mol := v_mol; + -- NEW.compound_mol := v_mol; -- store rdkit.mol (was default text form ~ SMILES over JDBC) + NEW.compound_mol := designdb.mol_to_ctab(v_mol); NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); END IF; EXCEPTION WHEN OTHERS THEN @@ -688,6 +754,41 @@ CREATE TRIGGER trg_populate_compound_cartridge_from_smiles FOR EACH ROW EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); +-- ========================================================= +-- RDKIT CARTRIDGE – CATALOGUE_COMPOUNDS TRIGGER +-- ========================================================= +-- Input: catalogue_smiles (inserted by application). Populates catalogue_inchikey (NOT NULL column). + +CREATE OR REPLACE FUNCTION designdb.populate_catalogue_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; + v_ik TEXT; +BEGIN + IF NEW.catalogue_smiles IS NULL OR btrim(NEW.catalogue_smiles) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: catalogue_smiles is required'; + END IF; + v_mol := designdb.mol_from_smiles(NEW.catalogue_smiles); + IF v_mol IS NULL THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_from_smiles returned NULL for catalogue_smiles'; + END IF; + v_ik := designdb.mol_to_inchikey(v_mol); + IF v_ik IS NULL OR btrim(v_ik) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_to_inchikey returned empty for catalogue_smiles'; + END IF; + NEW.catalogue_inchikey := v_ik; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_catalogue_cartridge_from_smiles ON designdb.catalogue_compounds; +CREATE TRIGGER trg_populate_catalogue_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF catalogue_smiles ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_catalogue_cartridge_from_smiles(); + -- ========================================================= -- RDKIT CARTRIDGE – POSE TRIGGER -- ========================================================= @@ -751,74 +852,150 @@ CREATE TRIGGER trg_check_score_values_compound_matches_pose -- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). -- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). -- Value in column: if score is numeric then JSONB number, if text then JSONB string. -CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() -RETURNS void +-- CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +-- RETURNS void +-- LANGUAGE plpgsql +-- AS $$ +-- DECLARE +-- select_qry text; +-- col text; +-- method_rec record; +-- score_txt text; +-- value_expr text; +-- numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +-- BEGIN +-- select_qry := 'SELECT sv.pose_id, sv.compound_id'; +-- FOR method_rec IN +-- SELECT m.id, m.method_name, m.method_version +-- FROM designdb.scoring_methods m +-- ORDER BY m.id +-- LOOP +-- col := regexp_replace( +-- trim(method_rec.method_name) || '_' || coalesce( +-- replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), +-- '' +-- ), +-- '[^a-zA-Z0-9_]', '_', 'g' +-- ) || '_m' || method_rec.id; +-- IF col <> '' AND col <> '_' THEN +-- col := quote_ident(col); +-- score_txt := '(sv.score->>' || quote_literal('score') || ')'; +-- value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) +-- || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; +-- select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id +-- || ' THEN ' || value_expr || ' END) AS ' || col; +-- END IF; +-- END LOOP; +-- select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; +-- EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; +-- EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; +-- EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; +-- END; +-- $$; + +-- CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +-- RETURNS trigger +-- LANGUAGE plpgsql +-- AS $$ +-- BEGIN +-- PERFORM designdb.create_scores_per_pose_pivoted_mv(); +-- RETURN NULL; +-- END; +-- $$; + +-- CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +-- RETURNS trigger +-- LANGUAGE plpgsql +-- AS $$ +-- BEGIN +-- REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; +-- RETURN NULL; +-- END; +-- $$; + +CREATE OR REPLACE FUNCTION designdb.update_updated_on() +RETURNS trigger AS $$ +BEGIN + NEW.updated_on = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- ========================================================= +-- Populate compound_catalogue_map when compounds and/or catalogue prices exist for the same registration hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +RETURNS trigger LANGUAGE plpgsql AS $$ -DECLARE - select_qry text; - col text; - method_rec record; - score_txt text; - value_expr text; - numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; BEGIN - select_qry := 'SELECT sv.pose_id, sv.compound_id'; - FOR method_rec IN - SELECT m.id, m.method_name, m.method_version - FROM designdb.scoring_methods m - ORDER BY m.id - LOOP - col := regexp_replace( - trim(method_rec.method_name) || '_' || coalesce( - replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), - '' - ), - '[^a-zA-Z0-9_]', '_', 'g' - ) || '_m' || method_rec.id; - IF col <> '' AND col <> '_' THEN - col := quote_ident(col); - score_txt := '(sv.score->>' || quote_literal('score') || ')'; - value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) - || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; - select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id - || ' THEN ' || value_expr || ' END) AS ' || col; + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; END IF; - END LOOP; - select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; - EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; - EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; - EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +-- When catalogue_hash changes on catalogue_compounds: refresh map rows for all price lines under that compound row. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - PERFORM designdb.create_scores_per_pose_pivoted_mv(); - RETURN NULL; + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; END; $$; -CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +-- When a catalogue_price row is inserted or its catalogue_id changes: link compounds by parent hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; - RETURN NULL; -END; -$$; + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; -CREATE OR REPLACE FUNCTION designdb.update_updated_on() -RETURNS trigger AS $$ -BEGIN - NEW.updated_on = now(); RETURN NEW; END; -$$ LANGUAGE plpgsql; +$$; -- ========================================================= -- AUDIT FUNCTIONS @@ -920,161 +1097,196 @@ $$ LANGUAGE plpgsql VOLATILE; -- TRIGGERS (updated_on) -- ========================================================= -DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; -CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; -CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; -CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv - AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods - FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); - -DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; -CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; -CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; -CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; -CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; -CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; -CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; -CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; -CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; -CREATE TRIGGER trg_score_values_refresh_pivoted_mv - AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values - FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); - -DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; -CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; -CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; -CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; -CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quotes; -CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quotes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- ========================================================= --- AUDIT TRIGGERS --- ========================================================= - -DROP TRIGGER IF EXISTS trg_quotes_event_audit ON designdb.quotes; -CREATE TRIGGER trg_quotes_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.quotes - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.quotes_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; -CREATE TRIGGER trg_pose_tags_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.pose_tags_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; -CREATE TRIGGER trg_compound_tags_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.compound_tags_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; -CREATE TRIGGER trg_pose_methods_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.pose_methods_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; -CREATE TRIGGER trg_enumeration_methods_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.enumeration_methods_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; -CREATE TRIGGER trg_scoring_methods_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods - FOR EACH ROW - EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.scoring_methods_event_audit', - 'id', - 'created_on,updated_on', - '' - ); - -DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; -CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; -CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; -CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- Removed due to replaced tables --- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; --- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; -CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; -CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; -CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; -CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; -CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +-- DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +-- CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +-- CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; -CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +-- -- DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +-- -- CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv +-- -- AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods +-- -- FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +-- DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +-- CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +-- CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +-- CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +-- CREATE TRIGGER trg_compound_catalogue_map_sync_compound +-- AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); + +-- DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +-- CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +-- CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +-- CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +-- CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +-- CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- -- DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +-- -- CREATE TRIGGER trg_score_values_refresh_pivoted_mv +-- -- AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values +-- -- FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +-- DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +-- CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +-- CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +-- CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +-- CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; +-- CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; +-- CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +-- CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue +-- AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); + +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +-- CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price +-- AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); + +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; +-- CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- -- ========================================================= +-- -- AUDIT TRIGGERS +-- -- ========================================================= + +-- DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; +-- CREATE TRIGGER trg_catalogue_compounds_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.catalogue_compounds_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; +-- CREATE TRIGGER trg_catalogue_prices_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.catalogue_prices_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +-- CREATE TRIGGER trg_pose_tags_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.pose_tags_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +-- CREATE TRIGGER trg_compound_tags_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.compound_tags_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +-- CREATE TRIGGER trg_pose_methods_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.pose_methods_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +-- CREATE TRIGGER trg_enumeration_methods_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.enumeration_methods_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +-- CREATE TRIGGER trg_scoring_methods_event_audit +-- AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods +-- FOR EACH ROW +-- EXECUTE FUNCTION designdb.event_audit_trigger( +-- 'designdb.scoring_methods_event_audit', +-- 'id', +-- 'created_on,updated_on', +-- '' +-- ); + +-- DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +-- CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +-- CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +-- CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- -- Removed due to replaced tables +-- -- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- -- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +-- CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +-- CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +-- CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +-- CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +-- CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +-- CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods -SELECT designdb.create_scores_per_pose_pivoted_mv(); +-- SELECT designdb.create_scores_per_pose_pivoted_mv(); diff --git a/pyproject.toml b/pyproject.toml index 278bd47..a1ed5b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ description = "Hit Interaction Profiling and Procurement Optimisation" readme = "README.md" # python version limit dictated by pandas -requires-python = ">=3.10,<3.13" +requires-python = ">=3.10,<3.14" requires = [] classifiers = [ "Programming Language :: Python :: 3", @@ -33,7 +33,7 @@ dependencies = [ "ipywidgets>=8.1", "networkx>=3.4", "openmm>=8.4", - "apsw>=3.52", + "apsw>=3.51", "python-louvain>=0.16", "psycopg[binary]>=3.3", "django>=5.2.12", @@ -52,6 +52,8 @@ dependencies = [ # - but.. probs don't even need it, it's only used for expressions and I got that covered "django-rdkit", "environs>=14.6.0", + "numpy>=1.26.4", + # "numpy>=2", # need it but not working ] [dependency-groups] @@ -62,6 +64,7 @@ dev = [ "mypy>=1.19", "commitizen>=4.13.5,<5", "pre-commit>=4.5.1", + "isort>=8.0.1", ] [tool.commitizen] @@ -93,7 +96,9 @@ target-version = "py313" exclude = [ "tests", "migrations", + "hippo", ] +force-exclude = true [tool.ruff.lint] select = [ @@ -104,6 +109,9 @@ select = [ "B", # bugbear ] +[tool.ruff.lint.pycodestyle] +ignore-overlong-task-comments = true + [tool.ruff.format] quote-style = "single" @@ -111,8 +119,12 @@ quote-style = "single" exclude = [ "migrations", "tests", + "hippo", ] +[tool.isort] +profile = "hug" +src_paths = ["src", "tests"] [tool.hatch.build] include = [ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e49f7b7 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +from .bootstrap import load_hippo as HIPPO + +__all__ = ['HIPPO'] diff --git a/src/bootstrap.py b/src/bootstrap.py new file mode 100644 index 0000000..24bec1d --- /dev/null +++ b/src/bootstrap.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +import django +import mrich +from django.conf import settings + +# fix path +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + + +def configure_django(db_config, manage_models: bool): + + if settings.configured: + return + + if manage_models: + # sqlite3 db, create and manage models + database = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': db_config, + } + else: + # postgres, existing installation, don't touch + # TODO: pass vars from dbconfig + database = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'designdb', + 'USER': 'postgres', + 'PASSWORD': 's_URzt7CWfWZ.AXD7RcF', + 'HOST': 'database', + 'PORT': '5432', + 'OPTIONS': { + # sets the schema + 'options': '-c search_path=rdkit,designdb' + }, + } + + settings.configure( + INSTALLED_APPS=[ + 'designdb.apps.DesigndbConfig', + ], + DATABASES={'default': database}, + SECRET_KEY='runtime', + DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', + TIME_ZONE='UTC', + USE_TZ=True, + MIGRATION_MODULES={'designdb': None}, + MANAGE_MODELS=manage_models, + ) + + django.setup() + + +def load_hippo( + target_name: str, + *, + db: str | Path | dict | None = None, + # copy_from: str | Path | None = None, + # overwrite_existing: bool = False, + # update_legacy: bool = False, +): + """Initialisation function for HIPPO object. + + User should not call HIPPO directly because the db needs to be initialised. + """ + + mrich.bold('Creating HIPPO animal') + mrich.var('target_name', target_name, color='arg') + + if db is None: + db = {} + + if isinstance(db, str): + # sqlite db + + db_path = Path(db) + + mrich.var('db_path', db_path, color='file') + + # if copy_from: + # self._db = Database.copy_from( + # source=copy_from, + # destination=db_path, + # animal=self, + # update_legacy=update_legacy, + # overwrite_existing=overwrite_existing, + # ) + # else: + # self._db = Database(db_path, animal=self, update_legacy=update_legacy) + + configure_django(db_path, manage_models=True) + + from django.apps import apps + from django.db import connection + + with connection.schema_editor() as schema_editor: + for model in apps.get_models(): + if model._meta.managed: + schema_editor.create_model(model) + + else: + # postgres db + # pass + + # self._db = PostgresDatabase(animal=self, **db) + configure_django(db, manage_models=False) + + # import .testmodule + from designdb.animal import HIPPO + + animal = HIPPO(target_name) + + mrich.success('Initialised animal', f'{target_name}') + return animal diff --git a/src/designdb/__init__.py b/src/designdb/__init__.py index d4a862e..967b94b 100644 --- a/src/designdb/__init__.py +++ b/src/designdb/__init__.py @@ -1 +1,3 @@ -from .animal import HIPPO +import logging + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/designdb/animal.py b/src/designdb/animal.py index 120ed41..64ca39d 100644 --- a/src/designdb/animal.py +++ b/src/designdb/animal.py @@ -1,96 +1,404 @@ """Main animal class for HIPPO""" +import logging +import re +from enum import Enum from pathlib import Path import mrich +import pandas as pd +from django.db import transaction -from .django_setup import configure_django +from .models import Pose, Target +from .services.ingestion import IngestionBatchResult, IngestionService +from .sets.pose import PoseSet +from .utils import make_warn_once_per_key -# can't import modules, because forces loading models before they're ready +logger = logging.getLogger(__name__) class HIPPO: - """The :class:`.HIPPO` `animal` class. Instantiating a :class:`.HIPPO` object will create or link a :class:`.HIPPO` :class:`.Database`. + """Entry-point class of the xchem-hippo package. - :: + Update: this is atm not being called directly by the user. + """ - from hippo import HIPPO - animal = HIPPO(project_name, db_path) + def __init__( + self, + target_name: str, + ) -> None: - .. attention:: + # TODO: user- or project based targets + self._target, _ = Target.objects.get_or_create(target_name=target_name) - In addition to this API reference please see the tutorial pages :doc:`getting_started` and :doc:`insert_elaborations`. + # TODO: the way this worked previously was it gave the HIPPO + # instance full access to the pose table. When working with + # multi-project central postgres db, this is almost certainly + # not what I want. How is it that I'm going to keep this + # updated? What does it mean upadte? Access to all objects + # along this target? - :param project_name: give this :class:`.HIPPO` a name - :param db_path: path where the :class:`.Database` will be stored - :param copy_from: optionally initialise this animal by copying the :class:`.Database` at this given path, defaults to None - :returns: :class:`.HIPPO` object - """ + # self._compounds = CompoundTable(self.db) + # self._poses = PoseSet(Pose.objects.all()) # <- NB! for testing + # self._tags = TagTable(self.db) + # self._reactions = ReactionTable(self.db) - def __init__( + # ### in memory subsets + # self._reactants = None + # self._products = None + # self._intermediates = None + # self._scaffolds = None + # self._elabs = None + + # @property + # def name(self) -> str: + # """Returns the project name + + # :returns: project name + # """ + # return self._name + + @property + def target(self) -> Target: + """Returns the target instance""" + return self._target + + # actually expected to return all poses. filtering in PoseTable + # class i.e. get_by_target. + + # Looks like I need to implement this. PoseService with some + # manager- and instance mthods as helpers? + + # Actually it's more compplex than this: in the original code + # there's PoseTable, and then there's PoseSet for a selection + @property + def poses(self): + """Return pose instances for this target""" + return Pose.objects.filter(target=self._target) + + @property + def num_poses(self) -> int: + """Total number of Poses in the Database""" + return self.poses.count() + + def add_hits( + self, + *, + metadata_csv: str | Path, + aligned_directory: str | Path, + tags: list | None = None, + skip: list | None = None, + # debug: bool = False, + # load_pose_mols: bool = False, + ) -> pd.DataFrame: + """Crystallographic hits from a Fragalysis download or XChemAlign alignment. + + For a Fragalysis download `aligned_directory` and `metadata_csv` + should point to the `aligned_files` and `metadata.csv` at the + root of the extracted download. + For an XChemAlign dataset the `aligned_directory` + should point to the `aligned_files`. + + :param target_name: Name of this protein :class:`.Target` + :param metadata_csv: Path to the metadata.csv from the Fragalysis download + :param aligned_directory: Path to the aligned_files directory + from the Fragalysis download + :param skip: optional list of observation names to skip + :param debug: bool: (Default value = False) + :returns: a DataFrame of metadata + + """ + + ### Process arguments + # NB! meta not required when loading XCA data + assert metadata_csv, 'metadata.csv required' + + assert aligned_directory, 'aligned_directory must be provided' + skip = skip or [] + tags = tags or ['hits'] + + if not isinstance(aligned_directory, Path): + aligned_directory = Path(aligned_directory) + + mrich.var('aligned_directory', aligned_directory) + + ### Determine data format + + # TODO: as it appears that users are currently only loading + # fragalysis data, XCA format is not supported. Leaving the + # format checks here to print a message for user + + class DataFormat(Enum): + """DataFormat enum""" + + Fragalysis_v2 = 1 + XChemAlign_v2 = 2 + XChemAlign_v3 = 3 + + def __str__(self) -> str: + """name""" + return self.name + + subdirs = list(aligned_directory.glob('*')) + + SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') + SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') + + fragalysis_subdirs_present = any( + SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs + ) + xca_subdirs_present = any( + SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs + ) + assert fragalysis_subdirs_present ^ xca_subdirs_present, ( + 'Unexpected mixed data format' + ) + + if fragalysis_subdirs_present: + data_format = DataFormat.Fragalysis_v2 + else: + if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): + data_format = DataFormat.XChemAlign_v3 + else: + data_format = DataFormat.XChemAlign_v2 + + mrich.error( + 'Loading XChemAlign data currently not supported.' + + ' Contact developers to enable this feature' + ) + + mrich.var('data_format', data_format) + + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_filesystem( + root_path=aligned_directory, + target=self.target, + skip_records=skip, + compound_tag_list=tags, + metadata_file=metadata_csv, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + # looking at the code, it seems to be the same, there are no + # skips between observations and dirs_parsed declaratiosn + mrich.var('#valid observations', result.attempts) + + # n_poses = self.num_poses + # n_poses = Pose.objects.count() + + mrich.var('#directories parsed', result.attempts) + mrich.var('#compounds registered', result.compounds_created) + mrich.var('#poses registered', result.poses_created) + + def load_sdf( self, - name: str, - db: str | Path | dict, - copy_from: str | Path | None = None, - overwrite_existing: bool = False, - update_legacy: bool = False, + *, + path: str | Path, + reference: int | Pose | None = None, + inspirations: list[int] | PoseSet | None = None, + compound_tags: None | list[str] = None, + pose_tags: None | list[str] = None, + mol_col: str = 'ROMol', + name_col: str = 'ID', + inspiration_col: str = 'ref_mols', + reference_col: str = 'ref_pdb', + inspiration_map: None | dict = None, + convert_floats: bool = True, + skip_equal_dict: dict | None = None, + skip_not_equal_dict: dict | None = None, ) -> None: - """HIPPO initialisation""" + """Add posed virtual hits from an SDF into the database. + + :param target: Name of the protein :class:`.Target` + :param path: Path to the SDF + :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` + :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's + :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` + :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` + :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` + :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` + :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` + :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` + :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids + :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` + :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` + :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` + :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` + :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` - mrich.bold('Creating HIPPO animal') + All non-name columns are added to the Pose metadata. + N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. + """ + # TODO: original code reads sdf into data frame. I don't see + # much point for this in this function. get rid of it at some + # point - self._name = name + if not isinstance(path, Path): + path = Path(path) - mrich.var('name', name, color='arg') + skip_equal_dict = skip_equal_dict or {} + skip_not_equal_dict = skip_not_equal_dict or {} - if isinstance(db, dict): - ### POSTGRES - pass - # from .postgres import PostgresDatabase + mrich.debug(f'{path=}') - # self._db = PostgresDatabase(animal=self, **db) - # configure_django(db, manage_models=False) + compound_tags = compound_tags or [] + pose_tags = pose_tags or [] + if isinstance(inspirations, PoseSet): + inspiration_list = list(inspirations.ids) + elif isinstance(inspirations, list): + # TODO: potentially check types + inspiration_list = inspirations else: - ### INITIALISE SQLITE DATABASE + inspiration_list = [] - # from .db import Database + if reference and isinstance(reference, Pose): + reference_id = reference.id + else: + reference_id = None - db_path = Path(db) + if inspiration_map is None: + inspiration_map = {} - mrich.var('db_path', db_path, color='file') + warn = make_warn_once_per_key() - # if copy_from: - # self._db = Database.copy_from( - # source=copy_from, - # destination=db_path, - # animal=self, - # update_legacy=update_legacy, - # overwrite_existing=overwrite_existing, - # ) - # else: - # self._db = Database(db_path, animal=self, update_legacy=update_legacy) + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_sdf( + file_path=path, + target=self.target, + compound_tag_list=compound_tags, + pose_tag_list=pose_tags, + mol_col=mol_col, + name_col=name_col, + inspiration_col=inspiration_col, + inspirations=inspiration_list, + reference_col=reference_col, + reference=reference_id, + skip_equal=skip_equal_dict, + skip_not_equal=skip_not_equal_dict, + convert_floats=convert_floats, + field_warning=warn, + inspiration_map=inspiration_map, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc - configure_django(db_path, manage_models=True) + # It's not clear what the original code was trying to do. I'm + # going to issue warning when number of compounds and poses + # was less than the number of compounds in sdf (not all were + # successfully parsed) but that may not have been the original + # intention + if result.attempts == result.compounds_created: + f = mrich.success + else: + f = mrich.warning - from django.apps import apps - from django.db import connection + f(f'{result.compounds_created} new compounds from {path}') - with connection.schema_editor() as schema_editor: - for model in apps.get_models(): - if model._meta.managed: - schema_editor.create_model(model) + if result.attempts == result.poses_created: + f = mrich.success + else: + f = mrich.warning - # self._compounds = CompoundTable(self.db) - # self._poses = PoseTable(self.db) - # self._tags = TagTable(self.db) - # self._reactions = ReactionTable(self.db) + f(f'{result.poses_created} new poses from {path}') - # ### in memory subsets - # self._reactants = None - # self._products = None - # self._intermediates = None - # self._scaffolds = None - # self._elabs = None + def add_syndirella_routes( + self, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + check_chemistry: bool = True, + register_routes: bool = True, + ) -> pd.DataFrame: + """Add routes found from syndirella --just_retro query""" + + try: + with transaction.atomic(): + result: IngestionBatchResult = ( + IngestionService.ingest_syndirella_routes( + pickle_path=pickle_path, + CAR_only=CAR_only, + pick_first=pick_first, + do_check_chemistry=check_chemistry, + register_routes=register_routes, + ) + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + def add_syndirella_elabs( + self, + df_path: str | Path, + max_energy_score: float | None = 0.0, + max_distance_score: float | None = 2.0, + require_intra_geometry_pass: bool = True, + reject_flags: list[str] | None = None, + register_reactions: bool = True, + dry_run: bool = False, + scaffold_route: 'Route | None' = None, + scaffold_compound: 'Compound | None' = None, + pose_tags: list[str] | None = None, + product_tags: list[str] | None = None, + ) -> pd.DataFrame: + """ + Load Syndirella elaboration compounds and poses from a pickled DataFrame + + :param df_path: Path to the pickled DataFrame + :param max_energy_score: Filter out poses with `∆∆G` above this value + :param max_distance_score: Filter out poses with `comRMSD` above this value + :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values + :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) + :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing + :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing + :param dry_run: Don't insert new records into the database (for debugging/testing) + :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] + :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] + :returns: annotated DataFrame + """ + + reject_flags = reject_flags or [ + 'one_of_multiple_products', + 'selectivity_issue_contains_reaction_atoms_of_both_reactants', + ] + + pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] + product_tags = product_tags or ['syndirella_product'] + + df_path = Path(df_path) + mrich.h3(df_path.name) + mrich.reading(df_path) + df = pd.read_pickle(df_path) + + # testing + # df = pd.read_csv(df_path.replace('.pkl.gz', '.csv')) - mrich.success('Initialised animal', f'[var_name]{self}') + try: + with transaction.atomic(): + result: pd.DataFrame = IngestionService.ingest_syndirella_elabs( + df=df, + # TODO: check if target eists + target=self.target, + reject_flags=reject_flags, + pose_tag_list=pose_tags, + product_tag_list=pose_tags, + max_energy_score=max_energy_score, + max_distance_score=max_distance_score, + require_intra_geometry_pass=require_intra_geometry_pass, + register_reactions=register_reactions, + scaffold_route=scaffold_route, + scaffold_compound=scaffold_compound, + ) + return result + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc diff --git a/src/designdb/chem.py b/src/designdb/chem.py new file mode 100644 index 0000000..654072d --- /dev/null +++ b/src/designdb/chem.py @@ -0,0 +1,397 @@ +"""functions for validating chemistry""" + +import mrich + +from designdb.models import Compound + +""" + +Checks +====== + +- Num heavy atoms difference +- Formula checks +- Num rings difference + +""" + +SUPPORTED_CHEMISTRY = { + 'Amidation': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Ester_amidation': { + 'heavy_atoms_diff': '>=3', + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': '>=1', '*': '*'}, + }, + }, + 'Williamson_ether_synthesis': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'N-Boc_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 2, 'C': 5, 'H': 8}, + }, + }, + 'TBS_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'C': 6, 'Si': 1, 'H': 14}, + }, + }, + 'Sp3-sp2_Suzuki_coupling': { + # "heavy_atoms_diff": 10, + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Sp2-sp2_Suzuki_coupling': { + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Buchwald-Hartwig_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Nucleophilic_substitution_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'N-nucleophilic_aromatic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'Reductive_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1}, # any halogen + }, + }, + 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': '>=1'}, + }, + }, + 'Steglich_esterification': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Benzyl_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + 'atomtype': { + 'removed': {'C': 7, 'H': 6}, + }, + }, + 'N-Bn_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + }, + 'Formation_of_urea_from_two_amines': { + 'heavy_atoms_diff': -2, + 'rings_diff': 0, + }, + 'Amide_Schotten-Baumann_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, + 'Nucleophilic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, +} + + +def check_reaction_types(types: list[str]) -> None: + """ + Prints a warning if any of the reaction type strings in ``types`` are not in ``SUPPORTED_CHEMISTRY`` + + :param types: A list of reaction type strings to check + """ + + for reaction_type in types: + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") + + +def check_chemistry( + reaction_type: str, + reactants: 'CompoundSet', + product: Compound, + debug: bool = False, +) -> bool: + """Check chemistry of given reaction""" + + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.var('reactants', reactants.ids) + mrich.var('product', product) + + raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') + + assert reactants + assert product + + CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] + + if 'heavy_atoms_diff' in CHEMISTRY: + check = check_count_diff( + 'heavy_atoms', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'rings_diff' in CHEMISTRY: + check = check_count_diff( + 'rings', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'atomtype' in CHEMISTRY: + check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) + if not check: + return False + + if debug: + mrich.success(f'{reaction_type}: All OK') + + return True + + +def check_count_diff( + check_type: str, + reaction_type: str, + reactants: 'CompoundSet', + product: 'Compound', + debug: bool = False, +): + """Check integer difference""" + + # get target value + diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] + + # get attribute name + attr = f'num_{check_type}' + + # get values + reac_count = getattr(reactants, attr) + prod_count = getattr(product, attr) + if debug: + mrich.var(f'#{check_type} reactants', reac_count) + if debug: + mrich.var(f'#{check_type} product', prod_count) + + # check against target value + if isinstance(diff, str): + assert diff.startswith('>='), diff + + diff = int(diff[2:]) + + if reac_count - prod_count < diff: + if debug: + mrich.error( + f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' + ) + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + else: + if reac_count - diff != prod_count: + if debug: + mrich.error(f'{reaction_type}: #{check_type} FAIL') + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + return True + + +def check_atomtype_diff( + reaction_type: str, + reactants: 'CompoundSet', + product: 'Compound', + debug: bool = False, +) -> bool: + """check atomtypes""" + + check_type = 'atomtype' + + # get values + reac = reactants.atomtype_dict + prod = product.atomtype_dict + + if debug: + mrich.var('reactants.atomtype_dict', str(reac)) + mrich.var('product.atomtype_dict', str(prod)) + + if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + removal = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=True, debug=debug + ) + + if not removal: + return False + + if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + addition = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=False, debug=debug + ) + + if not addition: + return False + + if debug: + mrich.success(f'{reaction_type}: atomtypes OK') + + return True + + +def check_specific_atomtype_diff( + reaction_type: str, + prod: 'Compound', + reac: 'Compound', + removal: bool = False, + debug: bool = False, +) -> bool: + """check specific atomtype difference""" + + if removal: + add_str = 'removed' + else: + add_str = 'added' + + add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] + + if not add_dict: + return True + + if debug: + mrich.var(add_str, str(add_dict)) + + for symbol, count in add_dict.items(): + if symbol == 'Ha': + p_count = halogen_count(prod) + r_count = halogen_count(reac) + + elif symbol == '*': + assert count == '*', (symbol, count) + if debug: + mrich.debug('Allowing wildcard atomtype differences') + continue + + else: + p_count = prod[symbol] if symbol in prod else 0 + r_count = reac[symbol] if symbol in reac else 0 + + if isinstance(count, str): + assert count.startswith('>='), (symbol, count) + + count = int(count[2:]) + + if removal and r_count - p_count < count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count < count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + else: + if removal and r_count - p_count != count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count != count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + return True + + +def halogen_count(atomtype_dict: dict[str, int]) -> int: + """Count halogens""" + count = 0 + symbols = ['F', 'Cl', 'Br', 'I'] + for symbol in symbols: + if symbol in atomtype_dict: + count += atomtype_dict[symbol] + return count + + +class InvalidChemistryError(Exception): + """Chemistry is not valid""" + + ... + + +class UnsupportedChemistryError(Exception): + """Chemistry is not supported""" + + ... diff --git a/src/designdb/django_setup.py b/src/designdb/django_setup.py deleted file mode 100644 index 4668498..0000000 --- a/src/designdb/django_setup.py +++ /dev/null @@ -1,40 +0,0 @@ -import django -from django.conf import settings - - -def configure_django(db_config, manage_models): - - if settings.configured: - return - - if manage_models: - # sqlite3 db, create and manage models - database = { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': db_config, - } - else: - # postgres, existing installation, don't touch - database = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': '...', - 'USER': '...', - 'PASSWORD': '...', - 'HOST': '...', - 'OPTIONS': { - # sets the schema - 'options': '-c search_path=designdb' - }, - } - - settings.configure( - INSTALLED_APPS=[ - 'designdb.apps.DesigndbConfig', - ], - DATABASES={'default': database}, - TIME_ZONE='UTC', - USE_TZ=True, - MANAGE_MODELS=manage_models, - ) - - django.setup() diff --git a/src/designdb/ingredient.py b/src/designdb/ingredient.py new file mode 100644 index 0000000..8bc0860 --- /dev/null +++ b/src/designdb/ingredient.py @@ -0,0 +1,267 @@ +import mcol +import mrich +import pandas as pd +from django.db.models import Exists, OuterRef, Q + +from designdb.models import CataloguePrice, CataloguePriceCompoundJunction, Compound + + +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, + compound: Compound, # or CatalogueCompound? + amount: float, + quote: CataloguePrice, + max_lead_time: float | None = None, + supplier: str | None = None, + ): + """Ingredient initialisation""" + + self._compound = compound + self._quote = quote + self._amount = amount + self._max_lead_time = max_lead_time + self._supplier = supplier + + def __str__(self) -> str: + """Plain string representation""" + return f'{self.amount:.2f}mg of C{self._compound.id}' + + 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)}' + + def __eq__(self, other) -> bool: + """Equality operator""" + + if self.compound != other.compound: + return False + + return self.amount == other.amount + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.Compound`""" + return getattr(self.compound, key) + + @classmethod + def from_compound( + cls, + compound: Compound, + 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 + + quote = cls.get_quotes( + compound=compound, + pick_cheapest=True, + min_amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + none=quote_none, + ) + + else: + quote = None + + return Ingredient( + compound=compound, + amount=amount, + quote=quote, + supplier=supplier, + max_lead_time=max_lead_time, + ) + + @classmethod + def get_quotes( + cls, + compound: Compound, + 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, + ): + """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 + + """ + + qs = CataloguePrice.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunction.objects.filter( + compound=compound, + catalogue_price=OuterRef('pk'), + ), + ), + ).filter( + has_compound=True, + ) + + if supplier: + if isinstance(supplier, str): + qs = qs.filter(supplier=supplier) + else: + qs = qs.filter(supplier__in=supplier) + + if not qs.exists(): + return None + + if max_lead_time: + qs = qs.filter(lead_time__lte=max_lead_time) + + if min_amount: + qs = qs.filter(amount__gte=min_amount) + + if not qs.exists(): + mrich.debug( + f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + ) + + if pick_cheapest: + return qs.order_by('price').first() + + if df: + return pd.DataFrame(qs.values()).drop(columns='compound') + + return qs + + ### METHODS + + def get_cheapest_quote_id( + self, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + ) -> 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. + """ + + query = Q(compound=self.compound) + + if supplier: + query &= Q(quote_supplier=supplier) + + if min_amount: + query &= Q(quote_amount__gte=min_amount) + + if max_lead_time: + query &= Q(quote_lead_time__lte=max_lead_time) + + return CataloguePrice.objects.filter(query).order_by('quote_price').first() + + ### PROPERTIES + + @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(self) -> int: + """Returns the ID of the associated :class:`.Quote`""" + return self._quote + + @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 = self.get_cheapest_quote_id( + min_amount=a, + max_lead_time=self._max_lead_time, + supplier=self._supplier, + none='quiet', + ) + + self._quote = quote + + 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 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 diff --git a/src/designdb/models.py b/src/designdb/models.py index dd831f9..9ffdacd 100644 --- a/src/designdb/models.py +++ b/src/designdb/models.py @@ -1,27 +1,88 @@ +from pathlib import Path + +import mrich # from django.db.models import indexes from django.conf import settings from django.db import models +from django.db.models import Q +from django.utils import timezone +from rdkit import Chem _MANAGE_MODELS = settings.MANAGE_MODELS +# Custom field type for text fields that store json. Once switchint to +# postgres, replace +class JSONTextField(models.TextField): + def from_db_value(self, value, expression, connection): + import json + + return json.loads(value) if value else {} + + def get_prep_value(self, value): + import json + + if isinstance(value, dict): + return json.dumps(value) + return value + + +class RDKitMolField(models.TextField): + """ + Stores RDKit molecules as MolBlock text (SDF format) in DB, + but returns RDKit Mol objects in Python. + """ + + description = 'RDKit molecule stored as MolBlock text' + + # ------------------------- + # DB → Python (read path) + # ------------------------- + def from_db_value(self, value, expression, connection): + if not value: + return None + return Chem.MolFromMolBlock(value) + + # ------------------------- + # Python → DB (write path) + # ------------------------- + def get_prep_value(self, value): + if value is None: + return None + + # Already serialized + if isinstance(value, str): + return value + + # RDKit Mol → MolBlock + if isinstance(value, Chem.Mol): + return Chem.MolToMolBlock(value) + + raise TypeError( + f'RDKitMolField only accepts RDKit Mol or MolBlock string, got {type(value)}' + ) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + return name, path, args, kwargs + + if settings.MANAGE_MODELS: # sqlite3, rdkit field types not available + # shouldn't this be binary as well? from django.db.models import BinaryField as BfpField - # shouldn't this be binary as well? - from django.db.models import TextField as MolField + from .models import RDKitMolField as MolField else: from django_rdkit.models import BfpField, MolField class BaseModel(models.Model): - created_on = models.DateTimeField(null=True, blank=True) - updated_on = models.DateTimeField(null=True, blank=True) + created_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + updated_on = models.DateTimeField(null=True, blank=True, default=timezone.now) class Meta: abstract = True - # managed = False managed = _MANAGE_MODELS app_label = 'designdb' default_related_name = '%(class)ss' @@ -49,11 +110,13 @@ class Meta(BaseModel.Meta): ] +# TODO: tautomer hashes class Compound(BaseModel): id = models.BigAutoField(primary_key=True) compound_inchikey = models.TextField(null=True, blank=True) compound_alias = models.TextField(null=True, blank=True) compound_smiles = models.TextField(null=True, blank=True) + compound_hash = models.TextField(null=False, blank=True, default='a') base_compound = models.ForeignKey( 'self', @@ -99,29 +162,30 @@ class Meta(BaseModel.Meta): db_table = 'compounds' constraints = [ # I believe there were supposed to be changes to these - models.UniqueConstraint( - fields=[ - 'compound_alias', - ], - name='uc_compound_alias', - ), + # models.UniqueConstraint( + # fields=[ + # 'compound_alias', + # ], + # name='uc_compound_alias', + # ), models.UniqueConstraint( fields=[ 'compound_inchikey', ], name='uc_compound_inchikey', ), - models.UniqueConstraint( - fields=[ - 'compound_smiles', - ], - name='uc_compound_smiles', - ), + # tautomers mess this up + # models.UniqueConstraint( + # fields=[ + # 'compound_smiles', + # ], + # name='uc_compound_smiles', + # ), ] indexes = [ - models.Index(fields=['base_compound'], name='idx_base_compound_id'), + # models.Index(fields=['base_compound'], name='idx_base_compound_id'), models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), - models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), + # models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), models.Index(fields=['created_on'], name='idx_compound_created'), ] @@ -181,7 +245,10 @@ class Pose(BaseModel): # this is integer in the db.. pretty sure this cannot be the case? pose_fingerprint = models.IntegerField(null=True, blank=True) - pose_metadata = models.TextField(null=True, blank=True) + # dicts dumped into that field, change to JSON? + # pose_metadata = models.TextField(null=True, blank=True) + pose_metadata = JSONTextField(null=True, blank=True) + # pose_metadata = models.JSONField(null=True, blank=True) note = models.TextField(null=True, blank=True) rdkit_version = models.TextField(null=True, blank=True) @@ -202,6 +269,12 @@ class Pose(BaseModel): inspirations = models.ManyToManyField( 'self', through='Inspiration', + symmetrical=False, + ) + + subsites = models.ManyToManyField( + Subsite, + through='SubsiteTag', ) class Meta(BaseModel.Meta): @@ -219,25 +292,59 @@ class Meta(BaseModel.Meta): models.Index(fields=['created_on'], name='idx_pose_created'), ] + @property + def mol_path(self) -> Path | None: + """Get Path to molecule file""" + path = Path(self.pose_path) + if path.name.endswith('.pdb'): + mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_ligand.mol' + ) + if not mol_path.exists(): + mol_path = path.parent / path.name.replace( + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_ligand.sdf') + if not mol_path.exists(): + mrich.error('Could not find ligand mol/sdf:', mol_path) + return None + return mol_path + elif path.name.endswith('.mol'): + return path + else: + raise NotImplementedError + + @property + def apo_path(self) -> Path | None: + """Get path to apo protein file""" + path = Path(self.pose_path) + if path.name.endswith('.pdb'): + apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_apo-desolv.pdb' + ) + if not apo_path.exists(): + return None + return apo_path + else: + raise NotImplementedError + class SubsiteTag(BaseModel): id = models.BigAutoField(primary_key=True) - subsite = models.ForeignKey( - Subsite, - on_delete=models.RESTRICT, - db_column='subsite_id', - ) pose = models.ForeignKey( Pose, on_delete=models.RESTRICT, db_column='pose_id', ) + subsite = models.ForeignKey( + Subsite, + on_delete=models.RESTRICT, + db_column='subsite_id', + ) subsite_tag_metadata = models.TextField(null=True, blank=True) class Meta(BaseModel.Meta): db_table = 'subsite_tags' - unique_together = ('subsite', 'pose') constraints = [ models.UniqueConstraint( fields=[ @@ -688,42 +795,98 @@ class Meta(BaseModel.Meta): ] -class Quote(BaseModel): +class CatalogueCompound(BaseModel): id = models.BigAutoField(primary_key=True) - quote_smiles = models.TextField(null=True, blank=True) - quote_amount = models.FloatField(null=True, blank=True) - quote_supplier = models.TextField(null=True, blank=True) - quote_catalogue = models.TextField(null=True, blank=True) - quote_entry = models.TextField(null=True, blank=True) - quote_lead_time = models.IntegerField(null=True, blank=True) - quote_price = models.FloatField(null=True, blank=True) - quote_currency = models.TextField(null=True, blank=True) - quote_purity = models.FloatField(null=True, blank=True) - quote_date = models.TextField(null=True, blank=True) - compound = models.ForeignKey( - Compound, - # sql schema speciefies SET_NULL. Doesn't seem right but not sure + catalogue_smiles = models.TextField(null=False, blank=True) + catalogue_inchikey = models.TextField(null=False, blank=True) + catalogue_hash = models.TextField(null=False, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'catalogue_compounds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'catalogue_smiles', + ], + name='uq_catalogue_compounds_smiles', + ), + models.CheckConstraint( + condition=Q(catalogue_hash__isnull=False) & Q(catalogue_hash__gt=''), + name='ck_catalogue_compounds_hash_nonempty', + ), + ] + + +class CataloguePrice(BaseModel): + id = models.BigAutoField(primary_key=True) + catalogue_compound = models.ForeignKey( + CatalogueCompound, null=True, - on_delete=models.SET_NULL, - db_column='compound_id', + on_delete=models.CASCADE, + db_column='catalogue_id', + ) + vendor = models.TextField(null=False, blank=True) + supplier = models.TextField(null=True, blank=True) + supplier_id = models.TextField(null=False, blank=True) + amount = models.FloatField(null=True, blank=True) + price = models.FloatField(null=True, blank=True) + currency = models.TextField(null=True, blank=True) + purity = models.FloatField(null=True, blank=True) + lead_time = models.IntegerField(null=True, blank=True) + + compounds = models.ManyToManyField( + Compound, + through='CataloguePriceCompoundJunction', + related_name='prices', ) class Meta(BaseModel.Meta): - db_table = 'quotes' + db_table = 'catalogue_prices' constraints = [ models.UniqueConstraint( fields=[ - 'quote_amount', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', + 'catalogue_compound', + 'vendor', + 'supplier', + 'supplier_id', + 'amount', ], - name='uc_quote', + name='uc_catalogue_price', ) ] - indexes = [ - models.Index(fields=['compound'], name='idx_quote_compound_id'), - models.Index(fields=['created_on'], name='idx_quote_created'), + + +class CataloguePriceCompoundJunction(BaseModel): + ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') + catalogue_price = models.ForeignKey( + CataloguePrice, + on_delete=models.CASCADE, + db_column='catalogue_price_id', + ) + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + + match_hash = models.TextField(null=False, blank=True) + + # Not needed, remove + # catalogue_inchikey = models.TextField(null=False, blank=True) + # supplier = models.TextField(null=True, blank=True) + # amount = models.FloatField(null=True, blank=True) + # price = models.FloatField(null=True, blank=True) + # lead_time = models.IntegerField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_catalogue_map' + constraints = [ + models.CheckConstraint( + condition=Q(match_hash__isnull=False) & Q(match_hash__gt=''), + name='ck_compound_catalogue_map_match_hash_nonempty', + ) ] diff --git a/src/designdb/price.py b/src/designdb/price.py new file mode 100644 index 0000000..397a605 --- /dev/null +++ b/src/designdb/price.py @@ -0,0 +1,249 @@ +"""Class for working with prices""" + +import mcol + +CURRENCIES = { + 'USD': '$', + 'EUR': '€', + 'GBP': '£', +} + + +class Price: + """Class to represent a certain amount of currency. Supported currencies: + + :: + + CURRENCIES = { + 'USD':'$', + 'EUR':'€', + 'GBP':'£', + } + + """ + + def __init__(self, amount: float | None, currency: str | None): + """Price initialisation""" + + if currency not in CURRENCIES: + assert currency is None, f'Unrecognised {currency=}' + assert not amount, f"Null Price can't have {amount=}" + amount = None + + if amount is not None: + amount = float(amount) + + self._amount = amount + self._currency = currency + + ### FACTORIES + + @classmethod + def null(cls) -> 'Price': + """Zero in any currency""" + self = cls.__new__(cls) + self.__init__(None, None) + return self + + @classmethod + def from_dict( + cls, + d: dict, + ) -> 'Price': + """Create a :class:`.Price` object from a dictionary: + + :: + + dict(amount: float, currency: str) + + :param d: dictionary in the above format: + + """ + self = cls.__new__(cls) + self.__init__(d['amount'], d['currency']) + return self + + ### PROPERTIES + + @property + def symbol(self) -> str: + """Currency symbol""" + return CURRENCIES[self.currency] + + @property + def currency(self) -> str: + """Currency string""" + return self._currency + + @property + def amount(self) -> float: + """Amount""" + return self._amountb + + @property + def is_null(self) -> bool: + """Is this :meth:`.Price.null` or zero?""" + return self.amount is None + + ### METHODS + + def get_dict(self) -> dict: + """Dictionary in the format: + + :: + + dict(amount: float, currency: str) + + """ + return dict(amount=self.amount, currency=self.currency) + + def copy(self) -> 'Price': + """Return a copy of this :class:`.Price`""" + return Price(amount=self.amount, currency=self.currency) + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + if self.currency is None: + return 'Null Price' + + return f'{self.symbol}{self.amount:.2f} {self.currency}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __add__(self, other: 'Price') -> 'Price': + """Add two :class:`.Price` objects + + :param other: :class:`.Price` object + :returns: :class:`.Price` object + + """ + + if other is None: + return self + + if other.is_null: + return self + + if self.is_null: + return other + + if self.currency != other.currency: + raise NotImplementedError( + f'Adding two different currencies: {self.currency} != {other.currency}' + ) + return Price(self.amount + other.amount, self.currency) + + def __truediv__(self, other: 'Price | float | int') -> 'Price | float': + """Divide this :class:`.Price` by another object + + :param other: :class:`.Price` or float or int + :returns: :class:`.Price` object or float + + """ + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return self + return Price(amount=self.amount / other, currency=self.currency) + + elif isinstance(other, Price): + assert self.currency == other.currency + assert not other.is_null + return self.amount / other.amount + + raise TypeError(f'Division not supported between Price and {type(other)}') + + def __mul__(self, other: 'Price | float | int') -> 'Price | float': + """Multiply this :class:`.Price` by another object + + :param other: :class:`.Price` or float or int + :returns: :class:`.Price` object or float + + """ + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return self + return Price(amount=self.amount * other, currency=self.currency) + + raise TypeError(f'Multiplication not supported between Price and {type(other)}') + + def __eq__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return other == 0 + return self.amount == other + + if self.is_null and other.is_null: + return True + + if self.is_null and not other.is_null: + return False + + if not self.is_null and other.is_null: + return False + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount == other.amount + + def __lt__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return False + return self.amount > other + + if self.is_null and other.is_null: + return False + + if self.is_null and not other.is_null: + return True + + if not self.is_null and other.is_null: + return False + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount < other.amount + + def __gt__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return False + return self.amount < other + + if self.is_null and other.is_null: + return False + + if self.is_null and not other.is_null: + return False + + if not self.is_null and other.is_null: + return True + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount > other.amount + + def __hash__(self) -> int: + """Allow for Prices to be hashed for comparison""" + if self.is_null: + return hash('NULL') + return hash(f'{self.currency} {self.amount}') diff --git a/src/designdb/recipe.py b/src/designdb/recipe.py new file mode 100644 index 0000000..5150c10 --- /dev/null +++ b/src/designdb/recipe.py @@ -0,0 +1,3074 @@ +"""Classes for working with Recipes (reaction networks)""" + +import mcol +import mrich + +from designdb.models import Compound, Reaction +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet + + +class Recipe: + """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" + + def __init__( + self, + *, + products: 'IngredientSet | None' = None, + reactants: 'IngredientSet | None' = None, + intermediates: 'IngredientSet | None' = None, + reactions: 'ReactionSet | None' = None, + compounds: 'IngredientSet | None' = None, + ) -> None: + """Recipe initialisation""" + + if products is None: + products = IngredientSet() + + if reactants is None: + reactants = IngredientSet() + + if intermediates is None: + intermediates = IngredientSet() + + if compounds is None: + compounds = IngredientSet() + + if reactions is None: + reactions = ReactionSet() + + # check typing + assert isinstance(products, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(compounds, IngredientSet) + assert isinstance(reactions, ReactionSet) + + self._products = products + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + self._compounds = compounds + self._hash = None + + self._score = None + + # caches + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None + + ### FACTORIES + + @classmethod + def from_reaction( + cls, + reaction, + amount=1, + *, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] = None, + reaction_reactant_cache: dict[int, bool] = None, + inner: bool = False, + get_ingredient_quotes: bool = True, + ) -> 'Recipe | list[Recipe]': + """Create a :class:`.Recipe` from a :class:`.Reaction` and its upstream dependencies + + :param reaction: reaction to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param quoted_only: bool: only allow reactants with quotes (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') + :param inner: used to indicate that this is a recursive call (Default value = False) + :param get_ingredient_quotes: get quotes for ingredients in this recipe + + """ + + assert isinstance(reaction, Reaction) + + if debug: + mrich.debug( + f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' + ) + mrich.debug(f'{reaction.product.id=}') + mrich.debug(f'{reaction.reactants.ids=}') + + if permitted_reactions: + assert reaction in permitted_reactions + # raise NotImplementedError + + recipe = cls.__new__(cls) + recipe.__init__( + products=IngredientSet( + [ + reaction.product.as_ingredient( + amount=amount, get_quote=get_ingredient_quotes + ) + ], + ), + reactants=IngredientSet([], supplier=supplier), + intermediates=IngredientSet([]), + reactions=ReactionSet([reaction.id], sort=False), + ) + + recipes = [recipe] + + if quoted_only or supplier: + if debug: + mrich.debug(f'Checking reactant_availability: {reaction=}') + if reaction_checking_cache and reaction.id in reaction_checking_cache: + ok = reaction_checking_cache[reaction.id] + print('reaction_checking_cache used') + else: + ok = reaction.check_reactant_availability(supplier=supplier) + # print('cache not used') + if reaction_checking_cache is not None: + reaction_checking_cache[reaction.id] = ok + if not ok: + if unavailable_reaction == 'error': + mrich.error(f'Reactants not available for {reaction=}') + if pick_cheapest: + return None + else: + return [] + + def get_reactant_amount_pairs(reaction: 'Reaction') -> list[tuple[int, float]]: + """Get pairs of reactant ID and float amounts""" + if reaction_reactant_cache and reaction.id in reaction_reactant_cache: + print('reaction_reactant_cache used') + return reaction_reactant_cache[reaction.id] + else: + pairs = reaction.get_reactant_amount_pairs(compound_object=False) + if reaction_reactant_cache is not None: + reaction_reactant_cache[reaction.id] = pairs + return pairs + + if debug: + mrich.debug(f'get_reactant_amount_pairs({reaction.id})') + pairs = get_reactant_amount_pairs(reaction) + + for reactant, reactant_amount in pairs: + # reactant = db.get_compound(id=reactant) + reactant = Compound.objects.get(pk=reactant) + + if debug: + mrich.debug(f'{reactant.id=}, {reactant_amount=}') + + # scale amount + reactant_amount *= amount + reactant_amount /= reaction.product_yield + + inner_reactions = reactant.get_reactions( + none='quiet', permitted_reactions=permitted_reactions + ) + + if inner_reactions: + if debug: + if len(inner_reactions) == 1: + mrich.debug('Reactant has ONE inner reaction') + else: + mrich.warning(f'{reactant=} has MULTIPLE inner reactions') + + new_recipes = [] + + inner_recipes = [] + for reaction in inner_reactions: + reaction_recipes = Recipe.from_reaction( + reaction=reaction, + amount=reactant_amount, + debug=debug, + pick_cheapest=False, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + inner=True, + ) + inner_recipes += reaction_recipes + + for recipe in recipes: + for inner_recipe in inner_recipes: + combined_recipe = recipe.copy() + + combined_recipe.reactants += inner_recipe.reactants + combined_recipe.intermediates += inner_recipe.intermediates + combined_recipe.reactions += inner_recipe.reactions + combined_recipe.intermediates.add( + reactant.as_ingredient(reactant_amount, supplier=supplier) + ) + + new_recipes.append(combined_recipe) + + recipes = new_recipes + + else: + ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) + for recipe in recipes: + recipe.reactants.add(ingredient) + + # reverse ReactionSet's + if not inner: + for recipe in recipes: + recipe.reactions.reverse() + + if pick_cheapest: + if debug: + mrich.debug('Picking cheapest') + priced = [r for r in recipes if r.get_price(supplier=supplier)] + # priced = [r for r in recipes if r.price] + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return recipes + sorted_recipes = sorted( + priced, key=lambda r: r.get_price(supplier=supplier) + ) + + if debug: + for recipe in recipes: + mrich.debug(f'{recipe}, {recipe.price}') + + return sorted_recipes[0] + # return sorted(priced, key=lambda r: r.price)[0] + + return recipes + + @classmethod + def from_reactions( + cls, + reactions: 'ReactionSet', + amount: float = 1, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + final_products_only: bool = True, + return_products: bool = False, + supplier: str | None = None, + use_routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'Recipe | list[Recipe] | CompoundSet': + """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies + + :param reactions: reactions to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param final_products_only: don't get routes to intermediates (Default value = True) + :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) + + """ + + from .cset import CompoundSet + from .rset import ReactionSet + + assert isinstance(reactions, ReactionSet) + + if debug: + mrich.debug('Recipe.from_reactions()') + mrich.var('reactions', reactions) + mrich.var('amount', amount) + mrich.var('final_products_only', final_products_only) + mrich.var('permitted_reactions', permitted_reactions) + + # get all the products + products = reactions.products + + if debug: + mrich.var('products', products) + + # return products + + if final_products_only: + if debug: + mrich.var('products.str_ids', products.str_ids) + + # raise NotImplementedError + ids = reactions.db.execute( + f""" + SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound + WHERE reactant_compound IS NULL + AND compound_id IN {products.str_ids} + """ + ).fetchall() + + ids = [i for (i,) in ids] + + products = CompoundSet(db, ids) + if debug: + mrich.var('final products', products) + + # return ids + + if return_products: + return products + + recipe = Recipe.from_compounds( + compounds=products, + amount=amount, + permitted_reactions=reactions, + pick_cheapest=pick_cheapest, + supplier=supplier, + use_routes=use_routes, + **kwargs, + ) + + return recipe + + @classmethod + def from_compounds( + cls, + compounds: 'CompoundSet', + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions=None, + quoted_only: bool = False, + supplier: None | str = None, + solve_combinations: bool = True, + pick_first: bool = False, + warn_multiple_solutions: bool = True, + pick_cheapest_inner_routes: bool = False, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, + use_routes: bool = False, + **kwargs, + ): + """Create recipe(s) to synthesis products in the :class:`.CompoundSet` + + :param compounds: set of compounds to find routes for + :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) + :param pick_first: return the first solution without comparison (Default value = False) + :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) + :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) + :param reaction: reaction to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param quoted_only: bool: only allow reactants with quotes (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') + + """ + + from .cset import CompoundSet + + assert isinstance(compounds, CompoundSet) + + db = compounds.db + + n_comps = len(compounds) + + assert n_comps + + if not hasattr(amount, '__iter__'): + amount = [amount] * n_comps + + if use_routes: + route_lookup = db.get_product_id_routes_dict() + + if supplier: + raise NotImplementedError + # supplier_lookup = db.get_compound_id_suppliers_dict() + + options = [] + + ok = 0 + mrich.var('#compounds', n_comps) + + for comp, a in mrich.track( + zip(compounds, amount, strict=False), + prefix='Solving individual compound recipes...', + total=n_comps, + ): + comp_options = [] + + if use_routes: + if comp.id not in route_lookup: + mrich.error('No routes to', comp) + continue + + comp_options = [] + for route_id in route_lookup[comp.id]: + route = db.get_route(id=route_id) + comp_options.append(route) + + else: + for reaction in comp.reactions: + if permitted_reactions and reaction not in permitted_reactions: + continue + + sol = Recipe.from_reaction( + reaction=reaction, + amount=a, + pick_cheapest=pick_cheapest_inner_routes, + debug=debug, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + **kwargs, + ) + + if pick_cheapest_inner_routes: + if sol: + comp_options.append(sol) + else: + assert isinstance(sol, list) + comp_options += sol + + if not comp_options: + mrich.error( + f'No solutions for compound={comp} ({comp.reactions.ids=})' + ) + continue + + if pick_cheapest and len(comp_options) > 1: + if warn_multiple_solutions: + mrich.warning( + 'Multiple solutions for', comp, '(', len(comp_options), ')' + ) + if debug: + mrich.debug('Picking cheapest...') + priced = [r for r in comp_options if r.price] + comp_options = sorted(priced, key=lambda r: r.price)[:1] + + if warn_multiple_solutions and len(comp_options) > 1: + mrich.warning(f'Multiple solutions for compound={comp}') + if debug: + mrich.debug(f'{comp_options=}') + else: + if n_comps <= 200: + mrich.success(f'Found solution for compound={comp}') + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', n_comps) + + options.append(comp_options) + + assert all(options) + + from itertools import product + + mrich.print('Solving recipe combinations...') + combinations = list(product(*options)) + + if not solve_combinations: + return combinations + + solutions = [] + + if n_comps > 1: + generator = mrich.track( + combinations, prefix='Combining recipes...', total=len(combinations) + ) + else: + generator = combinations + + ok = 0 + for combo in generator: + if debug: + mrich.debug(f'Combination of {len(combo)} recipes') + + if not combo: + continue + + solution = combo[0] + + for i, recipe in enumerate(combo[1:]): + if debug: + mrich.debug(i + 1) + solution += recipe + + solutions.append(solution) + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', len(combinations)) + + if not solutions: + mrich.error('No solutions') + return None + + if pick_first: + return solutions[0] + + if pick_cheapest: + mrich.debug('Calculating prices...') + priced = [r for r in solutions if r.price] + mrich.print('Picking cheapest from', len(priced), 'options') + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return solutions + return sorted(priced, key=lambda r: r.price)[0] + + return solutions + + @classmethod + def from_reactants( + cls, + reactants: 'CompoundSet | IngredientSet', + amount: float = 1, + debug: bool = False, + return_products: bool = False, + supplier: str | None = None, + pick_cheapest: bool = False, + use_routes: bool = False, + **kwargs, + ) -> 'list[Recipe] | Recipe | CompoundSet': + """Find the maximal recipe from a given set of reactants + + :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored + :param amount: amount of each product needed (Default value = 1) + :param debug: increase verbosity (Default value = False) + :param return_products: return products instead of recipe (Default value = False) + :param kwargs: passed to :meth:`.Recipe.from_reactions` + + """ + + from .cset import IngredientSet + + if isinstance(reactants, IngredientSet): + reactant_ids = reactants.compound_ids + else: + reactant_ids = reactants.ids + + db = reactants.db + + all_reactants = set(reactant_ids) + + possible_reactions = [] + + # recursively search for possible reactions + for i in range(300): + if debug: + mrich.debug(i) + + # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) + reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) + + if not reaction_ids: + break + + if debug: + mrich.debug(f'Adding {len(reaction_ids)} reactions') + + possible_reactions += reaction_ids + + if debug: + mrich.var('reaction_ids', reaction_ids) + + product_ids = db.get_possible_reaction_product_ids( + reaction_ids=reaction_ids + ) + + if debug: + mrich.var('product_ids', product_ids) + + n_prev = len(all_reactants) + + all_reactants |= set(product_ids) + + if n_prev == len(all_reactants): + break + + else: + raise NotImplementedError('Maximum recursion depth exceeded') + + possible_reactions = list(set(possible_reactions)) + + if debug: + mrich.var('all possible reactions', possible_reactions) + + from .rset import ReactionSet + + rset = ReactionSet(db, possible_reactions, sort=False) + + recipe = cls.from_reactions( + rset, + amount=amount, + permitted_reactions=rset, + debug=debug, + return_products=return_products, + supplier=supplier, + use_routes=use_routes, + **kwargs, + ) + + return recipe + + @classmethod + def from_json( + cls, + db: 'Database', + path: 'str | Path', + debug: bool = True, + allow_db_mismatch: bool = False, + clear_quotes: bool = False, + data: dict = None, + db_mismatch_warning: bool = True, + ): + """Load a serialised recipe from a JSON file + + :param db: database to link + :param path: path to JSON + :param debug: increase verbosity (Default value = True) + :param allow_db_mismatch: allow a database mismatch (Default value = False) + :param clear_quotes: ignore reactant quotes (Default value = False) + :param data: serialised data (Default value = None) + + """ + + # imports + import json + + from .cset import IngredientSet + from .rset import ReactionSet + + # load JSON + if not data: + if debug: + mrich.reading(path) + data = json.load(open(path)) + + # check metadata + if str(db.path.resolve()) != data['database']: + if db_mismatch_warning: + mrich.var('session', str(db.path.resolve())) + mrich.var('in file', data['database']) + if allow_db_mismatch: + if db_mismatch_warning: + mrich.warning('Database path mismatch') + else: + mrich.error( + 'Database path mismatch, set allow_db_mismatch=True to ignore' + ) + return None + + if debug: + mrich.print(f'Recipe was generated at: {data["timestamp"]}') + price = data['price'] + + # IngredientSets + products = IngredientSet.from_ingredient_dicts(db, data['products']) + intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) + reactants = IngredientSet.from_ingredient_dicts( + db, data['reactants'], supplier=data['reactant_supplier'] + ) + + if 'compounds' in data: + compounds = IngredientSet.from_ingredient_dicts( + db, data['compounds'], supplier=data['compound_supplier'] + ) + else: + compounds = IngredientSet(db) + + if clear_quotes: + reactants.df['quote_id'] = None + reactants.df['quoted_amount'] = None + compounds.df['quote_id'] = None + compounds.df['quoted_amount'] = None + + # ReactionSet + reactions = ReactionSet(db, data['reaction_ids'], sort=False) + + if debug: + mrich.var('reactants', reactants) + mrich.var('intermediates', intermediates) + mrich.var('products', products) + mrich.var('reactions', reactions) + mrich.var('compounds', compounds) + + # Create the object + self = cls.__new__(cls) + self.__init__( + products=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + compounds=compounds, + ) + + return self + + ### PROPERTIES + + @property + def products(self) -> 'IngredientSet': + """Product :class:`.IngredientSet`""" + return self._products + + @property + def compounds(self) -> 'IngredientSet': + """Product :class:`.IngredientSet`""" + return self._compounds + + @compounds.setter + def compounds(self, a: 'IngredientSet'): + """Set the compounds""" + self._compounds = a + self.__flag_modification() + + @property + def poses(self) -> 'PoseSet': + """Product poses""" + if self._poses is None: + self._poses = self.combined_compounds.poses + self._poses._name = f'poses of {self}' + return self._poses + + @property + def product_compounds(self) -> 'CompoundSet': + """Product compounds""" + if self._product_compounds is None: + self._product_compounds = self.products.compounds + self._product_compounds._name = f'products of {self}' + return self._product_compounds + + @property + def combined_compound_ids(self) -> set[int]: + """Combined :class:`.Compound` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" + return set(self.product_compounds.ids) | set(self.compounds.ids) + + @property + def combined_compounds(self) -> 'CompoundSet': + """Combined product and no-chem compounds""" + if self._combined_compounds is None: + from .cset import CompoundSet + + self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) + self._combined_compounds._name = f'combined compounds of {self}' + return self._combined_compounds + + @property + def interactions(self) -> 'InteractionSet': + """Product pose interactions""" + if self._interactions is None: + self._interactions = self.poses.interactions + return self._interactions + + @property + def product(self) -> 'Ingredient': + """Return single product (if there's only one)""" + assert len(self.products) == 1 + return self.products[0] + + @products.setter + def products(self, a: 'IngredientSet'): + """Set the products""" + self._products = a + self.__flag_modification() + + @property + def reactants(self): + """Reactant :class:`.IngredientSet`""" + return self._reactants + + @reactants.setter + def reactants(self, a: 'IngredientSet'): + """Set the reactants""" + self._reactants = a + self.__flag_modification() + + @property + def intermediates(self) -> 'IngredientSet': + """Intermediates :class:`.IngredientSet`""" + return self._intermediates + + @intermediates.setter + def intermediates(self, a: 'IngredientSet'): + """Set the intermediates""" + self._intermediates = a + # self.__flag_modification() + + @property + def reactions(self) -> 'ReactionSet': + """Intermediates :class:`.IngredientSet`""" + return self._reactions + + @reactions.setter + def reactions(self, a: 'ReactionSet'): + """Set the reactions""" + self._reactions = a + self.__flag_modification() + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.get_price() + self.compounds.get_price() + + @property + def num_products(self) -> int: + """Return the number of products""" + return len(self.products) + + @property + def num_compounds(self) -> int: + """Return the number of compounds""" + return len(self.combined_compound_ids) + + @property + def num_reactions(self): + """Return the number of reactions""" + return len(self.reactions) + + @property + def num_reaction_types(self): + """Return the number of reactions""" + return self.reactions.num_types + + @property + def num_reactants(self): + """Return the number of reactants""" + return len(self.reactants) + + @property + def num_intermediates(self): + """Return the number of intermediates""" + return len(self.intermediates) + + @property + def hash(self) -> str: + """Return the unique hash string""" + return self._hash + + @property + def score(self): + """Return the Recipe score""" + return self._score + + @property + def type(self) -> str: + """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" + + if self.empty: + return 'EMPTY' + + chem = bool(self.reactions) + nochem = bool(self.compounds) + + if chem and nochem: + return 'MIXED' + + if chem and not nochem: + return 'CHEM' + + if nochem and not chem: + return 'NOCHEM' + + @property + def empty(self) -> bool: + """Is this Recipe empty?""" + + if self.reactants: + return False + + if self.products: + return False + + if self.intermediates: + return False + + if self.reactions: + return False + + if self.compounds: + return False + + return True + + ### METHODS + + def get_price(self, supplier: str | None = None) -> 'Price': + """get the reactants price. See :meth:`.IngredientSet.get_price` + + :param supplier: restrict quotes to this supplier + + """ + return self.reactants.get_price(supplier=supplier) + + def draw(self, color_mapper=None, node_size=300, graph_only=False): + """draw graph of the reaction network + + :param color_mapper: (Default value = None) + :param node_size: (Default value = 300) + :param graph_only: (Default value = False) + + """ + + import networkx as nx + + color_mapper = color_mapper or {} + colors = {} + sizes = {} + + graph = nx.DiGraph() + + for reaction in self.reactions: + for reactant in reaction.reactants: + key = str(reactant) + ingredient = self.get_ingredient(id=reactant.id) + + graph.add_node( + key, + id=reactant.id, + smiles=reactant.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + + if not graph_only: + sizes[key] = self.get_ingredient(id=reactant.id).amount + if key in color_mapper: + colors[key] = color_mapper[key] + else: + colors[key] = (0.7, 0.7, 0.7) + + for product in self.products: + key = str(product.compound) + ingredient = self.get_ingredient(id=product.id) + + graph.add_node( + key, + id=product.id, + smiles=product.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + + if not graph_only: + sizes[key] = product.amount + if key in color_mapper: + colors[key] = color_mapper[key] + else: + colors[key] = (0.7, 0.7, 0.7) + + for reaction in self.reactions: + for reactant in reaction.reactants: + graph.add_edge( + str(reactant), + str(reaction.product), + id=reaction.id, + type=reaction.type, + product_yield=reaction.product_yield, + ) + + # rescale sizes + if not graph_only: + s_min = min(sizes.values()) + sizes = [s / s_min * node_size for s in sizes.values()] + + if graph_only: + return graph + else: + # return nx.draw(graph, pos, with_labels=True, font_weight='bold') + # pos = nx.spring_layout(graph, iterations=200, k=30) + pos = nx.spring_layout(graph) + return nx.draw( + graph, + pos=pos, + with_labels=True, + font_weight='bold', + node_color=list(colors.values()), + node_size=sizes, + ) + + def sankey(self, title: str | None = None) -> 'graph_objects.Figure': + """draw a plotly Sankey diagram + + :param title: (Default value = None) + + """ + + graph = self.draw(graph_only=True) + + import plotly.graph_objects as go + + nodes = {} + + for edge in graph.edges: + c = edge[0] + if c not in nodes: + nodes[c] = len(nodes) + + c = edge[1] + if c not in nodes: + nodes[c] = len(nodes) + + source = [nodes[a] for a, b in graph.edges] + target = [nodes[b] for a, b in graph.edges] + value = [1 for l in graph.edges] + + labels = list(nodes.keys()) + + hoverkeys = None + + customdata = [] + for key in nodes.keys(): + n = graph.nodes[key] + + if not hoverkeys: + hoverkeys = list(n.keys()) + + if not n: + mrich.error(f'problem w/ node {key=}') + compound_id = int(key[1:]) + customdata.append((compound_id, None)) + + else: + d = tuple(v if v is not None else 'N/A' for v in n.values()) + customdata.append(d) + + hoverkeys_edges = None + + customdata_edges = [] + + for s, t in graph.edges.keys(): + edge = graph.edges[s, t] + + if not hoverkeys_edges: + hoverkeys_edges = list(edge.keys()) + + if not n: + mrich.error(f'problem w/ edge {s=} {t=}') + customdata_edges.append((None, None, None)) + + else: + d = tuple(v if v is not None else 'N/A' for v in edge.values()) + customdata_edges.append(d) + + hoverlines = [] + for i, key in enumerate(hoverkeys): + hoverlines.append(f'{key}=%{{customdata[{i}]}}') + hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' + + hoverlines_edges = [] + for i, key in enumerate(hoverkeys_edges): + hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') + hovertemplate_edges = ( + 'Reaction ' + '
'.join(hoverlines_edges) + '' + ) + + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + # pad = 15, + # thickness = 20, + # line = dict(color = "black", width = 0.5), + label=labels, + # color = "blue" + customdata=customdata, + # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", + # "Long name C1", "Long name C2"], + # hovertemplate='Compound %{label}

smiles=%{customdata}', + hovertemplate=hovertemplate, + ), + link=dict( + customdata=customdata_edges, + hovertemplate=hovertemplate_edges, + source=source, + target=target, + value=value, + ), + ) + ] + ) + + if not title: + try: + title = f'Recipe
price={self.price}' + except AssertionError: + title = 'Recipe' + + fig.update_layout(title=title) + + return fig + + def summary(self, price: bool = True) -> None: + """Print a summary of this recipe + + :param price: print the price (Default value = True) + + """ + + mrich.h1(str(self)) + + if price: + price = self.price + if price: + mrich.var('\nprice', price.amount, price.currency) + # mrich.var('lead-time', self.lead_time, 'working days)) + + if self.products: + mrich.h3(f'{len(self.products)} products') + + if len(self.products) < 100: + for product in self.products: + mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') + + if self.intermediates: + mrich.h3(f'{len(self.intermediates)} intermediates') + + if len(self.intermediates) < 100: + for intermediate in self.intermediates: + mrich.var( + str(intermediate.compound), + f'{intermediate.amount:.2f}', + 'mg', + ) + + if self.reactants: + mrich.h3(f'{len(self.reactants)} reactants') + + if len(self.reactants) < 100: + for reactant in self.reactants: + mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') + + if self.reactions: + mrich.h3(f'{len(self.reactions)} reactions') + + if len(self.reactions) < 100: + for reaction in self.reactions: + mrich.var(str(reaction), reaction.reaction_str, reaction.type) + + if hasattr(self, '_compounds') and self.compounds: + mrich.h3(f'{len(self.compounds)} compounds') + + if len(self.compounds) < 100: + for compound in self.compounds: + mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') + + def get_ingredient(self, id) -> 'Ingredient': + """Get an ingredient by its compound ID + + :param id: compound ID + + """ + matches = [r for r in self.reactants if r.id == id] + if not matches: + matches = [r for r in self.intermediates if r.id == id] + if not matches: + matches = [r for r in self.products if r.id == id] + + assert len(matches) == 1 + return matches[0] + + def add_to_all_reactants(self, amount: float = 20) -> None: + """Increment all reactants by this amount + + :param amount: amount in ``mg`` (Default value = 20) + + """ + self.reactants.df['amount'] += amount + + def write_json( + self, + file: 'str | Path', + *, + extra: dict | None = None, + indent: str = '\t', + **kwargs, + ) -> None: + """Serialise this recipe object and write it to disk + + :param file: write to this path + :param extra: extra data to serialise + :param indent: indentation whitespace (Default value = '\t') + + """ + import json + from pathlib import Path + + file = Path(file).resolve() + + assert file.parent.exists(), f'Directory does not exist: {file.parent}' + + data = self.get_dict(serialise_price=True, **kwargs) + + if extra: + data.update(extra) + + mrich.writing(file) + json.dump(data, open(file, 'w'), indent=indent) + + def get_dict( + self, + *, + price: bool = True, + reactant_supplier: bool = True, + compound_supplier: bool = True, + database: bool = True, + timestamp: bool = True, + compound_ids_only: bool = False, + products: bool = True, + serialise_price: bool = False, + ): + """Serialise this recipe object + + Store + ===== + + - Path to database + - Timestamp + - Reactants (& their quotes, amounts) + - Intermediates (& their quotes) + - Products (& their poses/scores/fingerprints) + - Reactions + - Total Price + - Lead time + + :param price: include the price (Default value = True) + :param reactant_supplier: include the supplier (Default value = True) + :param database: include the database (Default value = True) + :param timestamp: add a timestamp (Default value = True) + :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) + :param products: include products (Default value = True) + :param serialise_price: serialise :class:`.Price` object (Default value = False) + + """ + + from datetime import datetime + + data = {} + + # Database + if database: + data['database'] = str(self.db.path.resolve()) + if timestamp: + data['timestamp'] = str(datetime.now()) + + # Recipe properties + try: + if price and serialise_price: + data['price'] = self.price.get_dict() + elif price: + data['price'] = self.price + except AssertionError as e: + mrich.warning(f'Could not get price: {e}') + data['price'] = None + + if reactant_supplier: + data['reactant_supplier'] = self.reactants.supplier + + if compound_supplier: + data['compound_supplier'] = self.compounds.supplier + + # IngredientSets + if compound_ids_only: + data['reactant_ids'] = self.reactants.compound_ids + data['intermediate_ids'] = self.intermediates.compound_ids + if products: + data['products_ids'] = self.products.compound_ids + data['compound_ids'] = self.compounds.compound_ids + + else: + data['reactants'] = self.reactants.df.to_dict(orient='list') + data['intermediates'] = self.intermediates.df.to_dict(orient='list') + if products: + data['products'] = self.products.df.to_dict(orient='list') + data['compounds'] = self.compounds.df.to_dict(orient='list') + + # ReactionSet + data['reaction_ids'] = self.reactions.ids + + return data + + def get_routes(self, return_ids: bool = False) -> 'RouteSet': + """Get routes""" + return self.products.get_routes( + permitted_reactions=self.reactions, return_ids=return_ids + ) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate missing routes to products of this Recipe""" + + return products.compounds.register_missing_routes( + missing_only=missing_only, supplier=supplier + ) + + if missing_only: + from .cset import CompoundSet + + records = self.db.select_where( + table='route', + key=f'route_product IN {products.str_ids}', + query='route_product', + multiple=True, + ) + existing = set(i for (i,) in records) + missing = set(products.ids) - existing + products = CompoundSet(self.db, missing) + + mrich.var('#products', len(products)) + + for i, c in mrich.track(enumerate(products), total=len(products)): + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + route = self.db.register_route(recipe=recipe) + + mrich.print(f'registered {route=}') + + self.db.prune_duplicate_routes() + + def write_CAR_csv( + self, file: 'str | Path', return_df: bool = False + ) -> 'DataFrame | None': + """Prepares CSVs for use with CAR. + + .. attention:: + + This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead + + Columns: + + * target-name + * no-steps + * concentration = None + * amount-required + * batch-tag + + per reaction + + * reactant-1-1 + * reactant-2-1 + * reaction-product-smiles-1 + * reaction-name-1 + * reaction-recipe-1 + * reaction-groupby-column-1 + + :param file: file to write to + :param return_df: return the dataframe (Default value = False) + + """ + + from pathlib import Path + + from pandas import DataFrame + + # solve each product's reaction + + file = str(Path(file).resolve()) + + rows = [] + + routes = self.get_routes() + + for sub_recipe in routes: + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction in enumerate(sub_recipe.reactions): + i = i + 1 + + row['no-steps'] += 1 + + match len(reaction.reactants): + case 1: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles + case _: + # mrich.warning(f"More than two reactants for {reaction=}") + for j, r in enumerate(reaction.reactants): + row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles + + row[f'reaction-product-smiles-{i}'] = reaction.product.smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + # row[f'reaction-id-{i}'] = int(reaction.id) + + rows.append(row) + + df = DataFrame(rows) + + if len(df[df.duplicated()]): + mrich.warning('Removing duplicates from CAR DataFrame') + df = df.drop_duplicates() + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + return df + + def write_reactant_csv( + self, + file: 'str | Path', + reaction_type_counts: bool = True, + return_df: bool = False, + ) -> 'DataFrame | None': + """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use + + Reactant + ======== + + - ID + - SMILES + - Inchikey + + Quote + ===== + + - Supplier + - Catalogue + - Entry + - Lead-time + - Quoted amount + - Quote currency + - Quote price + - Quote purity + + Downstream + ========== + + - num_reaction_dependencies + - num_product_dependencies + - reaction_dependencies + - product_dependencies + + """ + # - remove_with + + # from rich import print + + data = [] + + ### Get lookup data + + route_ids = self.get_routes(return_ids=True) + + sql = f""" + SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route + WHERE component_type = 2 + AND component_ref IN {self.reactants.compounds.str_ids} + AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} + """ + product_lookup = {} + for reactant_id, product_id in self.db.execute(sql): + product_lookup.setdefault(reactant_id, set()) + product_lookup[reactant_id].add(product_id) + + sql = f""" + WITH reactants AS ( + SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component + WHERE component_type = 2 + AND component_ref IN {self.reactants.compounds.str_ids} + ), + + reactions AS ( + SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id + WHERE component_type = 1 + AND component_ref IN {self.reactions.str_ids} + ) + + SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id + """ + reaction_lookup = {} + for reactant_id, reaction_id, reaction_type in self.db.execute(sql): + reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) + reaction_lookup[reactant_id]['ids'].add(reaction_id) + reaction_lookup[reactant_id]['types'].add(reaction_type) + reaction_lookup[reactant_id].setdefault('counts', {}) + reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) + reaction_lookup[reactant_id]['counts'][reaction_type] += 1 + + smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) + + inchikey_lookup = self.db.get_compound_id_inchikey_dict( + self.reactants.compounds + ) + + ### Reactant Dataframe + + df = self.reactants.df + + df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) + df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) + df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) + + ### Quote DataFrame + + qdf = self.db.get_quote_df(self.reactants.quote_ids) + + qdf = qdf.rename( + columns={ + 'id': 'quote_id', + 'smiles': 'quoted_smiles', + 'purity': 'quoted_purity', + 'date': 'quote_date', + 'lead_time': 'quote_lead_time_days', + 'price': 'quote_price', + 'currency': 'quote_currency', + 'catalogue': 'quote_catalogue', + 'supplier': 'quote_supplier', + 'entry': 'quote_entry', + 'amount': 'quoted_amount_mg', + } + ) + qdf = qdf.drop(columns=['compound']) + + ### Downstream info + + try: + df['downstream_product_ids'] = df['compound_id'].apply( + lambda x: product_lookup.get(x, set()) + ) + + df['downstream_reaction_ids'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['ids'] + ) + df['downstream_reaction_types'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['types'] + ) + except KeyError as e: + mrich.error(f'Reactant C{e} is missing downstream reaction') + mrich.error( + 'Are all routes enumerated? Try running calculate_missing_routes()' + ) + return None + + df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) + df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) + df['num_downstream_products'] = df['downstream_product_ids'].apply(len) + + ### Join and reformat + + df = df.merge(qdf, on='quote_id', how='left') + + df = df.rename( + columns={ + 'amount': 'required_amount_mg', + } + ) + + cols = [ + 'compound_id', + 'smiles', + 'inchikey', + 'required_amount_mg', + 'quoted_amount_mg', + 'quote_id', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_price', + 'quote_currency', + 'quote_lead_time_days', + 'quoted_purity', + 'quoted_smiles', + 'quote_date', + 'num_downstream_products', + 'num_downstream_reaction_types', + 'num_downstream_reactions', + ] + + if reaction_type_counts: + for i, row in df.iterrows(): + counts = reaction_lookup[row['compound_id']]['counts'] + + for reaction_type, count in counts.items(): + key = f'num_downstream ({reaction_type})' + df.loc[i, key] = count + if key not in cols: + cols.append(key) + + cols += [ + 'downstream_product_ids', + 'downstream_reaction_types', + 'downstream_reaction_ids', + ] + + df = df[[c for c in cols if c in df.columns]] + + ### Add estimated quotes + + unquoted = df[df['quote_id'].isna()] + + if len(unquoted): + for i, row in unquoted.iterrows(): + compound = self.db.get_compound(id=row['compound_id']) + ingredient = compound.as_ingredient( + amount=row['required_amount_mg'], get_quote=False + ) + + quote = ingredient.quote + + df.loc[i, 'quoted_amount_mg'] = quote.amount + df.loc[i, 'quote_supplier'] = quote.supplier + df.loc[i, 'quote_catalogue'] = quote.catalogue + df.loc[i, 'quote_entry'] = quote.entry + df.loc[i, 'quote_price'] = quote.price.amount + df.loc[i, 'quote_currency'] = quote.price.currency + df.loc[i, 'quote_lead_time_days'] = quote.lead_time + df.loc[i, 'quoted_purity'] = quote.purity + df.loc[i, 'quoted_smiles'] = quote.smiles + df.loc[i, 'quote_date'] = quote.date + + ### N.B. scaffold series no longer output + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def write_product_csv( + self, file: 'str | Path', return_df: bool = False + ) -> 'pd.DataFrame | None': + """Detailed CSV output including product information for selection and synthesis""" + + from pandas import DataFrame + + # from rich import print + from .pset import PoseSet + from .rset import ReactionSet + + data = [] + + routes = self.get_routes() + + pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) + + inspiration_map = self.db.get_compound_id_inspiration_ids_dict() + + for product in mrich.track( + self.products, prefix='Constructing product DataFrame' + ): + d = dict( + hippo_id=product.compound_id, + smiles=product.smiles, + inchikey=product.inchikey, + required_amount_mg=product.amount, + ) + + upstream_routes = [] + upstream_reactions = [] + + for route in routes: + if product in route.products: + upstream_routes.append(route) + + for reaction in route.reactions: + upstream_reactions.append(reaction) + + upstream_reactions = ReactionSet( + self.db, set(reaction.id for reaction in upstream_reactions) + ) + + if not upstream_routes: + mrich.error('No upstream routes for', product) + continue + + if not upstream_reactions: + mrich.error('No upstream reactions for', product) + continue + + def get_scaffold_series() -> tuple[list[int], bool]: + """Get scaffold series value""" + + if scaffolds := product.scaffolds: + return scaffolds.ids, False + + else: + return [product.id], True + + poses = pose_map.get(product.id, set()) + + d['num_poses'] = len(poses) + d['poses'] = poses + d['tags'] = product.tags + d['num_routes'] = len(upstream_routes) + d['num_reaction_steps'] = set( + len(route.reactions) for route in upstream_routes + ) + d['reaction_dependencies'] = upstream_reactions.ids + d['reactant_dependencies'] = set( + sum([route.reactants.ids for route in upstream_routes], []) + ) + d['route_ids'] = [route.id for route in upstream_routes] + d['chemistry_types'] = ', '.join(upstream_reactions.types) + series, is_scaffold = get_scaffold_series() + d['is_scaffold'] = is_scaffold + d['scaffold_series'] = series + + inspirations = inspiration_map.get(product.id, None) + + if not inspirations and not is_scaffold: + scaffold = product.scaffolds[0] + inspirations = inspiration_map.get(scaffold.id, None) + + if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: + inspirations = scaffold.metadata['inspiration_pose_ids'] + + if ( + not inspirations + and is_scaffold + and 'inspiration_pose_ids' in product.metadata + ): + inspirations = product.metadata['inspiration_pose_ids'] + + if inspirations: + inspirations = PoseSet(self.db, inspirations) + d['inspirations'] = ', '.join(n for n in inspirations.names) + else: + d['inspirations'] = '' + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def write_chemistry_csv( + self, file: 'str | Path', return_df: bool = True + ) -> 'pd.DataFrame | None': + """Detailed CSV output synthetis information for chemistry types in this set""" + + from pandas import DataFrame + + from .cset import CompoundSet + + data = [] + + # get compounds + + scaffolds = CompoundSet(self.db) + + for product in self.products: + if scaffolds := product.scaffolds: + scaffolds += scaffolds + else: + scaffolds.add(product.compound) + + routes = self.get_routes() + + route_types = {} + + for compound in scaffolds: + elabs = ( + self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') + or [] + ) + + d = dict( + scaffold_id=compound.id, + product_id=compound.id, + smiles=compound.smiles, + inchikey=compound.inchikey, + num_elaborations=len(elabs), + is_scaffold=True, + ) + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + if not upstream_routes: + mrich.warning(f'No routes to scaffold={compound}') + continue + + d['num_routes'] = len(upstream_routes) + + for j, route in enumerate(upstream_routes): + d[f'route_{j + 1}_num_steps'] = len(route.reactions) + + group = route_types.setdefault(compound.id, set()) + group.add(tuple([r.type for r in route.reactions])) + + for k, reaction in enumerate(route.reactions): + key = f'route_{j + 1}_reaction_{k + 1}' + + product = reaction.product + + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = product.smiles + d[f'{key}_product_id'] = product.id + d[f'{key}_product_yield'] = reaction.product_yield + + for i, reactant in enumerate(reaction.reactants): + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id + + data.append(d) + + missing_scaffolds = {} + + for compound in self.products.compounds: + if compound in scaffolds: + continue + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + scaffolds = compound.scaffolds + + for scaffold in scaffolds: + if scaffold.id not in route_types: + group = missing_scaffolds.setdefault(scaffold.id, []) + group.append(compound.id) + continue + + else: + for route in upstream_routes: + chem_types = tuple([r.type for r in route.reactions]) + + if chem_types not in route_types[base.id]: + mrich.success(scaffold) + mrich.success(chem_types) + raise ValueError( + 'Scaffold has route not present in dataframe' + ) + + for scaffold_id, elab_ids in missing_scaffolds.items(): + compound = self.db.get_compound(id=sorted(elab_ids)[0]) + + d = dict( + scaffold_id=scaffold_id, + product_id=compound.id, + smiles=compound.smiles, + inchikey=compound.inchikey, + num_elaborations=len(elab_ids), + is_scaffold=False, + ) + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + if not upstream_routes: + mrich.error(f'No routes to elab {compound}') + raise ValueError(f'No routes to elab {compound}') + + d['num_routes'] = len(upstream_routes) + + for j, route in enumerate(upstream_routes): + d[f'route_{j + 1}_num_steps'] = len(route.reactions) + + group = route_types.setdefault(compound.id, set()) + group.add(tuple([r.type for r in route.reactions])) + + for k, reaction in enumerate(route.reactions): + key = f'route_{j + 1}_reaction_{k + 1}' + + product = reaction.product + + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = product.smiles + d[f'{key}_product_id'] = product.id + d[f'{key}_product_yield'] = reaction.product_yield + + for i, reactant in enumerate(reaction.reactants): + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def to_syndirella( + self, + out_key: 'str | Path', + poses: 'PoseSet', + *, + separate: bool = False, + ) -> 'DataFrame': + """Generate inputs for running syndirella elaboration""" + + import shutil + from pathlib import Path + + out_key = Path('.') / out_key + out_dir = out_key.parent + out_key = out_key.name + + mrich.var('out_key', out_key) + mrich.var('out_dir', out_dir) + + if not out_dir.exists(): + mrich.writing(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + template_dir = out_dir / 'templates' + if not template_dir.exists(): + mrich.writing(template_dir) + template_dir.mkdir(parents=True, exist_ok=True) + + """ + + Need to create dataframe with columns: + - compound_id + - pose_id + - smiles + - reaction_name_step1 + - reactant_step1 + - reactant2_step1 + - product_step1 + ... + - hit1 + - hit2 + ... + - template + - compound_set + + """ + + pose_compounds = poses.compounds + assert set(self.products.compound_ids) == set(pose_compounds.ids), ( + 'supplied poses have different compounds to Recipe products' + ) + assert len(poses) == len(self.products), ( + 'some duplicate compounds in supplied poses' + ) + + df = poses.get_df( + inchikey=False, + alias=False, + name=False, + compound_id=True, + reference_id=True, + inspiration_aliases=True, + ) + + df = df.reset_index() + df = df.rename(columns={'id': 'pose_id'}) + df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') + df = df.set_index(['compound_id', 'pose_id']) + + ## CHECKS + + no_refs = df[df['reference_id'].isna()] + + if len(no_refs): + mrich.error(len(no_refs), 'poses without reference!') + ids = set(no_refs.index.get_level_values('pose_id')) + mrich.print(ids) + + no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) + + if no_insps: + mrich.error(len(no_insps), 'poses without inspirations!') + return None + + ## TEMPLATES + + references = poses.references + ref_lookup = self.db.get_pose_id_alias_dict(references) + df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) + + for ref_pose in references: + assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' + + template = template_dir / ref_pose.apo_path.name + + if not template.exists(): + mrich.writing(template) + shutil.copy(ref_pose.apo_path, template) + + ## INSPIRATIONS + + for i, row in df.iterrows(): + for j, alias in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = alias + + inspirations = poses.inspirations + + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' + + inspirations.write_sdf( + sdf_name, + tags=False, + metadata=False, + name_col='name', + ) + + ## ADD ROUTE INFO + + routes = self.get_routes() + + for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): + product = sub_recipe.product + + product_id = product.compound_id + + matches = df.xs(product_id, level='compound_id') + + if len(matches) > 1: + mrich.warning('Multiple rows for compound', product_id) + + for i, row in matches.iterrows(): + key = (product_id, i) + + for j, reaction in enumerate(sub_recipe.reactions): + j = j + 1 + + match len(reaction.reactants): + case 1: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = None + case 2: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ + 1 + ].smiles + case 3: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ + 1 + ].smiles + df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ + 2 + ].smiles + case _: + raise NotImplementedError('Too many reactants') + + df.loc[key, f'product_step{j}'] = reaction.product.smiles + df.loc[key, f'reaction_name_step{j}'] = reaction.type + + break + + ## REMOVE UNECESSARY COLS + + df = df.drop(columns=['reference_id', 'inspiration_aliases']) + + ## REORDER COLUMNS + + cols = [ + 'smiles', + 'reaction_name_step1', + 'reactant_step1', + 'reactant2_step1', + 'reactant3_step1', + 'product_step11', + 'hit1', + 'hit2', + 'hit3', + 'hit4', + 'hit5', + 'hit6', + 'hit7', + 'hit8', + 'hit9', + 'template', + 'compound_set', + ] + + if not any([c not in cols for c in df.columns]): + df = df[[c for c in cols if c in df.columns]] + + if not separate: + out_path = out_dir / f'{out_key}_syndirella_input.csv' + mrich.writing(out_path) + df.to_csv(out_path) + return df + + for idx, row in df.iterrows(): + out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' + mrich.writing(out_path) + single_df = row.to_frame().T + single_df = single_df.dropna(axis=1, how='all') + single_df.to_csv(out_path, index=False) + + return df + + def copy(self) -> 'Recipe': + """Copy this recipe""" + + if hasattr(self, 'compounds'): + compounds = self.compounds.copy() + else: + compounds = None + + return Recipe( + self.db, + products=self.products.copy(), + reactants=self.reactants.copy(), + intermediates=self.intermediates.copy(), + reactions=self.reactions.copy(), + compounds=compounds, + # supplier=self.supplier + ) + + def __flag_modification(self) -> None: + """Flag this recipe as modified""" + self._product_interactions = None + self._score = None + self._product_compounds = None + self._product_poses = None + + def check_integrity(self, debug: bool = False) -> bool: + """Verify integrity of this recipe""" + + # no duplicate ingredients + + if debug: + mrich.debug('Checking integrity:', self) + mrich.debug('Checking for duplicate compounds') + + if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): + mrich.error("Reactant compound ID's are not unique") + return False + if len(self.intermediates.compound_ids) != len( + set(self.intermediates.compound_ids) + ): + mrich.error("Intermediate compound ID's are not unique") + return False + if len(self.products.compound_ids) != len(set(self.products.compound_ids)): + mrich.error("Product compound ID's are not unique") + return False + + # all references should exist + + if debug: + mrich.debug('Checking for missing references') + + if self.db.count_where( + table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' + ) < len(self.reactions): + mrich.error('Not all Reactions in Database') + return False + + if self.db.count_where( + table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' + ) < len(self.products): + mrich.error('Not all product Compounds in Database') + return False + + if self.db.count_where( + table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' + ) < len(self.reactants): + mrich.error('Not all reactant Compounds in Database') + return False + + if self.db.count_where( + table='compound', + key=f'compound_id IN {self.intermediates.compounds.str_ids}', + ) < len(self.intermediates): + mrich.error('Not all intermediate Compounds in Database') + return False + + reaction_intermediates = self.reactions.intermediates + reaction_products = self.reactions.products + reaction_reactants = self.reactions.reactants + + if debug: + mrich.debug('Checking for missing reactions') + + # all products should have a reaction + for product in self.products: + if product not in reaction_products: + mrich.error(f'Product: {product} does not have associated reaction') + return False + + # intermediates + for intermediate in self.intermediates: + if intermediate not in reaction_intermediates: + mrich.error( + f'Intermediate: {intermediate} is not in self.reactions.intermediates' + ) + return False + + # reactants + for reactant in self.reactants: + if reactant not in reaction_reactants: + mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') + return False + + # all reactions should have enough reactant + + if debug: + mrich.debug('Checking reactant quantities') + + for reaction in self.reactions: + product_ingredient = self.products(compound_id=reaction.product_id) + + if product_ingredient is None: + product_ingredient = self.intermediates(compound_id=reaction.product_id) + + if debug and reaction.product_yield < 1.0: + mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') + + for reactant in reaction.reactants: + reactant_ingredient = self.intermediates(compound_id=reactant.id) + + if reactant_ingredient is None: + reactant_ingredient = self.reactants(compound_id=reactant.id) + + required_amount = product_ingredient.amount / reaction.product_yield + + if reactant_ingredient.amount < required_amount: + mrich.error( + f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' + ) + return False + + if debug: + mrich.success(self, 'OK') + + return True + + def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): + """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" + self.compounds.add(ingredient) + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.score: + s = f'(score={self.score:.3f})' + else: + s = '' + + if self.hash: + return f'Recipe_{self.hash}{s}' + + return f'Recipe{s}' + + def __longstr(self) -> str: + """Unformatted string representation""" + + if self.empty: + return 'Empty Recipe()' + + if self.reactions: + if self.intermediates: + s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' + else: + s = f'{self.reactants} --> {self.products} via {self.reactions}' + + if self.score: + s += f', score={self.score:.3f}' + + if self.hash: + return f'Recipe_{self.hash}({s})' + + return f'Recipe({s})' + + else: + s = f'{self.compounds}' + + if self.hash: + return f'Recipe_{self.hash}({s})' + + return f'Recipe(#compounds={self.num_compounds} [no-chem])' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self.__longstr()}' + + def __add__(self, other: 'Recipe'): + """Add another :class:`.Recipe` to this one""" + result = self.copy() + result.reactants += other.reactants + result.intermediates += other.intermediates + result.reactions += other.reactions + result.products += other.products + if hasattr(other, 'compounds'): + result.compounds += other.compounds + return result + + +class Route(Recipe): + """A recipe with a single product, that is stored in the database""" + + def __init__( + self, + db, + *, + route_id: int, + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', + ) -> None: + """Route initialisation""" + + from .cset import IngredientSet + from .rset import ReactionSet + + # check typing + assert isinstance(product, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(reactions, ReactionSet) + + assert len(product) == 1 + assert isinstance(route_id, int) + assert route_id + + self._id = route_id + self._products = product + self._product_id = product.ids[0] + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + self._db = db + + ### FACTORIES + + @classmethod + def from_json( + cls, db: 'Database', path: 'str | Path', data: dict = None + ) -> 'Route': + """Load a serialised route from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + import json + + from .cset import IngredientSet + from .rset import ReactionSet + + if data is None: + data = json.load(open(path)) + + self = cls.__new__(cls) + + self._db = db + self._id = data['id'] + + self._product_id = data['product_id'] + self._products = IngredientSet.from_compounds( + compounds=None, ids=[self._product_id], db=db + ) # IngredientSet + + self._reactants = IngredientSet.from_json( + db=db, + path=None, + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], + ) + self._intermediates = IngredientSet.from_json( + db=db, + path=None, + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], + ) + self._reactions = ReactionSet( + db=db, indices=data['reactions']['indices'] + ) # ReactionSet + + return self + + ### PROPERTIES + + @property + def product(self) -> 'Ingredient': + """Product ingredient""" + return self._products[0] + + @property + def product_compound(self) -> 'Compound': + """Product compound""" + return self.product.compound + + @property + def id(self) -> int: + """Route ID""" + return self._id + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.price + + ### METHODS + + def get_dict(self) -> dict: + """Serialisable dictionary""" + data = {} + + data['id'] = self.id + data['product_id'] = self.product.id + data['reactants'] = self.reactants.get_dict() + data['intermediates'] = self.intermediates.get_dict() + data['reactions'] = self.reactions.get_dict() + + return data + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'Route #{self.id}: {self.product_compound}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + +class RouteSet: + """A set of Route objects""" + + def __init__(self, db: 'Database', routes: 'list[Route]') -> None: + """RouteSet initialisation""" + + data = {} + for route in routes: + # assert isinstance(route, Route) + data[route.id] = route + + self._data = data + self._db = db + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + ### FACTORIES + + @classmethod + def from_ids(cls, db: 'Database', ids: list | set, progress: bool = True): + """Generate a routeset from a set of :class:`.Route` IDs + + :param db: database to link + :param ids: :class:`.Route` database IDs + :param progress: show progress bar + """ + + if progress: + ids = mrich.track(ids, prefix='Getting routes') + + routes = [db.get_route(id=route_id) for route_id in ids] + + self = cls.__new__(cls) + return RouteSet(db, routes) + + @classmethod + def from_product_ids(cls, db: 'Database', ids: list | set, progress: bool = True): + """Generate a routeset from a set of product :class:`.Compound` IDs + + :param db: database to link + :param ids: :class:`.Compound` database IDs + """ + + str_ids = str(tuple(ids)).replace(',)', ')') + + records = db.select_where( + table='route', + query='route_id', + key=f'route_product IN {str_ids}', + multiple=True, + ) + + route_ids = [i for (i,) in records] + + return cls.from_ids(db, route_ids, progress=progress) + + @classmethod + def from_json( + cls, db: 'Database', path: 'str | Path', data: dict = None + ) -> 'RouteSet': + """Load a serialised routeset from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + self = cls.__new__(cls) + + if data is None: + import json + + data = json.load(open(path)) + + new_data = {} + for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): + route_id = d['id'] + new_data[route_id] = Route.from_json(db=db, path=None, data=d) + + self._data = new_data + self._db = db + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + return self + + ### PROPERTIES + + @property + def data(self) -> 'dict[int, Route]': + """Get internal data dictionary""" + return self._data + + @property + def db(self): + """Get associated database""" + return self._db + + @property + def routes(self) -> 'list[Route]': + """Get route objects""" + return self.data.values() + + @property + def product_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the products""" + ids = self.db.select_where( + table='route', + query='DISTINCT route_product', + key=f'route_id IN {self.str_ids}', + multiple=True, + ) + return [i for (i,) in ids] + + @property + def reactant_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the reactants""" + sql = f""" + SELECT DISTINCT component_ref FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON component_route = route_id + WHERE component_type = 2 + AND route_id IN {self.str_ids} + """ + + c = self.db.execute(sql) + return [i for (i,) in c] + + @property + def products(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route products""" + from .cset import CompoundSet + + return CompoundSet(self.db, self.product_ids) + + @property + def reactants(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route reactants""" + from .cset import CompoundSet + + return CompoundSet(self.db, self.reactant_ids) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Route` ID's""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def ids(self) -> list[int]: + """Return the :class:`.Route` IDs""" + return self.data.keys() + + @property + def cluster_map(self) -> dict[tuple, set]: + """Create a dictionary grouping routes by their scaffold/base cluster. + + :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. + """ + + if self._cluster_map is None: + # get route mapping + pairs = self.db.select_where( + query='route_product, route_id', + key=f'route_id IN {self.str_ids}', + table='route', + multiple=True, + ) + + route_map = {route_product: route_id for route_product, route_id in pairs} + + # group compounds by cluster + compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) + + # create the map + self._cluster_map = {} + for cluster, compounds in compound_clusters.items(): + self._cluster_map[cluster] = [] + for compound in compounds: + route_id = route_map.get(compound, None) + if not route_id: + continue + self._cluster_map[cluster].append(route_id) + + if not self._cluster_map[cluster]: + del self._cluster_map[cluster] + + return self._cluster_map + + ### METHODS + + def copy(self) -> 'RouteSet': + """Copy this RouteSet""" + return RouteSet(self.db, self.data.values()) + + def set_db_pointers(self, db: 'Database') -> None: + """ + + :param db: + + """ + self._db = db + for route in self.data.values(): + route._db = db + + # def clear_db_pointers(self): + # """ """ + # self._db = None + # for route in self.data.values(): + # route._db = None + + def get_dict(self): + """Get serialisable dictionary""" + + data = dict(db=str(self.db), routes={}) + + # populate with routes + for route_id, route in self.data.items(): + data['routes'][route_id] = route.get_dict() + + return data + + def prune_unavailable(self, suppliers: list[str]): + """Remove routes that don't have all reactants available from given suppliers""" + + suppliers_str = str(tuple(suppliers)).replace(',)', ')') + + sql = f""" + WITH possible_reactants AS ( + SELECT quote_compound, COUNT( + CASE + WHEN quote_supplier IN {suppliers_str} THEN 1 + END) AS [count_valid] + FROM {self.db.SQL_SCHEMA_PREFIX}quote + GROUP BY quote_compound + ), + + route_reactants AS ( + SELECT route_id, route_product, + COUNT( + CASE + WHEN count_valid = 0 THEN 1 + WHEN count_valid IS NULL THEN 1 + END) + AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id + LEFT JOIN possible_reactants ON quote_compound = component_ref + WHERE component_type = 2 + GROUP BY route_id + ) + + SELECT route_id FROM route_reactants + WHERE count_unavailable = 0 + AND route_id IN {self.str_ids} + """ + + route_ids = self.db.execute(sql).fetchall() + + route_ids = [i for (i,) in route_ids] + + mrich.var('#routes before pruning', len(self)) + mrich.var('#routes after pruning', len(route_ids)) + + return RouteSet.from_ids(self.db, route_ids) + + def pop_id(self) -> int: + """Pop the last route from the set and return it's id""" + route_id, route = self.data.popitem() + return route_id + + def pop(self) -> 'Route': + """Pop the last route from the set and return it's object""" + route_id, route = self.data.popitem() + return route + + def balanced_pop( + self, permitted_clusters: set[tuple] | None = None, debug: bool = False + ) -> 'Route': + """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" + + if not self._data: + mrich.print('RouteSet depleted') + return None + + if not self.cluster_map: + # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") + return self.pop() + + # store the permitted clusters (or all clusters) list as property + + if self._permitted_clusters is None: + if permitted_clusters: + permitted_clusters = set( + (cluster,) if isinstance(cluster, int) else cluster + for cluster in permitted_clusters + ) + + self._permitted_clusters = [] + for cluster in permitted_clusters: + if cluster not in self.cluster_map: + mrich.warning( + cluster, 'in permitted_clusters but not cluster_map' + ) + else: + self._permitted_clusters.append(cluster) + + else: + self._permitted_clusters = list(self.cluster_map.keys()) + + if self._current_cluster is None: + self._current_cluster = self._permitted_clusters[0] + + ### pop a Route + + if debug: + mrich.debug(f'Would pop Route from {self._current_cluster=}') + + cluster = self._current_cluster + + # pop the last route id from the given cluster + + try: + route_id = self.cluster_map[cluster].pop() + except IndexError: + mrich.print(self._permitted_clusters) + mrich.print(self.cluster_map) + raise + except AttributeError: + mrich.print(cluster) + mrich.print(self.cluster_map) + raise + except KeyError: + mrich.print('cluster', cluster) + mrich.print('self._permitted_clusters', self._permitted_clusters) + mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) + raise + + # clean up empty clusters + + if debug: + mrich.debug('Popped route', route_id) + + # get the Route object + + if route_id in self._data: + route = self._data[route_id] + del self._data[route_id] + else: + # if debug: + mrich.debug('Route not present') + return self.balanced_pop() + + ### increment cluster + + # def increment_cluster(cluster): + n = len(self._permitted_clusters) + if n > 1: + for i, cluster in enumerate(self._permitted_clusters): + if cluster == self._current_cluster: + if i == n - 1: + self._current_cluster = self._permitted_clusters[0] + else: + self._current_cluster = self._permitted_clusters[i + 1] + break + else: + raise IndexError('This should never be reached...') + + # increment_cluster() + + if not self.cluster_map[cluster]: + del self.cluster_map[cluster] + if not self.cluster_map: + mrich.debug('RouteSet.cluster_map depleted') + self._permitted_clusters = [ + c for c in self._permitted_clusters if c != cluster + ] + # if debug: + mrich.debug('Depleted cluster', cluster) + + if not self._permitted_clusters: + mrich.debug('Depleted all permitted clusters', cluster) + mrich.debug('Removing cluster restriction', cluster) + self._permitted_clusters = list(self.cluster_map.keys()) + self._current_cluster = None + + if debug: + mrich.debug('#Routes in set', len(self._data)) + + return route + + def shuffle(self): + """Randomly shuffle the routes in this set""" + import random + + items = list(self.data.items()) + random.shuffle(items) + self._data = dict(items) + + ### shuffle the cluster map as well + + for cluster, routes in self.cluster_map.items(): + random.shuffle(routes) + self.cluster_map[cluster] = routes + + ### DUNDERS + + def __len__(self) -> int: + """Number of routes in this set""" + return len(self.data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Route × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate over routes in this set""" + return iter(self.data.values()) + + def __getitem__(self, key): + """Get a specific route in this set""" + return list(self.data.values())[key] + + +class RecipeSet: + """A set of recipes stored on disk""" + + def __init__( + self, db: 'Database', directory: 'str | Path', pattern: str = '*.json' + ): + """RecipeSet initialisation""" + + from json import JSONDecodeError + from pathlib import Path + + self._db = db + self._json_directory = Path(directory) + self._json_pattern = pattern + + self._json_paths = {} + for path in self._json_directory.glob(self._json_pattern): + self._json_paths[ + path.name.removeprefix('Recipe_').removesuffix('.json') + ] = path.resolve() + + mrich.reading(f'{directory}/{pattern}') + + self._recipes = {} + for key, path in mrich.track( + self._json_paths.items(), prefix='Loading recipes' + ): + try: + recipe = Recipe.from_json( + db=self.db, + path=path, + allow_db_mismatch=True, + debug=False, + db_mismatch_warning=False, + ) + except JSONDecodeError: + mrich.error(f'Bad JSON in {path}') + continue + recipe._hash = key + self._recipes[key] = recipe + + mrich.success('Loaded', len(self), 'Recipes') + + ### FACTORIES + + ### PROPERTIES + + @property + def db(self) -> 'Database': + """Associated database""" + return self._db + + ### METHODS + + def get_values( + self, + key: str, + progress: bool = False, + serialise_price: bool = False, + ): + """Get values of member recipes associated with attribute ``key`` + + :param key: attribute to query/calculate + :param progress: show a progress bar + :param serialise_price: serialise price objects + + """ + + values = [] + recipes = self._recipes.values() + + if progress: + recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') + + for recipe in recipes: + value = getattr(recipe, key) + if serialise_price and key == 'price': + value = value.amount + values.append(value) + + return values + + def get_df(self, **kwargs) -> 'pandas.DataFrame': + """Get dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`""" + + data = [] + + for recipe in self: + d = recipe.get_dict( + # reactant_supplier=False, + database=False, + timestamp=False, + **kwargs, + # timestamp=False, + ) + + data.append(d) + + from pandas import DataFrame + + return DataFrame(data) + + def items(self) -> 'list[tuple[str, Recipe]]': + """Get data dictionary items""" + return self._recipes.items() + + def keys(self) -> list[str]: + """Get data dictionary keys (recipe hashes)""" + return self._recipes.keys() + + ### DUNDERS + + def __len__(self) -> int: + """Number of recipes in this set""" + return len(self._recipes) + + def __getitem__( + self, + key: int | str, + ) -> Recipe: + """Get a :class:`.Recipe` in this set by it's index or key/hash""" + + match key: + case int(): + return list(self._recipes.values())[key] + + case str(): + return self._recipes[key] + + case _: + mrich.error( + f'Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}' + ) + + return None + + def __iter__(self): + """Iterate over recipes""" + return iter(self._recipes.values()) + + def __contains__(self, key: str): + """Is this hash contained in the set""" + assert isinstance(key, str) + return key in self._recipes + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Recipe × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' diff --git a/src/designdb/route.py b/src/designdb/route.py new file mode 100644 index 0000000..47c3753 --- /dev/null +++ b/src/designdb/route.py @@ -0,0 +1,219 @@ +import json + +import mcol +import mrich + +from designdb.models import Component, Reaction, Route + +from .recipe import Recipe + + +# name conflict with route model. Trying to get rid of this entirely +class RouteObj(Recipe): + """A recipe with a single product, that is stored in the database""" + + def __init__( + self, + *, + route_id: int, + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', + ) -> None: + """Route initialisation""" + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + # check typing + assert isinstance(product, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(reactions, ReactionSet) + + assert len(product) == 1 + assert isinstance(route_id, int) + assert route_id + + self._id = route_id + self._products = product + self._product_id = product.ids[0] + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + + ### FACTORIES + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'Route': + """Load a serialised route from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + if data is None: + data = json.load(open(path)) + + self = cls.__new__(cls) + + self._id = data['id'] + + self._product_id = data['product_id'] + self._products = IngredientSet.from_compounds( + compounds=None, ids=[self._product_id] + ) # IngredientSet + + self._reactants = IngredientSet.from_json( + path=None, + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], + ) + self._intermediates = IngredientSet.from_json( + path=None, + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], + ) + self._reactions = ReactionSet( + Reaction.objects.filter(pk__in=data['reactions']['indices']) + ) # ReactionSet + + return self + + @classmethod + def get_route( + cls, + *, + id: int, + debug: bool = False, + ) -> 'RouteObj': + """Fetch a :class:`.Route` object stored in the :class:`.Database`. + + :param id: the ID of the :class:`.Route` to be retrieved + :param debug: increase verbosity for debugging, defaults to False + :returns: :class:`.Route` object + + """ + + # avoiding circular dependencies + from designdb.sets.compound import CompoundSet, IngredientSet + from designdb.sets.reaction import ReactionSet + + # multiples?? + route = Route.objects.get(pk=id) + + if debug: + mrich.var('product_id', route.product_compound) + + qs = Component.objects.filter(route=route).order_by('id') + + reaction_ids = [] + reactant_ids = [] + reactant_amounts = [] + intermediate_ids = [] + intermediate_amounts = [] + + # for ref, c_type, amount in triples: + for k in qs: + ref = k.component_ref + c_type = k.component_type + amount = k.component_amount + match c_type: + case 1: + reaction_ids.append(ref) + case 2: + reactant_ids.append(ref) + reactant_amounts.append(amount) + case 3: + intermediate_ids.append(ref) + intermediate_amounts.append(amount) + case _: + raise ValueError(f'Unknown component type {c_type}') + + if debug: + mrich.var('pairs', qs) + + products = CompoundSet([route.pk]) + reactants = CompoundSet(reactant_ids) + intermediates = CompoundSet(intermediate_ids) + + products = IngredientSet.from_compounds(compounds=products, amount=1) + reactants = IngredientSet.from_compounds( + compounds=reactants, amount=reactant_amounts + ) + intermediates = IngredientSet.from_compounds( + compounds=intermediates, amount=intermediate_amounts + ) + + reactions = ReactionSet(reaction_ids) + + recipe = RouteObj( + route_id=id, + product=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + ) + + if debug: + mrich.var('recipe', recipe) + + return recipe + + ### PROPERTIES + + @property + def product(self) -> 'Ingredient': + """Product ingredient""" + return self._products[0] + + @property + def product_compound(self) -> 'Compound': + """Product compound""" + return self.product.compound + + @property + def id(self) -> int: + """Route ID""" + return self._id + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.price + + ### METHODS + + def get_dict(self) -> dict: + """Serialisable dictionary""" + data = {} + + data['id'] = self.id + data['product_id'] = self.product.id + data['reactants'] = self.reactants.get_dict() + data['intermediates'] = self.intermediates.get_dict() + data['reactions'] = self.reactions.get_dict() + + return data + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'Route #{self.id}: {self.product_compound}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' diff --git a/src/designdb/services/__init__.py b/src/designdb/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/designdb/services/compound.py b/src/designdb/services/compound.py new file mode 100644 index 0000000..290c489 --- /dev/null +++ b/src/designdb/services/compound.py @@ -0,0 +1,136 @@ +import logging +import re + +import mrich +import rdkit +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from designdb.models import Compound, CompoundTag +from designdb.utils import inchikey_from_smiles, sanitise_smiles + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class CompoundBatchResult: + def __init__(self): + self.created = [] + self.errors = [] + + +class CompoundService: + @classmethod + def create( + cls, + *, + mol: Chem.rdchem.Mol, + smiles: str, + inchikey: str, + ) -> tuple[Compound, bool]: + + # TODO: new fields to consider, fingerprints and tautomer hashes + + # TODO and SQLITE_RELIC: inchikey is calculated by postgres in + # trigger. But I need to calculate it here as well, for + # queries. I feel like having two different calculation + # methods is not ideal. Are the versions guaranteed to be the + # same? And even if I do this in trigger, it's already here, + # why not just insert it? + + compound, created = Compound.objects.get_or_create( + compound_inchikey=inchikey, + # compound_smiles=smiles, + defaults={ + 'compound_mol': mol, + 'compound_smiles': smiles, + 'rdkit_version': rdkit.__version__, + 'inchi_version': Chem.inchi.GetInchiVersion(), + }, + ) + if not created and logger.level == logging.DEBUG: + mrich.warning( + f'Skipping compound {inchikey}, {smiles}, duplicate of {compound.pk}' + ) + + # there's a following block in the original code + # I don't understand what it is trying to achieve + # smiles and inchikey are both inserted, so compound existing + # but not reachable by inchikey should not happen. maybe this + # covers compounds loaded through different pathway? + + # compound_id = self.db.insert_compound( + # smiles=smiles, + # tags=tags, + # warn_duplicate=debug, + # commit=False, + # ) + + # if not compound_id: + # inchikey = inchikey_from_smiles(smiles) + # compound = self.compounds[inchikey] + + # if not compound: + # mrich.error( + # 'Compound exists in database but could not be found by inchikey' + # ) + # mrich.var('smiles', smiles) + # mrich.var('inchikey', inchikey) + # mrich.var('observation_shortname', name) + # raise Exception + + # else: + # count_compound_registered += 1 + # compound = self.compounds[compound_id] + + return compound, created + + @classmethod + def create_from_smiles( + cls, + smiles_list: list[str], + ) -> list[tuple[str, str]]: + result = [] + for smiles in smiles_list: + sane_smiles = sanitise_smiles( + smiles, verbosity=logger.level == logging.DEBUG + ) + mol = Chem.MolFromSmiles(sane_smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + cls.create( + mol=mol, + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + + result.append((sane_inchikey, sane_smiles)) + + return result + + +class CompoundTagService: + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + CompoundTag.objects.bulk_create( + [CompoundTag(compound_tag_name=k.strip()) for k in tag_list if k.strip()], + ignore_conflicts=True, + ) + tags = CompoundTag.objects.filter(compound_tag_name__in=tag_list) + return tags diff --git a/src/designdb/services/ingestion.py b/src/designdb/services/ingestion.py new file mode 100644 index 0000000..dfae0c7 --- /dev/null +++ b/src/designdb/services/ingestion.py @@ -0,0 +1,1065 @@ +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import molparse as mp +import mrich +import pandas as pd +from numpy import isnan +from pandas import read_pickle +# from mypackage.services.compound import CompoundService +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools + +from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry +from designdb.ingredient import Ingredient +from designdb.models import Compound, Pose, Reactant, Reaction, Scaffold, Target +from designdb.recipe import Recipe +from designdb.route import RouteObj +from designdb.services.compound import CompoundService, CompoundTagService +from designdb.services.pose import PoseService, PoseTagService +from designdb.services.reaction import ReactionService +from designdb.services.route import RouteService +from designdb.services.score import ScoreService +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet +from designdb.utils import ( + SanitisationError, + inchikey_from_smiles, + remove_other_ligands, + sanitise_smiles, +) +from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode +from src.designdb.services.reaction import ReactionService + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +@dataclass +class FSRecord: + name: str + path: Path + sdf: Path + pdb: Path + + +def parse_sdf_pandas(sdf_path: Path) -> tuple[str, Chem.rdchem.Mol]: + df = PandasTools.LoadSDF( + str(sdf_path), molColName='ROMol', idName='ID', strictParsing=True + ) + # extract fields + longcode = df.ID[0] + mol = df.ROMol[0] + + return longcode, mol + + +def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: + logger.debug('Reading %s', pdb_path) + pdb = mp.parse(pdb_path, verbosity=0) + + # create the single ligand bound pdb + lig_residues = pdb.residues['LIG'] + if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues): + pdb = remove_other_ligands(pdb, residue, chain) + pdb.prune_alternative_sites('A', verbosity=0) + pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb') + # side effect: writes pdb into file + mp.write( + pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG + ) + else: + pose_path = str(pdb_path.resolve()) + + return pose_path + + +def iter_fs_fragalysis(root_path, skip_records): + assert skip_records is not None, '"None" passed instead as skip_records' + + for dset_path in list(sorted(root_path.glob('*'))): + if dset_path.name in skip_records: + continue + + sdfs = [] + for sdf_path in dset_path.glob('*.sdf'): + sdf_name = sdf_path.name + + if ( + '_ligand' in sdf_name + ): # Quick fix, _ligand.sdf are exactly the same as .sdf + # in aligned_directory. + continue + + # fragalysis SDF + if SDF_FRAGALYSIS_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + # fragalysis SDF from PDB id + elif PDBID_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + else: + mrich.warning( + sdf_name, + "doesn't not follow neither Fragalysis nor PDB ID patterns", + ) + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + pdbs = [ + p + for p in dset_path.glob('*.pdb') + if '_ligand' not in p.name + and '_apo' not in p.name + and '_hippo' not in p.name + ] + + if not len(pdbs) == 1: + mrich.error(dset_path.name, 'has invalid PDBs', pdbs) + continue + + record = FSRecord(name=dset_path.name, path=dset_path, sdf=sdfs[0], pdb=pdbs[0]) + + logger.debug('fs_frag record: %s', record) + + yield record + + +# unfinished, seems XCA data is not loaded now +def iter_fs_xca(root_path, skip): + for dset_path in sorted(root_path.glob('*[0-9][0-9][0-9][0-9]')): + if dset_path.name in skip: + continue + + sdfs = [] + + for sdf_path in sorted(dset_path.glob('*.sdf')): + sdf_name = sdf_path.name + + # TODO: switch between patterns?? + if SDF_XCAv2_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + for i, sdf in enumerate(sdfs): + subname = dset_path.name + chr(ord('a') + i) + + pdb = dset_path / sdf.name.replace('_ligand.sdf', '.pdb') + + if not pdb.exists(): + mrich.error(dset_path.name, 'is missing PDB', pdb) + continue + + record = FSRecord(name=subname, path=dset_path, sdf=sdf, pdb=pdb) + + logger.debug('fs_frag record: %s', record) + + yield record + + +def read_df(path: Path): + if path.name.endswith('.sdf'): + df = PandasTools.LoadSDF(str(path.resolve())) + else: + df = read_pickle(path) + + return df + + +def validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, +): + + # TODO: these are part of input validation and should be removed. or + # at least rewritten + assert mol_col in df.columns, f'{mol_col=} not in {df.columns}' + + if name_col: + assert name_col in df.columns, f'{name_col=} not in {df.columns}' + + if inspiration_col and not inspirations: + assert inspiration_col in df.columns, f'{inspiration_col=} not in {df.columns}' + + if not reference and reference_col: + assert reference_col in df.columns, f'{reference_col=} not in {df.columns}' + + +def preprocess_df( + df, + *, + skip_equal, + skip_not_equal, + name_col: str, +) -> list[dict[str, Any]]: + + mrich.var('SDF entries (pre-filter)', len(df)) + + df = df[df['ID'] != 'ver_1.2'] + + for k, v in skip_equal.items(): + df = df[df[k] == v] + + for k, v in skip_not_equal.items(): + df = df[df[k] != v] + + mrich.var('SDF entries (post-filter)', len(df)) + + df[name_col] = df[name_col].str.strip() + + records = df.to_dict(orient='records') + + return records + + +def metadata_from_record( + record: dict[str, str], + ignore_fields: list[str | None], + convert_floats: bool, + field_warning=None, +) -> dict[str, str | float]: + + result = {} + skip = { + 'smiles', + 'inchikey', + 'compound_id', + 'target_id', + 'reference_id', + 'path', + 'exports', + } + + skip = skip.union(set([k for k in ignore_fields if k])) + + for key, value in record.items(): + if key in skip: + continue + + if isinstance(value, float) and isnan(value): + continue + + if convert_floats: + try: + value = float(value) + except TypeError: + pass + except ValueError: + pass + + if not (isinstance(value, str) or isinstance(value, float)): + if field_warning: + field_warning(mrich.warning(f'Skipping metadata from column={key}.')) + continue + + result[key] = value + + return result + + +@dataclass +class IngestionBatchResult: + attempts: int = 0 + compounds_created: int = 0 + poses_created: int = 0 + + +class IngestionService: + @classmethod + def ingest_filesystem( + cls, + *, + root_path: Path, + target: Target, + skip_records: list[str], + compound_tag_list: list[str], + metadata_file: Path | str, + ) -> IngestionBatchResult: + + # this is now strictly for loading frag data. cannot switch inner funcs easily + result = IngestionBatchResult() + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tagger = PoseTagService(metadata_file, other_tags=compound_tag_list) + + # if needs xca paths, need to pass or select function + for fs_record in iter_fs_fragalysis(root_path, skip_records): + longcode, mol = parse_sdf_pandas(fs_record.sdf) + logger.debug(fs_record.name, longcode) + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = mp.rdkit.mol_to_smiles(mol) + sane_smiles = sanitise_smiles( + smiles, verbosity=logger.level == logging.DEBUG + ) + inchikey = inchikey_from_smiles(smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + # NB! different func if XCA data + try: + longcode_rec = parse_observation_longcode(longcode) + except UnsupportedFragalysisLongcodeError as exc: + # unhandled in original code. do what? + raise UnsupportedFragalysisLongcodeError from exc + + pose_path = parse_pdb_mp( + fs_record.pdb, longcode_rec.residue_number, longcode_rec.chain + ) + + compound, compound_created = CompoundService.create( + mol=mol, + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if compound_created: + result.compounds_created += 1 + + # pose_tags = PoseTagService.tags_from_list(pose_tag_set) + pose_tags, metadata = pose_tagger.tags_and_meta( + code=fs_record.name, + longcode=longcode, + ) + + metadata = {'fragalysis_longcode': longcode} + + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=mol, + alias=fs_record.name, + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + + # it seems fragalysis data is not expected to contain + # scores + + # in original code. what's that for? + # what I can think of is previously existing pose without mol + # if load_pose_mols: + # try: + # pose.mol + # except Exception as e: + # mrich.error('Could not load molecule', pose) + # mrich.error(e) + + return result + + @classmethod + def ingest_sdf( + cls, + *, + file_path: Path, + target, + compound_tag_list: list[str], + pose_tag_list: list[str], + mol_col: str, + name_col: str, + inspiration_col: str | None = None, + inspirations: list[int], + inspiration_map: dict[str, Pose], + reference: int | None, + reference_col: str, + skip_equal, + skip_not_equal, + convert_floats: bool = True, + field_warning=None, + ) -> IngestionBatchResult: + result = IngestionBatchResult() + + output_directory = Path(str(file_path.name).removesuffix('.sdf')) + output_directory.mkdir(parents=True, exist_ok=True) + + df = read_df(file_path) + validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, + ) + + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + + # I need to know here one of two things: + # - which scores to create + # - which fields in sdf to ignore + # I mean, probs shouldn't cats smiles, etc as scores + + # it's probably the latter, isn't it? then I don't actually + # need to init scores at all, especially with central + # deisgndb, the scoring method likely exists + + # scorer = ScoreService(['energy_score', 'distance_score']) + scorer = ScoreService() + + records = preprocess_df( + df, + skip_equal=skip_equal, + skip_not_equal=skip_not_equal, + name_col=name_col, + ) + + for r in records: + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = r.get('smiles', None) + if not smiles: + smiles = mp.rdkit.mol_to_smiles(r[mol_col]) + try: + sane_smiles = sanitise_smiles( + smiles, + sanitisation_failed='error', + radical='warning', + verbosity=logger.level == logging.DEBUG, + ) + except SanitisationError as e: + mrich.error(f'Could not sanitise {smiles=}') + mrich.error(str(e)) + continue + except AssertionError: + mrich.error(f'Could not sanitise {smiles=}') + continue + + inchikey = inchikey_from_smiles(smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + compound, compound_created = CompoundService.create( + mol=r[mol_col], + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if compound_created: + result.compounds_created += 1 + + pose_inspirations = PoseService.get_inspirations( + inspirations, + inspiration_map.get(r[name_col], []), + r.get(inspiration_col, []) if inspiration_col else None, + target=target, + ) + + if not reference and reference_col: + reference = PoseService.get_reference(r[reference_col], target) + + metadata = metadata_from_record( + r, + ignore_fields=[inspiration_col, name_col, mol_col], + convert_floats=convert_floats, + field_warning=field_warning, + ) + + pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve() + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=r[mol_col], + alias=r[name_col], + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + reference=reference, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + pose.inspirations.add(*Pose.objects.filter(pk__in=pose_inspirations)) + scorer.add_scores_from_record(pose=pose, record=r) + + return result + + # how is that without target?? + @classmethod + def ingest_syndirella_routes( + cls, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + do_check_chemistry: bool = True, + register_routes: bool = True, + ): + # this is pretty much a copy from the original method now + df = read_pickle(pickle_path) + + for i, row in mrich.track(df.iterrows(), total=len(df)): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) + + d = row.to_dict() + + # comp = self.compounds(smiles=d['smiles']) + + n_routes = 0 + for key in d: + if not key.startswith('route'): + continue + + if not key.endswith('_names'): + continue + + v = d[key] + + if isinstance(v, float) and pd.isna(v): + break + + n_routes += 1 + + if not n_routes: + # mrich.warning(comp, "#routes =", n_routes) + continue + + # routes = [] + for j in range(n_routes): + route_str = f'route{j}' + + route = d[route_str] + + if CAR_only and not d[route_str + '_CAR']: + continue + + reactions = ReactionSet() + reactants = IngredientSet() + intermediates = IngredientSet() + products = IngredientSet() + + # new models include Reaction, Reactant and + # Component. Should use these instead? + + try: + for k, reaction_struct in enumerate(route): + reaction_type = reaction_struct['name'] + + # product = self.compounds(smiles=reaction['productSmiles']) + # no error handling on sanitaiton, catchall at the end + # from original code + + smiles = reaction_struct['productSmiles'] + sane_smiles = sanitise_smiles( + smiles, + sanitisation_failed='error', + ) + + sane_inchikey = inchikey_from_smiles(sane_smiles) + product = Compound.objects.get(compound_inchikey=sane_inchikey) + + mrich.print(i, j, k, reaction_type, product) + + reaction, _ = Reaction.objects.get_or_create( + reaction_type=reaction_type, + product_compound=product, + ) + + rs = [] + print('reactant smiles', reaction_struct['reactantSmiles']) + for reactant_s in reaction_struct['reactantSmiles']: + reactant_comp, _ = Compound.objects.get_or_create( + compound_smiles=reactant_s, + ) + reactant, _ = Reactant.objects.get_or_create( + compound=reactant_comp, + reaction=reaction, + ) + rs.append(reactant.pk) + + if do_check_chemistry and not check_chemistry( + reaction_type, rs, product + ): + raise InvalidChemistryError( + f'{type=}, {rs=}, {product.id=}', + ) + + for r_id in rs: + if r_id in reactants: + intermediates.add(compound_id=r_id, amount=1) + else: + reactants.add(compound_id=r_id, amount=1) + + reactions.add(reaction) + + except InvalidChemistryError: + continue + except UnsupportedChemistryError: + mrich.warning('Skipping unsupported chemistry:', reaction_type) + continue + except Exception: + mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) + continue + + products.add(Ingredient.from_compound(product, amount=1)) + + recipe = Recipe( + reactions=reactions, + reactants=reactants, + intermediates=intermediates, + products=products, + ) + + if register_routes: + route, _ = RouteService.create_from_recipe( + recipe=recipe, + ) + mrich.success('registered route', route.pk) + + if pick_first: + break + + return df + + @classmethod + def ingest_syndirella_elabs( + cls, + *, + df: pd.DataFrame, + target: Target, + reject_flags: list[str], + pose_tag_list: list[str], + product_tag_list: list[str], + max_energy_score: float, + max_distance_score: float, + require_intra_geometry_pass: bool, + register_reactions: bool, + scaffold_route: RouteObj | None = None, + scaffold_compound: Compound | None = None, + ) -> pd.DataFrame: + + # work out number of reaction steps + num_steps = max( + [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] + ) + mrich.var('num_steps', num_steps) + + # add is_scaffold row + df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') + + ###### PREP ###### + + # flags + + present_flags = set() + for step in range(num_steps): + step += 1 + + for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): + for flag in flags: + present_flags.add(flag) + + if present_flags: + mrich.warning('Flags in DataFrame:', present_flags) + + for flag in reject_flags: + if flag in present_flags: + for step in range(num_steps): + step += 1 + matches = df[f'{step}_flag'].apply( + lambda x: flag in x if x is not None else False + ) + mrich.print( + 'Filtering out', + len(df[matches]), + 'rows from step', + step, + 'due to', + flag, + ) + df = df[~matches] + + # poses + + n_null_mol = len(df[df['path_to_mol'].isna()]) + if n_null_mol: + df = df[df['path_to_mol'].notna()] + mrich.var('#rows skipped due to null path_to_mol', n_null_mol) + + if not len(df): + mrich.warning('No valid rows') + return None + + # inspirations + inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) + # smth like {('z0637a', 'z1040a')} + + if len(inspiration_sets) != 1: + mrich.error('Varying inspirations not supported') + return df + + (inspiration_set,) = inspiration_sets + + inspirations = Pose.objects.filter( + pose_alias__in=inspiration_set, + target=target, + ) + + if inspirations.count() != len(inspiration_set): + print('target', target) + print('inspiration_set', inspiration_set) + print('inspiration comparison', inspirations.count(), len(inspiration_set)) + assert inspirations.count() == len(inspiration_set) + + # reference + template_paths = set(df['template'].to_list()) + assert len(template_paths) == 1, 'Multiple references not supported' + (template_path,) = template_paths + template_path = Path(template_path) + mrich.var('template_path', template_path) + base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') + # reference = self.poses[base_name] + + # TODO: error handling + reference = Pose.objects.get( + pose_alias=base_name, + target=target, + ) + + assert reference, 'Could not determine reference structure' + mrich.var('reference', reference) + + # that's nice but I need it before that + # target = reference.target + + # subset of rows + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + mrich.var('#scaffold entries', len(scaffold_df)) + mrich.var('#elab entries', len(elab_df)) + + if not len(scaffold_df) and not scaffold_route and not scaffold_compound: + mrich.error('No valid scaffold rows') + return None + + elif scaffold_route: + ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE + + assert scaffold_route.num_reactions == 1 + + product = scaffold_route.products[0].compound + reaction = scaffold_route.reactions[0] + + assert reaction.reactants.count() == 2 + + scaffold_dict = { + 'scaffold_smiles': product.compound_smiles, + '1_reaction': reaction.reaction_type, + # this is so hacky + '1_r1_smiles': reaction.reactants.first().compound.compound_smiles, + '1_r2_smiles': reaction.reactants.last().compound.compound_smiles, + '1_product_smiles': product.compound_smiles, + '1_product_name': 'scaffold', + '1_single_reactant_elab': False, + '1_num_atom_diff': 0, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + elif scaffold_compound: + ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT + + scaffold_dict = { + 'scaffold_smiles': scaffold_compound.smiles, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # if dry_run: + # mrich.error('Not registering records (dry_run)') + # return df + + ###### ELABS ###### + + # bulk register compounds + + smiles_cols = [ + c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' + ] + + for smiles_col in smiles_cols: + inchikey_col = smiles_col.replace('_smiles', '_inchikey') + compound_id_col = smiles_col.replace('_smiles', '_compound_id') + + unique_smiles = df[smiles_col].dropna().unique() + + mrich.debug( + f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' + ) + + # radical? + values = CompoundService.create_from_smiles(unique_smiles) + + orig_smiles_to_inchikey = { + orig_smiles: inchikey + for orig_smiles, (inchikey, new_smiles) in zip( + unique_smiles, values, strict=False + ) + } + + df[inchikey_col] = df[smiles_col].apply( + lambda x: orig_smiles_to_inchikey.get(x) + ) + + # get associated IDs + compound_inchikey_id_dict = { + k.compound_inchikey: k.pk + for k in Compound.objects.filter(compound_smiles__in=unique_smiles) + } + df[compound_id_col] = df[inchikey_col].apply( + lambda x: compound_inchikey_id_dict.get(x) + ) + + # bulk register reactions + + if register_reactions: + for step in range(num_steps): + step += 1 + + mrich.debug(f'Registering reactions for step {step}') + + reaction_dicts = [] + + for reaction_name, r1_id, r2_id, product_id in df[ + [ + f'{step}_reaction', + f'{step}_r1_compound_id', + f'{step}_r2_compound_id', + f'{step}_product_compound_id', + ] + ].values: + # skip invalid rows + if pd.isna(r1_id) or pd.isna(product_id): + mrich.warning("Can't insert reactions for missing scaffold") + continue + + # reactant IDs + + reactant_ids = set() + reactant_ids.add(int(r1_id)) + + if not pd.isna(r2_id): + reactant_ids.add(int(r2_id)) + + product_id = int(product_id) + + # registration data + + reaction_dicts.append( + dict( + reaction_name=reaction_name, + reactant_ids=reactant_ids, + product_id=int(product_id), + ) + ) + + # why is this outside of loop? + _ = ReactionService.create_from_lists( + reaction_types=[d['reaction_name'] for d in reaction_dicts], + product_ids=[d['product_id'] for d in reaction_dicts], + reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], + ) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # tag product compounds: + + product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) + products = Compound.objects.filter(pk__in=product_ids) + product_tags = CompoundTagService.tags_from_list(product_tag_list) + for compound in products: + compound.tags.add(*product_tags) + + # bulk register scaffold relationships + + for step in range(num_steps): + step += 1 + + for role in ['r1', 'r2', 'product']: + key = f'{step}_{role}_compound_id' + + mrich.debug(f'Registering scaffold relatonships for {key}') + + if step == num_steps and role == 'product' and scaffold_compound: + scaffold_id = scaffold_compound.id + + else: + scaffold_ids = list(scaffold_df[key].dropna().unique()) + + if not scaffold_ids: + mrich.warning( + "Can't insert scaffold relationships due to missing", + key, + 'for all scaffold rows', + ) + continue + + if len(scaffold_ids) > 1: + mrich.error('Multiple scaffold row values in', key) + return scaffold_df + + scaffold_id = scaffold_ids[0] + + # original code didn't do dropna? how? filter in later step? + superstructure_ids = [ + i for i in elab_df[key].dropna().unique() if i != scaffold_id + ] + + # comp service? + for superstructure_id in superstructure_ids: + base = Compound.objects.get(pk=scaffold_id) + superstructure = Compound.objects.get(pk=int(superstructure_id)) + Scaffold.objects.get_or_create( + base_compound=base, + superstructure_compound=superstructure, + ) + + # filter poses + + ok = df + + try: + if require_intra_geometry_pass: + mrich.var( + '#poses !intra_geometry_pass', + len(df[df['intra_geometry_pass'] == False]), + ) + ok = ok[ok['intra_geometry_pass'] == True] + + if max_energy_score is not None: + mrich.var( + f'#poses ∆∆G > {max_energy_score}', + len(df[df['∆∆G'] > max_energy_score]), + ) + ok = ok[ok['∆∆G'] <= max_energy_score] + + if max_distance_score is not None: + mrich.var( + f'#poses comRMSD > {max_distance_score}', + len(df[df['comRMSD'] > max_energy_score]), + ) + ok = ok[ok['comRMSD'] <= max_distance_score] + + except Exception as e: + mrich.error('Problem filtering dataframe') + mrich.error(e) + return df + + mrich.var('#acceptable poses', len(ok)) + + if not len(ok): + mrich.warning('No valid poses') + return None + + # bulk register poses + + pose_ids = [] + scorer = ScoreService() + for _, row in ok.iterrows(): + path = Path(row.path_to_mol).resolve() + print('comp id in row', row[f'{num_steps}_product_compound_id']) + + # closed for testing + if not path.exists(): + mrich.warning('Skipping pose w/ non-exising file:', path) + continue + + if pd.isna(row[f'{num_steps}_product_compound_id']): + continue + + pose, created = PoseService.create_from_record( + compound_id=int(row[f'{num_steps}_product_compound_id']), + target_id=int(target.id), + reference=int(reference.id), + path=str(path), + ) + if created: + scores = { + 'energy_score': float(row['∆∆G']), + 'distance_score': float(row['comRMSD']), + } + pose_ids.append(pose.id) + scorer.add_scores_from_record(pose=pose, record=scores) + + if not pose_ids: + mrich.warning('No valid poses') + return None + + poses = Pose.objects.filter(pk__in=pose_ids) + mrich.success('Registered', poses.count(), 'new poses') + + # query relevant poses (also previously registered) + paths = poses.values_list('path', flat=True) + + # what the hell is this?? + records = Pose.objects.filter( + path__in=paths, + ) + for pose in records: + # pose.inspirations.add(*Pose.objects.filter(pk__in=inspiration.ids)) + pose.inspirations.add(*inspirations.queryset) + + # if pose_tags: + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + for pose in poses: + pose.tags.add(*pose_tags) + + return df + + +# def create_compound(...): +# assert connection.in_atomic_block diff --git a/src/designdb/services/pose.py b/src/designdb/services/pose.py new file mode 100644 index 0000000..5e93a4e --- /dev/null +++ b/src/designdb/services/pose.py @@ -0,0 +1,208 @@ +import json +import logging +import re +from collections.abc import Iterable +from pathlib import Path + +import mrich +import pandas as pd +import rdkit +from django.db.models import Q +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from designdb.models import Compound, Pose, PoseTag, Target +from designdb.utils import normalize_string_list +from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class PoseService: + @classmethod + def create( + cls, + *, + compound: Compound, + target: Target, + mol: Chem.rdchem.Mol, + alias: str, + path: str, + metadata: dict[str, str], + inchikey: str, + smiles: str, + reference: int | None = None, + ): + + try: + pose = Pose.objects.get( + target=target, + compound=compound, + pose_alias=alias, + ) + # default is to overwrite metadata. what about other props? + # also, shoulnd't this be JSON? + pose.metadata = metadata + pose.save() + created = False + except Pose.DoesNotExist: + pose = Pose( + compound=compound, + target=target, + pose_alias=alias, + pose_path=path, + pose_inchikey=inchikey, # SQLITE_RELIC + pose_smiles=smiles, # SQLITE_RELIC + pose_metadata=json.dumps(metadata), + pose_mol=mol, + rdkit_version=rdkit.__version__, + inchi_version=Chem.inchi.GetInchiVersion(), + pose_reference=reference, + ) + pose.save() + created = True + # except MultipleObjectsReturned: + # pass + + return pose, created + + @classmethod + def create_from_record( + cls, + *, + compound_id: int, + target_id: int, + path: str, + reference: int | None = None, + ): + target = Target.objects.get(pk=target_id) + compound = Compound.objects.get(pk=compound_id) + pose, created = Pose.objects.get_or_create( + compound=compound, + target=target, + pose_path=path, + reference=reference, + ) + return pose, created + + # this is parsing input, maybe in ingestion? + @staticmethod + def get_inspirations(*args, target: Target | None = None): + parsed = [] + for el in args: + if isinstance(el, str): + parsed.extend(normalize_string_list(el)) + elif isinstance(el, Iterable) and not isinstance(el, dict): + parsed.extend(el) + else: + logger.warning( + 'Unsupported inspiration collection received: %s', + type(el), + ) + + # inputs can be pk or name + pks = [] + aliases = [] + + for val in parsed: + try: + pks.append(int(val)) + except ValueError: + # assume string alias + aliases.append(val) + + qs = Pose.objects.filter( + Q(pk__in=pks) | Q(pose_alias__in=aliases, target=target) + ) + + return qs + + @staticmethod + def get_reference(reference, target) -> int: + try: + reference = int(reference) + # should I check if exist here as well? + except ValueError: + try: + reference = Pose.objects.get( + pose_alias=reference, + target=target, + ).pk + except Pose.DoesNotExist as exp: + logger.error('Pose %s does not exist', reference) + raise Pose.DoesNotExist from exp + + return reference + + +class PoseTagService: + def __init__(self, metadata_file: Path | str, other_tags: list[str] | None = None): + self._df = pd.read_csv(metadata_file) + self._curated_tag_cols = [ + c + for c in self._df.columns + if c not in META_IGNORE_COLS + GENERATED_TAG_COLS + ] + # any other tags to be added + if other_tags: + self._other_tags = [k.strip() for k in other_tags if k.strip()] + else: + self._other_tags = [] + + mrich.var('curated_tag_cols', self._curated_tag_cols) + + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + PoseTag.objects.bulk_create( + [PoseTag(pose_tag_name=k.strip()) for k in tag_list if k.strip()], + ignore_conflicts=True, + ) + tags = PoseTag.objects.filter(pose_tag_name__in=tag_list) + return tags + + # might be a good idea to break meta and tags apart + def tags_and_meta( + self, + *, + code: str, + longcode: str, + ) -> tuple[list[PoseTag], dict[str, str]]: + meta_row = self._df[self._df['Code'] == code] + if not len(meta_row): + meta_row = self._df[self._df['Long code'] == longcode] + + # TODO: another unhandled exception, apprently not having + # meta_row is an option + + metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} + + for tag in GENERATED_TAG_COLS: + if tag in meta_row.columns: + metadata[tag] = meta_row[tag].values[0] + + pose_tag_set = set(self._other_tags) + + for tag in self._curated_tag_cols: + if meta_row[tag].values[0]: + pose_tag_set.add(tag) + + tags = PoseTagService.tags_from_list(pose_tag_set) + + return tags, metadata diff --git a/src/designdb/services/reaction.py b/src/designdb/services/reaction.py new file mode 100644 index 0000000..cf54029 --- /dev/null +++ b/src/designdb/services/reaction.py @@ -0,0 +1,109 @@ +import logging + +import mrich + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import Compound, Reactant, Reaction + +logger = logging.getLogger(__name__) + + +class ReactionService: + @classmethod + def create_from_lists( + cls, + *, + reaction_types: list[str], + product_ids: list[int], + reactant_id_lists: list[set[int]], + ) -> list[int]: + # insert reaction + + # insert reactant + + reaction_ids = [] + non_duplicates = {} + + # not entirely sure how the original query was meant to work + qs = Reactant.objects.filter(compound__pk__in=product_ids) + existing = {} + for r in qs: + reaction_type = r.reaction.reaction_type + reaction_product = r.reaction.product_compound.pk + reaction_id = r.reaction.pk + reactant_compound = r.compound.pk + + key = (reaction_type, reaction_product) + + if key not in existing: + existing[key] = {} + + if reaction_id not in existing[key]: + existing[key][reaction_id] = set() + + existing[key][reaction_id].add(reactant_compound) + + existing_count = 0 + + # why is strict false?? + for reaction_type, product_id, reactant_ids in zip( + reaction_types, product_ids, reactant_id_lists, strict=False + ): + key = (reaction_type, product_id) + + possible_matches = {k: v for k, v in existing.items() if k == key} + + assert len(possible_matches) < 2 + + if possible_matches: + possible_matches = list(possible_matches.values())[0] + + if any(reactant_ids == v for v in possible_matches.values()): + existing_count += 1 + continue + + non_duplicates[key] = reactant_ids + + if existing_count: + mrich.warning('Skipped', existing_count, 'existing reactions') + + if not non_duplicates: + mrich.warning('All reactions are duplicates') + return None + + for reaction_type, product_id in non_duplicates.keys(): + compound = Compound.objects.get(pk=product_id) + # if I understand the original procedure correctly, it + # should have already weeded out the duplicates + reaction, _ = Reaction.objects.get_or_create( + reaction_type=reaction_type, + product_compound=compound, + reaction_product_yield=1.0, + ) + reaction_ids.append(reaction.pk) + + payload = [] + for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( + reaction_ids, non_duplicates.items(), strict=False + ): + for reactant_id in reactant_ids: + payload.append((reaction_id, reactant_id)) + + for reaction_id, reactant_id in payload: + reaction = Reaction.objects.get(pk=reaction_id) + compound = Compound.objects.get(pk=reactant_id) + reaction, _ = Reactant.objects.get_or_create( + reaction=reaction, + compound=compound, + reactant_amount=1.0, + ) + + # delete orphaned reactions, srsly?? + Reaction.objects.filter( + pk__in=Reactant.objects.filter( + compound__isnull=True, + ).values('reaction'), + ).delete() + + return reaction_ids diff --git a/src/designdb/services/route.py b/src/designdb/services/route.py new file mode 100644 index 0000000..52d74fe --- /dev/null +++ b/src/designdb/services/route.py @@ -0,0 +1,80 @@ +# from mypackage.services.compound import CompoundService + +# from rdkit.Chem import inchi +from designdb.models import Component, Route +from designdb.recipe import Recipe + + +class RouteService: + @classmethod + def create_from_recipe( + cls, + *, + recipe: Recipe, + ) -> tuple[Route, bool]: + + route, created = Route.objects.get_or_create( + product_compound=recipe.product.compound + ) + + # are you joking?? reactants and intermediates are all of the + # sudden components + + # reactions + components = [] + components.extend( + [ + Component(route=route, component_type=1, component_ref=ref.pk) + for ref in recipe.reactions + ], + ) + + # this part needs data from ingredient df, which I don't have + # and is not implemented + + # reactants + # for ref, amount in recipe.reactants.id_amount_pairs: + # self.insert_component( + # component_type=2, ref=ref, route=route_id, amount=amount, commit=False + # ) + + components.extend( + [ + Component( + route=route, + component_type=1, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.reactants.id_amount_pairs + ], + ) + + # # intermediates + # for ref, amount in recipe.intermediates.id_amount_pairs: + # self.insert_component( + # component_type=3, ref=ref, route=route_id, amount=amount, commit=False + # ) + + components.extend( + [ + Component( + route=route, + component_type=1, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.intermediates.id_amount_pairs + ], + ) + + Component.objects.bulk_create(components, ignore_conflicts=True) + + return route, created + + # @property + # def id_amount_pairs(self) -> list[tuple]: + # """Get a list of compound ID and amount pairs""" + # return [ + # (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + # ] diff --git a/src/designdb/services/score.py b/src/designdb/services/score.py new file mode 100644 index 0000000..f80a817 --- /dev/null +++ b/src/designdb/services/score.py @@ -0,0 +1,74 @@ +import logging +import re + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import Pose, ScoreValue, ScoringMethod + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class ScoreService: + def __init__(self, scoring_method_list: list[str] | None = None): + self._scoring_method_list = scoring_method_list + # self._score_map = {} + self._scoring_method_cache = {} + + # unused, but I imagine this could take various arguments, + # like include or exclude list + + # if self._scoring_method_list: + # for m in self._scoring_method_list: + # sm, _ = ScoringMethod.objects.get_or_create( + # method_name=m, + # ) + # self._score_map[sm.method_name] = sm + + def add_scores_from_record( + self, + *, + pose: Pose, + record: dict[str, str | float], + ): + + # FIXME: this because don't know how to select + scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} + + for method_name, score_value in scores.items(): + try: + method = self.scoring_methods[method_name] + except KeyError: + # there's so many more fields, should I really be creating them? + method, _ = ScoringMethod.objects.get_or_create( + method_name=method_name, + ) + + score = ScoreValue( + pose=pose, + compound=pose.compound, + scoring_method=method, + score=score_value, + ) + score.save() + + # def bulk_scores(poses: list[pose], record: dict[str, str | float]): + # # potentially lots of scores, can do bulk insertion all at once + # pass + + @property + def scoring_methods(self) -> dict[str, ScoringMethod]: + return self._scoring_method_cache diff --git a/src/designdb/sets/__init__.py b/src/designdb/sets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/designdb/sets/compound.py b/src/designdb/sets/compound.py new file mode 100644 index 0000000..9955cc8 --- /dev/null +++ b/src/designdb/sets/compound.py @@ -0,0 +1,2511 @@ +import json +from collections.abc import Callable +from pathlib import Path + +import mcol +import mrich +import pandas as pd +from django.db.models import Exists, OuterRef, Q +from pandas import DataFrame, concat, isna +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import Mol + +from designdb.ingredient import Ingredient +from designdb.models import ( + CataloguePrice, + Compound, + CompoundTag, + CompoundTagJunction, + Reactant, + Reaction, +) +from designdb.price import Price + + +class CompoundSet: + """Object representing a subset of the 'compound' table in the :class:`.Database`. + + .. attention:: + + :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.Compound` objects in the set: + + :: + + cset = animal.compounds[:100] + + for compound in cset: + ... + + Check membership + ================ + + To determine if a :class:`.Compound` is present in the set: + + :: + + is_member = compound in cset + + Selecting compounds in the set + ============================== + + The :class:`.CompoundSet` can be indexed like standard Python lists by their indices + + :: + + cset = animal.compounds[1:100] + + # indexing individual compounds + comp = cset[0] # get the first compound + comp = cset[1] # get the second compound + comp = cset[-1] # get the last compound + + # getting a subset of compounds using a slice + cset2 = cset[13:18] # using a slice + + Tags and scaffold compounds can also be used to filter: + + :: + + cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' + cset = animal.compounds(scaffold=comp) # select elaborations of comp + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """CompoundSet initialisation""" + + if queryset: + if isinstance(queryset, list): + self._queryset = Compound.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = Compound.objects.none() + + if sort: + self._queryset = self._queryset.order_by('pk') + + self._name = name + + ### DUNDERS + + def __len__(self) -> int: + """The number of compounds in this set""" + return self._queryset.count() + + def __iter__(self): + """Iterate through compounds in this set""" + return iter(self._queryset) + + def __getitem__( + self, + key: int | slice, + ) -> 'Compound | CompoundSet': + """Get compounds or subsets thereof from this set + + :param key: integer index or slice of indices + + """ + match key: + case int(): + index = self.indices[key] + try: + return Compound.objects.get(id=index) + except Compound.DoesNotExist: + raise Compound.DoesNotExist from exc + + case slice(): + return CompoundSet(Compound.objects.filter(pk__in=key)) + + case _: + raise NotImplementedError + + def __sub__( + self, + other: 'Compound | CompoundSet | IngredientSet', + ) -> 'CompoundSet': + """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return CompoundSet( + Compound.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + sort=False, + ) + + def __add__( + self, + other: 'Compound | CompoundSet | IngredientSet | int', + ) -> 'CompoundSet': + """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + + match other: + case Compound(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case int(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case IngredientSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __and__(self, other: 'CompoundSet'): + """AND set operation, returns only compounds in both sets""" + + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __or__(self, other: 'CompoundSet'): + """OR set operation, returns union of both sets""" + + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __xor__(self, other: 'CompoundSet'): + """Exclusive OR set operation, returns all compounds in either set but not both""" + + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.name: + s = f'{self.name}: ' + else: + s = '' + + s += f'{{C × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __contains__(self, other: Compound | int): + """Check if compound or ingredient is a member of this set""" + match other: + case Compound(): + ik = other.pk + case int(): + pk = other + + return self._queryset.filter(pk=pk).exists() + + ### FILTERING + + def get_by_tag( + self, + tag: str, + inverse: bool = False, + ) -> 'CompoundSet': + """Get all child compounds with a certain tag""" + + self._queryset = self._queryset.annotate( + has_tag=Exists( + CompoundTagJunction.objects.filter( + pose=OuterRef('pk'), + pose_tag__pose_tag_name=tag, + ), + ), + ) + if inverse: + return CompoundSet(self._queryset.filter(has_tag=False)) + else: + return CompoundSet(self._queryset.filter(has_tag=True)) + + def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': + """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + + :param key: metadata key + :param value: metadata value (Default value = None) + """ + + q = Q(compound_metadata__has_key=key) + if value: + q = Q(compound_metadata__key=value) + + qs = Compound.objects.filter(q) + + return CompoundSet(qs) + + def get_by_scaffold( + self, + scaffold: Compound | int, + none: str = 'error', + ) -> 'CompoundSet': + """Get all compounds that elaborate the given scaffold compound + + :param scaffold: :class:`.Compound` object or ID to search by + + """ + + if not isinstance(scaffold, int): + assert scaffold._table == 'compound' + scaffold = scaffold.id + + values = self.db.select_where( + query='scaffold_superstructure', + table='scaffold', + key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', + multiple=True, + none=none, + ) + ids = [v for (v,) in values if v] + + if not ids: + return None + return CompoundSet(self.db, ids) + + def get_all_possible_reactants( + self, + debug: bool = False, + ) -> 'CompoundSet': + """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. + + :param debug: Increased verbosity for debugging (Default value = False) + + """ + + qs = Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ) + + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) + + seen |= new + frontier = new + + return CompoundSet(Compound.objects.filter(pk__in=seen)) + + def get_all_possible_reactions( + self, + debug: bool = False, + ) -> 'ReactionSet': + """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. + + :param debug: Increased verbosity for debugging (Default value = False) + + """ + qs = Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ) + + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) + + seen |= new + frontier = new + + return Reaction.objects.filter(product__compound__in=seen) + + def get_risk_diversity(self, debug: bool = False) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in this set + + :returns: average of the standard deviations of number of atoms added for each scaffold + + """ + + variances = self.db.execute( + f""" + WITH nums AS ( + SELECT scaffold_base AS base, scaffold_superstructure AS elab, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id + WHERE scaffold_superstructure IN {self.str_ids} + ), + + means AS ( + SELECT base, AVG(diff) AS mean FROM nums + GROUP BY base + ) + + SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums + LEFT JOIN means + ON nums.base = means.base + GROUP BY nums.base + """ + ).fetchall() + + if not variances: + return None + + variances = [v for (v,) in variances] + + if debug: + mrich.debug(f'{variances=}') + + return mean(variances) + + def count_by_tag( + self, + tag: str, + ) -> 'CompoundSet': + """Count all child compounds with a certain tag + + :param tag: tag to filter by + + """ + return self._queryset.annotate( + has_tag=Exists( + CompoundTag.objects.filter( + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, + ), + ), + ).count() + + ### CONSOLE / NOTEBOOK OUTPUT + + def draw(self) -> None: + """Draw a grid of all contained molecules. + + .. attention:: + + This method is only intended for use within a Jupyter Notebook. + + """ + + from molparse.rdkit import draw_grid + + data = [(str(c), c.mol) for c in self] + + mols = [d[1] for d in data] + labels = [d[0] for d in data] + + display(draw_grid(mols, labels=labels)) + + def grid(self) -> None: + """Draw a grid of all contained molecules. + + .. attention:: + + This method is only intended for use within a Jupyter Notebook. + + """ + + self.draw() + + def summary(self, return_df: bool = False) -> None: + """Print a summary of this compound set""" + + mrich.header(self) + + from pandas import DataFrame + + sql = f""" + SELECT tag_name, + COUNT(DISTINCT tag_compound) + FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ + + cursor = self.db.execute(sql) + + data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] + + df = DataFrame(data) + df = df.set_index('tag') + + # poses + + sql = f""" + SELECT tag_name, + COUNT(DISTINCT tag_pose) + FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose + ON pose_id = tag_pose + WHERE pose_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ + + cursor = self.db.execute(sql) + + for tag, count in cursor.fetchall(): + df.loc[tag, 'num_poses'] = count + + # compounds with poses + + sql = f""" + SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose + ON tag_pose = pose_id + WHERE pose_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ + + cursor = self.db.execute(sql) + + for tag, count in cursor.fetchall(): + df.loc[tag, 'num_posed_compounds'] = count + + df.loc['TOTAL', 'num_compounds'] = len(self) + df.loc['TOTAL', 'num_poses'] = self.num_poses + df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) + + df = df.fillna(0) + df = df.astype(int) + + if return_df: + return df + else: + mrich.print(df) + + def interactive( + self, + function: Callable | None = None, + ) -> None: + """Creates a ipywidget to interactively navigate this PoseSet.""" + + from IPython.display import display + from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, + ) + + if function: + + def widget(i): + """interactive function widget""" + compound = self[i] + display(compound) + function(compound) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ), + ) + + else: + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='2D', value=True) + e = Checkbox(description='Poses', value=False) + f = Checkbox(description='Reactions', value=False) + g = Checkbox(description='Tags', value=False) + h = Checkbox(description='Quotes', value=False) + i = Checkbox(description='Metadata', value=False) + j = Checkbox(description='Classify', value=False) + + ui1 = GridBox( + [b, c, d], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui2 = GridBox( + [e, f, g], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui3 = GridBox( + [h, i, j], layout=Layout(grid_template_columns='repeat(3, 100px)') + ) + ui = VBox([a, ui1, ui2, ui3]) + + def widget( + i, + name: bool = True, + summary: bool = True, + draw: bool = True, + poses: bool = True, + reactions: bool = True, + tags: bool = True, + quotes: bool = True, + metadata: bool = True, + classify: bool = True, + ): + """interactive default widget""" + """ + + :param i: param name: (Default value = True) + :param summary: Default value = True) + :param draw: Default value = True) + :param poses: Default value = True) + :param reactions: Default value = True) + :param metadata: Default value = True) + :param name: (Default value = True) + + """ + comp = self[i] + + if name and not summary: + print(repr(comp)) + + if summary: + comp.summary(metadata=False, draw=False, tags=False) + + if draw: + comp.draw() + + if poses and (pset := comp.poses): + for p in pset: + mrich.print(p) + pset.draw() + + if reactions and (reactions := comp.reactions): + for r in reactions: + mrich.print(r) + r.draw() + + if tags: + mrich.title('Tags') + mrich.print(comp.tags) + + if quotes: + mrich.title('Quotes') + display(comp.get_quotes(df=True)) + + if metadata: + mrich.title('Metadata:') + mrich.print(comp.metadata) + + if classify: + mrich.title('Classification:') + comp.classify() + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'poses': e, + 'reactions': f, + 'tags': g, + 'quotes': h, + 'metadata': i, + 'classify': j, + }, + ) + + display(ui, out) + + def tag_summary(self) -> 'pd.DataFrame': + """Print a summary table of tags with compound counts""" + + from pandas import DataFrame + + sql = f""" + SELECT tag_name, + COUNT(DISTINCT tag_compound) + FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name; + """ + + cursor = self.db.execute(sql) + + data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] + + df = DataFrame(data) + df = df.set_index('tag') + + df = df.astype(int) + + mrich.print(df) + + return df + + ### OTHER METHODS + + def get_recipes( + self, + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = False, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + **kwargs, + ): + """Generate the :class:`.Recipe` to make these compounds. + + See :meth:`.Recipe.from_compounds` + """ + + # avoiding circular imports + from designdb.recipe import Recipe + + return Recipe.from_compounds( + self, + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + **kwargs, + ) + + def get_routes( + self, + permitted_reactions: 'None | ReactionSet' = None, + return_ids: bool = False, + debug: bool = True, + ) -> 'RouteSet': + """Get a RoutSet to products in this set. + + :param permitted_reactions: optionally restrict reactions to those in this :class:`.ReactionSet` + + """ + + if 'route' not in self.db.table_names: + mrich.error('route table not in Database') + raise NotImplementedError + + if permitted_reactions is not None: + sql = f""" + SELECT route_id, route_product, component_ref + FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component + ON route_id = component_route + WHERE route_product IN {self.str_ids} + AND component_type = 1 + """ + + permitted_reactions = set(permitted_reactions.ids) + + if debug: + mrich.debug('Querying database for routes') + records = self.db.execute(sql).fetchall() + + if debug: + mrich.debug('Assembling route dictionary') + + routes = {} + for route_id, route_product, reaction_id in records: + if route_id not in routes: + routes[route_id] = dict(product=route_product, reactions=set()) + assert routes[route_id]['product'] == route_product + routes[route_id]['reactions'].add(reaction_id) + + if debug: + mrich.debug('Checking availability') + + available_routes = set() + for route_id, route_dict in routes.items(): + product = route_dict['product'] + assert product in self + reactions = route_dict['reactions'] + if all(r in permitted_reactions for r in reactions): + available_routes.add(route_id) + + if return_ids: + return list(available_routes) + + routes = [ + self.db.get_route(id=route_id) + for route_id in mrich.track(available_routes, prefix='Getting routes') + ] + + else: + sql = f""" + SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route + WHERE route_product IN {self.str_ids} + """ + + if debug: + mrich.debug('Querying database for routes') + records = self.db.execute(sql).fetchall() + + if return_ids: + return [i for (i,) in records] + + routes = [ + self.db.get_route(id=route_id) + for (route_id,) in mrich.track(records, prefix='Getting routes') + ] + + from .recipe import RouteSet + + return RouteSet(self.db, routes) + + def copy(self) -> 'CompoundSet': + """Returns a copy of this set""" + return CompoundSet(self.db, self.ids) + + def shuffled(self) -> 'CompoundSet': + """Returns a randomised copy of this set""" + copy = self.copy() + copy.shuffle() + return copy + + def pop(self) -> Compound: + """Pop the last compound in this set""" + c_id = self.pop_id() + return self.db.get_compound(id=c_id) + + def pop_id(self) -> int: + """Pop the last compound id in this set""" + return self._indices.pop() + + def shuffle(self) -> None: + """Randomises the order of compounds in this set""" + from random import shuffle + + shuffle(self._indices) + + def get_df( + self, + smiles: bool = True, + inchikey: bool = False, + alias: bool = True, + mol: bool = False, + metadata: bool = False, + expand_metadata: bool = True, + poses: bool = False, + num_reactant: bool = False, + num_reactions: bool = False, + num_poses: bool = False, + tags: bool = False, + scaffolds: bool = False, + elabs: bool = False, + routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'DataFrame': + """Get a DataFrame representation of this set + + :param smiles: include SMILES column (Default value = True) + :param inchikey: include InChIKey column (Default value = False) + :param alias: include alias column (Default value = True) + :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) + :param metadata: include metadata in output (Default value = False) + :param expand_metadata: create separate column for each metadata key (Default value = True) + :param poses: include poses in output (Default value = False) + :param num_reactant: include num_poses column + :param num_reactant: include num_reactant column (number of reactions where compound is a reactant) + :param num_reactions: include num_reactions column (number of reactions where compound is a product) + :param tags: include tags column + :param scaffolds: include scaffolds column + :param elabs: include elabs column + + """ + + data = [] + + query = ['compound_id'] + + if smiles: + query.append('compound_smiles') + + if inchikey: + query.append('compound_inchikey') + + if alias: + query.append('compound_alias') + + if mol: + query.append('mol_to_binary_mol(compound_mol)') + + if metadata: + query.append('compound_metadata') + + query = ', '.join(query) + + sql = f""" + SELECT {query} + FROM {self.db.SQL_SCHEMA_PREFIX}compound + WHERE compound_id IN {self.str_ids} + """ + + if debug: + mrich.debug('querying...') + records = self.db.execute(sql).fetchall() + + if debug: + generator = mrich.track(records) + else: + generator = records + + for row in generator: + row = list(row) + + d = dict(id=row.pop(0)) + + if smiles: + d['smiles'] = row.pop(0) + + if inchikey: + d['inchikey'] = row.pop(0) + + if alias: + d['alias'] = row.pop(0) + + if mol: + d['mol'] = Mol(row.pop(0)) + + if metadata and (meta_str := row.pop(0)): + meta_dict = loads(meta_str) + + if expand_metadata: + for k, v in meta_dict.items(): + d[k] = v + + else: + d['metadata'] = meta_dict + + data.append(d) + + df = DataFrame(data) + + if poses or num_poses: + if debug: + mrich.debug('adding pose column') + + lookup = self.db.get_compound_id_pose_ids_dict(self) + if poses: + df['poses'] = df['id'].apply(lambda x: lookup.get(x, {})) + if num_poses: + df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, {}))) + + if num_reactant or num_reactions: + if debug: + mrich.debug('adding reaction columns') + tuples = self.db.get_reactant_product_tuples(self.ids, deduplicated=False) + + if num_reactant: + lookup = {} + for r, p in tuples: + lookup.setdefault(r, 0) + lookup[r] += 1 + df['num_reactant'] = df['id'].apply(lambda x: lookup.get(x, 0)) + + if num_reactions: + lookup = {} + for r, p in tuples: + lookup.setdefault(p, 0) + lookup[p] += 1 + df['num_reactions'] = df['id'].apply(lambda x: lookup.get(x, 0)) + + if scaffolds or elabs: + if debug: + mrich.debug('adding scaffold columns') + tuples = self.db.get_scaffold_tuples(self.ids) + + if scaffolds: + lookup = {} + for b, e in tuples: + lookup.setdefault(e, set()) + lookup[e].add(b) + df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if elabs: + lookup = {} + for b, e in tuples: + lookup.setdefault(b, set()) + lookup[b].add(e) + df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if tags: + if debug: + mrich.debug('adding tag column') + lookup = self.db.get_compound_tag_dict() + df['tags'] = df['id'].apply(lambda x: lookup.get(x, {})) + + if routes: + if debug: + mrich.debug('adding route column') + lookup = self.db.get_product_id_routes_dict() + df['routes'] = df['id'].apply(lambda x: lookup.get(x, {})) + + df = df.set_index('id') + + return df + + def get_quoted( + self, + *, + supplier: str = 'any', + ) -> 'CompoundSet': + """Get all member compounds that have a quote from given supplier + + :param supplier: supplier name (Default value = 'any') + + """ + + if supplier == 'any': + key = f'quote_compound IN {self.str_ids}' + else: + key = f'quote_compound IN {self.str_ids} AND quote_supplier = "{supplier}"' + + ids = self.db.select_where( + table='quote', + query='DISTINCT quote_compound', + key=key, + multiple=True, + ) + + ids = [i for (i,) in ids] + return CompoundSet(self.db, ids) + + def get_unquoted( + self, + *, + supplier: str = 'any', + ) -> 'CompoundSet': + """Get all member compounds that do not have a quote from given supplier + + :param supplier: supplier name (Default value = 'any') + + """ + + quoted = self.get_quoted(supplier=supplier) + return self - quoted + + def get_dict(self) -> dict: + """Get a dictionary object with all serialisable data needed to reconstruct this set""" + return dict(db=str(self.db.path.resolve()), indices=self.indices) + + def write_smiles_csv( + self, file: str, tags: bool = True, split_tags: bool = True + ) -> None: + """Write a CSV of the smiles contained in this set to a file + + :param file: path of the CSV file + :param tags: include tags in output + :param split_tags: split tags into separate columns + + """ + from pandas import DataFrame + + if tags: + records = self.db.select_where( + table='tag', + query='tag_compound, tag_name', + key=f'tag_compound IN {self.str_ids}', + multiple=True, + none='quiet', + ) + TAGS = {} + if records: + for compound_id, tag_name in records: + if compound_id not in TAGS: + TAGS[compound_id] = set() + TAGS[compound_id].add(tag_name) + + records = self.db.select_where( + table=self.table, + query='compound_id, compound_smiles', + key=f'compound_id IN {self.str_ids}', + multiple=True, + ) + + data = [dict(id=id, smiles=smiles) for id, smiles in records] + + if tags: + for d in data: + tagset = TAGS.get(d['id'], set()) + + if split_tags: + for tag in tagset: + d[tag] = True + else: + d['tags'] = tagset + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + def write_postera_csv( + self, + file, + *, + supplier: str = 'Enamine', + prefix: str = 'fragment', + ) -> None: + """Write a CSV formatted for upload to Postera's Manifold + + :param file: path of the CSV file + :param supplier: supplier to use for quotes, (Default value = 'Enamine') + :param prefix: prefix to metadata columns, (Default value = 'fragment') + + """ + + from datetime import date as dt + + from pandas import DataFrame + + if prefix: + prefix = f'{prefix}_' + + data = [] + + for c in mrich.track(self, prefix='Creating DataFrame'): + # get props + smiles = c.smiles + tags = c.tags + metadata = c.metadata + poses = c.poses + scaffold = c.scaffold + + # method + assert len(tags) == 1, c + method = tags[0] + + # date + date = dt.today() + + # author + assert 'author' in metadata, c + author = metadata['author'] + + match len(poses): + case 1: + pose = poses[0] + case 0: + mrich.warning(f'{c} has no poses') + assert scaffold + pose = scaffold.poses[0] + case _: + mrich.warning(f'{c} has multiple poses') + pose = poses[0] + + # extract inspirations + inspirations = pose.inspirations + inspiration_names = ','.join(inspirations.names) + inspiration_smiles = '.'.join(inspirations.smiles) + + # quote info + quotes = c.get_quotes(supplier=supplier) + assert len(quotes) == 1, c + quote = quotes[0] + catalog_id = quote.entry + catalog_price = quote.price + catalog_lead_time = quote.lead_time + + # hippo string + hippo_str = f'compound={c.id}, pose={pose.id}' + + # create row + data.append( + { + 'SMILES': smiles, + f'{prefix}HIPPO_IDs': hippo_str, + f'{prefix}method': method, + f'{prefix}export_date': date, + f'{prefix}author': author, + f'{prefix}inspiration_names': inspiration_names, + f'{prefix}inspiration_SMILES': inspiration_smiles, + f'{prefix}supplier': supplier, + f'{prefix}supplier_catalogue': quote.catalogue, + f'{prefix}supplier_ID': catalog_id, + f'{prefix}supplier_price': catalog_price, + f'{prefix}supplier_lead_time': catalog_lead_time, + } + ) + + df = DataFrame(data) + + mrich.writing(file) + df.to_csv(file, index=False) + + return df + + def write_CAR_csv( + self, + file: 'str | Path', + amount: float = 1, # in mg + return_df: bool = False, + # pick_cheapest: bool = False, + quoted_only: bool = False, + get_ingredient_quotes: bool = True, + **kwargs, + ) -> 'DataFrame | None': + """List of reactions for CAR + + Columns: + + * target-name + * no-steps + * concentration = None + * amount-required + * batch-tag + + per reaction + + * reactant-1-1 + * reactant-2-1 + * reaction-product-smiles-1 + * reaction-name-1 + * reaction-recipe-1 + * reaction-groupby-column-1 + + :param file: output file + :param amount: amount of each product in `mg` + :param quoted_only: only choose reactants that have quotes + :param supplier: only choose reactants that have quotes from this supplier + :param kwargs: passed to :meth:`.Recipe.from_reaction` + :param return_df: return a `DataFrame` (Default value = False) + + """ + + # avoiding circular imports + from designdb.recipe import Recipe + + file = str(Path(file).resolve()) + + rows = [] + + for r_id in mrich.track(self.reaction_ids, prefix='Solving compound recipes'): + reaction = self.db.get_reaction(id=r_id) + + recipes = Recipe.from_reaction( + reaction, + amount=amount, + pick_cheapest=False, + quoted_only=quoted_only, + get_ingredient_quotes=get_ingredient_quotes, + **kwargs, + ) + + for sub_recipe in recipes: + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction in enumerate(sub_recipe.reactions): + i = i + 1 + + row['no-steps'] += 1 + + match len(reaction.reactants): + case 1: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles + case _: + raise NotImplementedError( + f'Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}' + ) + + row[f'reaction-product-smiles-{i}'] = reaction.product.smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + # row[f'reaction-id-{i}'] = int(reaction.id) + + rows.append(row) + + df = DataFrame(rows) + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + def add_tag( + self, + tag: str, + ) -> None: + """Add this tag to every member of the set""" + + assert isinstance(tag, str) + + for i in self.indices: + self.db.insert_tag(name=tag, compound=i, commit=False) + + mrich.print(f'Tagged {self} w/ "{tag}"') + + self.db.commit() + + def plot_tsnee(self, **kwargs) -> 'go.Figure': + """Plot a tanimoto similarity plot of these compounds""" + from .plotting import plot_compound_tsnee + + return plot_compound_tsnee(self, **kwargs) + + def as_ingredientset( + self, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Get an :class:`.IngredientSet` for these compounds""" + return IngredientSet.from_compounds( + compounds=self, amount=amount, supplier=supplier + ) + + def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': + """Split this set into subsets clustered by scaffold compound""" + + cluster_dict = self.db.get_compound_cluster_dict(cset=self) + + subsets = {} + for cluster, elabs in cluster_dict.items(): + cluster = CompoundSet(self.db, list(cluster)) + subsets[cluster] = CompoundSet(self.db, list(elabs)) + + return subsets + + def despaghettify( + self, + register_missing_routes: bool = True, + supplier='Enamine', + ) -> 'CompoundSet': + """Reduce this set to only compounds that elaborate a single reactant at a time. + Requires routes to be present in the database.""" + + if register_missing_routes: + mrich.debug('registering_missing_routes...') + route_lookup = self.register_missing_routes( + missing_only=True, supplier=supplier + ) + + mrich.debug('clustering by scaffold...') + clustered = self.split_by_scaffolds() + + n = len(clustered) + mrich.var('#clusters', n) + + mrich.debug('getting route lookup...') + route_lookup = self.db.get_product_id_routes_dict() + + mrich.debug('getting reactant lookup...') + reactant_lookup = self.db.get_route_id_reactant_ids_dict() + + keep = set() + for i, (cluster, elabs) in enumerate(clustered.items()): + for scaffold in cluster: + mrich.debug( + f'{i}/{n}', + 'scaffold:', + scaffold.id, + '#elabs:', + len(elabs), + '#kept:', + len(keep), + ) + + route_ids = route_lookup.get(scaffold.id) + + if not route_ids: + mrich.error(f'scaffold {scaffold} has no routes') + continue + + elif len(route_ids) > 1: + mrich.warning(f'scaffold {scaffold} has multiple routes') + + for route_id in route_ids: + scaffold_reactants = reactant_lookup[route_id] + + for elab in elabs: + route_ids = route_lookup.get(elab.id, set()) + + if len(route_ids) != 1: + mrich.error(f'elab {elab.id} has {route_ids=}') + continue + + reactants = reactant_lookup[list(route_ids)[0]] + + common = scaffold_reactants & reactants + + if len(common) == len(scaffold_reactants) - 1: + keep.add(elab.id) + + return CompoundSet(self.db, keep) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate missing routes to compounds in this set""" + + if missing_only: + from .cset import CompoundSet + + records = self.db.select_where( + table='route', + key=f'route_product IN {self.str_ids}', + query='route_product', + multiple=True, + ) + existing = set(i for (i,) in records) + missing = set(self.ids) - existing + return CompoundSet(self.db, missing).register_missing_routes( + missing_only=False, supplier=supplier + ) + + mrich.var('#compounds', len(self)) + + for i, c in mrich.track(enumerate(self), total=len(self)): + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + route = self.db.register_route(recipe=recipe) + + mrich.print(f'registered {route=}') + + self.db.prune_duplicate_routes() + + ### PROPERTIES + + @property + def queryset(self): + """Associated :class:`.Database` object""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self._queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def names(self) -> list[str]: + """Returns the aliases of compounds in this set""" + result = self.db.select_where( + query='compound_alias', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def smiles(self) -> list[str]: + """Returns the smiles of child compounds""" + result = self.db.select_where( + query='compound_smiles', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def mols(self) -> 'list[Chem.Mol]': + """Returns the molecules of child compounds""" + from rdkit.Chem import Mol + + result = self.db.select_where( + query='mol_to_binary_mol(compound_mol)', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [Mol(q) for (q,) in result] + + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of compounds in this set""" + result = self.db.select_where( + query='compound_inchikey', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this compound set""" + values = self.db.select_where( + table='tag', + query='DISTINCT tag_name', + key=f'tag_compound in {self.str_ids}', + multiple=True, + ) + if not values: + return set() + return set(v for (v,) in values) + + @property + def num_poses(self) -> int: + """Count the poses associated to this set of compounds""" + + return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') + + @property + def poses(self) -> 'PoseSet': + """Get the poses associated to this set of compounds""" + from .pset import PoseSet + + ids = self.db.select_where( + query='pose_id', + table='pose', + key=f'pose_compound in {self.str_ids}', + multiple=True, + none='warning', + ) + + if not ids: + return PoseSet(self.db, {}) + + ids = [v for (v,) in ids] + return PoseSet(self.db, ids) + + @property + def best_placed_poses(self) -> 'PoseSet': + """Get the best placed pose for each compound in this set""" + from .pset import PoseSet + + query = self.db.select_where( + table='pose', + query='pose_id, MIN(pose_distance_score)', + key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', + multiple=True, + ) + ids = [i for i, s in query] + return PoseSet(self.db, ids) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def num_heavy_atoms(self) -> int: + """Get the total number of heavy atoms""" + return sum([c.num_heavy_atoms for c in self]) + + @property + def num_rings(self): + """Get the total number of molecular rings""" + return sum([c.num_rings for c in self]) + + @property + def formula(self) -> str: + """Get the combined chemical formula for all compounds""" + from molparse.atomtypes import atomtype_dict_to_formula + + return atomtype_dict_to_formula(self.atomtype_dict) + + @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 combine_atomtype_dicts + + atomtype_dicts = [c.atomtype_dict for c in self] + return combine_atomtype_dicts(atomtype_dicts) + + @property + def num_atoms_added(self) -> list[int]: + """Calculate the number of atoms added w.r.t the scaffold + + :returns: list of number of atoms added values + + """ + + sql = f""" + WITH nums AS ( + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + WHERE A.compound_base = B.compound_id + AND A.compound_id IN {self.str_ids} + ) + + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound + LEFT JOIN nums + ON comp_id = compound_id + WHERE compound_id IN {self.str_ids} + """ + + query = self.db.execute(sql).fetchall() + + lookup = {k: v for k, v in query} + + return [lookup[i] for i in self.indices] + + @property + def avg_num_atoms_added(self) -> float: + """Calculate the average number of atoms added w.r.t the scaffold + + :returns: average number of atoms added values for compounds which have a scaffold + + """ + sql = f""" + WITH nums AS ( + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + WHERE A.compound_base = B.compound_id + AND A.compound_id IN {self.str_ids} + ) + + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound + INNER JOIN nums + ON comp_id = compound_id + WHERE compound_id IN {self.str_ids} + """ + + (avg,) = self.db.execute().fetchone() + + return avg + + @property + def risk_diversity(self) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in this set + + :returns: average of the standard deviations of number of atoms added for each scaffold + + """ + + return self.get_risk_diversity() + + @property + def elaboration_balance(self) -> float: + """Measure of how evenly elaborations are distributed across scaffolds in this set""" + + sql = f""" + SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + GROUP BY scaffold_base + """ + + counts = self.db.execute(sql).fetchall() + + counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] + + from hirsch import hirsch + + return hirsch(counts) + + # return -std(counts) + + @property + def num_scaffolds_elaborated(self) -> int: + """Count the number of scaffold compounds that have at least one elaboration in this set + + :returns: number of scaffold compounds + + """ + + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchone() + + return count + + @property + def scaffolds(self) -> 'CompoundSet': + """Get the scaffold compounds that have at least one elaboration in this set + + :returns: :class:`.CompoundSet` + + """ + return CompoundSet(self.db, self.scaffold_ids) + + @property + def scaffold_ids(self) -> list[int]: + """Return a list of :class:`.Compound` ID's for scaffolds of this set""" + scaffold_ids = self.db.execute( + f""" + SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchall() + return [i for (i,) in scaffold_ids] + + @property + def num_scaffolds(self) -> int: + """Return a count of scaffolds of this set""" + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchone() + return count + + @property + def elabs(self) -> 'CompoundSet': + """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" + + ids = self.db.select_where( + query='scaffold_superstructure', + table='scaffold', + key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', + multiple=True, + none='quiet', + ) + + if not ids: + return None + + ids = [q for (q,) in ids] + from .cset import CompoundSet + + return CompoundSet(self.db, ids) + + @property + def num_elabs(self) -> int: + """Return a count of elaborations of this set""" + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_base IN {self.str_ids} + """ + ).fetchone() + return count + + @property + def elab_df(self) -> 'pd.DataFrame': + """Get a DataFrame summarising the elaborations in this CompoundSet""" + from pandas import DataFrame + + cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) + + data = [] + for scaffold, elabs in cluster_dict.items(): + scaffold = self.db.get_compound(id=scaffold[0]) + elabs = CompoundSet(self.db, indices=elabs) + data.append( + dict( + scaffold_id=scaffold.id, + scaffold_compound=scaffold, + elabs=elabs, + num_elabs=len(elabs), + ) + ) + + return DataFrame(data) + + @property + def id_num_poses_dict(self) -> dict[int, int]: + """Get a dictionary mapping compound ids to the number of poses""" + + sql = f""" + SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose + WHERE pose_compound IN {self.str_ids} + GROUP BY pose_compound + """ + + records = self.db.execute(sql) + + assert records + + lookup = {k: v for k, v in records} + + for id in self.ids: + if id not in lookup: + lookup[id] = 0 + + return lookup + + @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 + + @property + def reaction_ids(self) -> list[int]: + """Returns a list of :class:`.Reaction` IDs that result in members of this set""" + records = self.db.select_where( + table='reaction', + query='reaction_id', + key=f'reaction_product IN {self.str_ids}', + multiple=True, + ) + if not records: + return None + return [r for (r,) in records] + + +class IngredientSet: + """An :class:`.Ingredient` is a :class:`.Compound` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. + + .. attention:: + + :class:`.IngredientSet` objects should not be created directly. Instead they are returned by several methods when working with :doc:`quoting` and :doc:`rgen`. + + Selecting ingredients in the set + ================================ + + The :class:`.IngredientSet` can be indexed like a Python list: + + :: + + ingredient = ingredient_set[0] # first ingredient + + To get the ingredient for a specific :class:`.Compound` ID: + + :: + + ingredient = ingredient_set(compound_id=13) + + """ + + _columns = [ + 'compound_id', + 'amount', + 'quote_id', + 'supplier', + 'max_lead_time', + 'quoted_amount', + ] + + def __init__( + self, + ingredients: 'None | list[Ingredient]' = None, + supplier: str | list | None = None, + debug: bool = False, + ) -> None: + """IngredientSet initialisation""" + + ingredients = ingredients or [] + + self._data = DataFrame(columns=self._columns, dtype=object) + + if debug: + mrich.debug(self._data) + + self._supplier = supplier + + for ingredient in ingredients: + self.add(ingredient) + + for col in self._columns: + assert col in self._data.columns, f'{col} not in df.columns' + + if debug: + mrich.debug(self._data) + + ### DUNDERS + + def __len__(self): + """The number of ingredients in this set""" + return len(self._data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Ingredient × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __add__(self, other): + """Add another :class:`.IngredientSet` this set""" + + for i, row in other._data.iterrows(): + self.add( + compound_id=row.compound_id, + amount=row.amount, + quote_id=row.quote_id, + supplier=row.supplier, + max_lead_time=row.max_lead_time, + quoted_amount=row.quoted_amount, + ) + + return self + + def __getitem__(self, key: int) -> 'Ingredient': + """Get a member by it's index""" + match key: + case int(): + series = self.df.loc[key] + return self._get_ingredient(series) + + case _: + raise NotImplementedError + + def __iter__(self): + """Iterate through the ingredients""" + return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) + + def __call__( + self, + *, + compound_id: int | None = None, + tag: str | None = None, + ) -> 'IngredientSet | Ingredient | CompoundSet': + """Get members based on a compound_id or tag""" + + if compound_id: + # get the ingredient with the matching compound ID + matches = self.df[self.df['compound_id'] == compound_id] + + if len(matches) == 0: + return None + + elif len(matches) != 1: + mrich.warning(f'Multiple ingredients in set with {compound_id=}') + # print(matches) + + return IngredientSet( + self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] + ) + + return self._get_ingredient(matches.iloc[0]) + + # elif tag: + # return self.compounds(tag=tag) + + else: + raise NotImplementedError + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.CompoundSet`""" + return getattr(self.compounds, key) + + def __contains__(self, other: Compound | Ingredient | int): + """Check if compound or ingredient is a member of this set""" + match other: + case Compound(): + id = other.id + case Ingredient(): + id = other.compound_id + case int(): + id = other + + return id in set(self.compound_ids) + + @classmethod + def from_ingredient_df( + cls, + df: 'DataFrame', + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a DataFrame + + :param db: HIPPO Database + :param df: DataFrame of Ingredients + :param supplier: supplier to use for all quoting, (Default value = None) + + """ + # from numpy import nan + self = cls.__new__(cls) + + for col in cls._columns: + if col not in df.columns: + raise Exception(f'{col} not in df.columns') + df[col] = None + + self._data = df.copy() + self._supplier = supplier + + return self + + @classmethod + def from_json( + cls, + path: None | str, + supplier: str | list | None = None, + data: None | dict = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from JSON data or a JSON file + + :param db: HIPPO Database + :param path: path to JSON data (can be ``None`` if ``data`` provided) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + :param data: optional JSON data to parse, (Default value = ``None``) + + """ + + if not data: + data = json.load(open(path)) + + df = DataFrame(columns=cls._columns, dtype=object) + + for col in cls._columns: + df[col] = data[col] + + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_ingredient_dicts( + cls, + dicts: list[dict], + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries + + :param db: HIPPO Database + :param dicts: List of individual ingredient dictionaries + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + df = DataFrame(dicts, dtype=object) + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_compounds( + cls, + *, + compounds: 'CompoundSet | None' = None, + ids: list[int] | None = None, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs + + :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) + :param ids: Compound IDs (Default value = None) + :param db: HIPPO Database (Default value = None) + :param amount: Amount(s) in ``mg`` (Default value = 1) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + if not ids: + ids = compounds.ids + + df = DataFrame( + dict( + compound_id=ids, + amount=amount, + quote_id=None, + supplier=supplier, + max_lead_time=None, + quoted_amount=None, + ), + dtype=object, + ) + + return cls.from_ingredient_df(df) + + ### METHODS + + def get_price( + self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False + ) -> 'Price': + """Calculate the price with a given supplier + + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] + + if debug: + mrich.debug('quote_ids', quote_ids) + + if quote_ids: + qs = CataloguePrice.objects.filter(pk__in=quote_ids) + + if supplier: + qs = qs.filter(quote_supplier=supplier) + + if qs.exists(): + prices = [ + Price( + amount=k.quote_amount, + currency=k.quote_currency, + ) + for k in qs + ] + quoted = sum(prices, Price.null()) + else: + quoted = Price.null() + self.df['quote_id'] = None + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + else: + quoted = Price.null() + + if debug: + mrich.debug('quoted', quoted) + + unquoted = [i for i, q in pairs.items() if q is None or isnan(q)] + + unquoted_price = Price.null() + + for i in unquoted: + ingredient = self[i] + + if debug: + mrich.debug('unquoted', i, ingredient) + + p = ingredient.price + + unquoted_price += p + + if debug: + mrich.debug(unquoted_price) + + quote = ingredient.quote + + if not quote: + mrich.warning('NULL Quote:', ingredient) + continue + + self.df.loc[i, 'quote_id'] = quote.id + + assert quote.amount + + self.df.loc[i, 'quoted_amount'] = quote.amount + + if debug: + mrich.debug('quoted', quoted) + mrich.debug('unquoted_price', unquoted_price) + mrich.error('end of IngredientSet.get_price()') + + return quoted + unquoted_price + + def interactive(self, **kwargs) -> None: + """Wrapper for :meth:`.CompoundSet.interactive`""" + self.compounds.interactive(**kwargs) + + def add( + self, + ingredient: 'Ingredient | None' = None, + *, + compound_id: int | None = None, + amount: float | None = None, + quote_id: int | None = None, + supplier: str | list[str] | None = None, + max_lead_time: float | None = None, + quoted_amount: float | None = None, + debug: bool = False, + ) -> None: + """Add an :class:`.Ingredient` to this set + + :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify other parameters, (Default value = None) + :param compound_id: :class:`.Compound` ID (Default value = None) + :param amount: amount in ``mg`` (Default value = None) + :param quote_id: :class:`.Quote` ID (Default value = None) + :param supplier: supplier name string or list (Default value = None) + :param max_lead_time: maximum lead-time for quoting (in days) (Default value = None) + :param quoted_amount: amount of associated :class:`.Quote` (Default value = None) + :param debug: increase verbosity for debugging (Default value = False) + + """ + + if ingredient: + compound_id = ingredient.compound.pk + amount = ingredient.amount + + if (q := ingredient.quote) and not ingredient.quote_id: + # I don't understand the logic for this. it's always + # true now. what was the meaning of storing id? + mrich.warning(f'Losing quote! {ingredient.quote=}') + + supplier = ingredient.supplier + max_lead_time = ingredient.max_lead_time + + if q is None: + quote_id = None + quoted_amount = None + else: + quote_id = q.id + quoted_amount = q.amount + + else: + assert compound_id + assert amount + + if quote_id: + # if not quoted_amount: + # mrich.warning(f'Requoting C{compound_id}...') + + assert quoted_amount + + supplier = self.supplier + + if self._data.empty: + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + self._data = addition + + else: + if compound_id in self._data['compound_id'].values: + index = self._data.index[ + self._data['compound_id'] == compound_id + ].tolist()[0] + self._data.loc[index, 'amount'] += amount + + # discard if the quote is no longer valid + if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ + index, 'amount' + ]: + self._data.loc[index, 'quote_id'] = None + self._data.loc[index, 'quoted_amount'] = None + + if debug and supplier: + mrich.debug('Adding to existing ingredient') + mrich.debug(f'{self._data.loc[index, "supplier"]=}') + mrich.debug(f'{supplier=}') + + else: + # from numpy import nan + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + + self._data = concat( + [self._data, addition], ignore_index=True, join='inner' + ) + + if debug: + mrich.out(addition) + + def _get_ingredient( + self, + series, + ) -> 'Ingredient': + """Get ingredient from one of the DataFrame rows""" + + q_id = series['quote_id'] + + if isinstance(q_id, float) and isnan(q_id): + q_id = None + + return Ingredient( + compound=Compound.objects.get(pk=series['compound_id']), + amount=series['amount'], + quote=q_id, + supplier=series['supplier'], + max_lead_time=series['max_lead_time'], + ) + + def copy(self) -> 'IngredientSet': + """Return a copy of this :class:`.IngredientSet`""" + return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) + + def draw(self) -> None: + """Wrapper for :meth:`.CompoundSet.draw`""" + self.compounds.draw() + + def set_amounts( + self, + amount: float | list[float], + ) -> None: + """Set the amount(s) for all ingredients in this set, and update quotes + + :param amount: amount in ``mg`` + + """ + + self.df['amount'] = amount + + # if amounts are modified the quotes should be cleared + self.df['quote_id'] = None + + assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) + + # # update quotes + # pairs = self.db.execute( + # f""" + # WITH matching_quotes AS ( + # SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote + # WHERE quote_compound IN {self.str_compound_ids} + # AND quote_amount >= {amount} + # GROUP BY quote_compound + # ) + # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + # LEFT JOIN matching_quotes ON quote_compound = compound_id + # WHERE compound_id IN {self.str_compound_ids} + # """ + # ).fetchall() + + qs = CataloguePrice.objects.filter( + compound__pk__in=self.compound_ids, + quote_amount__gte=amount, + ) + + for k in qs: + match = self.df.index[self.df['compound_id'] == k.compound.pk][0] + self.df.loc[match, 'quote_id'] = k.quote.pk + + def get_dict(self, data_orient: str = 'list') -> dict: + """Get serialisable dictionary + + :param data_orient: passed to ``pandas.DataFrame.to_dict`` (Default value = 'list') + + """ + return dict( + supplier=self.supplier, + data=self.df.to_dict(orient=data_orient), + ) + + def pop(self) -> Ingredient: + """Pop the last compound in this set""" + item = self[self.df.index[-1]] + self.df.drop(self.df.index[-1], inplace=True) + return item + + def shuffle(self) -> None: + """Randomises the order of compounds in this set""" + self._data = self.df.sample(frac=1).reset_index(drop=True) + + ### PROPERTIES + + @property + def df(self) -> 'DataFrame': + """Access the raw DataFrame""" + return self._data + + @property + def price_df(self) -> 'DataFrame': + """DataFrame including prices""" + df = self.df.copy() + tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] + df['price'] = [t[0] for t in tuples] + df['lead_time'] = [t[1] for t in tuples] + df['quote_supplier'] = [t[2] for t in tuples] + return df + + @property + def price(self) -> 'Price': + """Total price of these ingredients""" + return self.get_price() + + @property + def supplier(self) -> str | list[str]: + """Supplier(s)""" + return self._supplier + + @supplier.setter + def supplier(self, s): + if isinstance(s, list) or isinstance(s, tuple): + for x in s: + assert isinstance(x, str) + else: + assert isinstance(s, str) + + self._supplier = s + self.df['supplier'] = [s] * len(self) + + @property + def smiles(self) -> list[str]: + """SMILES for all ingredients""" + compound_ids = list(self.df['compound_id']) + return Compound.objects.filter( + pk__in=compound_ids, + ).values_list('compound_smiles', flat=True) + + @property + def inchikeys(self) -> list[str]: + """InChI-keys for all ingredients""" + compound_ids = list(self.df['compound_id']) + return Compound.objects.filter( + pk__in=compound_ids, + ).values_list('compound_inchikeys', flat=True) + + @property + def compound_ids(self) -> list[int]: + """Compound IDs for all ingredients""" + return list(self.df['compound_id'].values) + + @property + def ids(self) -> list[int]: + """Compound IDs for all ingredients""" + return self.compound_ids + + @property + def id_amount_pairs(self) -> list[tuple]: + """Get a list of compound ID and amount pairs""" + return [ + (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + ] + + @property + def str_compound_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + return str(tuple(self.df['compound_id'].values)).replace(',)', ')') + + @property + def compounds(self) -> 'CompoundSet': + """:class:`.CompoundSet` of all compounds in this set""" + return CompoundSet(self.compound_ids) + + @property + def quote_ids(self) -> list[int]: + """Get a list of quote ID's""" + + return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/src/designdb/sets/interaction.py b/src/designdb/sets/interaction.py new file mode 100644 index 0000000..9f75150 --- /dev/null +++ b/src/designdb/sets/interaction.py @@ -0,0 +1,802 @@ +"""Classes for working with sets of interactions""" + +import mcol +import mrich + +from designdb.models import Interaction + + +class InteractionTable: + """Class representing all :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. + + .. attention:: + + :class:`.InteractionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.interactions` property. + + """ + + def __init__(self, db: 'Database', table: str = 'interaction') -> None: + """InteractionTable initialisation""" + + self._db = db + self._df = None + self._table = table + + ### PROPERTIES + + @property + def db(self) -> 'Database': + """Returns the associated :class:`.Database`""" + return self._db + + @property + def table(self) -> str: + """Returns the name of the :class:`.Database` table""" + return self._table + + @property + def df(self) -> 'pandas.DataFrame': + """DataFrame representation of the interactions + + :returns: a ``pandas.Dataframe`` of the interactions + + """ + + if self._df is None: + records = self.db.select_all_where( + table='interaction', key='interaction_id > 0', multiple=True + ) + df = df_from_interaction_records(self.db, records) + self._df = df + + return self._df + + ### DUNDERS + + def __len__(self) -> int: + """The total number of interactions""" + return self.db.count(self.table) + + def __str__(self) -> str: + """Unformatted command-line representation""" + return f'{{I × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI formatted command-line representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich formatted command-line representation""" + return f'[bold underline]{self}' + + +class InteractionSet: + """Class representing a subset of the :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. + + .. attention:: + + :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. + + """ + + def __init__( + self, + indices: list = None, + ) -> None: + """InteractionSet initialisation""" + + indices = indices or [] + + if not isinstance(indices, list): + indices = list(indices) + + indices = [int(i) for i in indices] + + self._indices = sorted(list(set(indices))) + self._df = None + self._qs = Interaction.objects.filter(pk__in=indices) + + ### FACTORIES + + @classmethod + def from_pose( + cls, + pose: 'Pose | PoseSet', + table: str = 'interaction', + db: 'Database | None' = None, + ) -> 'InteractionSet': + """Construct a :class:`.InteractionSet` from one or more poses. + + :param pose: a :class:`.Pose` or :class:`.PoseSet` object + :param table: Database table name + :param db: Use this instead of Pose's Database + :returns: an :class:`.InteractionSet` + """ + + self = cls.__new__(cls) + + db = db or pose.db + + ### get the ID's + + from .pset import PoseSet + + if isinstance(pose, PoseSet): + # check if all poses have fingerprints + (has_invalid_fps,) = db.select_where( + query='COUNT(1)', + table='pose', + key=f'pose_id IN {pose.str_ids} AND pose_fingerprint = 0', + ) + + if has_invalid_fps: + mrich.warning(f'{has_invalid_fps} Poses have not been fingerprinted') + + sql = f""" + SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_pose IN {pose.str_ids} + """ + + else: + sql = f""" + SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_pose = {pose.id} + """ + + ids = db.execute(sql).fetchall() + + ids = [i for (i,) in ids] + + self.__init__(db, ids, table=table) + + return self + + @classmethod + def all( + cls, + ) -> 'InteractionSet': + """Construct a :class:`.InteractionSet` for all interactions in the table. + + :returns: an :class:`.InteractionSet` + + """ + + # bit of a round-trip + ids = Interaction.objects.values_list('pk', flat=True) + self = cls.__new__(cls) + self.__init__(ids) + + return self + + @classmethod + def from_residue( + cls, + db: 'Database', + residue_number: int, + chain: None | str = None, + target: 'Target | int' = 1, + ) -> 'InteractionSet': + """Get the set of interactions for a given residue number (and chain) + + :param db: HIPPO :class:`.Database` + :param residue_number: the residue number + :param chain: the chain name / letter, defaults to any chain + :param target: the protein :class:`.Target` object or ID, defaults to first target in database + :returns: a :class:`.InteractionSet` object + """ + + from .target import Target + + self = cls.__new__(cls) + + if isinstance(target, Target): + target = target.id + + sql = f""" + SELECT interaction_id FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON interaction_feature = feature_id + WHERE feature_target = {target} + AND feature_residue_number = {residue_number} + """ + + if chain: + sql += f' AND feature_chain_name = "{chain}"' + + ids = db.execute(sql).fetchall() + + ids = [i for (i,) in ids] + + self.__init__(db, ids) + + return self + + ### PROPERTIES + + @property + def indices(self) -> list[int]: + """Returns the ids of interactions in this set""" + return self._indices + + @property + def ids(self) -> list[int]: + """Returns the ids of interactions in this set""" + return self._indices + + @property + def types(self) -> list[str]: + """Returns the ids of interactions in this set""" + records = self.db.select_where( + query='interaction_type', + table=self.table, + key=f'interaction_id IN {self.str_ids}', + multiple=True, + ) + return [r for (r,) in records] + + @property + def db(self) -> 'Database': + """The associated HIPPO :class:`.Database`""" + return self._db + + @property + def table(self) -> str: + """Get the name of the database table""" + return self._table + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def feature_ids(self) -> list[int]: + """Return a list of :class:`.Feature` ID's""" + records = self.db.select_where( + query='DISTINCT interaction_feature', + table=self.table, + key=f'interaction_id IN {self.str_ids}', + multiple=True, + ) + return [r for (r,) in records] + + @property + def classic_fingerprint(self) -> dict: + """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" + return self.get_classic_fingerprint() + + @property + def df(self) -> 'pandas.DataFrame': + """DataFrame representation of the interactions + + :returns: a ``pandas.Dataframe`` of the interactions + + """ + + if self._df is None: + records = self.db.select_all_where( + table=self.table, + key=f'interaction_id IN {self.str_ids}', + multiple=True, + ) + df = df_from_interaction_records(self.db, records) + self._df = df + + return self._df + + @property + def residue_number_chain_pairs(self) -> list[tuple]: + """Get a list of ``(residue_number, chain_name)`` tuples""" + + sql = f""" + SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON feature_id = interaction_feature + WHERE interaction_id IN {self.str_ids} + """ + + return self.db.execute(sql).fetchall() + + @property + def avg_num_residues_per_pose(self) -> list[tuple]: + """Get a list of ``(residue_number, chain_name)`` tuples""" + + sql = f""" + SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON feature_id = interaction_feature + WHERE interaction_id IN {self.str_ids} + """ + + records = self.db.execute(sql).fetchall() + + from collections import defaultdict + + from numpy import mean + + d = defaultdict(set) + + for pose_id, res_num, chain_name in records: + d[pose_id].add((res_num, chain_name)) + + return mean(list(len(v) for v in d.values())) + + @property + def avg_num_interactions_per_pose(self) -> list[tuple]: + """Get a list of ``(residue_number, chain_name)`` tuples""" + + sql = f""" + SELECT interaction_pose FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + WHERE interaction_id IN {self.str_ids} + """ + + records = self.db.execute(sql).fetchall() + + from collections import defaultdict + + from numpy import mean + + d = defaultdict(int) + + for (pose_id,) in records: + d[pose_id] += 1 + + return mean(list(d.values())) + + @property + def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: + """Get a list of ``(residue_number, chain_name)`` tuples""" + + sql = f""" + SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON feature_id = interaction_feature + WHERE interaction_id IN {self.str_ids} + """ + + records = self.db.execute(sql).fetchall() + + from collections import defaultdict + + from numpy import mean + + d = defaultdict(set) + + for pose_id, type, res_num, chain_name in records: + d[pose_id].add((res_num, type, chain_name)) + + return mean(list(len(v) for v in d.values())) + + @property + def type_residue_number_chain_triples(self) -> list[tuple]: + """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" + + sql = f""" + SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON feature_id = interaction_feature + WHERE interaction_id IN {self.str_ids} + """ + + return self.db.execute(sql).fetchall() + + @property + def num_features(self) -> int: + """Count the funmber of protein :class:`.Feature`s with which interactions are formed""" + + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT interaction_feature) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + WHERE interaction_id IN {self.str_ids} + """ + ).fetchone() + + return count + + @property + def avg_num_interactions_per_feature(self) -> float: + """Average number of interactions formed with each protein :class:`.Feature`""" + + (count,) = self.db.execute( + f""" + WITH counts AS + ( + SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + WHERE interaction_id IN {self.str_ids} + GROUP BY interaction_feature + ) + + SELECT AVG(count) FROM counts + """ + ).fetchone() + + return count + + @property + def per_feature_count_hirsch(self) -> float: + """A measure for how evenly protein :class:`.Feature`s are being interacted with""" + + counts = self.db.execute( + f""" + SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + WHERE interaction_id IN {self.str_ids} + GROUP BY interaction_feature + """ + ).fetchall() + + counts = [count for f_id, count in counts] + + # return -std(counts) + + from hirsch import hirsch + + if not counts: + return 0 + + return hirsch(counts) + + ### METHODS + + def summary( + self, + families: bool = False, + ) -> None: + """Print a summary of this :class:`.InteractionSet`""" + + mrich.header(self) + + for interaction in self: + # print(interaction) + + # mrich.var(f'{interaction.family_str}', f'{interaction.distance:.1f}') + s = f'{interaction.description}' + + if families: + s += f' {interaction.feature.family} ~ {interaction.family}' + + mrich.var(s, f'{interaction.distance:.1f}', 'Å') + + def get_classic_fingerprint(self) -> dict: + """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" + + pairs = self.db.execute( + f""" + SELECT interaction_feature, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + WHERE interaction_id IN {self.str_ids} + GROUP BY interaction_feature + """ + ).fetchall() + + return {f: c for f, c in pairs} + + def resolve( + self, + debug: bool = False, + commit: bool = True, + feature_cache: dict | None = None, + # table: str = 'interaction', + ) -> 'InteractionSet': + """Resolve into predicted key interactions. In place modification. + + :param debug: Increased verbosity for debugging (Default value = False) + :param commit: commit the changes (Default value = True) + :param feature_cache: lookup dictionary for feature data + :returns: a filtered :class:`.InteractionSet` + """ + + keep_list = [] + + table = self.table + + # get feature cache + + feature_cache = feature_cache or { + i: self.db.get_feature(id=i) for i in self.feature_ids + } + + ### H-Bonds (closest) + + sql = f""" + SELECT interaction_id, MIN(interaction_distance) + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "Hydrogen Bond" + GROUP BY interaction_atom_ids + """ + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + keep_list += ids + + ### pi-stacking (closest) + + sql = f""" + SELECT interaction_id, MIN(interaction_distance) + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "π-stacking" + GROUP BY interaction_feature + """ + # INNER JOIN feature + # ON feature_id = interaction_feature + # GROUP BY feature_atom_names + # GROUP BY interaction_atom_ids + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + keep_list += ids + + ### pi-cation (closest) + + sql = f""" + SELECT interaction_id, MIN(interaction_distance) + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "π-cation" + GROUP BY interaction_atom_ids + """ + # GROUP BY interaction_atom_ids + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + keep_list += ids + + ### electrostatic (closest) + + sql = f""" + SELECT interaction_id, MIN(interaction_distance) + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "Electrostatic" + GROUP BY interaction_atom_ids + """ + # GROUP BY interaction_atom_ids + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + keep_list += ids + + ### sulfur-sulfur (all) + + sql = f""" + SELECT interaction_id + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "Sulfur-Sulfur" + """ + + records = self.db.execute(sql).fetchall() + ids = [a for (a,) in records] + keep_list += ids + + ### hydrophobic + + sql = f""" + SELECT interaction_id, interaction_distance + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {self.str_ids} + AND interaction_type = "Hydrophobic" + """ + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + subset = InteractionSet(self.db, ids, table=table) + + # aggregate lumped + + hydrophobic_interactions_in_lumped = {} + lumped_hydrophobic_in_lumped_lumped = {} + + for interaction in subset: + feature = feature_cache[interaction.feature_id] + + families = (feature.family, interaction.family) + + if families == ('LumpedHydrophobe', 'Hydrophobe'): + for name in feature.atom_names.split(): + key = (name, interaction.atom_ids[0]) + if key not in hydrophobic_interactions_in_lumped: + hydrophobic_interactions_in_lumped[key] = [] + hydrophobic_interactions_in_lumped[key].append(interaction.id) + + elif families == ('Hydrophobe', 'LumpedHydrophobe'): + for atom_id in interaction.atom_ids: + key = (feature.atom_names, atom_id) + if key not in hydrophobic_interactions_in_lumped: + hydrophobic_interactions_in_lumped[key] = [] + hydrophobic_interactions_in_lumped[key].append(interaction.id) + + elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): + for name in feature.atom_names.split(): + for atom_id in interaction.atom_ids: + key = (name, atom_id) + if key not in hydrophobic_interactions_in_lumped: + hydrophobic_interactions_in_lumped[key] = [] + hydrophobic_interactions_in_lumped[key].append(interaction.id) + + key = feature.atom_names + lumped_hydrophobic_in_lumped_lumped[key] = tuple(interaction.atom_ids) + + keep_hydrophobic_ids = set(subset.ids) + rev_hydrophobic_in_lumped_lumped = { + v: k for k, v in lumped_hydrophobic_in_lumped_lumped.items() + } + + # modify keep list by those covered in lumped + + for interaction in subset: + feature = feature_cache[interaction.feature_id] + + families = (feature.family, interaction.family) + + if families == ('Hydrophobe', 'Hydrophobe'): + key = (feature.atom_names, interaction.atom_ids[0]) + + if key in hydrophobic_interactions_in_lumped: + keep_hydrophobic_ids -= set([interaction.id]) + + elif families == ('LumpedHydrophobe', 'Hydrophobe'): + key = feature.atom_names + + if key in lumped_hydrophobic_in_lumped_lumped: + atom_id = interaction.atom_ids[0] + value = lumped_hydrophobic_in_lumped_lumped[key] + if atom_id in value: + keep_hydrophobic_ids -= set([interaction.id]) + + elif families == ('Hydrophobe', 'LumpedHydrophobe'): + key = tuple(interaction.atom_ids) + + if key in rev_hydrophobic_in_lumped_lumped: + atom_name = feature.atom_names + value = rev_hydrophobic_in_lumped_lumped[key] + + if atom_name in value: + keep_hydrophobic_ids -= set([interaction.id]) + + keep_list += list(keep_hydrophobic_ids) + + ### cull non-keepers + + cull_list = set(self.ids) - set(keep_list) + cull_iset = InteractionSet(self.db, cull_list) + self.db.delete_where( + table=table, + key=f'interaction_id IN {cull_iset.str_ids}', + commit=commit, + ) + self._indices = sorted(list(set(keep_list))) + + ### revisit hydrophobes + + # for a given protein feature, choose the closest interaction + + cull_list = [] + + hydrophobic_keeper_iset = InteractionSet(self.db, keep_hydrophobic_ids) + + sql = f""" + SELECT interaction_id, MIN(interaction_distance) + FROM {self.db.SQL_SCHEMA_PREFIX}{table} + WHERE interaction_id IN {hydrophobic_keeper_iset.str_ids} + GROUP BY interaction_feature + """ + + records = self.db.execute(sql).fetchall() + ids = [a for a, b in records] + + cull_list = set(hydrophobic_keeper_iset.ids) - set(ids) + cull_iset = InteractionSet(self.db, cull_list) + self.db.delete_where( + table=table, + key=f'interaction_id IN {cull_iset.str_ids}', + commit=commit, + ) + self._indices = sorted(list(set(keep_list) - cull_list)) + + ### Summary + + # if debug: + # self.summary() + + ### DUNDERS + + def __len__(self) -> int: + """The number of interactions in this set""" + return len(self.indices) + + def __str__(self) -> str: + """Unformatted command-line representation""" + return f'{{I × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI formatted command-line representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich formatted command-line representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate through interactions in this set""" + return iter( + self.db.get_interaction(id=i, table=self.table) for i in self.indices + ) + + def __getitem__(self, key) -> 'Interaction | InteractionSet': + """Get interaction or subsets thereof from this set""" + match key: + case int(): + index = self.indices[key] + return self.db.get_interaction(id=index, table=self.table) + + case slice(): + indices = self.indices[key] + return InteractionSet(self.db, indices, table=self.table) + + case _: + raise NotImplementedError + + +def df_from_interaction_records( + db: 'Database', + records: list[tuple], +) -> 'pandas.DataFrame': + """Construct a dataframe from the 'interaction' table records""" + + import json + + from pandas import DataFrame + + data = [] + for record in records: + ( + id, + feature_id, + pose_id, + type, + family, + atom_ids, + prot_coord, + lig_coord, + distance, + angle, + energy, + ) = record + + feature = db.get_feature(id=feature_id) + + d = dict(id=id) + + d['feature_id'] = feature_id + d['pose_id'] = pose_id + d['target_id'] = feature.target + + # d['type'] = INTERACTION_TYPES[(feature.family, family)] + d['type'] = type + + d['prot_family'] = feature.family + d['lig_family'] = family + + d['residue_name'] = feature.residue_name + d['residue_number'] = feature.residue_number + d['chain_name'] = feature.chain_name + + d['distance'] = distance + d['angle'] = angle + d['energy'] = energy + + d['prot_coord'] = json.loads(prot_coord) + d['lig_coord'] = json.loads(lig_coord) + + d['prot_atoms'] = feature.atom_names + d['lig_atoms'] = atom_ids + + d['backbone'] = feature.backbone + d['sidechain'] = feature.sidechain + + data.append(d) + + df = DataFrame.from_records(data=data) + + return df diff --git a/src/designdb/sets/pose.py b/src/designdb/sets/pose.py new file mode 100644 index 0000000..377f8f8 --- /dev/null +++ b/src/designdb/sets/pose.py @@ -0,0 +1,2243 @@ +import inspect +import json +import logging +import re +import shutil +from collections.abc import Callable +from itertools import combinations +from os.path import relpath +from pathlib import Path +from pprint import pprint +from zipfile import ZipFile + +import community as louvain +import mcol +import molparse as mp +import mrich +import networkx as nx +import pandas as pd +from django.conf import settings +from django.db import IntegrityError +from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery +from IPython.display import display +from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, +) +from molparse.rdkit import draw_grid, draw_mols +from pandas import DataFrame +# from mypackage.services.compound import CompoundService +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools, SDWriter + +from designdb.models import ( + Compound, + Inspiration, + Interaction, + Pose, + PoseTag, + PoseTagJunction, + Subsite, + SubsiteTag, + Target, +) +from designdb.sets.interaction import InteractionSet +from designdb.utils import ScoreSubquery, normalize_string_list +from designdb.utils_frag import generate_header + +if settings.MANAGE_MODELS: + from designdb.utils import JsonGroupArray as ArrayAgg +else: + from django.contrib.postgres.aggregates import ArrayAgg + + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class PoseSet: + """Object representing a subset of the 'pose' table in the :class:`.Database`. + + .. attention:: + + :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.Pose` objects in the set: + + :: + + pset = animal.poses[:100] + + for pose in pset: + ... + + Check membership + ================ + + To determine if a :class:`.Pose` is present in the set: + + :: + + is_member = pose in cset + + Selecting compounds in the set + ============================== + + The :class:`.PoseSet` can be indexed like standard Python lists by their indices + + :: + + pset = animal.poses[1:100] + + # indexing individual compounds + pose = pset[0] # get the first pose + pose = pset[1] # get the second pose + pose = pset[-1] # get the last pose + + # getting a subset of compounds using a slice + pset2 = pset[13:18] # using a slice + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """PoseSet initialisation""" + + # let's have a queryset no matter what. + if queryset: + self._queryset = queryset + else: + self._queryset = Pose.objects.none() + + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') + + self._interactions = None + self._metadata_dict = None + + ### DUNDERS + + def __str__(self): + """Unformatted string representation""" + if self.name: + s = f'{self._name}: ' + else: + s = '' + + s += f'{{P × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __len__(self) -> int: + """The number of poses in this set""" + return self._queryset.count() + + def __iter__(self): + """Iterate through poses in this set""" + return iter(self._queryset) + + def __getitem__( + self, + key: int | slice, + ) -> 'Pose | PoseSet': + """Get poses or subsets thereof from this set + + :param key: integer index or slice of indices + + """ + match key: + case int(): + try: + pose = Pose.objects.get(pk=key) + except Pose.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise Pose.DoesNotExist from exc + + return pose + + case slice(): + return PoseSet(Pose.objects.filter(pk__in=key)) + + case _: + raise NotImplementedError + + def __add__( + self, + other: 'PoseSet', + ) -> 'PoseSet': + """Add a :class:`.PoseSet` to this set""" + if isinstance(other, PoseSet): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + elif isinstance(other, Pose): + return PoseSet( + Pose.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), + sort=False, + ) + else: + raise NotImplementedError + + def __sub__( + self, + other: 'PoseSet', + ) -> 'PoseSet': + """Substract a :class:`.PoseSet` from this set""" + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return PoseSet( + Pose.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + sort=False, + ) + + def __and__(self, other: 'PoseSet'): + """AND set operation, returns only poses in both sets""" + + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __or__(self, other: 'PoseSet'): + """OR set operation, returns union of both sets""" + + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __xor__(self, other: 'PoseSet'): + """Exclusive OR set operation, returns all poses in either set but not both""" + + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) + + case _: + raise NotImplementedError + + def __call__( + self, + *, + tag: str = None, + target: int = None, + subsite: int = None, + ) -> 'PoseSet': + """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" + + if tag: + return self.get_by_tag(tag) + elif target: + return self.get_by_target(target=Target.objects.get(pk=target)) + elif subsite: + return self.get_by_subsite(subsite=Subsite.objects.get(pk=subsite)) + else: + raise NotImplementedError + + @classmethod + def get_by_references(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + Pose.objects.filter(pk__in=poseset._queryset.values('pose_reference')) + ) + + # there's a method get_by_inspiration + @classmethod + def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + Pose.objects.filter( + pk__in=Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'original_pose', + ), + ), + ) + + ### FILTERING + + def get_by_tag( + self, + tag: str, + inverse: bool = False, + ) -> 'PoseSet': + """Get all child poses with a certain tag + + :param tag: tag to filter by + :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) + + """ + self._queryset = self._queryset.annotate( + has_tag=Exists( + PoseTagJunction.objects.filter( + pose=OuterRef('pk'), + pose_tag__pose_tag_name=tag, + ), + ), + ) + if inverse: + return PoseSet(self._queryset.filter(has_tag=False)) + else: + return PoseSet(self._queryset.filter(has_tag=True)) + + def get_by_metadata( + self, key: str, value: str | None = None, debug: bool = False + ) -> 'PoseSet': + """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + + :param key: metadata key to search for + :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) + + """ + results = self.db.select_where( + query='pose_id, pose_metadata', + key=f'pose_id IN {self.str_ids}', + table='pose', + multiple=True, + ) + + if value is None: + # metadata stored as string + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}"'), + ) + + else: + if isinstance(value, str): + value = f'"{value}"' + + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}: {value}"'), + ) + + def get_by_inspiration(self, inspiration: Pose, inverse: bool = False): + """Get all child poses with with this inspiration. + + :param inspiration: inspiration :class:`.Pose` ID or object + :param inverse: invert the selection (Default value = False) + + """ + # not entirely sure which way the filtering should go + qs = ( + Inspiration.objects.filter( + derivative_pose=inspiration, + ).values('original_pose'), + ) + + if inverse: + return PoseSet(self._queryset.exclude(pk__in=qs)) + else: + return PoseSet(self._queryset.filter(pk__in=qs)) + + def get_df( + self, + smiles: bool = True, + inchikey: bool = True, + alias: bool = True, + name: bool = True, + compound_id: bool = False, + target_id: bool = False, + reference_id: bool = False, + reference_alias: bool = False, + path: bool = False, + mol: bool = False, + energy_score: bool = False, + distance_score: bool = False, + inspiration_score: bool = False, + metadata: bool = False, + expand_metadata: bool = True, + debug: bool = True, + inspiration_ids: bool = False, + inspiration_aliases: bool = False, + derivative_ids: bool = False, + tags: bool = False, + expand_tags: bool = False, + subsites: bool = False, + # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs + ) -> 'pandas.DataFrame': + """Get a DataFrame of the poses in this set. + + :param smiles: include SMILES column (Default value = True) + :param inchikey: include InChIKey column (Default value = True) + :param alias: include alias column (Default value = True) + :param name: include name column (Default value = True) + :param compound_id: include :class:`.Compound` ID column (Default value = False) + :param reference_id: include reference :class:`.Pose` ID column (Default value = False) + :param target_id: include reference :class:`.Target` ID column (Default value = False) + :param path: include path column (Default value = False) + :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) + :param energy_score: include energy_score column (Default value = False) + :param distance_score: include distance_score column (Default value = False) + :param inspiration_score: include inspiration_score column (Default value = False) + :param metadata: include metadata in output (Default value = False) + :param expand_metadata: create separate column for each metadata key (Default value = True) + :param inspiration_ids: include inspiration :class:`.Pose` ID column + :param inspiration_aliases: include inspiration :class:`.Pose` alias column + :param derivative_ids: include derivative :class:`.Pose` ID column + :param tags: include tags column + :param subsites: include subsites column + """ + + sig = inspect.signature(self.get_df) + flags = { + name: locals()[name] + for name in sig.parameters + if name not in ('self', 'debug', 'expand_tags', 'expand_metadata') + } + # need id in output + flags['id'] = True + + print('input flags', flags) + + # alias and name both point to same thing. prefer 'name' + if flags.get('name', False): + flags['alias'] = True + + # this is still not working right and I don't understand. What + # was the original code doing here? simply adding both fields, + # name and alias? + + # dict :: func arg: (col title, qs field lookup, queryset annotation) + # this is going to get out of hand with multiple scoring methods + fields = { + 'id': ('id', 'id', None), + 'smiles': ('smiles', 'pose_smiles', None), + 'inchikey': ('inchikey', 'pose_inchikey', None), + # 'alias': ('alias', 'pose_alias', None), + 'name': ('name', 'pose_alias', None), + 'compound_id': ('compound_id', 'compound__id', None), + 'target_id': ('target_id', 'target__id', None), + 'reference_id': ('reference_id', 'pose_reference', None), + 'reference_alias': ( + 'reference_alias', + 'reference_alias', + Subquery( + Pose.objects.filter( + pk=OuterRef('pose_reference'), + ).values('pose_alias')[0:1] + ), + ), + 'path': ('pose_path', 'pose_path', None), + 'mol': ('mol', 'pose_mol', None), + 'energy_score': ( + 'energy_score', + 'energy_score', + ScoreSubquery('energy_score'), + ), + 'distance_score': ( + 'distance_score', + 'distance_score', + ScoreSubquery('distance_score'), + ), + 'inspiration_score': ( + 'inspiration_score', + 'inspiration_score', + ScoreSubquery('inspiration_score'), + ), + 'metadata': ('metadata', 'pose_metadata', None), + 'inspiration_ids': ( + 'inspiration_ids', + 'inspiration_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'inspiration_aliases': ( + 'inspiration_aliases', + 'inspiration_aliases', + ArrayAgg('inspirations__pose_alias'), + # JsonGroupArray('inspirations__pose_alias'), + ), + 'derivative_ids': ( + 'derivative_ids', + 'derivative_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'tags': ( + 'tags', + 'tag_names', + ArrayAgg('tags__pose_tag_name'), + # JsonGroupArray('tags__pose_tag_name'), + ), + 'subsites': ( + 'subsites', + 'subsites_names', + ArrayAgg( + 'subsites__subsite_name', + filter=Q(subsites__isnull=False), + ), + # JsonGroupArray('subsites__subsite_name', filter=Q(subsites__isnull=False),), + ), + } + + annotations = { + v[1]: v[2] for k, v in fields.items() if flags.get(k, False) and v[2] + } + values = [v[1] for k, v in fields.items() if flags.get(k, False)] + columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} + + print('df values', values) + print('df columns', columns) + qs = self._queryset.annotate(**annotations).values(*values) + + print('queryset', self._queryset.count(), self._queryset) + + df = pd.DataFrame(qs) + print(df) + print('df columns from df before', df.columns) + df = df.rename(columns=columns) + print('df columns from df after', df.columns) + df = df.set_index('id') + + if alias: + df['alias'] = df.name + + if metadata and expand_metadata: + # TODO: code specific to my current situation. have to + # parse string to json (does postgres handle this + # automatically?) + # expanded = pd.json_normalize( + # df["metadata"].apply(lambda x: json.loads(x) if x else {}), + # ) + expanded = pd.json_normalize(df['metadata']) + # dropping columns is due to confusion with scores. can't be + # permanent solution, for now, drop the common ones + expanded = expanded.drop( + columns=set(expanded.columns).intersection(set(df.columns)), + ) + + df = df.drop(columns=['metadata']).join(expanded) + + if tags and expand_tags: + # surprisingly manual compared to expand_metadata, but + # kept running into problems + df['tags'] = df['tags'].apply(normalize_string_list) + # get all unique tags + all_tags = sorted(set(tag for tags in df['tags'] for tag in tags)) + + # build boolean columns + for tag in all_tags: + df[tag] = df['tags'].apply(lambda tags: tag in tags) + + df = df.drop(columns=['tags']) + + # custom aggreagte field is giving me string, parse to list + for col in [ + 'inspiration_aliases', + ]: + if col in df.columns: + df[col] = df[col].apply(normalize_string_list) + + return df + + def get_by_reference( + self, + ref_id: int, + ) -> 'PoseSet | None': + """Get poses with a certain reference id + + :param ref_id: reference :class:`.Pose` ID + + """ + qs = self._queryset.filter(pose_reference=ref_id) + if not qs.exists(): + # odd, but keeping now + return None + + return PoseSet(qs) + + def get_by_compound( + self, + *, + compound: 'int | Compound | CompoundSet', + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. + + :param compound: :class:`.Compound` object or ID + :returns: a :class:`.PoseSet` of the selection + + """ + if isinstance(compound, int): + return PoseSet(self._queryset.filter(compound__id=compound)) + elif isinstance(compound, Compound): + return PoseSet(self._queryset.filter(compound=compound)) + else: + # possible crash point: assuming CompoundSet but not + # testing type, still trying to fiugre out circular + # imports + return PoseSet(self._queryset.filter(compound__in=compound.queryset)) + + def get_by_target( + self, + *, + target: Target, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. + + :param id: :class:`.Target` ID + :returns: a :class:`.PoseSet` of the selection + + """ + # where would you need this method?? do you ever create sets + # of poses from different targets? + return PoseSet(self._queryset.filter(target=target)) + + def get_by_subsite( + self, + *, + subsite: Subsite, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. + + :param id: :class:`.Subsite` ID + :returns: a :class:`.PoseSet` of the selection + + """ + qs = self._queryset.filter( + id__in=SubsiteTag.objects.filter( + subsite=subsite, + ).values('pose'), + ) + + if self.name: + name = f'{self.name} & subsite={subsite.pk}' + else: + name = None + + return PoseSet(qs, name=name) + + # def get_best_placed_poses_per_compound(self): + # """Choose the best placed pose (best distance_score) grouped by compound""" + + # sql = f""" + # SELECT pose_id, MIN(pose_distance_score) + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # GROUP BY pose_compound + # """ + + # cursor = self.db.execute(sql) + + # ids = [i for i, _ in cursor] + + # return PoseSet(self._queryset) + + # def filter( + # self, + # function=None, + # *, + # key: str = None, + # value: str = None, + # operator='=', + # inverse: bool = False, + # ): + # """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values + + # :param function: callable object + # :param key: database field for 'pose' table ('pose_' prefix not needed) + # :param value: value to compare to + # :param operator: comparison operator (default = "=") + # :param inverse: invert the selection (Default value = False) + + # """ + + # if function: + # ids = set() + # for pose in self: + # value = function(pose) + # # mrich.debug(f'{pose=} {value=}') + # if value and not inverse: + # ids.add(pose.id) + # elif not value and inverse: + # ids.add(pose.id) + + # return PoseSet(self.db, ids) + + # sql = f""" + # SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # AND pose_{key} {operator} {value} + # """ + + # cursor = self.db.execute(sql) + + # ids = [i for (i,) in cursor] + + # return PoseSet(self.db, ids) + + def add_tag( + self, + tag: str, + ) -> None: + """Add this tag to every member of the set""" + + assert isinstance(tag, str) + + pose_tag = PoseTag(pose_tag_name=tag) + pose_tag.save() + + PoseTagJunction.objects.bulk_create( + [PoseTagJunction(pose=pose, pose_tag=pose_tag) for pose in self._queryset], + ignore_conflicts=True, + ) + + mrich.print(f'Tagged {self} w/ "{tag}"') + + # refetch in case was evaluated + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + + # NB! I'm now realizing this is potentially a huge + # problem. with every evaluation and refretch some attributes + # may be lost. how can this be kept clean? + + # unused? the original method didn't save object + def append_to_metadata( + self, + key, + value, + ) -> None: + """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries + + :param key: the :class:`.Metadata` key to match + :param value: the value to append to the list + + """ + for pose in self._queryset: + # metadata = json.loads(pose.payload) + metadata = pose.pose_metadata + try: + metadata.append(key, value) + except AttributeError: + mrich.error(f'Could not append to metadata {key=}. Not a list?') + + pose.save() + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + + def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: + """Create and assign subsite entries from a metadata field + + :param field: the metadata field to use + + """ + for pose in self._queryset: + metadata = json.loads(pose.payload) + key = metadata.get(field) + if not key: + mrich.warning(field, 'not in metadata pose_id=', pose_id) + continue + + # I'm still not entirely clear can you really have + # posesets from different target, if not, and it really + # seems that not, this should be a single subsite + subsite, _ = Subsite.get_or_create(target=pose.target, subsite_name=key) + subsite_tag = SubsiteTag(subsite=subsite, pose=pose) + subsite_tag.save() + + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + + # TODO: implement scores + # def calculate_inspiration_scores( + # self, + # alpha: float = 0.95, + # beta: float = 0.05, + # score_type: str = 'combo', + # ) -> 'pd.DataFrame': + # """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky + + # :param alpha: Tversky alpha parameter + # :param beta: Tversky beta parameter + # :param score_type: Score type to add to database, choose from "combo", "shape", "colour" + # :returns: Pandas DataFrame with molecules and scores + # """ + + # from mocassin.mocassin import calculate_mocassin_tversky + + # df = self.get_df( + # alias=False, + # smiles=False, + # inchikey=False, + # inspiration_ids=True, + # mol=True, + # ) + + # inspirations = {p.id: p for p in self.inspirations} + + # df['inspiration_mols'] = df['inspiration_ids'].apply( + # lambda x: [inspirations[i].mol for i in x] + # ) + + # n = len(df) + + # for j, (i, row) in mrich.track( + # enumerate(df.iterrows()), prefix='MoCASSIn', total=n + # ): + # mrich.set_progress_field('j', j) + # mrich.set_progress_field('n', n) + + # try: + # combo, shape, colour = calculate_mocassin_tversky( + # row['inspiration_mols'], + # row['mol'], + # alpha=0.95, + # beta=0.05, + # ) + # df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo + # df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape + # df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour + # except Exception as e: + # mrich.error(e) + + # tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() + + # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" + + # mrich.debug('Updating pose_inspiration_score values') + # self.db.executemany(sql, [(b, a) for a, b in tuples]) + # self.db.commit() + + # return df + + ### SPLITTING + + def split_by_reference(self) -> 'dict[int,PoseSet]': + """Split this :class:`.PoseSet` into subsets grouped by reference ID + + :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values + + """ + sets = {} + for ref_id in self.reference_ids: + sets[ref_id] = self.get_by_reference(ref_id) + return sets + + def split_by_inspirations( + self, + single_set: bool = False, + ) -> 'dict[PoseSet,PoseSet] | PoseSet': + """Split this :class:`.PoseSet` into subsets grouped by inspirations + + :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) + :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values + + """ + + sets = {} + + for pose in self._queryset: + insp_ids = list(pose.inspirations.distinct().values_list('pk', flat=True)) + key = tuple(insp_ids) + sets.setdefault(key, set()) + sets[key].add(pose.pk) + + mrich.var('#unique inspiration combinations', len(sets)) + + if single_set: + return PoseSet( + Pose.objects.filter( + pk__in=[id for s in sets.values() for id in s.ids], + sort=False, + ) + ) + + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + + return { + PoseSet(Pose.objects.filter(pk__in=insp_ids)): PoseSet( + Pose.objects.filter(pk__in=pose_ids) + ) + for insp_ids, pose_ids in sets.items() + } + + ### EXPORTING + + def write_sdf( + self, + out_path: str, + name_col: str = 'alias', + inspiration_ids: bool = False, + inspiration_aliases: bool = False, + **kwargs, + ) -> None: + """Write an SDF + + :param out_path: filepath of the output + :param name_col: pose property to use as the name column, can be ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') + :param inspiration_ids: include inspiration :class:`.Pose` ID column + :param inspiration_aliases: include inspiration :class:`.Pose` alias column + :param fragalysis_inspirations: create inspirations column "ref_mols" + """ + + df = self.get_df( + mol=True, + inspiration_ids=inspiration_ids, + inspiration_aliases=inspiration_aliases, + name=name_col == 'name', + **kwargs, + ) + + print('what do I have for name col', name_col) + print(df.columns) + + if name_col not in ['name', 'alias', 'inchikey', 'id']: + # try getting name from metadata + records = self._queryset.values('id', 'pose_metadata') + + longcode_lookup = {} + for i, d in records: + if d: + metadata = json.loads(d) + else: + metadata = {} + + longcode_lookup[i] = metadata.get(name_col, None) + + values = [] + for i, row in df.iterrows(): + values.append(longcode_lookup[row['id']]) + + df[name_col] = values + + df = df.rename(columns={name_col: '_Name', 'mol': 'ROMol'}) + + mrich.writing(out_path) + + PandasTools.WriteSDF(df, out_path, 'ROMol', '_Name', list(df.columns)) + + # keep record of export + value = str(Path(out_path).resolve()) + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + self.append_to_metadata(key='exports', value=value) + + def to_fragalysis( + self, + out_path: str, + *, + method: str, + ref_url: str = 'https://hippo.winokan.com', + submitter_name: str, + submitter_email: str, + submitter_institution: str, + metadata: bool = True, + sort_by: str | None = None, + sort_reverse: bool = False, + generate_pdbs: bool = False, + copy_reference_pdbs: bool = False, + # ingredients: IngredientSet = None, + skip_no_reference: bool = True, + skip_no_inspirations: bool = True, + skip_metadata: list[str] | None = None, + tags: bool = True, + subsites: bool = True, + extra_cols: dict[str, list] = None, + inspiration_score: bool = True, + # name_col: str = "name", + **kwargs, + ): + """Prepare an SDF for upload to the RHS of Fragalysis. + + :param out_path: the file path to write to + :param method: method used to generate the compounds + :param ref_url: reference URL for the method + :param submitter_name: name of the person submitting the compounds + :param submitter_email: email of the person submitting the compounds + :param submitter_institution: institution name of the person submitting the compounds + :param metadata: include metadata in the output? (Default value = True) + :param skipmetadata: exclude metadata keys from output + :param sort_by: if set will sort the SDF by this column/field (Default value = None) + :param sort_reverse: reverse the sorting (Default value = False) + :param generate_pdbs: generate accompanying protein-ligand complex PDBs (Default value = False) + :param ingredients: get procurement and amount information from this :class:`.IngredientSet` (Default value = None) + :param tags: include a column for tags in the output (Default value = True) + :param subsites: include a column for subsites in the output (Default value = True) + :param extra_cols: extra_cols should be a dictionary with a key for each column name, and list values where the first element is the field description, and all subsequent elements are values for each pose. + + """ + + assert out_path.endswith('.sdf') + + _name_col = '_Name' + mol_col = 'ROMol' + mol_col = 'mol' + + # make sure references are defined: + logger.debug('entering') + + mrich.debug(len(self), 'poses in set') + poses = None + + if skip_no_reference: + values = self._queryset.filter(pose_reference__isnull=False) + + if not values.exists(): + mrich.debug('no references, quitting') + logger.warning('no references, quitting') + return + + poses = PoseSet(values) + + mrich.debug(len(poses), 'remaining after skipping null reference') + + if skip_no_inspirations: + if not poses: + poses = self + + values = Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'derivative_pose', + ) + + if not values.exists(): + rich.debug('no inspirations, quitting') + logger.warning('no inspirations, quitting') + return + + poses = PoseSet(Pose.objects.filter(pk__in=values)) + + mrich.debug(len(poses), 'remaining after skipping null inspirations') + + if not poses: + # huh? + poses = PoseSet(self._queryset) + + mrich.var('#poses', len(poses)) + logger.debug('about to create df') + # get the dataframe of poses + + # TODO: this should not go through the df + + # Scope issue - this code expect access to all poses in the db + self._queryset = Pose.objects.all() + + pose_df = poses.get_df( + mol=True, + inspiration_ids=True, + # duplicate_name="original ID", + name=True, + compound_id=True, + reference_id=True, + metadata=metadata, + tags=tags, + subsites=subsites, + energy_score=True, + distance_score=True, + inspiration_score=inspiration_score, + # sanitise_null_metadata_values=True, + expand_tags=False, + # sanitise_tag_list_separator=";", + # sanitise_metadata_list_separator=";", + # skip_metadata=skip_metadata, + # **kwargs, + ) + + pose_df = pose_df.reset_index() + + # fix inspirations and reference column (comma separated aliases) + + lookup = {k.pk: k.pose_alias for k in self._queryset} + + inspiration_strs = [] + # for i, row in pose_df.iterrows(): + # strs = [] + # for i in normalize_string_list(row['inspiration_ids']): + # # this is what it did in original code + # alias = self._queryset.get(pk=i).pose_alias + # if not alias: + # continue + # strs.append(alias) + # inspiration_strs.append(','.join(strs)) + + # comma separate subsites + if subsites: + + def fix_subsites(subsite_list: list[str]): + """Fix subsites""" + if not subsite_list: + logger.warning('no subsite list') + return 'None' + return ','.join(subsite_list) + + pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) + + if tags: + pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(x)) + + # pose_df['ref_mols'] = inspiration_strs + pose_df['ref_mols'] = 'inspiration_strs' + pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup[x]) + + # add compound identifier column (inchikey?) + + drops = ['inspiration_ids', 'reference_id'] + + # if ingredients: + # drops.pop(drops.index("compound")) + + if skip_no_reference: + prev = len(pose_df) + pose_df = pose_df[pose_df['reference_id'].notna()] + if len(pose_df) < prev: + mrich.warning(f'Skipping {prev - len(pose_df)} Poses with no reference') + + pose_df = pose_df.drop(columns=drops, errors='ignore') + + pose_df[_name_col] = pose_df['name'] + + pose_df.rename( + inplace=True, + columns={ + 'id': 'HIPPO Pose ID', + 'compound_id': 'HIPPO Compound ID', + 'mol': mol_col, + # "smiles": "original SMILES", + # "compound_id": "compound inchikey", + }, + ) + + extras = { + 'HIPPO Pose ID': 'HIPPO Pose ID', + 'HIPPO Compound ID': 'HIPPO Compound ID', + 'smiles': 'smiles', + 'ref_pdb': 'protein reference', + 'ref_mols': 'fragment inspirations', + 'alias': 'alias', + # "compound inchikey": "compound inchikey", + 'distance_score': 'distance_score', + 'energy_score': 'energy_score', + 'inspiration_score': 'inspiration_score', + } + + if subsites: + extras['subsites'] = 'subsites' + + if tags: + extras['tags'] = 'tags' + + if extra_cols: + for key, value in extra_cols.items(): + extras[key] = value[0] + + # if ingredients: + + # q_entries = [] + # q_prices = [] + # q_lead_times = [] + # q_amounts = [] + + # currency = None + + # for i, row in pose_df.iterrows(): + + # compound_id = self.db.get_compound_id(inchikey=row["compound inchikey"]) + + # ingredient = ingredients(compound_id=compound_id) + + # if isinstance(ingredient, IngredientSet): + # ingredient = sorted( + # [i for i in ingredient], key=lambda x: x.quote.price + # )[0] + + # quote = ingredient.quote + # if not currency: + # currency = quote.currency + # else: + # assert quote.currency == currency + + # q_entries.append(quote.entry_str) + # q_prices.append(quote.price) + # q_lead_times.append(quote.lead_time) + # q_amounts.append(quote.amount) + + # pose_df["Supplier Catalogue Entry"] = q_entries + # # pose_df['Supplier:Catalogue:Entry'] = q_entries + # pose_df[f"Price ({currency})"] = q_prices + # pose_df["Lead time (working days)"] = q_lead_times + # pose_df["Amount (mg)"] = q_amounts + + # extras["Supplier Catalogue Entry"] = "Supplier Catalogue Entry string" + # extras[f"Price ({currency})"] = "Quoted price" + # extras["Lead time (working days)"] = "Quoted lead-time" + # extras["Amount (mg)"] = "Quoted amount" + + out_path = Path(out_path).resolve() + mrich.var('out_path', out_path) + + if generate_pdbs: + # output subdirectory + out_key = Path(out_path).name.removesuffix('.sdf') + pdb_dir = Path(out_path).parent / Path(out_key) + pdb_dir.mkdir(exist_ok=True) + zip_path = Path(out_path).parent / f'{out_key}_pdbs.zip' + + # create the zip archive + with ZipFile(str(zip_path.resolve()), 'w') as z: + # loop over poses + for (i, row), pose in zip(pose_df.iterrows(), poses, strict=False): + # filenames + pdb_name = f'{out_key}_{row._Name}.pdb' + pdb_path = pdb_dir / pdb_name + pose_df.loc[i, 'ref_pdb'] = pdb_name + + # generate the PL-complex + sys = pose.complex_system + + # write the PDB + mrich.writing(pdb_path) + sys.write(pdb_path, verbosity=0) + z.write(pdb_path) + + mrich.writing(f'{out_key}_pdbs.zip') + + if copy_reference_pdbs: + # output subdirectory + out_key = Path(out_path).name.removesuffix('.sdf') + pdb_dir = Path(out_path).parent / Path(out_key) + pdb_dir.mkdir(exist_ok=True) + zip_path = Path(out_path).parent / f'{out_key}_refs.zip' + + references = self.references + # lookup = self.db.get_pose_alias_path_dict(references) + lookup = {k.pose_alias: k.pose_path for k in self._queryset} + + zips = set() + for ref_alias in pose_df['ref_pdb'].values: + source_path = Path(lookup[ref_alias]) + + apo_path = source_path.parent / source_path.name.replace( + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_apo-desolv.pdb') + + if not apo_path.exists(): + sys = mp.parse(source_path).protein_system + sys.write(apo_path, verbosity=0) + + target_path = pdb_dir / f'{ref_alias}.pdb' + + if not target_path.exists(): + mrich.writing(target_path) + shutil.copy(apo_path, target_path) + + zips.add(target_path) + + # create the zip archive + with ZipFile(str(zip_path.resolve()), 'w') as z: + for path in zips: + z.write(path, arcname=path.name) + + mrich.writing(f'{out_key}_refs.zip') + + # create the header molecule + + df_cols = set(pose_df.columns) + + header = generate_header( + # self[0], # <- what does that do?? + self._queryset.first(), + method=method, + ref_url=ref_url, + submitter_name=submitter_name, + submitter_email=submitter_email, + submitter_institution=submitter_institution, + extras=extras, + metadata=metadata, + ) + + header_cols = set(header.GetPropNames()) + + # # empty properties + # pose_df["generation_date"] = [None] * len(pose_df) + # pose_df["submitter_name"] = [None] * len(pose_df) + # pose_df["method"] = [None] * len(pose_df) + # pose_df["submitter_email"] = [None] * len(pose_df) + # pose_df["ref_url"] = [None] * len(pose_df) + + if extra_cols: + for key, value in extra_cols.items(): + if len(value) != len(pose_df) + 1: + mrich.error( + f'extra_col "{key}" does not have the correct number of values' + ) + raise ValueError( + f'extra_col "{key}" does not have the correct number of values' + ) + pose_df[key] = value[1:] + + if sort_by: + pose_df = pose_df.sort_values(by=sort_by, ascending=not sort_reverse) + + fields = [] + + mrich.writing(out_path) + + with open(out_path, 'w') as sdfh: + with SDWriter(sdfh) as w: + w.write(header) + PandasTools.WriteSDF( + pose_df, sdfh, mol_col, _name_col, set(pose_df.columns) + ) + + # keep record of export + value = str(Path(out_path).resolve()) + + # FIXME + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + + self.append_to_metadata(key='exports', value=value) + + return pose_df + + def to_pymol(self, prefix: str | None = None) -> None: + """Group the poses by reference protein and inspirations and output relevant PDBs and SDFs. + + :param prefix: prefix to give all output subdirectories (Default value = None) + + """ + + commands = [] + + prefix = prefix or '' + if prefix: + prefix = f'{prefix}_' + + from pathlib import Path + + for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): + ref_pose = Pose.objects.get(id=ref_id) + ref_name = ref_pose.pose_alias or ref_id + + # create the subdirectory + ref_dir = Path(f'{prefix}ref_{ref_name}') + mrich.writing(ref_dir) + ref_dir.mkdir(parents=True, exist_ok=True) + + # write the reference protein + ref_pdb = ref_dir / f'ref_{ref_name}.pdb' + ref_pose.protein_system.write(ref_pdb, verbosity=0) + + # color the reference: + commands.append(f'load {ref_pdb.resolve()}') + commands.append('hide') + commands.append('show lines') + commands.append('show surface') + commands.append('util.cbaw') + commands.append('set surface_color, white') + commands.append('set transparency, 0.4') + + for j, (insp_ids, poses) in enumerate( + poses.split_by_inspirations().items() + ): + inspirations = PoseSet(self.db, insp_ids) + insp_names = '-'.join(inspirations.names) + + # create the subdirectory + insp_dir = ref_dir / insp_names + insp_dir.mkdir(parents=True, exist_ok=True) + + # write the inspirations + insp_sdf = insp_dir / f'{insp_names}_frags.sdf' + inspirations.write_sdf(insp_sdf) + + commands.append(f'load {insp_sdf.resolve()}') + commands.append( + f'set all_states, on, {insp_sdf.name.removesuffix(".sdf")}' + ) + commands.append(f'util.rainbow "{insp_sdf.name.removesuffix(".sdf")}"') + + # write the poses + pose_sdf = insp_dir / f'{insp_names}_derivatives.sdf' + poses.write_sdf(pose_sdf) + + commands.append(f'load {pose_sdf.resolve()}') + commands.append(f'util.cbaw "{pose_sdf.name.removesuffix(".sdf")}"') + + if j > 0: + commands.append(f'disable "{insp_sdf.name.removesuffix(".sdf")}"') + commands.append(f'disable "{pose_sdf.name.removesuffix(".sdf")}"') + + return '; '.join(commands) + + def to_knitwork( + self, out_path: str, path_root: str = '.', aligned_files_dir: str | None = None + ) -> None: + """Knitwork takes a CSV input with: + + - observation shortcode + - smiles + - path_to_ligand_mol + - path_to_pdb + + :param out_path: path to output CSV + :param path_root: paths in CSV will be relative to here + + """ + + out_path = Path(out_path).resolve() + path_root = Path(path_root).resolve() + mrich.var('out_path', out_path) + mrich.var('path_root', path_root) + mrich.var('aligned_files_dir', aligned_files_dir) + + assert out_path.name.endswith('.csv') + + with open(out_path, 'w') as f: + mrich.writing(out_path) + + for pose in self._queryset: + assert pose.pose_alias + assert pose.tags.filter(pose_tag_name='hits').exists() + + if aligned_files_dir: + mol = str(pose.mol_path) + pdb = str(pose.apo_path) + + assert 'aligned_files' in mol + assert 'aligned_files' in pdb + + mol = mol.split('aligned_files/')[-1] + pdb = pdb.split('aligned_files/')[-1] + + aligned_files_dir = Path(aligned_files_dir) + + mol = relpath(aligned_files_dir / mol, path_root) + pdb = relpath(aligned_files_dir / pdb, path_root) + + else: + mol = relpath(pose.mol_path, path_root) + pdb = relpath(pose.apo_path, path_root) + + data = [pose.pose_alias, pose.compound.compound_smiles, mol, pdb] + + f.write(','.join(data)) + f.write('\n') + + def to_syndirella( + self, out_key: 'str | Path', separate: bool = False + ) -> 'DataFrame': + """Create syndirella inputs""" + + out_key = Path('.') / out_key + + out_dir = out_key.parent + out_key = out_key.name + + mrich.var('out_key', out_key) + mrich.var('#poses', len(self)) + + out_dir.mkdir(parents=True, exist_ok=True) + + ### Prepare Syndirella CSV data + + df = self.get_df( + inchikey=False, alias=False, reference_alias=True, inspiration_aliases=True + ) + df = df.rename(columns={'reference_alias': 'template'}) + + # compound_set + + if separate: + df['compound_set'] = df.apply( + lambda row: f'{out_key}_{row["name"]}', axis=1 + ) + + else: + df['compound_set'] = out_key + + # template + + null_template = df['template'].isnull() + if null_template.any(): + mrich.warning( + len(null_template), 'poses have no reference. Setting to self' + ) + mrich.print(df.loc[null_template, 'name'].values) + df['template'] = df['template'].fillna(df['name']) + + # inspirations + + null_inspirations = df['inspiration_aliases'].apply(lambda x: not x) + + if null_inspirations.any(): + mrich.warning( + len(null_inspirations), 'poses have no inspirations. Setting to self' + ) + mrich.print(df.loc[null_inspirations, 'name'].values) + df.loc[null_inspirations, 'inspiration_aliases'] = df.loc[ + null_inspirations + ].apply(lambda row: set([row['name']]), axis=1) + + for i, row in df.iterrows(): + for j, inspiration in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = inspiration + + # this from original code. looking at the data type I have, + # this cannot possibly work. did I get something wrong filling + # the df? + # all_inspirations = set.union(*list(df['inspiration_aliases'].values)) + all_inspirations = set().union(*df['inspiration_aliases']) + + df = df.drop(columns=['name', 'inspiration_aliases']) + + ### Copy Templates + + template_dir = out_dir / 'templates' + mrich.writing(template_dir) + template_dir.mkdir(parents=True, exist_ok=True) + + templates = df['template'].unique() + + # records = self._queryset.filter(pose_alias__in=templates) + records = Pose.objects.filter( + target__in=self.targets, + pose_alias__in=templates, + ) + + templates = PoseSet(records) + + for ref in templates: + template = template_dir / ref.apo_path.name + if not template.exists(): + mrich.writing(template) + shutil.copy(ref.apo_path, template) + + ### Inspirations + print('all inspirations', all_inspirations) + # records = self._queryset.filter(pose_alias__in=all_inspirations) + # isn't this overwriting the one few lines above?? + records = Pose.objects.filter( + target__in=self.targets, pose_alias__in=all_inspirations + ) + + all_inspirations = PoseSet(records) + + ### Write CSV + + if separate: + for i, row in df.iterrows(): + csv_name = out_dir / f'{row["compound_set"]}_syndirella_input.csv' + mrich.writing(csv_name) + row.to_frame().T.to_csv(csv_name, index=False) + + else: + csv_name = out_dir / f'{out_key}_syndirella_input.csv' + mrich.writing(csv_name) + df.to_csv(csv_name, index=False) + + ### Write Inspirations + + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' + all_inspirations.write_sdf( + sdf_name, + tags=False, + metadata=False, + name_col='name', + ) + + return df + + ### OUTPUT + + def interactive( + self, + print_name: str = True, + method: str | None = None, + function: Callable | None = None, + **kwargs, + ): + """Interactive widget to navigate compounds in the table + + :param print_name: print the :class:`.Pose` name (Default value = True) + :param method: pass the name of a :class:`.Pose` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) + :param function: pass a callable which will be called as `function(pose)` + + """ + + if method: + + def widget(i): + """Method widget""" + pose = self[i] + if print_name: + print(repr(pose)) + value = getattr(pose, method)(**kwargs) + if value: + display(value) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description='Pose:', + disabled=False, + ), + ) + + elif function: + + def widget(i): + """Function widget""" + pose = self[i] + if print_name: + display(pose) + function(pose) + + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description='Pose:', + disabled=False, + ), + ) + + else: + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Pose (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + h = Checkbox(description='Tags', value=False) + i = Checkbox(description='Subsites', value=False) + d = Checkbox(description='2D (Comp.)', value=False) + e = Checkbox(description='2D (Pose)', value=False) + f = Checkbox(description='3D', value=True) + g = Checkbox(description='Metadata', value=False) + + ui1 = GridBox( + [b, c, d, h], + layout=Layout(grid_template_columns='repeat(4, 100px)'), + ) + ui2 = GridBox( + [e, f, g, i], + layout=Layout(grid_template_columns='repeat(4, 100px)'), + ) + ui = VBox([a, ui1, ui2]) + + def widget( + i, + name: bool = True, + summary: bool = True, + grid: bool = True, + draw2d: bool = True, + draw: bool = True, + tags: bool = True, + subsites: bool = True, + metadata: bool = True, + ): + """Default widget""" + pose = self._queryset.get(pk=i) + if name: + print(repr(pose)) + + if summary: + pose.summary(metadata=False, tags=False, subsites=False) + if tags: + print(pose.tags) + if subsites: + print(pose.subsites) + if grid: + pose.grid() + if draw2d: + pose.draw2d() + if draw: + pose.draw() + if metadata: + mrich.title('Metadata:') + pprint(pose.metadata) + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'grid': d, + 'draw2d': e, + 'draw': f, + 'metadata': g, + 'tags': h, + 'subsites': i, + }, + ) + + display(ui, out) + + def summary(self) -> None: + """Print a summary of this pose set""" + mrich.header('PoseSet()') + mrich.var('#poses', len(self)) + mrich.var('#compounds', self.num_compounds) + mrich.var('tags', self.tags) + + def draw(self) -> None: + """Render this pose set with Py3Dmol""" + + mols = [p.mol for p in self] + + drawing = draw_mols(mols) + # display(drawing) + + def grid(self) -> None: + """Draw a grid of all contained molecules""" + + data = [(p.name, p.compound.mol) for p in self] + + mols = [d[1] for d in data] + labels = [d[0] for d in data] + + drawing = draw_grid(mols, labels=labels) + display(drawing) + + # TODO: disabled, the field subsite_tag_ref doesn't exist anymore, + # don't know what the query is doing + # def subsite_summary(self) -> 'pd.DataFrame': + # """Print a table counting poses by subsite""" + + # sql = f""" + # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite + # INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag + # ON subsite_id = subsite_tag_ref + # WHERE subsite_tag_pose IN {self.str_ids} + # GROUP BY subsite_name + # """ + + # cursor = self.db.execute(sql) + + # df = DataFrame( + # [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] + # ) + + # df = df.set_index('id') + + # df = df.sort_values(by='num_poses', ascending=False) + + # mrich.print(df) + + # return df + + def get_interaction_overlaps(self, return_pairs: bool = False) -> int: + """Count the number of member pose pairs which share at least one but not all interactions""" + + records = Interaction.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_id = r['feature'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_id)) + ISETS[pose_id] = values + + ids = [i for i in self.ids if i in ISETS] + + count = 0 + + pairs = set() + + for pose_j, pose_k in combinations(ids, 2): + iset_j = ISETS[pose_j] + iset_k = ISETS[pose_k] + + intersection = iset_j & iset_k + diff1 = iset_j - iset_k + diff2 = iset_k - iset_j + + if intersection and diff1 and diff2: + count += 1 + pairs.add((pose_j, pose_k)) + + if return_pairs: + return [PoseSet(Pose.objects.filter(pk__in[a, b])) for a, b in pairs] + + return count + + def get_interaction_clusters(self) -> 'dict[int, PoseSet]': + """Cluster poses based on shared interactions.""" + + # get interaction records + + sql = f""" + SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON interaction_feature = feature_id + WHERE interaction_pose IN {self.str_ids} + """ + + records = self.db.execute(sql).fetchall() + records = Interaction.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature__feature_residue_name', + 'feature__feature_residue_number', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_residue_name = r['feature_residue_name'] + feature_residue_number = r['feature_residue_number'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_residue_name, feature_residue_number)) + ISETS[pose_id] = values + + pairs = combinations(ISETS.keys(), 2) + + # construct overlap dictionary + + OVERLAPS = {} + for id1, id2 in pairs: + iset1 = ISETS[id1] + iset2 = ISETS[id2] + OVERLAPS[(id1, id2)] = len(iset1 & iset2) + + # make the graph + G = nx.Graph() + + for (id1, id2), count in OVERLAPS.items(): + G.add_edge(id1, id2, weight=count) + + # partition the graph + + partition = louvain.best_partition(G, weight='weight') + + # find the clusters + + clusters = {} + for node, cluster_id in partition.items(): + clusters.setdefault(cluster_id, set()).add(node) + + # create the PoseSets + + psets = { + i: PoseSet(Pose.objects.filter(pk__in=ids), name=f'Cluster {i}') + for i, ids in enumerate(clusters.values()) + } + + all_ids = set(sum((pset.ids for pset in psets.values()), [])) + + # calculate modal interactions + + for i, cluster in psets.items(): + mrich.var(cluster.name, len(cluster), unit='poses') + + df = cluster.interactions.df + + unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ + 'pose_id' + ].nunique() + + max_count = unique_counts.max() + max_pairs = unique_counts[unique_counts == max_count] + + for ( + interaction_type, + residue_name, + residue_number, + ) in max_pairs.index.values: + mrich.print(interaction_type, 'w/', residue_name, residue_number) + + # unclustered + unclustered = set(i for i in self.ids if i not in all_ids) + psets[None] = PoseSet( + Pose.objects.filter(pk__in=unclustered), name='Unclustered' + ) + + return psets + + ### PROPERTIES + + @property + def queryset(self) -> QuerySet[Pose]: + """Returns the ids of poses in this set""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def names(self) -> list[str]: + """Returns the aliases of poses in this set""" + return self._queryset.values_list('pose_alias', flat=True) + + @property + def aliases(self) -> list[str]: + """Returns the aliases of child poses""" + return self._queryset.values_list('pose_alias', flat=True) + + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of child poses""" + return self._queryset.values_list('pose_inchikey', flat=True) + + @property + def id_name_dict(self) -> dict: + """Return a dictionary mapping pose ID's to their name""" + return {p.pk: p.pose_alias for p in Pose.objects.all()} + + @property + def smiles(self) -> list[str]: + """Returns the smiles of poses in this set""" + return self._queryset.values_list('pose_smiles', flat=True) + + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this pose set""" + return self._queryset.values_list('tags__pose_tag_name', flat=True).distinct() + + @property + def num_fingerprinted(self) -> int: + """Count the number of fingerprinted poses""" + # that's one field suspect not in use + return self._queryset.filter(pose_fingerprint=1).count() + + # seems unused and causes circular dependency + # @property + # def compounds(self) -> 'CompoundSet': + # """Get the compounds associated to this set of poses""" + # from .cset import CompoundSet + + # ids = self.db.select_where( + # table='pose', + # query='DISTINCT pose_compound', + # key=f'pose_id in {self.str_ids}', + # multiple=True, + # ) + # ids = [v for (v,) in ids] + # return CompoundSet(self.db, ids) + + @property + def mols(self) -> list[Chem.rdchem.Mol]: + """Get the rdkit Molecules contained in this set""" + return self._queryset.values_list('pose_mol', flat=True) + + @property + def num_compounds(self) -> int: + """Count the compounds associated to this set of poses""" + return self._queryset.values('compound').distinct().count() + + @property + def df(self) -> pd.DataFrame: + """Get a DataFrame of the poses in this set""" + return self.get_df(mol=True) + + @property + def references(self) -> 'PoseSet': + """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" + # TODO: call through proper factory method + return self.get_by_references(self) + + @property + def reference_ids(self) -> set[int]: + """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" + return self.get_by_references(self).values_list('pk', flat=True) + + @property + def inspiration_sets(self) -> list[set[int]]: + """Return a list of unique sets of inspiration :class:`.Pose` IDs""" + + pairs = Inspiration.objects.filter(derivative_pose__in=self._queryset) + data = {} + for p in pairs: + if p.derivative_pose not in data: + data[p.derivative_pose] = set() + data[p.derivative_pose].add(p.original_pose) + + data = {k: tuple(sorted(list(v))) for k, v in data.items()} + + unique = set(data.values()) + + return unique + + @property + def num_inspiration_sets(self) -> int: + """Return the number of unique sets of inspirations""" + return len(self.inspiration_sets) + + @property + def num_inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + # fmt: off + return Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'original_pose', + ).distinct().count() + # fmt: on + + @property + def inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + return self.get_by_inspirations(self._queryset) + + # @property + # def str_ids(self) -> str: + # """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" + # return str(tuple(self.ids)).replace(',)', ')') + + @property + def targets(self) -> QuerySet[Target]: + """Returns the :class:`.Target` objects of poses in this set""" + return Target.objects.filter(pk__in=self._queryset.values('target')) + + @property + def target_names(self) -> list[str]: + """Returns the :class:`.Target` objects of poses in this set""" + return self.targets.values_list('target_name', flat=True) + + @property + def target_ids(self) -> list[int]: + """Returns the :class:`.Target` objects ID's of poses in this set""" + return self.targets.values_list('id', flat=True) + + @property + def best_placed_pose(self) -> Pose: + """Returns the pose with the best distance_score in this subset""" + return self._queryset.get(pk=self.best_placed_pose_id) + + @property + def best_placed_pose_id(self) -> int: + """Get the id of the pose with the best distance_score in this subset""" + + # if len(self) == 1: + # return self.ids[0] + + # query = 'pose_id, MIN(pose_distance_score)' + # query = self.db.select_where( + # table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False + # ) + # return query[0] + + # TODO: scoring not implemented yet + return self.queryset.first().pk + + @property + def interactions(self) -> 'InteractionSet': + """Get a :class:`.InteractionSet` for this :class:`.Pose`""" + if self._interactions is None: + self._interactions = InteractionSet.from_pose(self) + return self._interactions + + @property + def pose_id_metadata_dict(self) -> dict[int, dict]: + """Get a dictionary mapping pose_ids to metadata dicts""" + if self._metadata_dict is None: + metadata = {} + for p in self._queryset: + metadata[p.pk] = p.pose_metadata + self._metadata_dict = metadata + return self._metadata_dict + + @property + def fraction_fingerprinted(self) -> float: + """Return the fraction of fingerprinted poses in this set""" + return self.num_fingerprinted / len(self) + + @property + def num_subsites(self) -> int: + """Count the number of subsites that poses in this set come into contact with""" + return Subsite.objects.filter(pose__in=self._queryset).distinct().count() + + @property + def subsite_balance(self) -> float: + """Measure of how evenly subsite counts are distributed across poses in this set""" + # TODO: subsites not implemented yet + # from numpy import std + + # sql = f""" + # SELECT COUNT(DISTINCT subsite_tag_ref) + # FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag + # WHERE subsite_tag_pose IN {self.str_ids} + # GROUP BY subsite_tag_pose + # """ + + # counts = self.db.execute(sql).fetchall() + + # counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] + + # return -std(counts) + return 4 + + @property + def subsite_ids(self) -> set[int]: + """Return a list of subsite id's of member poses""" + return Subsite.objects.filter( + pk__in=SubsiteTag.objects.filter( + pose__in=self._queryset, + ).values(subsite), + ).values_list('pk', flat=True) + + @property + def avg_energy_score(self) -> float: + """Average energy score of poses in this set""" + # TODO: scores not implemented + # from numpy import mean + + # sql = f""" + # SELECT pose_energy_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ + + # scores = self.db.execute(sql).fetchall() + # return mean([s for (s,) in scores if s is not None]) + return 4 + + @property + def avg_distance_score(self) -> float: + """Average distance score of poses in this set""" + # TODO: scores not implemented yet + # from numpy import mean + + # sql = f""" + # SELECT pose_distance_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ + + # scores = self.db.execute(sql).fetchall() + + # return mean([s for (s,) in scores if s is not None]) + return 4 + + @property + def derivatives(self) -> 'PoseSet': + """Get the :class:`.PoseSet` of derivatives""" + return PoseSet( + Pose.objects.filter( + pk__in=Inspiration.objects.filter( + original_pose__in=self._queryset, + ).values( + 'derivative_pose', + ), + ), + ) + + @property + def reference(self): + """Bulk set the references for poses in this set""" + raise NotImplementedError( + 'This attribute only allows setting, ``PoseSet.reference = ...``' + ) + + @reference.setter + def reference(self, r) -> None: + """Bulk set the references for poses in this set""" + self._queryset.update(pose_reference=r) + + ### PRIVATE + + def _delete(self, *, force: bool = False) -> None: + """Delete poses in this set""" + + if not force: + mrich.warning('Deleting Poses is risky! Set force=True to continue') + return + + try: + with transaction.atomic(): + Inspiration.objects.filter(original_pose__in=self._queryset).delete() + Inspiration.objects.filter(derivative_pose__in=self._queryset).delete() + SubsiteTag.objects.filter(pose__in=self._queryset).delete() + Interaction.objects.filter(pose__in=self._queryset).delete() + self._queryset.delete() + except IntegrityError as exc: + mrich.error(exc) diff --git a/src/designdb/sets/reaction.py b/src/designdb/sets/reaction.py new file mode 100644 index 0000000..8756d31 --- /dev/null +++ b/src/designdb/sets/reaction.py @@ -0,0 +1,362 @@ +"""Classes for working with sets of :class:`.Reaction` objects""" + +import mcol +import mrich +import pandas as pd +from django.db.models import Q +from hippo.recipe import Recipe +from IPython.display import display +from ipywidgets import BoundedIntText, Checkbox, GridBox, Layout, VBox, interactive_output + +from designdb.models import Compound, Reactant, Reaction +from designdb.sets.compound import CompoundSet + + +class ReactionSet: + """Object representing a subset of the 'reaction' table in the :class:`.Database`. + + .. attention:: + + :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.Reaction` objects in the set: + + :: + + rset = animal.reactions[:100] + + for reaction in rset: + ... + + Check membership + ================ + + To determine if a :class:`.Reaction` is present in the set: + + :: + + is_member = reaction in cset + + Selecting compounds in the set + ============================== + + The :class:`.ReactionSet` can be indexed like standard Python lists by their indices + + :: + + rset = animal.reactions[1:100] + + # indexing individual compounds + reaction = rset[0] # get the first reaction + reaction = rset[1] # get the second reaction + reaction = rset[-1] # get the last reaction + + # getting a subset of compounds using a slice + rset2 = rset[13:18] # using a slice + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """ReactionSet initialisation""" + + if queryset: + if isinstance(queryset, list): + self._queryset = Reaction.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = Reaction.objects.none() + + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.name: + s = f'{self.name}: ' + else: + s = '' + + s += f'{{R × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __len__(self) -> int: + """Number of member :class:`.Reaction` objects""" + return self._queryset.count() + + def __iter__(self): + """Iterate through member :class:`.Reaction` objects""" + return iter(self._queryset) + + def __getitem__(self, key) -> 'Reaction | ReactionSet': + """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" + + match key: + case int(): + try: + # reaction = Reaction.objects.get(pk=key) + reaction = self._queryset[key] + except Reaction.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise Reaction.DoesNotExist from exc + + return reaction + + case slice(): + return ReactionSet(Reaction.objects.filter(pk__in=key)) + + case _: + mrich.error( + f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' + ) + + return None + + def __add__(self, other: 'ReactionSet') -> 'ReactionSet': + """Add a :class:`.ReactionSet` to this one""" + if other: + return ReactionSet( + Reaction.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + def __sub__( + self, + other: 'ReactionSet', + ) -> 'ReactionSet': + """Substract a :class:`.ReactionSet` from this set""" + match other: + case ReactionSet(): + return ReactionSet( + Reaction.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + + ### METHODS + + def add(self, r: Reaction) -> None: + """Add a :class:`.Reaction` to this set + + :param r: :class:`.Reaction` to be added + + """ + assert isinstance(r, Reaction) + self._queryset = Reaction.objects.filter( + pk__in=list(self._queryset.values_list('pk', flat=True)) + [r.pk], + ) + + def interactive(self): + """Creates a ipywidget to interactively navigate this PoseSet.""" + + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Rs (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='Draw', value=True) + e = Checkbox(description='Check chemistry', value=False) + f = Checkbox(description='Reactant Quotes', value=False) + + ui1 = GridBox( + [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') + ) + ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) + ui = VBox([a, ui1, ui2]) + + def widget( + i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False + ): + """ + + :param i: + :param name: (Default value = True) + :param summary: (Default value = True) + :param draw: (Default value = True) + :param check_chemistry: (Default value = True) + :param reactants: (Default value = False) + + """ + reaction = self[i] + if name: + print(repr(reaction)) + if summary: + reaction.summary(draw=False) + if draw: + reaction.draw() + if check_chemistry: + reaction.check_chemistry(debug=True) + if reactants: + for comp in reaction.reactants: + # if summary: + # comp.summary(draw=False) + # elif name: + print(repr(comp)) + + quotes = comp.get_quotes(df=True) + display(quotes) + + # break + + # if draw: + # comp.draw() + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'check_chemistry': e, + 'reactants': f, + }, + ) + + display(ui, out) + + def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: + """Construct a pandas.DataFrame of this ReactionSet + + :param smiles: Include smiles column (Default value = True) + :param mols: Include `rdkit.Chem.Mol` column (Default value = True) + :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: + + """ + + mrich.debug('Using slower Reaction.dict rather than direct SQL query...') + + data = [] + for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): + data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) + + return pd.DataFrame(data) + + def copy(self) -> 'ReactionSet': + """Return a copy of this set""" + return ReactionSet(self._queryset.all(), sort=False, name=self.name) + + def get_recipes( + self, amounts: float | list[float] = 1.0, **kwargs + ) -> Recipe | list[Recipe]: + """Get the :class:`.Recipe` object(s) from this set of recipes + + :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) + :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: + + """ + # avoiding circular imports + from designdb.recipe import Recipe + + return Recipe.from_reactions(reactions=self, amounts=1, **kwargs) + + def summary(self) -> None: + """Print a summary of the Reactions""" + + mrich.header(self) + for reaction in self: + print(repr(reaction)) + + ### PROPERTIES + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def indices(self) -> list[int]: + """Returns the ids of reactions in this set""" + return self._queryset.values_list('pk', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of reactions in this set""" + return self._indices + + @property + def types(self) -> list[str]: + """Returns the types of reactions in this set""" + return self._queryset.values('reaction_type').distinct() + + @property + def num_types(self) -> int: + """Returns the number of reaction types in this set""" + return self._queryset.values('reaction_type').distinct().count() + + @property + def products(self) -> CompoundSet: + """Get all product compounds that can be synthesised with these reactions (no intermediates)""" + + qs = Compound.objects.filter( + pk__in=self._queryset.values('product_compound'), + ).exclude( + pk__in=self.intermediates.queryset.values('pk'), + ) + cset = CompoundSet(qs) + if self.name: + cset._name = f'products of {self}' + return cset + + @property + def intermediates(self) -> CompoundSet: + """Get all intermediate compounds that can be synthesised with these reactions""" + + # NB! not 100% sure about this queryset + qs = Compound.objects.filter( + Q( + pk__in=Reactant.objects.values('compound'), + ) + & Q(pk__in=self._queryset.values('product_compound')), + ) + cset = CompoundSet(qs) + + if self.name: + cset._name = f'intermediates of {self}' + return cset + + @property + def reactants(self) -> 'CompoundSet': + """Get all reactant compounds that are used by these reactions""" + + qs = Reactant.objects.filter( + reaction__in=self._queryset, + ).values('compound') + cset = CompoundSet(qs) + if self.name: + cset._name = f'reactants of {self}' + return cset + + @property + def get_dict(self) -> dict[str]: + """Serializable dictionary""" + return dict(indices=self.indices) diff --git a/src/designdb/sets/route.py b/src/designdb/sets/route.py new file mode 100644 index 0000000..e1fa364 --- /dev/null +++ b/src/designdb/sets/route.py @@ -0,0 +1,427 @@ +import json + +import mcol +import mrich + +from designdb.models import Component, Route +from designdb.sets.compound import CompoundSet + + +class RouteSet: + """A set of Route objects""" + + def __init__(self, routes: 'list[Route]') -> None: + """RouteSet initialisation""" + + data = {} + for route in routes: + # assert isinstance(route, Route) + data[route.id] = route + + self._data = data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + ### FACTORIES + + @classmethod + def from_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of :class:`.Route` IDs + + :param db: database to link + :param ids: :class:`.Route` database IDs + :param progress: show progress bar + """ + + # this gets stuck + # if progress: + # ids = mrich.track(ids, prefix='Getting routes') + + # avoiding circular reference + # avoiding name conflict + from designdb.route import RouteObj + + routes = [RouteObj.get_route(id=r) for r in ids] + + # self = cls.__new__(cls) + return RouteSet(routes) + + @classmethod + def from_product_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of product :class:`.Compound` IDs + + :param db: database to link + :param ids: :class:`.Compound` database IDs + """ + + # str_ids = str(tuple(ids)).replace(',)', ')') + + # records = db.select_where( + # table='route', + # query='route_id', + # key=f'route_product IN {str_ids}', + # multiple=True, + # ) + records = Route.objects.filter( + product_compound__pk__in=ids, + ) + + # route_ids = [i for (i,) in records] + + return cls.from_ids(records.values_list('id', flat=True), progress=progress) + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': + """Load a serialised routeset from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + self = cls.__new__(cls) + + if data is None: + data = json.load(open(path)) + + new_data = {} + for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): + route_id = d['id'] + new_data[route_id] = Route.from_json(db=db, path=None, data=d) + + self._data = new_data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + return self + + ### PROPERTIES + + @property + def data(self) -> 'dict[int, Route]': + """Get internal data dictionary""" + return self._data + + @property + def db(self): + """Get associated database""" + return self._db + + @property + def routes(self) -> 'list[Route]': + """Get route objects""" + return self.data.values() + + @property + def product_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the products""" + return Route.objects.values_list('product_compound__id', flat=True).distinct() + + @property + def reactant_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the reactants""" + + return Component.objects.filter( + route__in=self.ids, + component_type=2, + ).values_list('component_ref', flat=True) + + @property + def products(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route products""" + return CompoundSet(self.product_ids) + + @property + def reactants(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route reactants""" + return CompoundSet(self.reactant_ids) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Route` ID's""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def ids(self) -> list[int]: + """Return the :class:`.Route` IDs""" + return self.data.keys() + + @property + def cluster_map(self) -> dict[tuple, set]: + """Create a dictionary grouping routes by their scaffold/base cluster. + + :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. + """ + + if self._cluster_map is None: + # get route mapping + pairs = self.db.select_where( + query='route_product, route_id', + key=f'route_id IN {self.str_ids}', + table='route', + multiple=True, + ) + + route_map = {route_product: route_id for route_product, route_id in pairs} + + # group compounds by cluster + compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) + + # create the map + self._cluster_map = {} + for cluster, compounds in compound_clusters.items(): + self._cluster_map[cluster] = [] + for compound in compounds: + route_id = route_map.get(compound, None) + if not route_id: + continue + self._cluster_map[cluster].append(route_id) + + if not self._cluster_map[cluster]: + del self._cluster_map[cluster] + + return self._cluster_map + + ### METHODS + + def copy(self) -> 'RouteSet': + """Copy this RouteSet""" + return RouteSet(self.db, self.data.values()) + + def set_db_pointers(self, db: 'Database') -> None: + """ + + :param db: + + """ + self._db = db + for route in self.data.values(): + route._db = db + + # def clear_db_pointers(self): + # """ """ + # self._db = None + # for route in self.data.values(): + # route._db = None + + def get_dict(self): + """Get serialisable dictionary""" + + data = dict(db=str(self.db), routes={}) + + # populate with routes + for route_id, route in self.data.items(): + data['routes'][route_id] = route.get_dict() + + return data + + def prune_unavailable(self, suppliers: list[str]): + """Remove routes that don't have all reactants available from given suppliers""" + + suppliers_str = str(tuple(suppliers)).replace(',)', ')') + + sql = f""" + WITH possible_reactants AS ( + SELECT quote_compound, COUNT( + CASE + WHEN quote_supplier IN {suppliers_str} THEN 1 + END) AS [count_valid] + FROM {self.db.SQL_SCHEMA_PREFIX}quote + GROUP BY quote_compound + ), + + route_reactants AS ( + SELECT route_id, route_product, + COUNT( + CASE + WHEN count_valid = 0 THEN 1 + WHEN count_valid IS NULL THEN 1 + END) + AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id + LEFT JOIN possible_reactants ON quote_compound = component_ref + WHERE component_type = 2 + GROUP BY route_id + ) + + SELECT route_id FROM route_reactants + WHERE count_unavailable = 0 + AND route_id IN {self.str_ids} + """ + + route_ids = self.db.execute(sql).fetchall() + + route_ids = [i for (i,) in route_ids] + + mrich.var('#routes before pruning', len(self)) + mrich.var('#routes after pruning', len(route_ids)) + + return RouteSet.from_ids(self.db, route_ids) + + def pop_id(self) -> int: + """Pop the last route from the set and return it's id""" + route_id, route = self.data.popitem() + return route_id + + def pop(self) -> 'Route': + """Pop the last route from the set and return it's object""" + route_id, route = self.data.popitem() + return route + + def balanced_pop( + self, permitted_clusters: set[tuple] | None = None, debug: bool = False + ) -> 'Route': + """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" + + if not self._data: + mrich.print('RouteSet depleted') + return None + + if not self.cluster_map: + # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") + return self.pop() + + # store the permitted clusters (or all clusters) list as property + + if self._permitted_clusters is None: + if permitted_clusters: + permitted_clusters = set( + (cluster,) if isinstance(cluster, int) else cluster + for cluster in permitted_clusters + ) + + self._permitted_clusters = [] + for cluster in permitted_clusters: + if cluster not in self.cluster_map: + mrich.warning( + cluster, 'in permitted_clusters but not cluster_map' + ) + else: + self._permitted_clusters.append(cluster) + + else: + self._permitted_clusters = list(self.cluster_map.keys()) + + if self._current_cluster is None: + self._current_cluster = self._permitted_clusters[0] + + ### pop a Route + + if debug: + mrich.debug(f'Would pop Route from {self._current_cluster=}') + + cluster = self._current_cluster + + # pop the last route id from the given cluster + + try: + route_id = self.cluster_map[cluster].pop() + except IndexError: + mrich.print(self._permitted_clusters) + mrich.print(self.cluster_map) + raise + except AttributeError: + mrich.print(cluster) + mrich.print(self.cluster_map) + raise + except KeyError: + mrich.print('cluster', cluster) + mrich.print('self._permitted_clusters', self._permitted_clusters) + mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) + raise + + # clean up empty clusters + + if debug: + mrich.debug('Popped route', route_id) + + # get the Route object + + if route_id in self._data: + route = self._data[route_id] + del self._data[route_id] + else: + # if debug: + mrich.debug('Route not present') + return self.balanced_pop() + + ### increment cluster + + # def increment_cluster(cluster): + n = len(self._permitted_clusters) + if n > 1: + for i, cluster in enumerate(self._permitted_clusters): + if cluster == self._current_cluster: + if i == n - 1: + self._current_cluster = self._permitted_clusters[0] + else: + self._current_cluster = self._permitted_clusters[i + 1] + break + else: + raise IndexError('This should never be reached...') + + # increment_cluster() + + if not self.cluster_map[cluster]: + del self.cluster_map[cluster] + if not self.cluster_map: + mrich.debug('RouteSet.cluster_map depleted') + self._permitted_clusters = [ + c for c in self._permitted_clusters if c != cluster + ] + # if debug: + mrich.debug('Depleted cluster', cluster) + + if not self._permitted_clusters: + mrich.debug('Depleted all permitted clusters', cluster) + mrich.debug('Removing cluster restriction', cluster) + self._permitted_clusters = list(self.cluster_map.keys()) + self._current_cluster = None + + if debug: + mrich.debug('#Routes in set', len(self._data)) + + return route + + def shuffle(self): + """Randomly shuffle the routes in this set""" + import random + + items = list(self.data.items()) + random.shuffle(items) + self._data = dict(items) + + ### shuffle the cluster map as well + + for cluster, routes in self.cluster_map.items(): + random.shuffle(routes) + self.cluster_map[cluster] = routes + + ### DUNDERS + + def __len__(self) -> int: + """Number of routes in this set""" + return len(self.data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Route × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate over routes in this set""" + return iter(self.data.values()) + + def __getitem__(self, key): + """Get a specific route in this set""" + return list(self.data.values())[key] diff --git a/src/designdb/utils.py b/src/designdb/utils.py new file mode 100644 index 0000000..592b574 --- /dev/null +++ b/src/designdb/utils.py @@ -0,0 +1,341 @@ +"""Generic tools for use in the HIPPO package""" + +import ast +import json +import re +from datetime import datetime +from string import ascii_uppercase + +import mcol +import molparse as mp +import mrich +import numpy as np +from django.db.models import Aggregate, OuterRef, Subquery +from molparse.rdkit import mol_from_smiles +from rdkit import Chem +from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RemoveHs +from rdkit.Chem.inchi import MolToInchiKey + +from .models import Pose, ScoreValue + + +def strip_sql(sql) -> str: + """Reduce unecessary whitespace in SQL""" + return re.sub(r'\s+', ' ', sql).strip() + + +def df_row_to_dict(df_row) -> dict: + """Convert a dataframe row to a dictionary + + :param df_row: pandas dataframe row / series + """ + + assert len(df_row) == 1, f'{len(df_row)=}' + + data = {} + + for col in df_row.columns: + if col == 'Unnamed: 0': + continue + + value = df_row[col].values[0] + + if not isinstance(value, str) and np.isnan(value): + value = None + + data[col] = value + + return data + + +def remove_other_ligands(sys: mp.System, residue_number: int, chain: str) -> mp.System: + """Remove ligands other than the specified one""" + + ligand_residues = [r.number for r in sys['rLIG'] if r.number != residue_number] + + # if ligand_residues: + for c in sys.chains: + if c.name != chain: + c.remove_residues(names=['LIG'], verbosity=0) + elif ligand_residues: + c.remove_residues(numbers=ligand_residues, verbosity=0) + + # print([r.name_number_str for r in sys['rLIG']]) + + assert len([r.name_number_str for r in sys['rLIG']]) == 1, ( + f'{sys.name} {[r.name_number_str for r in sys["rLIG"]]}' + ) + + return sys + + +def inchikey_from_smiles(smiles: str) -> str: + """InChI-Key from smiles string""" + mol = mol_from_smiles(smiles) + return MolToInchiKey(mol) + + +def flat_inchikey(smiles: str) -> str: + """Stereochemistry-flattened InChI-Key from smiles string""" + smiles = sanitise_smiles(smiles) + return inchikey_from_smiles(smiles) + + +def remove_isotopes_from_smiles(smiles: str) -> str: + """Remove isotopes from smiles string""" + + mol = MolFromSmiles(smiles) + + atom_data = [(atom, atom.GetIsotope()) for atom in mol.GetAtoms()] + + for atom, isotope in atom_data: + if isotope: + atom.SetIsotope(0) + + return MolToSmiles(mol) + + +def smiles_has_isotope(smiles: str, regex=True) -> bool: + """Does provided smiles string contain isotopes?""" + if regex: + return re.search(r'([\[][0-9]+[A-Z]+\])', smiles) + else: + mol = MolFromSmiles(smiles) + return any(atom.GetIsotope() for atom in mol.GetAtoms()) + + +REPLACE = { + '[STB]': '[S]', +} + + +def sanitise_smiles( + s: str, + verbosity: bool = False, + sanitisation_failed: str = 'error', + radical: str = 'error', +) -> str: + """Sanitise smiles by: + + - Taking largest fragment + - Flattening stereochemistry + - Removing isotopes + - RDKit round-trip + - Treating radicals + + :param s: input smiles string + :param verbosity: print smiles changes (Default value = False) + :param sanitisation_failed: behvaiour when sanitisation fails, + choose from ["error", "warning", "quiet"] (Default value = 'error') + :param radical: behvaiour when radicals occur, choose from + ["error", "warning", "remove"] (Default value = 'error') + :returns: SMILES string + """ + + assert isinstance(s, str), f'non-string smiles={s}' + + orig_smiles = s + + # if multiple molecules take the largest + if '.' in s: + s = sorted(s.split('.'), key=lambda x: len(x))[-1] + + # flatten the smiles + # stereo_smiles = s + smiles = s.replace('@', '') + smiles = smiles.replace('/', '') + smiles = smiles.replace('\\', '') + + # remove isotopic stuff + if smiles_has_isotope(smiles): + mrich.warning(f'Isotope(s) in SMILES: {smiles}') + smiles = remove_isotopes_from_smiles(smiles) + + # replace specific sequences + for key in REPLACE: + if key in smiles: + smiles = smiles.replace(key, REPLACE[key]) + + # canonicalise + mol = MolFromSmiles(smiles) + if mol: + smiles = MolToSmiles(mol, True) + elif sanitisation_failed == 'error': + raise SanitisationError + elif sanitisation_failed == 'warning': + mrich.warning(f'sanitisation failed for {smiles=}') + + # check radicals + reconstruct = False + for atom in mol.GetAtoms(): + if not atom.GetNumRadicalElectrons(): + continue + + if radical == 'warning': + mrich.warning(f'Radical atom in {smiles=}') + elif radical == 'error': + raise SanitisationError(f'Radical atom in {smiles=}') + elif radical == 'remove': + mrich.warning('Removed radical atom') + atom.SetNumRadicalElectrons(0) + smiles = MolToSmiles(mol, True) + reconstruct = True + # atom.SetFormalCharge(0) + else: + raise NotImplementedError(f'Unknown option {radical=}') + + if reconstruct: + mol = AddHs(mol) + mol = RemoveHs(mol, implicitOnly=True) + smiles = MolToSmiles(mol, True) + mrich.warning(f'New {smiles=}') + + if verbosity: + if smiles != orig_smiles: + annotated_smiles_str = orig_smiles.replace( + '.', f'{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}' + ) + annotated_smiles_str = annotated_smiles_str.replace( + '@', f'{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}' + ) + + mrich.warning(f'SMILES was changed: {annotated_smiles_str} --> {smiles}') + + return smiles + + +def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: + """Sanitise by RDKit round-trip""" + from rdkit.Chem import MolFromMolBlock, MolToMolBlock + + return MolFromMolBlock(MolToMolBlock(m)) + + +def pose_gap(a: Pose, b: Pose) -> float: + """Calculate minimum distance between two :class:`.Pose` objects""" + + from molparse.rdkit import mol_to_AtomGroup + from numpy.linalg import norm + + min_dist = None + + a = mol_to_AtomGroup(a.mol) + b = mol_to_AtomGroup(b.mol) + + for atom1 in a.atoms: + for atom2 in b.atoms: + dist = norm(atom1.np_pos - atom2.np_pos) + if min_dist is None or dist < min_dist: + min_dist = dist + + return min_dist + + +ALPHANUMERIC_CHARS = '0123456789' + ascii_uppercase + + +def number_to_base(n: int, b: int) -> int: + """Convert an integer `n` into base `b` representation""" + if n == 0: + return [0] + digits = [] + while n: + digits.append(int(n % b)) + n //= b + return digits[::-1] + + +def dt_hash() -> str: + """Create 7 alphanumeric-character hash based on current timestamp""" + dt = datetime.now() + x = int( + dt.month * 36000 * 24 * 365.25 + + dt.day * 36000 * 24 + + dt.hour * 36000 + + dt.minute * 600 + + dt.second * 10 + + dt.microsecond / 10000 + ) + timehash = ''.join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) + return f'{timehash:>07}' + + +class SanitisationError(Exception): + """Something went wrong in Molecule/SMILES sanitisation""" + + ... + + +def make_warn_once_per_key(): + """Warn once per field type in sdf file. + + When attribute is defined but broken in all molecules, no need to + complain every time. + + Instatiate at the beginning of the loading process and pass where + needed. + + """ + warned = set() + + def warn(key, msg): + if key not in warned: + print(f'WARNING: {msg}') + warned.add(key) + + return warn + + +class ScoreSubquery(Subquery): + def __init__(self, scoring_method): + query = ScoreValue.objects.filter( + pose=OuterRef('pk'), + compound=OuterRef('compound'), + scoring_method__method_name=scoring_method, + ).values('score')[:1] + super().__init__(query) + + +# Don't understand the distinct here. Shouldn't have to use it. +# Workaround for missing ArrayAgg in sqlite, can get rid of when +# moving to postgres +class JsonGroupArray(Aggregate): + function = 'json_group_array' + # template = "%(function)s(%(expressions)s)" + template = '%(function)s(DISTINCT %(expressions)s)' + + +def normalize_string_list(x): + """Convert string representation of list to proper list""" + if not x: + return [] + if isinstance(x, list): + # return list(set(x)) + return x + if isinstance(x, str): + # try JSON first + try: + parsed = json.loads(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # fallback for python-style strings + try: + parsed = ast.literal_eval(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # ultimate fallback, comma-separated string + try: + splits = x.split(',') + if isinstance(splits, list): + return splits + except Exception: + pass + return [] diff --git a/src/designdb/utils_frag.py b/src/designdb/utils_frag.py new file mode 100644 index 0000000..de75c23 --- /dev/null +++ b/src/designdb/utils_frag.py @@ -0,0 +1,197 @@ +"""Functions for interfacing with Fragalysis data""" + +from dataclasses import dataclass, fields + +import mrich +from rdkit import Chem + +GENERATED_TAG_COLS = [ + 'ConformerSites alias', + 'CanonSites alias', + 'CrystalformSites alias', + 'Quatassemblies alias', + 'Crystalforms alias', + 'ConformerSites upload name', + 'CanonSites upload name', + 'CrystalformSites upload name', + 'Quatassemblies upload name', + 'Crystalforms upload name', + 'ConformerSites short tag', + 'CanonSites short tag', + 'CrystalformSites short tag', + 'Quatassemblies short tag', + 'Crystalforms short tag', + 'Centroid res', + 'Experiment code', + 'Pose', +] + + +META_IGNORE_COLS = [ + 'Code', + 'Long code', + 'Compound code', + 'Smiles', + 'Downloaded', + 'Main status', + 'GOOD count', + 'MEDIOCRE count', + 'BAD count', + 'RefinementResolution', +] + + +def generate_header( + pose, + method, + ref_url, + submitter_name, + submitter_email, + submitter_institution, + generation_date: str | None = None, + extras=None, + metadata: bool = True, +) -> Chem.rdchem.Mol: + """Generate a header molecule for Fragalysis RHS upload""" + + extras = extras or {} + + from datetime import date + + from molparse.rdkit import mol_from_smiles + from rdkit.Chem.AllChem import EmbedMolecule + + header = mol_from_smiles(pose.compound.compound_smiles) + + header.SetProp('_Name', 'ver_1.2') + EmbedMolecule(header) + + generation_date = str(generation_date or date.today()) + + header.SetProp('ref_url', ref_url) + header.SetProp('submitter_name', submitter_name) + header.SetProp('submitter_email', submitter_email) + header.SetProp('submitter_institution', submitter_institution) + header.SetProp('generation_date', generation_date) + header.SetProp('method', method) + + if metadata: + for k, _ in pose.pose_metadata.items(): + header.SetProp(k, str(k)) + + for k, v in extras.items(): + header.SetProp(k, str(v)) + + return header + + +@dataclass +class LongcodeRecord: + target: str + crystal: str + chain: str + residue_number: int + version: int + + +def parse_observation_longcode(longcode: str) -> LongcodeRecord: + """Parse a Fragalysis longcode and try to extract the following information: + + - Target name (target) + - Crystal/dataset code (crystal) + - Chain letter (chain) + - Residue number (residue_number) + - Version number (version) + + :returns: dictionary of the above keys in parentheses + """ + + import re + + match = re.search( + r'(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG', longcode + ) + + if not match: + raise UnsupportedFragalysisLongcodeError(longcode) + + cryst_str, lig_str, _, _ = match.groups() + + chain, residue_number, version = lig_str.split('_') + + residue_number = int(residue_number) + version = int(version) + + if match := re.search(r'(.*)-(\w[0-9]{4})', cryst_str): + target_name = match.group(0) + crystal = match.group(1) + + else: + target_name = '' + crystal = cryst_str + + return LongcodeRecord( + target=target_name, + crystal=crystal, + chain=chain, + residue_number=residue_number, + version=version, + ) + + +def find_observation_longcode_matches( + query: str, codes: list[str], debug: bool = False, allow_version_none: bool = False +) -> list[str]: + """find_observation_longcode_matches""" + + dq = parse_observation_longcode(query) + + if debug: + mrich.var('allow_version_none', allow_version_none) + mrich.var('dq', str(dq)) + + matches = [] + + for code in codes: + if code == query: + if debug: + mrich.debug('exact match') + matches.append(code) + continue + + dc = parse_observation_longcode(code) + + for key in fields(dq): + if ( + allow_version_none + and key.name == 'version' + and (getattr(dc, key.name) is None or getattr(dq, key.name) is None) + ): + continue + + if getattr(dc, key.name) != getattr(dq, key.name): + break + else: + if debug: + mrich.debug(f'{query} matches {code}') + matches.append(code) + + if debug: + mrich.var('#matches', len(matches)) + + if len(matches) < 1 and not allow_version_none: + return find_observation_longcode_matches(query, codes, allow_version_none=True) + + return matches + + +STACK_URLS = { + 'production': 'https://fragalysis.diamond.ac.uk', + 'staging': 'https://fragalysis.xchem.diamond.ac.uk', +} + + +class UnsupportedFragalysisLongcodeError(NotImplementedError): + """Provided Fragalysis observation long code syntax is not supported""" + + ... diff --git a/src/designdb/utils_xca.py b/src/designdb/utils_xca.py new file mode 100644 index 0000000..4bb8a56 --- /dev/null +++ b/src/designdb/utils_xca.py @@ -0,0 +1,39 @@ +"""Functions for interfacing with XChemAlign data""" + +import re + + +def parse_observation_longcode(longcode: str) -> dict[str]: + """Parse an XChemAlign longcode and try to extract the following information: + + - Target name (target) + - Crystal/dataset code (crystal) + - Chain letter (chain) + - Residue number (residue_number) + - Version number (version) + + :returns: dictionary of the above keys in parentheses + """ + + match = re.search( + r'^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$', longcode + ) + + if not match: + raise UnsupportedXCALongcodeError(longcode) + + target_name, crystal, chain, residue_number, version = match.groups() + + return dict( + target=target_name, + crystal=crystal, + chain=chain, + residue_number=int(residue_number), + version=int(version), + ) + + +class UnsupportedXCALongcodeError(NotImplementedError): + """XChemAlign longcode has unsupported syntax""" + + ... diff --git a/src/xchem_hippo/settings.py b/src/xchem_hippo/settings.py deleted file mode 100644 index c0a9282..0000000 --- a/src/xchem_hippo/settings.py +++ /dev/null @@ -1,123 +0,0 @@ -""" -Django settings for xchem_hippo project. - -Generated by 'django-admin startproject' using Django 6.0.3. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/6.0/ref/settings/ -""" - -from pathlib import Path - -from environs import Env - -env = Env() -env.read_env() - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env('SECRET_KEY') - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool('DEBUG', default=False) - -ALLOWED_HOSTS = [] - - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'designdb.apps.DesigndbConfig', -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'xchem_hippo.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'xchem_hippo.wsgi.application' - - -# Database -# https://docs.djangoproject.com/en/6.0/ref/settings/#databases - - -# database is configured dynamically. when ready for static setup, -# move postgres settings from django_setup.py -DATABASES = [] - - -# Password validation -# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - - -# Internationalization -# https://docs.djangoproject.com/en/6.0/topics/i18n/ - -LANGUAGE_CODE = 'en-us' - -TIME_ZONE = 'UTC' - -USE_I18N = True - -USE_TZ = True - - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/6.0/howto/static-files/ - -STATIC_URL = 'static/' - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/uv.lock b/uv.lock index 46378a1..f08d2e1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,13 @@ version = 1 revision = 3 -requires-python = ">=3.10, <3.13" +requires-python = ">=3.10, <3.14" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -36,7 +39,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -54,49 +57,62 @@ wheels = [ [[package]] name = "apsw" -version = "3.52.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/c9/7435800e496f12e2b7b45525a87be1aa0cc66e4adaa634d970d034e09ca4/apsw-3.52.0.0.tar.gz", hash = "sha256:2244ba3a341f4278bb579c8a918ef926683c3569e4faa07608346ea1f61f35b4", size = 1230500, upload-time = "2026-03-09T18:31:25.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/17/058f41256b046e3bdfd6c50508b611b3bf0719c9612902990856e0613132/apsw-3.52.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ad48710e21095eca756bede03bb654fbe6bdca46172f07d456f297510cca5f7", size = 3654675, upload-time = "2026-03-09T18:28:17.158Z" }, - { url = "https://files.pythonhosted.org/packages/7f/32/e0354373ddcce7955774287739070192ebbc62980a950c13543b517a595e/apsw-3.52.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:97b5c1ecd0e81c504d65b98781291c7a1783a876dd04512d7de2d8bfe039525f", size = 3460901, upload-time = "2026-03-09T18:28:19.151Z" }, - { url = "https://files.pythonhosted.org/packages/fa/03/9f07bf9f90eb3ea0b9ce959e0508d32d4ea21fd82a2a8b18f109ad9253ac/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:91beeaf8cf58f52d94488322c57841246018cdcf9c35cf19d9c11de74456c5ec", size = 12250587, upload-time = "2026-03-09T18:28:21.557Z" }, - { url = "https://files.pythonhosted.org/packages/b5/34/b9b2daecea6627a9b1738cf543922780862a0b220a2b591628f238b33142/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d169caa3639f2e77cd61d1f18cce695d81f1560fbc26372d73eb5ef689e60869", size = 11552784, upload-time = "2026-03-09T18:28:24.507Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5f/6d16a02f2f0b52e507d37d97ac70aeaaafae12e26c86359935cd527441de/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:e440f9b11255a8ec9bb88ce7805f324e9605f87b6aaaf168f39571744f82641d", size = 12224819, upload-time = "2026-03-09T18:28:26.834Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0c/ca8b7f557e531b7c8afe0185e7e07d56c55aa83042f8d604ee3700d71c1f/apsw-3.52.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:e9c8238f0ca227dfa4b8498c0bcf67384ec780a81c9ba4064b12e9a4cfa4264d", size = 12393927, upload-time = "2026-03-09T18:28:29.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/a7/bdc6374e9c9b5e97eb61a79a2f3a7a6ff471083570569fe88e88292e59cd/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5301b3ddef13981cd251c0dd57af6f13b2147b1b4c1985615ff4259512b66705", size = 12458777, upload-time = "2026-03-09T18:28:32.398Z" }, - { url = "https://files.pythonhosted.org/packages/eb/67/af6d634c8dc36697cd3441d7c95697cf7e0d92db412f3840c32d99ed8472/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:55c0cd753fc20a4c5287ae19657b7f41750d177fd3d81cd8340c6b7747636db7", size = 12131406, upload-time = "2026-03-09T18:28:35.019Z" }, - { url = "https://files.pythonhosted.org/packages/55/39/8cd1fd89b90fe20d18304fa41edc8bb3d975256a17150e9fd3d28a20222f/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:144546b6cde6fc6c22187fb1c6f22c941f74fe91a365c36ada3eece7c2dfa01f", size = 12697413, upload-time = "2026-03-09T18:28:37.543Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bf/557bf98c69e1537c53547bf99f46c2edd767b5be29609e83744ac6ecd0e5/apsw-3.52.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:957436d0b8b371683d1fe55f241aa7b16d39d13ac7769f84e7210a9401a2226e", size = 12700466, upload-time = "2026-03-09T18:28:40.162Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5d/782b7f7f349ca31ae5f57fbbfd4cd8995a4f2ddb00575d0903eae99a56df/apsw-3.52.0.0-cp310-cp310-win32.whl", hash = "sha256:b22de74249725b820ceee24852eefa6fba08e9b99f5254c68ba3f0407fae021d", size = 3104358, upload-time = "2026-03-09T18:28:42.551Z" }, - { url = "https://files.pythonhosted.org/packages/f9/12/3535c53b967d9c88c717294b12d32c567dd20ec852829531fd671907813e/apsw-3.52.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:5273ae9f882101021097494daa00dd1a760d86cc88d47d9881ec88b80bfdaca9", size = 3528965, upload-time = "2026-03-09T18:28:44.583Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/63f916a6853e3ee6f241195cb4d01acb6717729b64c3810acd197f93c827/apsw-3.52.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:bcf323955572061446eb87f6382493dfb073d2b2b1bdcfc4c4d791b3bb0d1f80", size = 3088639, upload-time = "2026-03-09T18:28:46.504Z" }, - { url = "https://files.pythonhosted.org/packages/02/d0/3af38ba8bdaad1aae578825b367f52cf5b4415d6f6a5321c8de577c4533c/apsw-3.52.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f75bcfe25c2766cea56ac00b77a61753dcaeaf6a3b6b6a08596cd39a0cea9c82", size = 3659947, upload-time = "2026-03-09T18:28:48.104Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/4333dbd238f01c9e541fae0e73846585bc93f496fc89e0cb052817951d6e/apsw-3.52.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:10dd7128205613e43f42b850d64e7f1a8c396ddc746f40b856caa1b1e298c437", size = 3467435, upload-time = "2026-03-09T18:28:49.866Z" }, - { url = "https://files.pythonhosted.org/packages/42/e6/1ebff106af61ec06953d3cd621d8447cf3f5de80947dff14b2a13a9faa74/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:52d5504e838b3b14cb580c82c2f2dff97c2f967ff66b1c9b318f56bb80d283fa", size = 12464127, upload-time = "2026-03-09T18:28:51.745Z" }, - { url = "https://files.pythonhosted.org/packages/bc/a8/8c41532975de9cfba86b8d5919ea79ce5663e222d8f1b8f9e68e5d598f06/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bbc978ca3062fc1b9776227a17256182e3d9e4d9490d5435754b9f4d91035e87", size = 11751497, upload-time = "2026-03-09T18:28:54.281Z" }, - { url = "https://files.pythonhosted.org/packages/3a/83/61a2fa7bba9a4c621938ffbb4165cc6ae6312747181bfae46b8de858a991/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:4b0624502b4b2564bd787de6e584d2bb271d1bf4fb3f53f9e55eac713bbbc7c9", size = 12372950, upload-time = "2026-03-09T18:28:58.071Z" }, - { url = "https://files.pythonhosted.org/packages/60/f0/460162f8486231c3f253622f6c4567551059a08d8508e14930fb2530b861/apsw-3.52.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:bbb812364085073172bbf9d977b5e0a917c6292f03859374db5577429772f8b2", size = 12556201, upload-time = "2026-03-09T18:29:00.664Z" }, - { url = "https://files.pythonhosted.org/packages/18/03/2a96bed534b5620807bfb10df955de091fc090a0e17459ab60f25d8684b5/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:019c7bd7d140dff8e02ba07bb06541ddf31dce7465e889ea09f8579d9d7e09b9", size = 12657976, upload-time = "2026-03-09T18:29:03.146Z" }, - { url = "https://files.pythonhosted.org/packages/2f/06/046153fa2481113a9c5b0f61c25192269207ed7c7adffcf8edd6c168e85e/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa10d38e9e7732b439483f23da1ad5ee4721808d9b2fead574f4a670266ced20", size = 12324781, upload-time = "2026-03-09T18:29:05.655Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/a981360071398cede27a452209b44d663e242726f98535022f1c7c02dc96/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:44a9a277a4777f30546a525d82b956a4c4617484bdb6347bec621ee155dcf9af", size = 12847104, upload-time = "2026-03-09T18:29:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/ca/7d/4f501c820b3b8418f8cffee1332204a390207850737b6964f9916512b8e7/apsw-3.52.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c754b188ace178a163453f49a749069647b56a79873b608bca1dfdf1a680bdd", size = 12855448, upload-time = "2026-03-09T18:29:11.182Z" }, - { url = "https://files.pythonhosted.org/packages/e8/07/5afdb8025e4f88ee3c2998467fa8a7fac8cee4718ad88b6d9c0c9181fabe/apsw-3.52.0.0-cp311-cp311-win32.whl", hash = "sha256:824e502a34fcb5cbf5f23586a5474dc0cfdd35dd0070e1aa42e2e857e7c10aed", size = 3099025, upload-time = "2026-03-09T18:29:14.086Z" }, - { url = "https://files.pythonhosted.org/packages/28/68/e84a6e721f0252a72a8ef40058c3dfb9d25aa15e91ac40ad19db61806e63/apsw-3.52.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:db497d7b325695f04b1cabac2cfec6abdf72ca86b693bc09ec44a316f49bb375", size = 3528161, upload-time = "2026-03-09T18:29:16.465Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ac/996b71426d2442b5ee57363055e7d7408ccc00b407a506c5a3c912153095/apsw-3.52.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:9c72b0408047d09129d74cad4cf4be28d3d18e31aae955a3173b8b128ef21d95", size = 3088591, upload-time = "2026-03-09T18:29:18.067Z" }, - { url = "https://files.pythonhosted.org/packages/32/92/27fa38cf5f6892169a02ccce0f1c985e3bfaec555760d5b6e9675cc4176d/apsw-3.52.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5e3403d1cd586c0c4b20604d3d0111c70551fbc1f12a5a5f613971903be6f5f", size = 3658693, upload-time = "2026-03-09T18:29:19.921Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5a/8def063527c2f400e7559f5020d7ef6b58b11887f29043d7c12ff7e957ac/apsw-3.52.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6a7cdc27f717f2056964950d8a14a1ee36c4c1da59aebfb9e88c5a5223199dec", size = 3467021, upload-time = "2026-03-09T18:29:21.587Z" }, - { url = "https://files.pythonhosted.org/packages/02/64/b75e4e5eeca78ec8d646eac7ab30aca25dcb50c76acceb4e2ce8ea7df2ad/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:87fc1c98a4c884eff54e9cb34c2efad42ce0d797d127b2d10c09bd8874f4500b", size = 12451880, upload-time = "2026-03-09T18:29:23.763Z" }, - { url = "https://files.pythonhosted.org/packages/4d/bb/3aa093ba70eaf00cfd40658472485f92118b5ff76065f5e168aee3cf11cb/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d935a826afd089c4c11e8fd3b61aed1b78a78b5ace9fa3956097c0a2b0bbd8f6", size = 11737316, upload-time = "2026-03-09T18:29:26.172Z" }, - { url = "https://files.pythonhosted.org/packages/7d/7b/a95b135824ab779668ebf2936af23ec7bc3ed67894b5ace3d7eeb9b5afcd/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:4693db12d33ba1d3a2ed68c06b17421413d946d4385fcce3da455734c735343d", size = 12350147, upload-time = "2026-03-09T18:29:28.772Z" }, - { url = "https://files.pythonhosted.org/packages/55/64/5cf6154c7be30cb79d1ef839d07480c6ed4dc074dc9567d62aaca6bb5c14/apsw-3.52.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:8ae28f739c0dc3c067c5e979fce3656820f48a1e2a770a33eb0c3184910ec717", size = 12540380, upload-time = "2026-03-09T18:29:31.184Z" }, - { url = "https://files.pythonhosted.org/packages/0b/04/4d057e928f6b978b76970f3725e00b3c01c997190248845be11cfd2e7227/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a3213024b1e3555e1ebec779edd08edef57dad23f49c6e1d95774e47bc0045be", size = 12647874, upload-time = "2026-03-09T18:29:34.109Z" }, - { url = "https://files.pythonhosted.org/packages/c5/97/61254cee874af7a03babdf10f8669a94ee7c2e8e8acb2a62a60097a389e5/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9ceebe709c21bf4917654d7216637b9af0065b4b494eb15fc67e8b7d014890cc", size = 12329765, upload-time = "2026-03-09T18:29:36.801Z" }, - { url = "https://files.pythonhosted.org/packages/b8/02/b26f7aa597634c96c3a7d580fbf5b9722ffd966aa11bad7000f0775acac5/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1c19c5524eb473078371574e4745af2be5738292871e24225c90ad0783095730", size = 12820199, upload-time = "2026-03-09T18:29:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/9d/b9/74511e2a1ffd894533e56a3453d636d851bd8e92a83937ac0a84af624d9c/apsw-3.52.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:313e7670de146aa65e6de9a86fe571661da4bf49b1db20f7c3f0cd8bf8cdf115", size = 12843160, upload-time = "2026-03-09T18:29:42.948Z" }, - { url = "https://files.pythonhosted.org/packages/bb/bb/4fb99804fd64e03fdb919bf4a8dc505edbece2cd5b53a9be77d87e934b17/apsw-3.52.0.0-cp312-cp312-win32.whl", hash = "sha256:9519559366bb737bcba0795db4307da4931406b4b68829f27f7fbeb821142e5c", size = 3098702, upload-time = "2026-03-09T18:29:45.105Z" }, - { url = "https://files.pythonhosted.org/packages/8a/e0/6377ca5bd8b9b9aa4785fa4ebfa7bd3065ab518064ab425a0463cd40c5dd/apsw-3.52.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:63c4fc72f79e707a64d1cba06983e6548ddd806e210f804cdc7fdb62a0fb7b02", size = 3525558, upload-time = "2026-03-09T18:29:46.998Z" }, - { url = "https://files.pythonhosted.org/packages/c6/91/28ecad6169fdd268e46e4c803277300d155c1a984fcb8d0457a11f4f4bc1/apsw-3.52.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:277ac389768f1fc301c1566c9eeb2426b9116705339f552f14affa763e90b58f", size = 3088702, upload-time = "2026-03-09T18:29:48.49Z" }, +version = "3.51.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/87/ae61c54529fb44610962599ab3b909627928992e59c28775dff76bf04125/apsw-3.51.3.0.tar.gz", hash = "sha256:821966a66ed5fd539e863a8f60d9a53497e0a47ffdabde4ec7714fae9ed00261", size = 1231663, upload-time = "2026-03-14T16:05:10.236Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fa/3e0d2fabfd5e0529e039da183f5ef8cbad2fe1980b8da6c88cc785bde801/apsw-3.51.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ea214a63f811f769c1d54fd03a4be0a261d5b1bebf70820785bd0717b404d84c", size = 3729236, upload-time = "2026-03-14T16:02:32.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/09/607b745b189157a8b8e3f314d13f18a6091b1eb07476b41bf03a3378185c/apsw-3.51.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:01ff1d8553813d122326bade5e6402f11cb27afcb4db889f969584432e58e77a", size = 3496106, upload-time = "2026-03-14T16:02:34.93Z" }, + { url = "https://files.pythonhosted.org/packages/51/29/d7a2851c1df4671eccd589cc5eed592102d626a86568bffff98fecff55a0/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ea5058ed15d058d39b1309741365134d16de4ad617d718deb462da727cc2430a", size = 4229040, upload-time = "2026-03-14T16:02:36.616Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9d/75da82eb79cbc2e4b15231e432134d502e69fade6a46652256589aa1bcdd/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:f6c740c5b241cc66c5e99d280aa5f8696977a9941c8d42efcfe1b092fa08e03a", size = 4665332, upload-time = "2026-03-14T16:02:38.519Z" }, + { url = "https://files.pythonhosted.org/packages/91/06/588756292684968ba56d5b9ed859fa6432c693a77f35dc9d0ee8bd690a41/apsw-3.51.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:6bba622b3b524ab7de8a353b4081d382bca0ef3253d22064a6a6753caeaea651", size = 4336266, upload-time = "2026-03-14T16:02:40.201Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/5bcbabb0b244b51f32eb0ba0d439e463f1276c0c8185c82947eefe5ed02f/apsw-3.51.3.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:289777a11591a1882a21f49993d7a0ae2588055b6edbfbb5e1121d2fdd39d28d", size = 3775489, upload-time = "2026-03-14T16:02:42.079Z" }, + { url = "https://files.pythonhosted.org/packages/2c/03/6fa781378518b39f1b81d9eea4b5068a4e57d3ca888f94b509f8f62613b1/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74f6ceb71e651971af9b93c82a26969743fc69af0ed2a8b992b99148e5e4d554", size = 4185240, upload-time = "2026-03-14T16:02:43.806Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/0dbc17170989513d1802b693c75b83f41e7ffd9f636bcbd015242c5b856a/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9ab30dcf3f1c9bfe2f13e0fdbde895b90a2a22ad486343389c5084f857b4b83a", size = 3805687, upload-time = "2026-03-14T16:02:45.567Z" }, + { url = "https://files.pythonhosted.org/packages/af/f4/d628c29fb5a3741efdd7d7116ce3f7ef055f2048631149cb85a582638f4a/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e60c39abe3cb997e6cf1b7d875108b39583e575e1e247609a768849d50a39efb", size = 4632999, upload-time = "2026-03-14T16:02:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d9/a86383c9cf97163795b80a4e375fcec0093d056a9e44387a7d2f5ff76d80/apsw-3.51.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08c05a62e84b96ab742032fb912753b3c11d313ae111c439f323ba3daac019db", size = 4297505, upload-time = "2026-03-14T16:02:48.859Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/c7b3432dd5889787d574a9196040e7be31720d00f3e7cd6bb2931093126f/apsw-3.51.3.0-cp310-cp310-win32.whl", hash = "sha256:c30a8062434b4edff6ff0ecd36328e4eb0c7c28b837ca3bd683a85f8030fb79b", size = 3192449, upload-time = "2026-03-14T16:02:50.518Z" }, + { url = "https://files.pythonhosted.org/packages/85/cd/b2887b58b8c23f1a009eb6cd03fd6b6f58a456695b8f47ba4101bd625a03/apsw-3.51.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:2db6b9327fd5ab6a7d9e7691b03bef8ba57d201f0d65714b34c788a2d18595b1", size = 3629320, upload-time = "2026-03-14T16:02:52.087Z" }, + { url = "https://files.pythonhosted.org/packages/a7/32/06946cbf029b0736fb532c7ac80bea8bfa3a7735f81c4526c24911a0052d/apsw-3.51.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:bc835e3f123ebed299159381fb764276e59ceb4030cc495187a6aff52ad6e141", size = 3183785, upload-time = "2026-03-14T16:02:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/86/e5/16824fc8909d769391dbedf08294a0d09094cbdfdae4feaa8f3f1290c57a/apsw-3.51.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:78e0956c0c53c1fc46d73e0000a9a42c07942f728d162fe0b512dd5273f264d0", size = 3735649, upload-time = "2026-03-14T16:02:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/97/dc/eaa0fb33c4e5c80dc827ac2b8b145cab059b52f5f234fc4a6f2b181aaaca/apsw-3.51.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72a1df1df8f792a8218b248590b29551e09a2abf0192b84cb18c45878cd73310", size = 3503115, upload-time = "2026-03-14T16:02:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d2/5e54a26cee3de37963b9e9cd6440059fcdcb93cd4e30f60c7e83c8689c81/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9cd5832f1b97bcbe5b5da902425af99308962c1eab49348c680647c4288842e6", size = 4166314, upload-time = "2026-03-14T16:02:59.187Z" }, + { url = "https://files.pythonhosted.org/packages/2f/08/92522d09c27354ea740bf76512f541094db5e0f70cf3d00ad87c63147785/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a233841ef6beb0f4571d55a34910df636fd409140ed38d790d4428b052281d69", size = 3777700, upload-time = "2026-03-14T16:03:01.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a6/be8e777c5eaf4190902db1af7e65be1efa3152f27ec2c65f45dcfa56f2f3/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:aad69b1e1a08c5359cb6c72f1ec752dde34187f8e9c71d71d0000a266480e1f4", size = 4621390, upload-time = "2026-03-14T16:03:03.266Z" }, + { url = "https://files.pythonhosted.org/packages/a0/77/67188edac94ea077635f30d4a0c2247d15cdf1829bea1489b6c532e54137/apsw-3.51.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:5c9e7a16dcf3e474d6a696ce17b566a2d6964219b4d887c49c8d67562669b9bd", size = 4293456, upload-time = "2026-03-14T16:03:05.056Z" }, + { url = "https://files.pythonhosted.org/packages/8d/07/12b57f4f27d13b219994a8093fa1987da24868e788cbd7ed406f1297366d/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bae631aa0c32a64a5e1989c0fcbbbd74e458b83bb242de47b9a28f5230f83256", size = 4189204, upload-time = "2026-03-14T16:03:07.078Z" }, + { url = "https://files.pythonhosted.org/packages/98/ef/dadbe0bb4f34cf5b1a305d2eb74cdc47f1f8888f6ad849606c403dbc18fd/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6c51e7788ad88a30229e738d3669ab1930258e72255b397308fc3c5da16dd8ce", size = 3812470, upload-time = "2026-03-14T16:03:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/93/d0/31a11edbb119d2d4e82e29c393d9b3c65e92864c8ffa363c50022748998e/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4945ab142ef98da69720d7f9c5e7a9a86ea0fa19637320c9a3a35729298d6cd3", size = 4642149, upload-time = "2026-03-14T16:03:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ce/1fc9a70ccbf14f881dc25ea4c858fca5faa88383c4dae093227fe170c282/apsw-3.51.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20fd05b9302bc6e8d9fbe3b9e6034015fb31779c1dd50c463cd879610a6114b6", size = 4301621, upload-time = "2026-03-14T16:03:12.263Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5b/b557c9456692064dc0ee98dc3da790ad5c4f6966e2f090af7424a5c79169/apsw-3.51.3.0-cp311-cp311-win32.whl", hash = "sha256:193e348a8a71a179c4fffb414ef61f71f3009c82ab4aed711bc1169be3e68044", size = 3187115, upload-time = "2026-03-14T16:03:13.873Z" }, + { url = "https://files.pythonhosted.org/packages/79/a6/7cdb75e60cfcc4ba15a037f984b18daa79fc3882649cdf07d9ace60ecc8d/apsw-3.51.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:63e043b56549c159ca30a6474779e0b482fc88bd02b9bc17f5050e69ff62f44c", size = 3627629, upload-time = "2026-03-14T16:03:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/49/06/e08843bb4bb404ccb8cae4592aca9ae23d048c15ea7c28c975acb1b23dd4/apsw-3.51.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:c9f2d544036c675c7332db32eb845f01261abcfcc74f27278610a5031bbb95ce", size = 3183400, upload-time = "2026-03-14T16:03:16.905Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6a/4544afa5e29c1bb48f9d13ea49f897537167b43990346af2237094acbbe3/apsw-3.51.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69b45f0496d62ea673ff75a45c6610366b606baeb0ab9a24c5796b1fa7248f79", size = 3733554, upload-time = "2026-03-14T16:03:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/ce/30/0e29cfdbb882b94a2d09ab58d351162b8f47efe5c75b20b17786603caa71/apsw-3.51.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14cf60bb9e354d7272e21bc175653247e3ef3f4e5f60dd0cbd01150d6f9e51a5", size = 3502556, upload-time = "2026-03-14T16:03:20.499Z" }, + { url = "https://files.pythonhosted.org/packages/fb/77/c2e14fb98306ec4cdfed6543b95b285c4d5042086859dc0b2af438691eed/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a9feb28cbd061a5e9e045b47cbe7a77f0a80bbfa19b2ab2071af95e43e998eaa", size = 4163452, upload-time = "2026-03-14T16:03:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ad/fcbda02af13c154f929422015f69b2d0a5c6a2c7b20a693772d02eb05e58/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e42c589b0aaa8520ab43590d24b92d6ba78b73ec394f81c4d28f3fee38188816", size = 3772011, upload-time = "2026-03-14T16:03:24.063Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c7/13956b1cfcdd45db66b3738b75373367e5ac27cd62e04556a7bfa697de7e/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:f11e49aead6a0165060dd117e96e27fbf17e371eaf30bce39417e7b3bb2531e1", size = 4610512, upload-time = "2026-03-14T16:03:25.992Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e5/5e42bff00f375ca8702c4566d2248d6d00f23c95a2f7c5ca600941603b37/apsw-3.51.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:97233fa1ee8811530f09989acb0ad99ab6b2ca8cae14baf8defe07897d39f6d1", size = 4283567, upload-time = "2026-03-14T16:03:27.903Z" }, + { url = "https://files.pythonhosted.org/packages/15/e6/d0c028420f0195e12a7fcede7037fa24e2cd9685b7c36534690a5d2ca9cc/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4965e0f31d1c14ad0c2ba1fcf56e086f21386a4198820654a538cb4315f976ec", size = 4185086, upload-time = "2026-03-14T16:03:29.537Z" }, + { url = "https://files.pythonhosted.org/packages/d5/89/845c8ba00053b02d878136863bf5a20af3b7060f12189fd3f4ce3897ce7e/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:47ce2bbfaa3f4ca28d54ff1e45234906df2b2a9686367c01cf91605c2feac230", size = 3806413, upload-time = "2026-03-14T16:03:31.471Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5f/57cc46978b9673e09945bb3039b4bf2628666dc89afcffa7893c8c60bdb6/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9c341cf5b650c3fd648066d8b09a8da8af8ebeb94491b1d7c09637bc7423315f", size = 4629478, upload-time = "2026-03-14T16:03:33.412Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b6/c8f53804bd90407b907a0ca5b7a669232e654d9ec41b1e102bfe2358264e/apsw-3.51.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5b3578746400b748def42500de5e2666e07819fef025e3ca02e82366712560d1", size = 4298045, upload-time = "2026-03-14T16:03:35.132Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/43336dd507ffb6d30209b89c257f3e0f453e7aef442ffd6c0facb4249f8f/apsw-3.51.3.0-cp312-cp312-win32.whl", hash = "sha256:74b361fc27bd9c233576adad24875b757ffdfa3f5f5453d98a9e021a3ab73806", size = 3186682, upload-time = "2026-03-14T16:03:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7a/82e15c8da7c0de5df9fa47655ffd286ee8eb4a11eedbe6889b47f7604aea/apsw-3.51.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:ec869f9ffb7dff49aef68f3200a5fab9d55c956561b6e1a2f3e5ca5b499b4d22", size = 3625919, upload-time = "2026-03-14T16:03:38.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/7f9d919fefcb9e2ac633e1feb28b72ae466b6ec7e2128acd197d13ce90fe/apsw-3.51.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:a6426bdcb9cd60a68b6534abffedce11418b28cbeb6b89cc23f50611bdb1d7eb", size = 3183248, upload-time = "2026-03-14T16:03:40.24Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/e652801e3ff3e3f065c3f07c54794024ded2750d66e6f21b8f0098d4e28f/apsw-3.51.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4fd2c96c98f223e9732b894662ff2b91636c8335f089293ab1b3fbe233f31bf2", size = 3731439, upload-time = "2026-03-14T16:03:41.883Z" }, + { url = "https://files.pythonhosted.org/packages/16/2a/85d753b1b7c430283f4fd831672165eec74bc91ab5b9cea7f3fef652249e/apsw-3.51.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:376c3ec4da6058fd110f2339e58cbe9a2bfded08d60795fbbc8fcc225e5c3d50", size = 3499870, upload-time = "2026-03-14T16:03:43.872Z" }, + { url = "https://files.pythonhosted.org/packages/90/5a/81a65f260fc6e8f0f9d07929fe6b3f59ddc5bf1a0e2b87f18c49a8bbe25f/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8dcb52104f26b41dbf82eba793987611f42c88d3ce0b2e4049f7bab6703fd28c", size = 4159338, upload-time = "2026-03-14T16:03:45.451Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/aab87cc58220d1d4e83cb1f6eb7efdfc150fb165dab9c211c1967fc71489/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4b1f66f0753cae3163fc2b57d7c77dc16bebcd279384072df65d17053480a77", size = 3774233, upload-time = "2026-03-14T16:03:47.356Z" }, + { url = "https://files.pythonhosted.org/packages/75/ad/0cb2efccd548f951220b5d74d7baad6c5d3af9879cd4a9fe24d6ef374d55/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:1457f9a356351e9a6a34afe7dba7e1232c72279e97e04351936f0c20d20c3606", size = 4603017, upload-time = "2026-03-14T16:03:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/56/00/188d4edf72178706df6556d01c6357af068e45bbcbf11fa1902fac9987e8/apsw-3.51.3.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5f0c116efd1a5db0e94885313cc321a8f6e0c3dc7dcb21823d19bdc0c689cc0d", size = 4288777, upload-time = "2026-03-14T16:03:50.807Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b7/1b985fd892c1fa95cc3d9e5f128e560ab35b64bfb663337c2b499323b4f6/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6ca90f943c19fa4cc15459b82eafd581355186a2ba4c2c904aba8549a75eec9f", size = 4178855, upload-time = "2026-03-14T16:03:52.402Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/5ff3467d87febd970b469980044a58e172c608af37d0c5bb19e193547d09/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3cecf686e00c511e6565b024d0b8d9b9689ea5c92dad4ab889abe8fcc2a59e6c", size = 3808644, upload-time = "2026-03-14T16:03:54.292Z" }, + { url = "https://files.pythonhosted.org/packages/94/b9/5c2513f1cdb0c5ba9944be4e3f0f0ea216010102404835cbc30195889b9d/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d56d51b03970c78900dcb2003a5db1df8be9690b0abdfccdf5c04b5af984586f", size = 4623847, upload-time = "2026-03-14T16:03:56.016Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1b/3a545ef60d58c3feaa41a0d18f3444fda1c82a7bb4694ae0a5006da2de23/apsw-3.51.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e6db970976c5645d255d9944a8d9d7e38d30835121354bb11ebf5687b2f376df", size = 4297109, upload-time = "2026-03-14T16:03:57.735Z" }, + { url = "https://files.pythonhosted.org/packages/99/2b/3035b1a44498ff63dda458606fd42547f9e6b5a733f81ee75c50a27f4307/apsw-3.51.3.0-cp313-cp313-win32.whl", hash = "sha256:d10159228ea3bb342b84b5be9546e26100e9f28f79a1f1b990e0c23adc566fd1", size = 3185665, upload-time = "2026-03-14T16:04:00.436Z" }, + { url = "https://files.pythonhosted.org/packages/bb/06/3942fcb970af53d6ca9040b4bee690bd9f0afd9a42c040044ee4a2f877b1/apsw-3.51.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:b3b2bf0ffe99d19affdc80f9c3208f697155bc2add1c562eb79d79d3867b0042", size = 3624831, upload-time = "2026-03-14T16:04:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/e29659da47ce9f4e618f8397ea6c00b94054dfcda324d87e44a1257289c9/apsw-3.51.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:8f3c9a1d92cfd12925e4d43379a51fbac6203e36d1d316a49cb09b9460496cc4", size = 3182787, upload-time = "2026-03-14T16:04:03.988Z" }, ] [[package]] @@ -306,6 +322,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/22/828b08fac8dbc8c1dbc1ad03815137cebc9c78303ec7d21b568544028119/biopython-1.86-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a6ab2c60742f1c8494cfbbe3b7a8b45f0400c8f2b36b686b895d5e4d625f04e", size = 3197586, upload-time = "2025-10-28T23:53:47.136Z" }, { url = "https://files.pythonhosted.org/packages/36/7a/122aea7653fa93d7eb72978928e80759082efffa70afe0c25a17e18521da/biopython-1.86-cp312-cp312-win32.whl", hash = "sha256:192c61bc3d782c171b7d50bb7d8189d84790d6e3c4b24fd41d1d7ffc7d303efe", size = 2698043, upload-time = "2025-10-28T21:32:39.452Z" }, { url = "https://files.pythonhosted.org/packages/a9/13/00db03b01e54070d5b0ec9c71eef86e61afa733d9af76e5b9b09f5dc9165/biopython-1.86-cp312-cp312-win_amd64.whl", hash = "sha256:35a6b9c5dcdfb5c2631a313a007f3f41a7d72573ba2b68c962e10ea92096ff3b", size = 2733610, upload-time = "2025-10-28T21:32:34.99Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/84d6c66ab93095aa7adb998a8eef045328470eafd36b9237c4db213e587c/biopython-1.86-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fb3a11a98e49428720dca227e2a5bdd57c973ee7c4df3cf6734c0aa13fd134c7", size = 2693185, upload-time = "2025-10-28T21:27:39.709Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/60386f2640f13765b1651f2f26d8b4f893c46ee663df3ca76eda966d4f6a/biopython-1.86-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e161f3d3b6e65fbfd1ce22a01c3e9fa9da789adde4972fd0cc2370795ea5357b", size = 2669980, upload-time = "2025-10-28T21:26:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/dd/de/a39adb98a0552a257219503c236ef17f007598af55326c0d143db52e5a92/biopython-1.86-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aa8c9e92ee6fe59dfe0d2c2daf9a9eec6b812c78328caad038f79163c500218", size = 3209657, upload-time = "2025-10-29T00:36:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c7/b2e7aca3de8981f4ecb6ab1e0334c3c4a512e5e9898b57b3d8734b086da7/biopython-1.86-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:593ec6a2a4fedec08ddcee1a8a0e0b0ed56835b2714904b352ec4a93d5b9d973", size = 3235774, upload-time = "2025-10-29T00:36:34.07Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/e6647b0b9cf2bb67347612e8e443b84378c44768a8d8439276e4ba881178/biopython-1.86-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd2f9ebf9b14d67ca92f48779c4f0ba404c35dba3e8b9d6c34d1a3591c3b746d", size = 3178415, upload-time = "2025-10-28T23:54:05.475Z" }, + { url = "https://files.pythonhosted.org/packages/ff/37/f6a14b835842c66a52f212136a99416265f5ce76813d668ceac1cb306357/biopython-1.86-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:137fe9aafd93baa5127d17534b473f6646f92a883f52b34f7c306b800ac50038", size = 3197201, upload-time = "2025-10-28T23:54:10.462Z" }, + { url = "https://files.pythonhosted.org/packages/f2/73/0eac930016c509763c174a0e25e92e6d7a711f6f5de1f7001e54fd5c49f7/biopython-1.86-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e784dc8382430c9893aa084ca18fe8a8815b5811f1c324492ef3f4b54e664fff", size = 3145106, upload-time = "2025-10-28T23:54:15.235Z" }, + { url = "https://files.pythonhosted.org/packages/00/aa/26e836274d03402e8011b04a1714d4ac2f704add303a493e54d2d5646973/biopython-1.86-cp313-cp313-win32.whl", hash = "sha256:5329a777ba90ea624447173046e77c4df2862acc46eea4e94fe2211fe041750f", size = 2698051, upload-time = "2025-10-28T21:32:55.225Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/fa1f8fa57f2ac8fdc41d14ab36001b8ba0fce5eac01585227b99a4da0e9d/biopython-1.86-cp313-cp313-win_amd64.whl", hash = "sha256:f6f2f1dc75423b15d8a22b8eceae32785736612b6740688526401b8c2d821270", size = 2733649, upload-time = "2025-10-28T21:32:51.052Z" }, ] [[package]] @@ -389,6 +414,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, ] [[package]] @@ -421,6 +458,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/75/37bee6900183ea08a3a0ae04b9f018f9e64c6b10716e1f7b423db0c4356c/chardet-7.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd6db7505556ae8f9e2a3bf6d689c2b86aa6b459cf39552645d2c4d3fdbf489c", size = 554182, upload-time = "2026-03-04T21:25:02.168Z" }, { url = "https://files.pythonhosted.org/packages/e8/ed/2fe5ea435ae480bd3a76be1415920ce52b3ff6e188d8eab6a635d6a2a1d1/chardet-7.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f907962b18df78d5ca87a7484e4034354408d2c97cec6f53634b0ea0424c594", size = 557933, upload-time = "2026-03-04T21:25:03.694Z" }, { url = "https://files.pythonhosted.org/packages/07/ba/7ca89301e492ac4184ba7f4736565d954ba3125acf6bf02c66a38a802bda/chardet-7.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:302798e1e62008ca34a216dd04ecc5e240993b2090628e2a35d4c0754313ea9a", size = 524256, upload-time = "2026-03-04T21:25:05.581Z" }, + { url = "https://files.pythonhosted.org/packages/56/26/1a22b9a19b4ca167ca462eaf91d0fc31285874d80b0381c55fdc5bc5f066/chardet-7.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67fe3f453416ed9343057dcf06583b36aae6d8bdb013370b3ff46bc37b7e30ac", size = 541652, upload-time = "2026-03-04T21:25:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/24/fe/2f2425f3b0801e897653723ee827bc87e5a0feacf826ab268a9216680615/chardet-7.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:63bc210ce73f8a1b87430b949f84d086cb326d67eb259305862e7c8861b73374", size = 533333, upload-time = "2026-03-04T21:25:08.886Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/6b5f4b49c471b396bdbddad55b569e05d686ea65d91795dae6c774b285f0/chardet-7.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f51985946b49739968b6dc2fa70e7d8f490bb15574377c5ee114f33d19ef7e", size = 553815, upload-time = "2026-03-04T21:25:10.861Z" }, + { url = "https://files.pythonhosted.org/packages/b9/45/860a82d618e5c3930faef0a0fe205b752323e5d10ce0c18fe5016fd4f8d2/chardet-7.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8714f0013c208452a98e23595d99cef53c5364565454425f431446eb586e2591", size = 557506, upload-time = "2026-03-04T21:25:14.081Z" }, + { url = "https://files.pythonhosted.org/packages/ed/44/7acb8f84fc7b5ad3c977ac31865b308881da1c0a6ca58be35554d2473dd7/chardet-7.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:c12abc65830068ad05bd257fb953aaaf63a551446688e03e145522086be5738c", size = 524145, upload-time = "2026-03-04T21:25:15.696Z" }, { url = "https://files.pythonhosted.org/packages/a3/1f/c1a089db6333b1283409cad3714b8935e7e56722c9c60f9299726a1e57c2/chardet-7.0.1-py3-none-any.whl", hash = "sha256:e51e1ff2c51b2d622d97c9737bd5ee9d9b9038f05b7dd8f9ea10b9e2d9674c24", size = 408292, upload-time = "2026-03-04T21:25:25.214Z" }, ] @@ -478,6 +520,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, ] @@ -590,6 +648,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, @@ -603,9 +681,12 @@ name = "contourpy" version = "1.3.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -637,6 +718,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, @@ -697,6 +800,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, ] @@ -759,7 +866,7 @@ wheels = [ [[package]] name = "django" -version = "5.2.12" +version = "5.2.13" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", @@ -772,9 +879,9 @@ dependencies = [ { name = "sqlparse", marker = "python_full_version < '3.12'" }, { name = "tzdata", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" }, + { url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, ] [[package]] @@ -782,9 +889,12 @@ name = "django" version = "6.0.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "asgiref", marker = "python_full_version >= '3.12'" }, @@ -919,6 +1029,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/d4/b717a4874175146029ca1517e85474b1af80c9d9a306fc3161e71485eea5/fonttools-4.62.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f086120e8be9e99ca1288aa5ce519833f93fe0ec6ebad2380c1dee18781f0b5", size = 5122503, upload-time = "2026-03-09T16:49:02.464Z" }, { url = "https://files.pythonhosted.org/packages/cb/4b/92cfcba4bf8373f51c49c5ae4b512ead6fbda7d61a0e8c35a369d0db40a0/fonttools-4.62.0-cp312-cp312-win32.whl", hash = "sha256:37a73e5e38fd05c637daede6ffed5f3496096be7df6e4a3198d32af038f87527", size = 2281060, upload-time = "2026-03-09T16:49:04.385Z" }, { url = "https://files.pythonhosted.org/packages/cd/06/cc96468781a4dc8ae2f14f16f32b32f69bde18cb9384aad27ccc7adf76f7/fonttools-4.62.0-cp312-cp312-win_amd64.whl", hash = "sha256:658ab837c878c4d2a652fcbb319547ea41693890e6434cf619e66f79387af3b8", size = 2331193, upload-time = "2026-03-09T16:49:06.598Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/985c1670aa6d82ef270f04cde11394c168f2002700353bd2bde405e59b8f/fonttools-4.62.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:274c8b8a87e439faf565d3bcd3f9f9e31bca7740755776a4a90a4bfeaa722efa", size = 2864929, upload-time = "2026-03-09T16:49:09.331Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/c409c8ceec0d3119e9ab0b7b1a2e3c76d1f4d66e4a9db5c59e6b7652e7df/fonttools-4.62.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93e27131a5a0ae82aaadcffe309b1bae195f6711689722af026862bede05c07c", size = 2412586, upload-time = "2026-03-09T16:49:11.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ac/8e300dbf7b4d135287c261ffd92ede02d9f48f0d2db14665fbc8b059588a/fonttools-4.62.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83c6524c5b93bad9c2939d88e619fedc62e913c19e673f25d5ab74e7a5d074e5", size = 5013708, upload-time = "2026-03-09T16:49:14.063Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/60d93477b653eeb1ddf5f9ec34be689b79234d82dbdded269ac0252715b8/fonttools-4.62.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:106aec9226f9498fc5345125ff7200842c01eda273ae038f5049b0916907acee", size = 4964355, upload-time = "2026-03-09T16:49:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/6dc62bcc3c3598c28a3ecb77e69018869c3e109bd83031d4973c059d318b/fonttools-4.62.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15d86b96c79013320f13bc1b15f94789edb376c0a2d22fb6088f33637e8dfcbc", size = 4953472, upload-time = "2026-03-09T16:49:18.494Z" }, + { url = "https://files.pythonhosted.org/packages/82/b3/3af7592d9b254b7b7fec018135f8776bfa0d1ad335476c2791b1334dc5e4/fonttools-4.62.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f16c07e5250d5d71d0f990a59460bc5620c3cc456121f2cfb5b60475699905f", size = 5094701, upload-time = "2026-03-09T16:49:21.67Z" }, + { url = "https://files.pythonhosted.org/packages/31/3d/976645583ab567d3ee75ff87b33aa1330fa2baeeeae5fc46210b4274dd45/fonttools-4.62.0-cp313-cp313-win32.whl", hash = "sha256:d31558890f3fa00d4f937d12708f90c7c142c803c23eaeb395a71f987a77ebe3", size = 2279710, upload-time = "2026-03-09T16:49:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/e25245a30457595740041dba9d0ea8ec1b2517f2f1a6a741f15eba1a4edc/fonttools-4.62.0-cp313-cp313-win_amd64.whl", hash = "sha256:6826a5aa53fb6def8a66bf423939745f415546c4e92478a7c531b8b6282b6c3b", size = 2330291, upload-time = "2026-03-09T16:49:26.237Z" }, { url = "https://files.pythonhosted.org/packages/9c/57/c2487c281dde03abb2dec244fd67059b8d118bd30a653cbf69e94084cb23/fonttools-4.62.0-py3-none-any.whl", hash = "sha256:75064f19a10c50c74b336aa5ebe7b1f89fd0fb5255807bfd4b0c6317098f4af3", size = 1152427, upload-time = "2026-03-09T16:50:04.074Z" }, ] @@ -985,6 +1103,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/e0/ca646b4e22b3d6129ce56a087a9031f7a7843d47425f0adc38a7ab789b24/gemmi-0.7.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:74e1b5177b626aadb819fd8168f5d6064c04a2a1e45c87f357a96d30ddafc749", size = 3146031, upload-time = "2026-03-02T08:31:39.744Z" }, { url = "https://files.pythonhosted.org/packages/d5/cc/47e6039859393175a9b38f9a72732c018a3052d838fecf1ff635d8b84d95/gemmi-0.7.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6d30fa7ae889149c22dbb58899e77117e6548edc6e8ccfae3b4b2a259464d2ee", size = 3505196, upload-time = "2026-03-02T08:31:41.651Z" }, { url = "https://files.pythonhosted.org/packages/eb/f2/53be7a4ba5816e13c39be0f728facac4bcb39cf4903ceeec54b006511c8f/gemmi-0.7.5-cp312-cp312-win_amd64.whl", hash = "sha256:a1fdb6f72006495b5119e3a8bb5c3185efa708b785bd4a5ce4397ef7abb3fec7", size = 2270488, upload-time = "2026-03-02T08:31:43.898Z" }, + { url = "https://files.pythonhosted.org/packages/c4/80/fd758344a72ca7b5e1c5bbdc1d263f3b215d3897941b5f450380445ca0a9/gemmi-0.7.5-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:ef9b6ada1c00c6ba7c7a5b9e938cc3b45d83e775c23d12bf63b6882d5f3cdd6b", size = 2844981, upload-time = "2026-03-02T08:31:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9c/1236dd7d22ed48527286b613c84e3376ea731b65e6734b6e6a0b4d03744c/gemmi-0.7.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c7d8b08c33fe6ba375223306149092440c69cbfbd55c3d3e3436e5fb315a225d", size = 2720773, upload-time = "2026-03-02T08:31:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/ccf890f054f2fc12ff3a43a604a7a1e9f99706f057394e5c7d51c67cf6ed/gemmi-0.7.5-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2bd55985d7cf4403985118f677a187a3f0bb96fd314fb4582e66c2ab4a752ec", size = 2625116, upload-time = "2026-03-02T08:31:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8c/db8e79c4c744ebae1dcf25f7dbcc5d7df912cdbcdf7221e761479e8bd04b/gemmi-0.7.5-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:750b4d9751aaf1460ac4f0f45308ddced25f47bcf7a30355eb3b1f779f03952a", size = 2982474, upload-time = "2026-03-02T08:31:52.09Z" }, + { url = "https://files.pythonhosted.org/packages/05/eb/24c0071ad231b22dac9acf7e7e544e0b6466307a01d716c8a06363fa70a4/gemmi-0.7.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:789f0e05e8ad020c69011351c54cc1a9555f6aaf2ac18e00e5624eb5255c309d", size = 3146075, upload-time = "2026-03-02T08:31:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/08/06/da6fe6eb09e7f3e439e8c5b85908bf9539dfb7afe19bb4853f0c1fd98e4c/gemmi-0.7.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f34643c917c9ae0c26cded3044ad4634987469797188782b882cd2812c7769b1", size = 3505371, upload-time = "2026-03-02T08:31:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ab/7d7463cda94f8b68b969ea97aaad679655a0e436efd6a643e528a8de114e/gemmi-0.7.5-cp313-cp313-win_amd64.whl", hash = "sha256:ad1f72ffa24adbfaf259e11471f6f071a668667f6ca846051f3bfea024fd337d", size = 2270352, upload-time = "2026-03-02T08:31:58.538Z" }, ] [[package]] @@ -1119,8 +1244,8 @@ dependencies = [ { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, @@ -1139,7 +1264,7 @@ wheels = [ [[package]] name = "ipython" -version = "8.38.0" +version = "8.39.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", @@ -1157,14 +1282,14 @@ dependencies = [ { name = "traitlets", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/df/db59624f4c71b39717c423409950ac3f2c8b2ce4b0aac843112c7fb3f721/ipython-8.38.0-py3-none-any.whl", hash = "sha256:750162629d800ac65bb3b543a14e7a74b0e88063eac9b92124d4b2aa3f6d8e86", size = 831813, upload-time = "2026-01-05T10:59:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, ] [[package]] name = "ipython" -version = "9.10.0" +version = "9.10.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.11.*' and sys_platform == 'win32'", @@ -1184,9 +1309,9 @@ dependencies = [ { name = "traitlets", marker = "python_full_version == '3.11.*'" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/aa/898dec789a05731cd5a9f50605b7b44a72bd198fd0d4528e11fc610177cc/ipython-9.10.0-py3-none-any.whl", hash = "sha256:c6ab68cc23bba8c7e18e9b932797014cc61ea7fd6f19de180ab9ba73e65ee58d", size = 622774, upload-time = "2026-02-02T10:00:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, ] [[package]] @@ -1194,9 +1319,12 @@ name = "ipython" version = "9.11.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, @@ -1233,8 +1361,8 @@ version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "comm" }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "jupyterlab-widgets" }, { name = "traitlets" }, @@ -1257,6 +1385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -1396,8 +1533,8 @@ version = "6.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ipykernel" }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, @@ -1624,6 +1761,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, @@ -1649,6 +1815,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, ] +[[package]] +name = "legacy-cgi" +version = "2.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, +] + [[package]] name = "librt" version = "0.8.1" @@ -1693,6 +1868,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, ] [[package]] @@ -1755,6 +1943,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -1808,6 +2018,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, + { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, + { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, + { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, + { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, @@ -1864,8 +2088,8 @@ version = "0.0.41" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ase" }, - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "kaleido" }, { name = "matplotlib" }, @@ -1941,6 +2165,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, ] @@ -2055,9 +2285,12 @@ name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -2156,6 +2389,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/0a/e9d1080eb107349ef090cbe0bd8335f3920708f1435b943df8c1c5496f50/openmm-8.4.0.post2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bde30736f7b4b595041caf083bb4e4e79a794ab164faedd0664eda0348a299f", size = 12739310, upload-time = "2025-11-24T21:41:49.437Z" }, { url = "https://files.pythonhosted.org/packages/69/45/ab3937509f5dcde71fe7ac300f24a8d0684448d9b4820470360202bb95e4/openmm-8.4.0.post2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:168544c0b388ae71cc3f85e27c36c4f393854a313c4203f9e9957d6214836c13", size = 14253728, upload-time = "2025-11-24T21:41:55.662Z" }, { url = "https://files.pythonhosted.org/packages/06/d4/a6022476db6cd0baf0218fa33ed70182f1447e0b6bf5ded124a846c4dab9/openmm-8.4.0.post2-cp312-cp312-win_amd64.whl", hash = "sha256:6e9fd826aedf34b4c27a4dcda83da93e90f3e81305c58bcc07dafee22460a469", size = 13098413, upload-time = "2025-11-24T21:42:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4f/be754e36197e075281ce333e906515bc33a326d3f9f9a0e6b97bbf81159a/openmm-8.4.0.post2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:07dfe965a205e57eff525edad4a65f5b426daa068e491536019bc9b3957bc1f3", size = 13224644, upload-time = "2025-11-24T21:42:07.488Z" }, + { url = "https://files.pythonhosted.org/packages/9a/50/0cf408fecd04c23aa2767b10293404b513f37a76d0c41f60eba113d85c3b/openmm-8.4.0.post2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fdfae1f8c7612520f3bd2f7c0dccb9d9df5f085e219272ca43617574b88c3a6", size = 12738320, upload-time = "2025-11-24T21:42:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/2e/da/5534914daa40455f5ed92b4d82c980e5f346922841c4d86b32ed6cb1382b/openmm-8.4.0.post2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:70cdd3309064b95bd304af1f195377e54b6f7060b15b37af725d15764b756b09", size = 14251710, upload-time = "2025-11-24T21:42:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/34/9e/e42c8c6d29050dd8188d33c79c9a40291bf8885d1588f755246bb734effe/openmm-8.4.0.post2-cp313-cp313-win_amd64.whl", hash = "sha256:e2912d803e7473048351cddb59176df191db3cbe21d0ab1a6f83e7020b92f01f", size = 13096685, upload-time = "2025-11-24T21:42:29.885Z" }, ] [[package]] @@ -2219,6 +2456,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/6e0e52cac5aab51d7b6dcd257e855e1dec1c2060f6b28566c509b4665f62/orjson-3.11.7-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:1d98b30cc1313d52d4af17d9c3d307b08389752ec5f2e5febdfada70b0f8c733", size = 228390, upload-time = "2026-02-02T15:38:06.8Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/a77f48d2fc8a05bbc529e5ff481fb43d914f9e383ea2469d4f3d51df3d00/orjson-3.11.7-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d897e81f8d0cbd2abb82226d1860ad2e1ab3ff16d7b08c96ca00df9d45409ef4", size = 125189, upload-time = "2026-02-02T15:38:08.181Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/0a16e0729a0e6a1504f9d1a13cdd365f030068aab64cec6958396b9969d7/orjson-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814be4b49b228cfc0b3c565acf642dd7d13538f966e3ccde61f4f55be3e20785", size = 128106, upload-time = "2026-02-02T15:38:09.41Z" }, + { url = "https://files.pythonhosted.org/packages/66/da/a2e505469d60666a05ab373f1a6322eb671cb2ba3a0ccfc7d4bc97196787/orjson-3.11.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d06e5c5fed5caedd2e540d62e5b1c25e8c82431b9e577c33537e5fa4aa909539", size = 123363, upload-time = "2026-02-02T15:38:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/ed73f88396ea35c71b38961734ea4a4746f7ca0768bf28fd551d37e48dd0/orjson-3.11.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31c80ce534ac4ea3739c5ee751270646cbc46e45aea7576a38ffec040b4029a1", size = 129007, upload-time = "2026-02-02T15:38:12.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/3c/b05d80716f0225fc9008fbf8ab22841dcc268a626aa550561743714ce3bf/orjson-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f50979824bde13d32b4320eedd513431c921102796d86be3eee0b58e58a3ecd1", size = 141667, upload-time = "2026-02-02T15:38:13.398Z" }, + { url = "https://files.pythonhosted.org/packages/61/e8/0be9b0addd9bf86abfc938e97441dcd0375d494594b1c8ad10fe57479617/orjson-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e54f3808e2b6b945078c41aa8d9b5834b28c50843846e97807e5adb75fa9705", size = 130832, upload-time = "2026-02-02T15:38:14.698Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ec/c68e3b9021a31d9ec15a94931db1410136af862955854ed5dd7e7e4f5bff/orjson-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12b80df61aab7b98b490fe9e4879925ba666fccdfcd175252ce4d9035865ace", size = 133373, upload-time = "2026-02-02T15:38:16.109Z" }, + { url = "https://files.pythonhosted.org/packages/d2/45/f3466739aaafa570cc8e77c6dbb853c48bf56e3b43738020e2661e08b0ac/orjson-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:996b65230271f1a97026fd0e6a753f51fbc0c335d2ad0c6201f711b0da32693b", size = 138307, upload-time = "2026-02-02T15:38:17.453Z" }, + { url = "https://files.pythonhosted.org/packages/e1/84/9f7f02288da1ffb31405c1be07657afd1eecbcb4b64ee2817b6fe0f785fa/orjson-3.11.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ab49d4b2a6a1d415ddb9f37a21e02e0d5dbfe10b7870b21bf779fc21e9156157", size = 408695, upload-time = "2026-02-02T15:38:18.831Z" }, + { url = "https://files.pythonhosted.org/packages/18/07/9dd2f0c0104f1a0295ffbe912bc8d63307a539b900dd9e2c48ef7810d971/orjson-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:390a1dce0c055ddf8adb6aa94a73b45a4a7d7177b5c584b8d1c1947f2ba60fb3", size = 144099, upload-time = "2026-02-02T15:38:20.28Z" }, + { url = "https://files.pythonhosted.org/packages/a5/66/857a8e4a3292e1f7b1b202883bcdeb43a91566cf59a93f97c53b44bd6801/orjson-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1eb80451a9c351a71dfaf5b7ccc13ad065405217726b59fdbeadbcc544f9d223", size = 134806, upload-time = "2026-02-02T15:38:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/0a/5b/6ebcf3defc1aab3a338ca777214966851e92efb1f30dc7fc8285216e6d1b/orjson-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7477aa6a6ec6139c5cb1cc7b214643592169a5494d200397c7fc95d740d5fcf3", size = 127914, upload-time = "2026-02-02T15:38:23.511Z" }, + { url = "https://files.pythonhosted.org/packages/00/04/c6f72daca5092e3117840a1b1e88dfc809cc1470cf0734890d0366b684a1/orjson-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:b9f95dcdea9d4f805daa9ddf02617a89e484c6985fa03055459f90e87d7a0757", size = 124986, upload-time = "2026-02-02T15:38:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/ba/077a0f6f1085d6b806937246860fafbd5b17f3919c70ee3f3d8d9c713f38/orjson-3.11.7-cp313-cp313-win_arm64.whl", hash = "sha256:800988273a014a0541483dc81021247d7eacb0c845a9d1a34a422bc718f41539", size = 126045, upload-time = "2026-02-02T15:38:26.216Z" }, ] [[package]] @@ -2287,6 +2539,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, ] [[package]] @@ -2294,9 +2559,12 @@ name = "pandas" version = "3.0.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -2324,6 +2592,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, ] [[package]] @@ -2374,6 +2657,7 @@ name = "pdbfixer" version = "1.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "legacy-cgi", marker = "python_full_version >= '3.13'" }, { name = "numpy" }, { name = "openmm" }, ] @@ -2439,6 +2723,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, @@ -2522,6 +2831,12 @@ version = "7.2.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, @@ -2537,7 +2852,7 @@ name = "psycopg" version = "3.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } @@ -2588,6 +2903,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, ] [[package]] @@ -2691,6 +3017,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, @@ -2849,6 +3189,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/1e/8a54166a8c5e4f5cb516514bdf4090be4d51a71e8d9f6d98c0aa00fe45d4/pywinpty-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc1e230e5b193eef4431cba3f39996a288f9958f9c9f092c8a961d930ee8f68", size = 236191, upload-time = "2026-02-04T21:50:36.239Z" }, { url = "https://files.pythonhosted.org/packages/7c/d4/aeb5e1784d2c5bff6e189138a9ca91a090117459cea0c30378e1f2db3d54/pywinpty-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c9081df0e49ffa86d15db4a6ba61530630e48707f987df42c9d3313537e81fc0", size = 2113098, upload-time = "2026-02-04T21:54:37.711Z" }, { url = "https://files.pythonhosted.org/packages/b9/53/7278223c493ccfe4883239cf06c823c56460a8010e0fc778eef67858dc14/pywinpty-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:15e79d870e18b678fb8a5a6105fd38496b55697c66e6fc0378236026bc4d59e9", size = 234901, upload-time = "2026-02-04T21:53:31.35Z" }, + { url = "https://files.pythonhosted.org/packages/e5/cb/58d6ed3fd429c96a90ef01ac9a617af10a6d41469219c25e7dc162abbb71/pywinpty-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9c91dbb026050c77bdcef964e63a4f10f01a639113c4d3658332614544c467ab", size = 2112686, upload-time = "2026-02-04T21:52:03.035Z" }, + { url = "https://files.pythonhosted.org/packages/fd/50/724ed5c38c504d4e58a88a072776a1e880d970789deaeb2b9f7bd9a5141a/pywinpty-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:fe1f7911805127c94cf51f89ab14096c6f91ffdcacf993d2da6082b2142a2523", size = 234591, upload-time = "2026-02-04T21:52:29.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ad/90a110538696b12b39fd8758a06d70ded899308198ad2305ac68e361126e/pywinpty-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:3f07a6cf1c1d470d284e614733c3d0f726d2c85e78508ea10a403140c3c0c18a", size = 2112360, upload-time = "2026-02-04T21:55:33.397Z" }, + { url = "https://files.pythonhosted.org/packages/44/0f/7ffa221757a220402bc79fda44044c3f2cc57338d878ab7d622add6f4581/pywinpty-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:15c7c0b6f8e9d87aabbaff76468dabf6e6121332c40fc1d83548d02a9d6a3759", size = 233107, upload-time = "2026-02-04T21:51:45.455Z" }, ] [[package]] @@ -2885,6 +3229,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -2926,6 +3280,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, @@ -2971,6 +3337,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/6f/d6420b5343c9b113b41235f3497e4d9fbff9ac5ee7df3be5c6f09f25723e/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:24ab792c9eae49bc0cffe619bcac4ccc91ce61eb09ea68be5a74f29d7671681d", size = 35118879, upload-time = "2026-02-16T08:48:53.36Z" }, { url = "https://files.pythonhosted.org/packages/6e/86/5609ddde91431190739919e6c41821b4fcd2a3fcc05dcc4094a666b34796/rdkit-2025.9.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:07705d64bf33b832eff1ea8819c0ffe38e079b31ebb194a73e5ae47554910c8b", size = 36663662, upload-time = "2026-02-16T08:48:57.757Z" }, { url = "https://files.pythonhosted.org/packages/03/21/1da115389d2eae82364ac5eb4c95c7c57841f2906239257b17af3b8d48f4/rdkit-2025.9.5-cp312-cp312-win_amd64.whl", hash = "sha256:f99b65d3b52d76532ceecb73befae0180e147120972d4054fcd3c66fa71f3a95", size = 24300316, upload-time = "2026-02-16T08:49:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/692d9f44c4dfa1068bfc0d9240dba5f96205b33054367c19c1e4d2448290/rdkit-2025.9.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e31e1924e4d0ce19e5474037875294937bc5835b2f95d2c2c3025d6cd4ec4dfa", size = 29559487, upload-time = "2026-02-16T08:49:05.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f3/baffbc78adad2324662d9c72bc132af40506eec2b612f7c07756b970426b/rdkit-2025.9.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:158d04129bfdeb2513c49cea208721bc5d6efb6611ba578006f81d3a9807381b", size = 35117644, upload-time = "2026-02-16T08:49:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c2/41b6344807a63fb8367be2f3a503d13d22d6d905c2254091e1c2004fd3bc/rdkit-2025.9.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6fd443a12fc14a8258629d08916d39080a470b839d5a91c2275cc48fe93712f2", size = 36662343, upload-time = "2026-02-16T08:49:13.216Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/dad9db005fc843a1ac718e4d09ec2d744e5849b6b453b196a065515b3edd/rdkit-2025.9.5-cp313-cp313-win_amd64.whl", hash = "sha256:957050e2c5b9cf623dc2a82f10847f7f6cebd15ef2edac40cd10120117c9927c", size = 24299076, upload-time = "2026-02-16T08:49:16.746Z" }, ] [[package]] @@ -2992,7 +3362,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ @@ -3119,6 +3489,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, @@ -3188,6 +3587,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/d0/0c577d9325b05594fdd33aa970bf53fb673f051a45496842caee13cfd7fe/scikit_learn-1.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5bf3d930aee75a65478df91ac1225ff89cd28e9ac7bd1196853a9229b6adb0b", size = 9478381, upload-time = "2025-09-09T08:20:47.982Z" }, { url = "https://files.pythonhosted.org/packages/82/70/8bf44b933837ba8494ca0fc9a9ab60f1c13b062ad0197f60a56e2fc4c43e/scikit_learn-1.7.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d6e9deed1a47aca9fe2f267ab8e8fe82ee20b4526b2c0cd9e135cea10feb44", size = 9300296, upload-time = "2025-09-09T08:20:50.366Z" }, { url = "https://files.pythonhosted.org/packages/c6/99/ed35197a158f1fdc2fe7c3680e9c70d0128f662e1fee4ed495f4b5e13db0/scikit_learn-1.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:6088aa475f0785e01bcf8529f55280a3d7d298679f50c0bb70a2364a82d0b290", size = 8731256, upload-time = "2025-09-09T08:20:52.627Z" }, + { url = "https://files.pythonhosted.org/packages/ae/93/a3038cb0293037fd335f77f31fe053b89c72f17b1c8908c576c29d953e84/scikit_learn-1.7.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b7dacaa05e5d76759fb071558a8b5130f4845166d88654a0f9bdf3eb57851b7", size = 9212382, upload-time = "2025-09-09T08:20:54.731Z" }, + { url = "https://files.pythonhosted.org/packages/40/dd/9a88879b0c1104259136146e4742026b52df8540c39fec21a6383f8292c7/scikit_learn-1.7.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:abebbd61ad9e1deed54cca45caea8ad5f79e1b93173dece40bb8e0c658dbe6fe", size = 8592042, upload-time = "2025-09-09T08:20:57.313Z" }, + { url = "https://files.pythonhosted.org/packages/46/af/c5e286471b7d10871b811b72ae794ac5fe2989c0a2df07f0ec723030f5f5/scikit_learn-1.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:502c18e39849c0ea1a5d681af1dbcf15f6cce601aebb657aabbfe84133c1907f", size = 9434180, upload-time = "2025-09-09T08:20:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fd/df59faa53312d585023b2da27e866524ffb8faf87a68516c23896c718320/scikit_learn-1.7.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a4c328a71785382fe3fe676a9ecf2c86189249beff90bf85e22bdb7efaf9ae0", size = 9283660, upload-time = "2025-09-09T08:21:01.71Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c7/03000262759d7b6f38c836ff9d512f438a70d8a8ddae68ee80de72dcfb63/scikit_learn-1.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:63a9afd6f7b229aad94618c01c252ce9e6fa97918c5ca19c9a17a087d819440c", size = 8702057, upload-time = "2025-09-09T08:21:04.234Z" }, + { url = "https://files.pythonhosted.org/packages/55/87/ef5eb1f267084532c8e4aef98a28b6ffe7425acbfd64b5e2f2e066bc29b3/scikit_learn-1.7.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9acb6c5e867447b4e1390930e3944a005e2cb115922e693c08a323421a6966e8", size = 9558731, upload-time = "2025-09-09T08:21:06.381Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/6c1e3fc14b10118068d7938878a9f3f4e6d7b74a8ddb1e5bed65159ccda8/scikit_learn-1.7.2-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:2a41e2a0ef45063e654152ec9d8bcfc39f7afce35b08902bfe290c2498a67a6a", size = 9038852, upload-time = "2025-09-09T08:21:08.628Z" }, + { url = "https://files.pythonhosted.org/packages/83/87/066cafc896ee540c34becf95d30375fe5cbe93c3b75a0ee9aa852cd60021/scikit_learn-1.7.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98335fb98509b73385b3ab2bd0639b1f610541d3988ee675c670371d6a87aa7c", size = 9527094, upload-time = "2025-09-09T08:21:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2b/4903e1ccafa1f6453b1ab78413938c8800633988c838aa0be386cbb33072/scikit_learn-1.7.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191e5550980d45449126e23ed1d5e9e24b2c68329ee1f691a3987476e115e09c", size = 9367436, upload-time = "2025-09-09T08:21:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/8444be3cfb10451617ff9d177b3c190288f4563e6c50ff02728be67ad094/scikit_learn-1.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:57dc4deb1d3762c75d685507fbd0bc17160144b2f2ba4ccea5dc285ab0d0e973", size = 9275749, upload-time = "2025-09-09T08:21:15.96Z" }, ] [[package]] @@ -3195,9 +3604,12 @@ name = "scikit-learn" version = "1.8.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -3222,6 +3634,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, + { url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" }, + { url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" }, + { url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" }, ] [[package]] @@ -3263,6 +3687,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, ] [[package]] @@ -3270,9 +3712,12 @@ name = "scipy" version = "1.17.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'win32'", - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.13' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version >= '3.13' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.13' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version == '3.11.*' and sys_platform == 'win32'", "python_full_version == '3.11.*' and sys_platform == 'emscripten'", "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", @@ -3302,6 +3747,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, ] [[package]] @@ -3400,6 +3865,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" }, { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" }, { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" }, + { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" }, + { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" }, + { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" }, + { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" }, + { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" }, + { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" }, + { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" }, { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" }, ] @@ -3426,8 +3904,8 @@ name = "smallworld-api" version = "1.1.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipython", version = "8.38.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "ipython", version = "9.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3549,29 +4027,38 @@ wheels = [ [[package]] name = "tomli" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, - { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, - { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, - { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, - { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, - { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, - { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, - { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, - { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, - { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, - { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, - { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, - { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] @@ -3835,6 +4322,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" }, { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" }, { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, + { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, + { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, + { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, + { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, + { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, + { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, + { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, + { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, + { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, + { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] @@ -3845,7 +4354,7 @@ source = { editable = "." } dependencies = [ { name = "apsw" }, { name = "chardet" }, - { name = "django", version = "5.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "django-rdkit" }, { name = "environs" }, @@ -3860,6 +4369,7 @@ dependencies = [ { name = "neo4j" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, { name = "openmm" }, { name = "openpyxl" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -3878,6 +4388,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "commitizen" }, + { name = "isort" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -3887,7 +4398,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apsw", specifier = ">=3.52" }, + { name = "apsw", specifier = ">=3.51" }, { name = "chardet", specifier = ">=7" }, { name = "django", specifier = ">=5.2.12" }, { name = "django-rdkit", git = "https://github.com/rdkit/django-rdkit" }, @@ -3902,6 +4413,7 @@ requires-dist = [ { name = "mrich", specifier = ">=1.0" }, { name = "neo4j", specifier = ">=6.1.0" }, { name = "networkx", specifier = ">=3.4" }, + { name = "numpy", specifier = ">=1.26.4" }, { name = "openmm", specifier = ">=8.4" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "pandas", specifier = ">=2.3" }, @@ -3918,6 +4430,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "commitizen", specifier = ">=4.13.5,<5" }, + { name = "isort", specifier = ">=8.0.1" }, { name = "mypy", specifier = ">=1.19" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2,<10" }, From 59323a295f5a2bb27c8e5b6baf28ac8b202c18af Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 10 Apr 2026 11:39:00 +0100 Subject: [PATCH 124/163] fix: directory rename --- hippo/__init__.py | 33 +- hippo/__main__.py | 248 - hippo/animal.py | 3246 ---------- hippo/apsw.py | 11 - hippo/bootstrap.py | 116 + hippo/compound.py | 1301 ---- hippo/db.py | 5717 ----------------- hippo/designdb/__init__.py | 3 + hippo/designdb/admin.py | 1 + hippo/designdb/animal.py | 404 ++ hippo/designdb/apps.py | 5 + hippo/{ => designdb}/chem.py | 4 +- hippo/designdb/ingredient.py | 267 + hippo/designdb/models.py | 994 +++ hippo/{ => designdb}/price.py | 2 +- hippo/{ => designdb}/recipe.py | 49 +- hippo/designdb/route.py | 219 + hippo/designdb/services/__init__.py | 0 hippo/designdb/services/compound.py | 136 + hippo/designdb/services/ingestion.py | 1065 +++ hippo/designdb/services/pose.py | 208 + hippo/designdb/services/reaction.py | 109 + hippo/designdb/services/route.py | 80 + hippo/designdb/services/score.py | 74 + hippo/designdb/sets/__init__.py | 0 hippo/{cset.py => designdb/sets/compound.py} | 2788 ++++---- .../{iset.py => designdb/sets/interaction.py} | 20 +- hippo/{pset.py => designdb/sets/pose.py} | 3263 ++++------ hippo/designdb/sets/reaction.py | 362 ++ hippo/designdb/sets/route.py | 427 ++ hippo/designdb/tests.py | 1 + hippo/{tools.py => designdb/utils.py} | 98 +- .../{fragalysis.py => designdb/utils_frag.py} | 69 +- hippo/{xca.py => designdb/utils_xca.py} | 0 hippo/designdb/views.py | 1 + hippo/feature.py | 87 - hippo/interaction.py | 191 - hippo/manage.py | 23 + hippo/metadata.py | 112 - hippo/migration.py | 1384 ---- hippo/pca.py | 91 - hippo/plotting.py | 2010 ------ hippo/pose.py | 1744 ----- hippo/postgres.py | 811 --- hippo/prolif.py | 218 - hippo/pyvis.py | 164 - hippo/quote.py | 242 - hippo/reaction.py | 425 -- hippo/rgen.py | 802 --- hippo/rset.py | 737 --- hippo/scoring.py | 1069 --- hippo/subsite.py | 179 - hippo/syndirella.py | 42 - hippo/tags.py | 352 - hippo/target.py | 168 - hippo/test.db | Bin 8192 -> 0 bytes hippo/web.py | 1103 ---- hippo/xchem_hippo/__init__.py | 0 hippo/xchem_hippo/asgi.py | 16 + hippo/xchem_hippo/urls.py | 23 + hippo/xchem_hippo/wsgi.py | 16 + 61 files changed, 7089 insertions(+), 26241 deletions(-) delete mode 100644 hippo/__main__.py delete mode 100644 hippo/animal.py delete mode 100644 hippo/apsw.py create mode 100644 hippo/bootstrap.py delete mode 100644 hippo/compound.py delete mode 100644 hippo/db.py create mode 100644 hippo/designdb/__init__.py create mode 100644 hippo/designdb/admin.py create mode 100644 hippo/designdb/animal.py create mode 100644 hippo/designdb/apps.py rename hippo/{ => designdb}/chem.py (99%) create mode 100644 hippo/designdb/ingredient.py create mode 100644 hippo/designdb/models.py rename hippo/{ => designdb}/price.py (99%) rename hippo/{ => designdb}/recipe.py (99%) create mode 100644 hippo/designdb/route.py create mode 100644 hippo/designdb/services/__init__.py create mode 100644 hippo/designdb/services/compound.py create mode 100644 hippo/designdb/services/ingestion.py create mode 100644 hippo/designdb/services/pose.py create mode 100644 hippo/designdb/services/reaction.py create mode 100644 hippo/designdb/services/route.py create mode 100644 hippo/designdb/services/score.py create mode 100644 hippo/designdb/sets/__init__.py rename hippo/{cset.py => designdb/sets/compound.py} (72%) rename hippo/{iset.py => designdb/sets/interaction.py} (98%) rename hippo/{pset.py => designdb/sets/pose.py} (50%) create mode 100644 hippo/designdb/sets/reaction.py create mode 100644 hippo/designdb/sets/route.py create mode 100644 hippo/designdb/tests.py rename hippo/{tools.py => designdb/utils.py} (72%) rename hippo/{fragalysis.py => designdb/utils_frag.py} (70%) rename hippo/{xca.py => designdb/utils_xca.py} (100%) create mode 100644 hippo/designdb/views.py delete mode 100644 hippo/feature.py delete mode 100644 hippo/interaction.py create mode 100755 hippo/manage.py delete mode 100644 hippo/metadata.py delete mode 100644 hippo/migration.py delete mode 100644 hippo/pca.py delete mode 100644 hippo/plotting.py delete mode 100644 hippo/pose.py delete mode 100644 hippo/postgres.py delete mode 100644 hippo/prolif.py delete mode 100644 hippo/pyvis.py delete mode 100644 hippo/quote.py delete mode 100644 hippo/reaction.py delete mode 100644 hippo/rgen.py delete mode 100644 hippo/rset.py delete mode 100644 hippo/scoring.py delete mode 100644 hippo/subsite.py delete mode 100644 hippo/syndirella.py delete mode 100644 hippo/tags.py delete mode 100644 hippo/target.py delete mode 100644 hippo/test.db delete mode 100644 hippo/web.py create mode 100644 hippo/xchem_hippo/__init__.py create mode 100644 hippo/xchem_hippo/asgi.py create mode 100644 hippo/xchem_hippo/urls.py create mode 100644 hippo/xchem_hippo/wsgi.py diff --git a/hippo/__init__.py b/hippo/__init__.py index a0e7ac8..e49f7b7 100644 --- a/hippo/__init__.py +++ b/hippo/__init__.py @@ -1,32 +1,3 @@ -""" +from .bootstrap import load_hippo as HIPPO -Hit Interaction Profiling for Progression Optimisation - -HIPPO is a Python toolkit for structure- and fragment-based computational drug discovery, -storing large datasets in a database and facilitating rational decision making. -HIPPO was originally developed by Max Winokan while in XChem at Diamond Light Source. - -See https://hippo-docs.winokan.com and https://github.com/mwinokan/HIPPO - -""" - -__version__ = '0.3.38' - -from .animal import HIPPO -from .compound import Compound, Ingredient -from .cset import CompoundSet, CompoundTable, IngredientSet -from .db import Database -from .feature import Feature -from .metadata import MetaData -from .pose import Pose -from .price import Price -from .pset import PoseSet, PoseTable -from .quote import Quote -from .reaction import Reaction -from .recipe import Recipe, Route, RouteSet -from .rgen import RandomRecipeGenerator -from .rset import ReactionSet, ReactionTable -from .scoring import CustomAttribute, Scorer -from .tags import TagSet, TagTable -from .target import Target -from .web import ProjectPage +__all__ = ['HIPPO'] diff --git a/hippo/__main__.py b/hippo/__main__.py deleted file mode 100644 index 476d616..0000000 --- a/hippo/__main__.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Define CLI commands for HIPPO""" - -import mrich -from typer import Typer - -app = Typer() - - -def setup_animal( - database: str, - backup: bool = True, - update_legacy: bool = False, -) -> 'HIPPO': - """Setup the :class:`.HIPPO` object and optionally perform a database backup""" - - from .animal import HIPPO - - animal = HIPPO('CLI', database, update_legacy=update_legacy) - if backup: - animal.db.backup() - return animal - - -@app.command() -def backup(database: str): - """Backup database file""" - mrich.h1('hippo.backup') - - mrich.h3('Params') - mrich.var('database', database) - - from hippo.db import backup - - backup(database) - mrich.success('Successfully backed up') - - -@app.command() -def update_legacy(database: str, backup: bool = True): - """Update legacy database format""" - - mrich.h1('hippo.update_legacy') - - mrich.h3('Params') - mrich.var('database', database) - - if backup: - from hippo.db import backup - - backup(database) - - animal = setup_animal(database, backup=False, update_legacy=True) - mrich.success('Successfully updated database format') - - -@app.command() -def calculate_scaffolds( - database: str, - backup: bool = True, -): - """Calculate scaffold/superstructure relationships for all compounds""" - - mrich.h1('hippo.calculate_scaffolds') - - mrich.h3('Params') - mrich.var('database', database) - mrich.var('backup', backup) - - mrich.h3('Animal') - animal = setup_animal(database=database, backup=backup) - - mrich.h3('State Before') - mrich.var('scaffolds', animal.scaffolds) - mrich.var('elabs', animal.elabs) - - mrich.h3('Calculation') - animal.db.calculate_all_scaffolds() - - mrich.h3('State After') - mrich.var('scaffolds', animal.scaffolds) - mrich.var('elabs', animal.elabs) - - mrich.success('Completed') - - -@app.command() -def calculate_interactions( - database: str, - prolif: bool = False, - backup: bool = True, - force: bool = False, - # n_tasks: int = 1, -) -> None: - """Calculate interactions for all poses""" - - mrich.h1('hippo.calculate_interactions') - - mrich.h3('Params') - mrich.var('database', database) - mrich.var('backup', backup) - - mrich.h3('Animal') - animal = setup_animal(database=database, backup=backup) - - mrich.h3('State Before') - mrich.var('#total poses', animal.num_poses) - mrich.var('#fingerprinted', animal.poses.num_fingerprinted) - - mrich.h3('Calculation') - - n_tasks = 1 - - if not force: - pose_ids = animal.db.select_id_where( - table='pose', key='pose_fingerprint != 1', multiple=True - ) - else: - pose_ids = animal.db.execte('SELECT pose_id FROM pose').fetchall() - - pose_ids = [i for (i,) in pose_ids] - - mrich.var('#poses', len(pose_ids)) - - if n_tasks == 1: - poses = animal.poses[pose_ids] - - n = len(poses) - for i, pose in mrich.track(enumerate(poses), total=n): - mrich.set_progress_prefix(f'{i}/{n}') - - try: - if prolif: - pose.calculate_prolif_interactions(force=force) - else: - pose.calculate_interactions(force=force) - - except Exception as e: - mrich.error(e) - mrich.error('Could not fingerprint pose') - continue - - else: - from joblib import Parallel, delayed - - poses = animal.db.get_poses(ids=pose_ids) - - if prolif: - raise NotImplementedError( - 'ProLIF fingerprint calculation does not support in-memory resolution' - ) - - def calculate_interactions(pose: 'Pose') -> None: - """Joblib wrapper for the calculation""" - pose.calculate_interactions(force=force) - - tasks = [] - for pose in poses: - tasks.append(delayed(calculate_interactions)(pose)) - - Parallel(verbose=100, n_jobs=n_tasks)(task for task in tasks) - - mrich.h3('State After') - mrich.var('#fingerprinted', animal.poses.num_fingerprinted) - - mrich.success('Completed') - - -@app.command() -def verify() -> None: - """Verify installation""" - - import os - - file_path = '_test.sqlite' - - try: - animal = setup_animal(file_path, backup=False) - c = animal.register_compound(smiles='COc1ccc2sc(N)nc2c1') - c.mol - mrich.success('HIPPO/rdkit/chemicalite installations are compatible') - except Exception as e: - mrich.error(e) - - if os.path.exists(file_path): - os.remove(file_path) - - -@app.command() -def tag_summary( - database: str, -) -> None: - """Print a table of statistics for all tags in the database""" - - mrich.h1('hippo.tag_summary') - - mrich.h3('Params') - mrich.var('database', database) - - animal = setup_animal(database=database, backup=False) - animal.tags.summary() - - -@app.command() -def add_hits( - database: str, - target_name: str, - aligned_directory: str, - metadata_csv: str = None, - tags: list[str] = None, - skip: list[str] = None, - debug: bool = False, - load_pose_mols: bool = False, - backup: bool = True, -): - """Load hits from Fragalysis / XCA data package""" - - mrich.h1('hippo.tag_summary') - - mrich.h3('Params') - mrich.var('database', database) - mrich.var('target_name', target_name) - mrich.var('metadata_csv', metadata_csv) - mrich.var('aligned_directory', aligned_directory) - mrich.var('tags', tags) - mrich.var('skip', skip) - mrich.var('debug', debug) - mrich.var('load_pose_mols', load_pose_mols) - - animal = setup_animal(database=database, backup=False) - - animal.add_hits( - target_name=target_name, - metadata_csv=metadata_csv, - aligned_directory=aligned_directory, - tags=tags, - skip=skip, - debug=debug, - load_pose_mols=load_pose_mols, - ) - - -def main() -> None: - """CLI entry point""" - app() - - -if __name__ == '__main__': - main() diff --git a/hippo/animal.py b/hippo/animal.py deleted file mode 100644 index 1639b52..0000000 --- a/hippo/animal.py +++ /dev/null @@ -1,3246 +0,0 @@ -"""Main animal class for HIPPO""" - -from pathlib import Path - -import mcol -import mrich -import pandas as pd -from mrich import print - -from .compound import Compound -from .cset import CompoundSet, CompoundTable, IngredientSet -from .iset import InteractionTable -from .pose import Pose -from .pset import PoseSet, PoseTable -from .reaction import Reaction -from .rset import ReactionTable -from .tags import TagTable -from .target import Target -from .tools import ( - SanitisationError, - flat_inchikey, - inchikey_from_smiles, - sanitise_smiles, -) - - -class HIPPO: - """The :class:`.HIPPO` `animal` class. Instantiating a :class:`.HIPPO` object will create or link a :class:`.HIPPO` :class:`.Database`. - - :: - - from hippo import HIPPO - animal = HIPPO(project_name, db_path) - - .. attention:: - - In addition to this API reference please see the tutorial pages :doc:`getting_started` and :doc:`insert_elaborations`. - - :param project_name: give this :class:`.HIPPO` a name - :param db_path: path where the :class:`.Database` will be stored - :param copy_from: optionally initialise this animal by copying the :class:`.Database` at this given path, defaults to None - :returns: :class:`.HIPPO` object - """ - - def __init__( - self, - name: str, - db: str | Path | dict, - copy_from: str | Path | None = None, - overwrite_existing: bool = False, - update_legacy: bool = False, - ) -> None: - """HIPPO initialisation""" - - mrich.bold('Creating HIPPO animal') - - self._name = name - - mrich.var('name', name, color='arg') - - if isinstance(db, dict): - ### POSTGRES - - from .postgres import PostgresDatabase - - self._db = PostgresDatabase(animal=self, **db) - - else: - ### INITIALISE SQLITE DATABASE - - from .db import Database - - db_path = Path(db) - - mrich.var('db_path', db_path, color='file') - - if copy_from: - self._db = Database.copy_from( - source=copy_from, - destination=db_path, - animal=self, - update_legacy=update_legacy, - overwrite_existing=overwrite_existing, - ) - else: - self._db = Database(db_path, animal=self, update_legacy=update_legacy) - - self._compounds = CompoundTable(self.db) - self._poses = PoseTable(self.db) - self._tags = TagTable(self.db) - self._reactions = ReactionTable(self.db) - - ### in memory subsets - self._reactants = None - self._products = None - self._intermediates = None - self._scaffolds = None - self._elabs = None - - mrich.success('Initialised animal', f'[var_name]{self}') - - ### PROPERTIES - - @property - def name(self) -> str: - """Returns the project name - - :returns: project name - """ - return self._name - - @property - def db_path(self) -> str: - """Returns the database path""" - return self.db.path - - @property - def db(self) -> 'Database': - """Returns the Database object""" - return self._db - - @property - def compounds(self) -> CompoundTable: - """Access compounds in the Database""" - return self._compounds - - @property - def poses(self) -> PoseTable: - """Access Poses in the Database""" - return self._poses - - @property - def reactions(self) -> ReactionTable: - """Access Reactions in the Database""" - return self._reactions - - @property - def tags(self) -> TagTable: - """Access Tags in the Database""" - return self._tags - - @property - def interactions(self) -> InteractionTable: - """Access Interactions in the Database""" - # return self._interactions - from .iset import InteractionTable - - return InteractionTable(self.db) - - @property - def num_compounds(self) -> int: - """Total number of Compounds in the Database""" - return len(self.compounds) - - @property - def num_poses(self) -> int: - """Total number of Poses in the Database""" - return len(self.poses) - - @property - def num_reactions(self) -> int: - """Total number of Reactions in the Database""" - return len(self.reactions) - - @property - def num_tags(self) -> int: - """Number of unique Tags in the Database""" - return len(self.tags.unique) - - @property - def targets(self) -> list[Target]: - """Access Targets in the Database""" - target_ids = self.db.select(table='target', query='target_id', multiple=True) - return [self.db.get_target(id=q) for (q,) in target_ids] - - @property - def reactants(self) -> CompoundSet: - """Returns all compounds that are reactants for at least one :class:`.Reaction` (and not products of others)""" - if ( - self._reactants is None - or self._reactants['total_changes'] != self.db.total_changes - ): - self._reactants = dict( - set=self.compounds.reactants, total_changes=self.db.total_changes - ) - return self._reactants['set'] - - @property - def products(self) -> CompoundSet: - """Returns all compounds that are products of at least one :class:`.Reaction` (and not reactants of others)""" - if ( - self._products is None - or self._products['total_changes'] != self.db.total_changes - ): - self._products = dict( - set=self.compounds.products, total_changes=self.db.total_changes - ) - return self._products['set'] - - @property - def intermediates(self) -> CompoundSet: - """Returns all compounds that are products and reactants of :class:`.Reaction`""" - if ( - self._intermediates is None - or self._intermediates['total_changes'] != self.db.total_changes - ): - self._intermediates = dict( - set=self.compounds.intermediates, total_changes=self.db.total_changes - ) - return self._intermediates['set'] - - @property - def num_reactants(self) -> int: - """Returns the number of reactants (see :meth:`reactants`)""" - return len(self.reactants) - - @property - def num_intermediates(self) -> int: - """Returns the number of intermediates (see :meth:`intermediates`)""" - return len(self.intermediates) - - @property - def num_products(self) -> int: - """Returns the number of products (see :meth:`products`)""" - return len(self.products) - - @property - def elabs(self) -> CompoundSet: - """Returns compounds that are an based on another""" - if self._elabs is None or self._elabs['total_changes'] != self.db.total_changes: - self._elabs = dict( - set=self.compounds.elabs, total_changes=self.db.total_changes - ) - return self._elabs['set'] - - @property - def scaffolds(self) -> CompoundSet: - """Returns compounds that are the basis for one or more elaborations""" - if ( - self._scaffolds is None - or self._scaffolds['total_changes'] != self.db.total_changes - ): - self._scaffolds = dict( - set=self.compounds.scaffolds, total_changes=self.db.total_changes - ) - return self._scaffolds['set'] - - @property - def num_elabs(self) -> int: - """Number of compounds that are an elaboration of an existing scaffold""" - return len(self.elabs) - - @property - def num_scaffolds(self) -> int: - """Number of compounds that are the basis for elaborations""" - return len(self.scaffolds) - - ### BULK INSERTION - - def add_hits( - self, - target_name: str, - metadata_csv: str | Path, - aligned_directory: str | Path, - tags: list | None = None, - skip: list | None = None, - debug: bool = False, - load_pose_mols: bool = False, - ) -> pd.DataFrame: - """Load in crystallographic hits from a Fragalysis download or XChemAlign alignment. - - For a Fragalysis download `aligned_directory` and `metadata_csv` should point to the `aligned_files` and `metadata.csv` at the root of the extracted download. - For an XChemAlign dataset the `aligned_directory` should point to the `aligned_files`. - - :param target_name: Name of this protein :class:`.Target` - :param metadata_csv: Path to the metadata.csv from the Fragalysis download - :param aligned_directory: Path to the aligned_files directory from the Fragalysis download - :param skip: optional list of observation names to skip - :param debug: bool: (Default value = False) - :returns: a DataFrame of metadata - - """ - - import re - from enum import Enum - - import molparse as mp - from rdkit.Chem import PandasTools - - from .tools import remove_other_ligands - - ### Process arguments - - assert aligned_directory, 'aligned_directory must be provided' - - skip = skip or [] - tags = tags or ['hits'] - - if not isinstance(aligned_directory, Path): - aligned_directory = Path(aligned_directory) - - mrich.var('aligned_directory', aligned_directory) - - ### Register Target - - target = self.register_target(name=target_name) - - ### Determine data format - - class DataFormat(Enum): - """DataFormat enum""" - - Fragalysis_v2 = 1 - XChemAlign_v2 = 2 - XChemAlign_v3 = 3 - - def __str__(self) -> str: - """name""" - return self.name - - subdirs = list(aligned_directory.glob('*')) - - SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') - SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') - - fragalysis_subdirs_present = any( - SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs - ) - xca_subdirs_present = any( - SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs - ) - assert fragalysis_subdirs_present ^ xca_subdirs_present, ( - 'Unexpected mixed data format' - ) - - if fragalysis_subdirs_present: - data_format = DataFormat.Fragalysis_v2 - else: - if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): - data_format = DataFormat.XChemAlign_v3 - else: - data_format = DataFormat.XChemAlign_v2 - - mrich.var('data_format', data_format) - - ### Counters - - count_directories_tried = 0 - count_compound_registered = 0 - count_poses_registered = 0 - - ### Read metadata - - if data_format is DataFormat.Fragalysis_v2: - assert metadata_csv, 'metadata.csv required' - - meta_df = pd.read_csv(metadata_csv) - curated_tag_cols = [ - c - for c in meta_df.columns - if c - not in [ - 'Code', - 'Long code', - 'Compound code', - 'Smiles', - 'Downloaded', - 'Main status', - 'GOOD count', - 'MEDIOCRE count', - 'BAD count', - 'RefinementResolution', - ] - + GENERATED_TAG_COLS - ] - - mrich.var('curated_tag_cols', curated_tag_cols) - - ### Parse subdirectories - - match data_format: - case DataFormat.Fragalysis_v2: - from .fragalysis import parse_observation_longcode - - fragalysis_pattern = re.compile(r'^.*\d{4}[a-z].sdf$') - pdbid_pattern = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - observations = {} - - for path in list(sorted(aligned_directory.glob('*'))): - name = path.name - - if name in skip: - continue - - d = dict( - name=name, - path=path, - ) - - ### SDFs - - sdfs = [] - - for sdf_path in path.glob('*.sdf'): - sdf_name = sdf_path.name - - if ( - '_ligand' in sdf_name - ): # Quick fix, _ligand.sdf are exactly the same as .sdf in aligned_directory. - continue - - # fragalysis SDF - if fragalysis_pattern.match(sdf_name): - sdfs.append(sdf_path) - # fragalysis SDF from PDB id - elif pdbid_pattern.match(sdf_name): - sdfs.append(sdf_path) - else: - mrich.warning( - sdf_name, - "doesn't not follow neither Fragalysis nor PDB ID patterns", - ) - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(name, 'has no compatible SDFs', path) - continue - - elif len(sdfs) > 1: - mrich.warning(name, 'has multiple compatible SDFs', sdfs) - - d['sdf'] = sdfs[0] - - ### PDBs - - pdbs = [ - p - for p in path.glob('*.pdb') - if '_ligand' not in p.name - and '_apo' not in p.name - and '_hippo' not in p.name - ] - - if not len(pdbs) == 1: - mrich.error(name, 'has invalid PDBs', pdbs) - continue - - d['pdb'] = pdbs[0] - - observations[name] = d - - if debug: - print(d) - - case _: - from .xca import parse_observation_longcode - - observations = {} - - match data_format: - case DataFormat.XChemAlign_v2: - sdf_pattern = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' - ) - case DataFormat.XChemAlign_v3: - sdf_pattern = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' - ) - - for path in list( - sorted(aligned_directory.glob('*[0-9][0-9][0-9][0-9]')) - ): - name = path.name - - if name in skip: - continue - - ### Group by SDF - - sdfs = [] - - for sdf_path in sorted(path.glob('*.sdf')): - sdf_name = sdf_path.name - - if sdf_pattern.match(sdf_name): - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(name, 'has no compatible SDFs', path) - continue - - for i, sdf in enumerate(sdfs): - subname = name + chr(ord('a') + i) - - d = dict( - name=subname, - path=path, - sdf=sdf, - ) - - pdb = path / sdf.name.replace('_ligand.sdf', '.pdb') - - if not pdb.exists(): - mrich.error(name, 'is missing PDB', pdb) - continue - - d['pdb'] = pdb - - observations[name] = d - - mrich.var('#valid observations', len(observations)) - - n_poses = self.num_poses - - for observation_dict in mrich.track( - observations.values(), prefix='Adding hits...' - ): - path = observation_dict['path'] - name = observation_dict['name'] - sdf = observation_dict['sdf'] - pdb = observation_dict['pdb'] - - if debug: - mrich.debug('Processing', path) - - count_directories_tried += 1 - - # load the SDF - df = PandasTools.LoadSDF( - str(sdf), molColName='ROMol', idName='ID', strictParsing=True - ) - - # extract fields - longcode = df.ID[0] - mol = df.ROMol[0] - - match data_format: - case DataFormat.Fragalysis_v2: - obs_dict = parse_observation_longcode(longcode) - case DataFormat.XChemAlign_v2: - obs_dict = parse_observation_longcode(longcode) - - if debug: - mrich.debug(name, longcode) - - # parse the PDB file - if debug: - mrich.reading(pdb) - sys = mp.parse(pdb, verbosity=0) - - # create the single ligand bound pdb - lig_residues = sys.residues['LIG'] - if len(lig_residues) > 1 or any( - r.contains_alternative_sites for r in lig_residues - ): - sys = remove_other_ligands( - sys, obs_dict['residue_number'], obs_dict['chain'] - ) - sys.prune_alternative_sites('A', verbosity=0) - pose_path = str(pdb.resolve()).replace('.pdb', '_hippo.pdb') - mp.write(pose_path, sys, shift_name=True, verbosity=debug) - else: - pose_path = str(pdb.resolve()) - - # smiles - smiles = mp.rdkit.mol_to_smiles(mol) - smiles = sanitise_smiles(smiles, verbosity=debug) - - # create the molecule / pose - compound_id = self.db.insert_compound( - smiles=smiles, - tags=tags, - warn_duplicate=debug, - commit=False, - ) - - if not compound_id: - inchikey = inchikey_from_smiles(smiles) - compound = self.compounds[inchikey] - - if not compound: - mrich.error( - 'Compound exists in database but could not be found by inchikey' - ) - mrich.var('smiles', smiles) - mrich.var('inchikey', inchikey) - mrich.var('observation_shortname', name) - raise Exception - - else: - count_compound_registered += 1 - compound = self.compounds[compound_id] - - # metadata - - match data_format: - case DataFormat.Fragalysis_v2: - meta_row = meta_df[meta_df['Code'] == name] - if not len(meta_row): - assert longcode - meta_row = meta_df[meta_df['Long code'] == longcode] - - assert len(meta_row) - - metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} - - for tag in GENERATED_TAG_COLS: - if tag in meta_row.columns: - metadata[tag] = meta_row[tag].values[0] - - pose_tags = set(tags) - - for tag in curated_tag_cols: - if meta_row[tag].values[0]: - pose_tags.add(tag) - - case DataFormat.XChemAlign_v2: - metadata = {'xca_longcode': longcode} - pose_tags = set(tags) - - pose = self.register_pose( - compound=compound, - alias=name, - target=target.id, - path=pose_path, - tags=pose_tags, - metadata=metadata, - duplicate_alias='skip', - ) - - if load_pose_mols: - try: - pose.mol - except Exception as e: - mrich.error('Could not load molecule', pose) - mrich.error(e) - - mrich.var('#directories parsed', count_directories_tried) - mrich.var('#compounds registered', count_compound_registered) - mrich.var('#poses registered', self.num_poses - n_poses) - - def load_sdf( - self, - *, - target: str, - path: str | Path, - reference: int | Pose | None = None, - inspirations: list[int] | PoseSet | None = None, - compound_tags: None | list[str] = None, - pose_tags: None | list[str] = None, - mol_col: str = 'ROMol', - name_col: str | None = 'ID', - inspiration_col: str | None = 'ref_mols', - reference_col: str = 'ref_pdb', - energy_score_col: str = 'energy_score', - distance_score_col: str = 'distance_score', - inspiration_map: None | dict = None, - convert_floats: bool = True, - skip_equal_dict: dict | None = None, - skip_not_equal_dict: dict | None = None, - ) -> None: - """Add posed virtual hits from an SDF into the database. - - :param target: Name of the protein :class:`.Target` - :param path: Path to the SDF - :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's - :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` - :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` - :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` - :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` - :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` - :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` - :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids - :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` - :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` - :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` - :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` - :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` - - All non-name columns are added to the Pose metadata. - N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. - """ - - if not isinstance(path, Path): - path = Path(path) - - skip_equal_dict = skip_equal_dict or {} - skip_not_equal_dict = skip_not_equal_dict or {} - - mrich.debug(f'{path=}') - - compound_tags = compound_tags or [] - pose_tags = pose_tags or [] - - from molparse.rdkit import mol_to_smiles - from numpy import isnan - from pandas import read_pickle - from rdkit.Chem import PandasTools - - if path.name.endswith('.sdf'): - df = PandasTools.LoadSDF(str(path.resolve())) - else: - df = read_pickle(path) - - df_columns = list(df.columns) - - target = self.register_target(target) - - assert mol_col in df_columns, f'{mol_col=} not in {df_columns}' - - if name_col: - assert name_col in df_columns, f'{name_col=} not in {df_columns}' - - if inspiration_col and not inspirations: - assert inspiration_col in df_columns, ( - f'{inspiration_col=} not in {df_columns}' - ) - - if not reference and reference_col: - assert reference_col in df_columns, f'{reference_col=} not in {df_columns}' - - output_directory = str(path.name).removesuffix('.sdf') - output_directory = Path(output_directory) - if not output_directory.exists: - mrich.writing(f'Creating output directory {output_directory}') - os.system(f'mkdir -p {output_directory}') - - n_poses = self.num_poses - n_comps = self.num_compounds - - ### FILTER DATAFRAME - - mrich.var('SDF entries (pre-filter)', len(df)) - - df = df[df['ID'] != 'ver_1.2'] - - for k, v in skip_equal_dict.items(): - df = df[df[k] == v] - - for k, v in skip_not_equal_dict.items(): - df = df[df[k] != v] - - mrich.var('SDF entries (post-filter)', len(df)) - - ### COMPOUND REGISTRATION - - if 'smiles' not in df.columns: - df['smiles'] = df[mol_col].apply(mol_to_smiles) - smiles = list(set(df['smiles'].values)) - mrich.debug('#smiles', len(smiles)) - mrich.debug('Registering compounds...') - pairs = self.register_compounds(smiles=smiles, sanitisation_verbosity=False) - - # fix for 2033, replace smiles_lookup generation procedure - # smiles_lookup = {s1: i for s1, (i, s2) in zip(smiles, pairs)} - # duplicated sanitation in register_compounds - smiles_lookup = {} - for s in smiles: - try: - new_smiles = sanitise_smiles( - s, - sanitisation_failed='error', - radical='warning', - verbosity=True, - ) - except SanitisationError as e: - mrich.error(f'Could not sanitise {s=}') - mrich.error(str(e)) - continue - except AssertionError: - mrich.error(f'Could not sanitise {s=}') - continue - - # smiles must now be sanitised and should not throw error - # in flat_inchikey method - smiles_lookup[s] = flat_inchikey(new_smiles) - - inchi_lookup = self.db.get_compound_inchikey_id_dict( - inchikeys=smiles_lookup.values() - ) - - df['inchikey'] = df['smiles'].apply(lambda x: smiles_lookup.get(x)) - df['compound_id'] = df['inchikey'].apply(lambda x: inchi_lookup.get(x)) - df['compound_id'] = df['compound_id'].fillna(0).astype(int) - - if n := len(df[df['compound_id'].isna()]): - mrich.error(n, 'invalid compound rows') - - cset = self.compounds[set(i for i in df['compound_id'].values if i)] - for tag in compound_tags: - cset.add_tag(tag) - - ### POSE REGISTRATION - - if not inspiration_map: - inspiration_map = self.db.get_pose_alias_id_dict() - - # dicts: (alias, compound, target, path, metadata, inspirations, tags, reference,) - data = [] - - for i, row in mrich.track(df.iterrows(), prefix='Reading SDF rows...'): - if name_col: - name = row[name_col].strip() or f'pose_{i}' - alias = name - else: - name = f'pose_{i}' - alias = None - - mol = row[mol_col] - inchikey = row['inchikey'] - smiles = row['smiles'] - compound_id = row['compound_id'] - if not compound_id: - mrich.error('Skipping invalid compound', i) - continue - pose_path = (output_directory / f'{name}.fake.mol').resolve() - energy_score = float(row[energy_score_col]) - distance_score = float(row[distance_score_col]) - - # inspirations - - inspiration_list = [] - - if isinstance(inspirations, PoseSet): - inspiration_list = list(inspirations.ids) - - elif inspirations or inspiration_col: - if inspirations: - insp_str = inspirations - else: - insp_str = row[inspiration_col] - - if isinstance(insp_str, str): - insp_str = insp_str.removeprefix('[') - insp_str = insp_str.removesuffix(']') - insp_str = insp_str.replace("'", '') - generator = insp_str.split(',') - - elif isinstance(insp_str, float): - generator = [] - - else: - generator = insp_str - - for insp in generator: - insp = insp.strip() - - try: - pose_id = int(insp) - inspiration_list.append(pose_id) - - except ValueError: - if ( - isinstance(inspiration_map, dict) - and insp in inspiration_map - ): - pose_id = inspiration_map[insp] - if pose_id: - inspiration_list.append(pose_id) - elif callable(inspiration_map): - pose_id = inspiration_map(insp) - if pose_id: - inspiration_list.append(pose_id) - else: - mrich.error( - f'Could not find inspiration pose with alias={insp}' - ) - continue - - if not reference: - ref_str = row.get(reference_col) - if ref_str: - try: - row_reference = int(ref_str) - except ValueError: - row_reference = inspiration_map[ref_str] - else: - row_reference = None - - elif isinstance(reference, Pose): - row_reference = reference.id - - # metadata - metadata = {} - skip = { - 'smiles', - 'inchikey', - 'compound_id', - inspiration_col, - name_col, - mol_col, - energy_score_col, - distance_score_col, - 'target_id', - 'reference_id', - 'path', - 'exports', - } - - for col in df_columns: - value = row[col] - - if col in skip: - continue - - if isinstance(value, float) and isnan(value): - continue - - if convert_floats: - try: - value = float(value) - except TypeError: - pass - except ValueError: - pass - - if not (isinstance(value, str) or isinstance(value, float)): - if i == 0: - mrich.warning(f'Skipping metadata from column={col}.') - continue - - metadata[col] = value - - data.append( - dict( - alias=alias, - compound_id=compound_id, - target_id=target.id, - path=pose_path, - metadata=metadata, - inspiration_ids=inspiration_list, - reference_id=row_reference, - mol=mol, - inchikey=inchikey, - smiles=smiles, - energy_score=energy_score, - distance_score=distance_score, - ) - ) - - ### ACTUALLY DO THE BULK INSERTION - - mrich.debug('Registering poses...') - ids = self.db.register_poses(data) - pset = self.poses[ids] - mrich.debug('Adding tags...') - for tag in pose_tags: - pset.add_tag(tag) - - if n := self.num_compounds - n_comps: - f = mrich.success - else: - f = mrich.warning - - f(f'{n} new compounds from {path}') - - if n := self.num_poses - n_poses: - f = mrich.success - else: - f = mrich.warning - - f(f'{n} new poses from {path}') - - def add_syndirella_scaffolds( - self, - output_directory: str | Path, - *, - pattern: str = '*-*-?-scaffold-check/scaffold-*', - tags: None | list[str] = None, - target: int | str = 1, - debug: bool = False, - ) -> None: - """ - Load Poses from Syndirella "scaffold-check" outputs - - :param df_path: Path to the pickled DataFrame or SDF. - :param tags: list of tags to assign to compounds and poses, defaults to ``None`` - :param target: :class:`.Target` ID or name - :param pattern: UNIX pattern by which to search for subdirectories - :param debug: Increase verbosity of output, defaults to ``False`` - :returns: None - """ - - import json - - output_directory = Path(output_directory) - - n_poses = self.num_poses - - mrich.warning('Not setting inspirations and references') - - for subdir in mrich.track( - list(output_directory.glob(pattern)), prefix='Loading scaffolds...' - ): - inchikey = subdir.parent.name.replace('-scaffold-check', '') - - compound = self.compounds[inchikey] - - if debug: - mrich.var('subdir', subdir) - mrich.var('inchikey', inchikey) - mrich.var('compound', compound) - - name = subdir.name - - mol_file = subdir / f'{name}.minimised.mol' - if not mol_file.exists(): - continue - - json_file = subdir / f'{name}.minimised.json' - if not json_file.exists(): - continue - - metadata = json.load(open(json_file)) - - if debug: - mrich.print(metadata) - - energy_score = ( - metadata['Energy']['bound']['total_score'] - - metadata['Energy']['unbound']['total_score'] - ) - distance_score = metadata['mRMSD'] - - tags = tags or ['Syndirella scaffold'] - - self.register_pose( - path=mol_file, - compound=compound, - target=target, - tags=tags, - return_pose=False, - ) - - n_poses = self.num_poses - n_poses - - if n_poses: - mrich.success(f'Added {n_poses} scaffold Poses') - else: - mrich.warning(f'Added {n_poses} scaffold Poses') - - def add_syndirella_elabs( - self, - df_path: str | Path, - max_energy_score: float | None = 0.0, - max_distance_score: float | None = 2.0, - require_intra_geometry_pass: bool = True, - reject_flags: list[str] | None = None, - register_reactions: bool = True, - dry_run: bool = False, - scaffold_route: 'Route | None' = None, - scaffold_compound: 'Compound | None' = None, - pose_tags: list[str] | None = None, - product_tags: list[str] | None = None, - ) -> 'pd.DataFrame': - """ - Load Syndirella elaboration compounds and poses from a pickled DataFrame - - :param df_path: Path to the pickled DataFrame - :param max_energy_score: Filter out poses with `∆∆G` above this value - :param max_distance_score: Filter out poses with `comRMSD` above this value - :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values - :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) - :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing - :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing - :param dry_run: Don't insert new records into the database (for debugging/testing) - :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] - :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] - :returns: annotated DataFrame - """ - - reject_flags = reject_flags or [ - 'one_of_multiple_products', - 'selectivity_issue_contains_reaction_atoms_of_both_reactants', - ] - - pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] - product_tags = product_tags or ['syndirella_product'] - - df_path = Path(df_path) - mrich.h3(df_path.name) - - mrich.reading(df_path) - df = pd.read_pickle(df_path) - - # work out number of reaction steps - num_steps = max( - [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] - ) - mrich.var('num_steps', num_steps) - - # add is_scaffold row - df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') - - ###### PREP ###### - - # flags - - present_flags = set() - for step in range(num_steps): - step += 1 - - for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): - for flag in flags: - present_flags.add(flag) - - if present_flags: - mrich.warning('Flags in DataFrame:', present_flags) - - for flag in reject_flags: - if flag in present_flags: - for step in range(num_steps): - step += 1 - matches = df[f'{step}_flag'].apply( - lambda x: flag in x if x is not None else False - ) - mrich.print( - 'Filtering out', - len(df[matches]), - 'rows from step', - step, - 'due to', - flag, - ) - df = df[~matches] - - # poses - - n_null_mol = len(df[df['path_to_mol'].isna()]) - if n_null_mol: - df = df[df['path_to_mol'].notna()] - mrich.var('#rows skipped due to null path_to_mol', n_null_mol) - - if not len(df): - mrich.warning('No valid rows') - return None - - # inspirations - inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) - if len(inspiration_sets) != 1: - mrich.error('Varying inspirations not supported') - return df - - (inspiration_set,) = inspiration_sets - inspirations = self.poses[inspiration_set] - assert len(inspirations) == len(inspiration_set) - - # reference - template_paths = set(df['template'].to_list()) - assert len(template_paths) == 1, 'Multiple references not supported' - (template_path,) = template_paths - template_path = Path(template_path) - mrich.var('template_path', template_path) - base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') - reference = self.poses[base_name] - assert reference, 'Could not determine reference structure' - mrich.var('reference', reference) - - target = reference.target - - # subset of rows - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - mrich.var('#scaffold entries', len(scaffold_df)) - mrich.var('#elab entries', len(elab_df)) - - if not len(scaffold_df) and not scaffold_route and not scaffold_compound: - mrich.error('No valid scaffold rows') - return None - - elif scaffold_route: - ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE - - assert scaffold_route.num_reactions == 1 - - product = scaffold_route.products[0].compound - reaction = scaffold_route.reactions[0] - - assert len(reaction.reactants) == 2 - - scaffold_dict = { - 'scaffold_smiles': product.smiles, - '1_reaction': reaction.type, - '1_r1_smiles': reaction.reactants[0].smiles, - '1_r2_smiles': reaction.reactants[1].smiles, - '1_product_smiles': product.smiles, - '1_product_name': 'scaffold', - '1_single_reactant_elab': False, - '1_num_atom_diff': 0, - 'is_scaffold': True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - elif scaffold_compound: - ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT - - scaffold_dict = { - 'scaffold_smiles': scaffold_compound.smiles, - 'is_scaffold': True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - if dry_run: - mrich.error('Not registering records (dry_run)') - return df - - ###### ELABS ###### - - # bulk register compounds - - smiles_cols = [ - c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' - ] - - for smiles_col in smiles_cols: - inchikey_col = smiles_col.replace('_smiles', '_inchikey') - compound_id_col = smiles_col.replace('_smiles', '_compound_id') - - unique_smiles = df[smiles_col].dropna().unique() - - mrich.debug( - f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' - ) - - values = self.register_compounds( - smiles=unique_smiles, - radical=False, - sanitisation_verbosity=False, - ) - - orig_smiles_to_inchikey = { - orig_smiles: inchikey - for orig_smiles, (inchikey, new_smiles) in zip( - unique_smiles, values, strict=False - ) - } - - df[inchikey_col] = df[smiles_col].apply( - lambda x: orig_smiles_to_inchikey.get(x) - ) - - # get associated IDs - compound_inchikey_id_dict = self.db.get_compound_inchikey_id_dict( - list(orig_smiles_to_inchikey.values()) - ) - df[compound_id_col] = df[inchikey_col].apply( - lambda x: compound_inchikey_id_dict.get(x) - ) - - # bulk register reactions - - if register_reactions: - for step in range(num_steps): - step += 1 - - mrich.debug(f'Registering reactions for step {step}') - - reaction_dicts = [] - - for reaction_name, r1_id, r2_id, product_id in df[ - [ - f'{step}_reaction', - f'{step}_r1_compound_id', - f'{step}_r2_compound_id', - f'{step}_product_compound_id', - ] - ].values: - # skip invalid rows - if pd.isna(r1_id) or pd.isna(product_id): - mrich.warning("Can't insert reactions for missing scaffold") - continue - - # reactant IDs - - reactant_ids = set() - reactant_ids.add(int(r1_id)) - - if not pd.isna(r2_id): - reactant_ids.add(int(r2_id)) - - product_id = int(product_id) - - # registration data - - reaction_dicts.append( - dict( - reaction_name=reaction_name, - reactant_ids=reactant_ids, - product_id=int(product_id), - ) - ) - - reaction_ids = self.register_reactions( - types=[d['reaction_name'] for d in reaction_dicts], - product_ids=[d['product_id'] for d in reaction_dicts], - reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], - ) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - # tag product compounds: - - product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) - products = self.compounds[product_ids] - for tag in product_tags: - products.add_tag(tag) - - # bulk register scaffold relationships - - for step in range(num_steps): - step += 1 - - for role in ['r1', 'r2', 'product']: - key = f'{step}_{role}_compound_id' - - mrich.debug(f'Registering scaffold relatonships for {key}') - - if step == num_steps and role == 'product' and scaffold_compound: - scaffold_id = scaffold_compound.id - - else: - scaffold_ids = list(scaffold_df[key].dropna().unique()) - - if not scaffold_ids: - mrich.warning( - "Can't insert scaffold relationships due to missing", - key, - 'for all scaffold rows', - ) - continue - - if len(scaffold_ids) > 1: - mrich.error('Multiple scaffold row values in', key) - return scaffold_df - - scaffold_id = scaffold_ids[0] - - superstructure_ids = [ - i for i in elab_df[key].unique() if i != scaffold_id - ] - - match self.db.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO scaffold(scaffold_base, scaffold_superstructure) - VALUES(?1, ?2) - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - - self.db.executemany( - sql, - [(int(scaffold_id), int(i)) for i in superstructure_ids], - ) - self.db.commit() - - # filter poses - - ok = df - - try: - if require_intra_geometry_pass: - mrich.var( - '#poses !intra_geometry_pass', - len(df[df['intra_geometry_pass'] == False]), - ) - ok = ok[ok['intra_geometry_pass'] == True] - - if max_energy_score is not None: - mrich.var( - f'#poses ∆∆G > {max_energy_score}', - len(df[df['∆∆G'] > max_energy_score]), - ) - ok = ok[ok['∆∆G'] <= max_energy_score] - - if max_distance_score is not None: - mrich.var( - f'#poses comRMSD > {max_distance_score}', - len(df[df['comRMSD'] > max_energy_score]), - ) - ok = ok[ok['comRMSD'] <= max_distance_score] - - except Exception as e: - mrich.error('Problem filtering dataframe') - mrich.error(e) - return df - - mrich.var('#acceptable poses', len(ok)) - - if not len(ok): - mrich.warning('No valid poses') - return None - - # bulk register poses - - payload = [] - - for i, row in ok.iterrows(): - path = Path(row.path_to_mol).resolve() - - if not path.exists(): - mrich.warning('Skipping pose w/ non-exising file:', path) - continue - - pose_tuple = ( - int(reference.id), - str(path), - int(row[f'{num_steps}_product_compound_id']), - int(target.id), - float(row['∆∆G']), - float(row['comRMSD']), - ) - - payload.append(pose_tuple) - - if not payload: - mrich.warning('No valid poses') - return None - - mrich.debug(f'Registering {len(payload)} poses...') - - match self.db.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO pose( - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_energy_score, - pose_distance_score - ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6) - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.pose( - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_energy_score, - pose_distance_score - ) - VALUES(%s, %s, %s, %s, %s, %s) - ON CONFLICT DO NOTHING; - """ - - n_before = self.num_poses - self.db.executemany(sql, payload) - self.db.commit() - diff = self.num_poses - n_before - - if diff: - mrich.success('Registered', diff, 'new poses') - else: - mrich.warning('Registered', diff, 'new poses') - - # query relevant poses (also previously registered) - paths = [t[1] for t in payload] - str_ids = str(tuple(paths)).replace(',)', ')') - records = self.db.select_where( - table='pose', query='pose_id', key=f'pose_path IN {str_ids}', multiple=True - ) - pose_ids = [i for (i,) in records] - - # bulk register inspirations - - payload = set() - for pose_id in pose_ids: - for inspiration in inspirations.ids: - payload.add((inspiration, pose_id)) - - match self.db.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) - VALUES(?%s1, %s) - ON CONFLICT DO NOTHING; - """ - - self.db.executemany(sql, list(payload)) - self.db.commit() - - # if pose_tags: - poses = self.poses[pose_ids] - for tag in pose_tags: - poses.add_tag(tag) - - return df - - def add_syndirella_routes( - self, - pickle_path: str | Path, - CAR_only: bool = True, - pick_first: bool = True, - check_chemistry: bool = True, - register_routes: bool = True, - ) -> pd.DataFrame: - """Add routes found from syndirella --just_retro query""" - - from .chem import InvalidChemistryError, UnsupportedChemistryError - from .cset import IngredientSet - from .recipe import Recipe - - df = pd.read_pickle(pickle_path) - - for i, row in mrich.track(df.iterrows(), total=len(df)): - mrich.set_progress_field('i', i) - mrich.set_progress_field('n', len(df)) - - d = row.to_dict() - - comp = self.compounds(smiles=d['smiles']) - - n_routes = 0 - for key in d: - if not key.startswith('route'): - continue - - if not key.endswith('_names'): - continue - - v = d[key] - - if isinstance(v, float) and pd.isna(v): - break - - n_routes += 1 - - if not n_routes: - # mrich.warning(comp, "#routes =", n_routes) - continue - - routes = [] - for j in range(n_routes): - route_str = f'route{j}' - - route = d[route_str] - - if CAR_only and not d[route_str + '_CAR']: - continue - - reactions = ReactionSet(self.db) - reactants = IngredientSet(self.db) - intermediates = IngredientSet(self.db) - products = IngredientSet(self.db) - - try: - for k, reaction in enumerate(route): - reaction_type = reaction['name'] - - product = self.compounds(smiles=reaction['productSmiles']) - - mrich.print(i, j, k, reaction_type, product) - - rs = [] - for reactant_s in reaction['reactantSmiles']: - reactant = self.register_compound(smiles=reactant_s) - rs.append(reactant.id) - - # register the reaction - reaction = self.register_reaction( - type=reaction_type, - product=product, - reactants=rs, - check_chemistry=check_chemistry, - ) - - for r_id in rs: - if r_id in reactants: - intermediates.add(compound_id=r_id, amount=1) - else: - reactants.add(compound_id=r_id, amount=1) - - reactions.add(reaction) - except InvalidChemistryError: - continue - except UnsupportedChemistryError: - mrich.warning('Skipping unsupported chemistry:', reaction_type) - continue - except Exception: - mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) - continue - - products.add(product.as_ingredient(amount=1)) - - recipe = Recipe( - db=self.db, - reactions=reactions, - reactants=reactants, - intermediates=intermediates, - products=products, - ) - - if register_routes: - route_id = self.register_route(recipe=recipe) - mrich.success('registered route', route_id) - - if pick_first: - break - - return df - - def add_enamine_quote( - self, - path: str | Path, - *, - orig_name_col: str = 'Customer Code', - # orig_name_col: str = 'Diamond ID (Molecule Name)', - price_col: str | None = None, - fixed_amount: float | None = None, - fixed_lead_time: float | None = False, - fixed_purity: float | None = False, - entry_col: str = 'Catalog ID', - catalogue_col: str = 'Collection', - smiles_col: str = 'SMILES', - amount_col: str = 'Amount, mg', - purity_col: str = 'Purity, %', - lead_time_col: str | None = 'Lead time', - stop_after: None | int = None, - orig_name_is_hippo_id: bool = False, - allow_no_catalogue_col: bool = False, - delete_unavailable: bool = True, - overwrite_existing_quotes: bool = False, - supplier_name: str = 'Enamine', - warn_nan_orig_name: bool = True, - currency: str = None, - dry_run: bool = False, - debug: bool = False, - ): - """ - Load an Enamine quote provided as an excel file - - :param path: Path to the excel file - :param orig_name_col: Column name of the original alias, defaults to 'Customer Code' - :param entry_col: Column name of the catalogue ID/entry, defaults to 'Catalog ID' - :param price_col: Column name of the price, defaults to 'Price, EUR' or 'Price, USD' if present - :param catalogue_col: Column name of the price, defaults to 'Price, EUR' or 'Price, USD' if present - :param fixed_amount: Optionally use a fixed amount for all quotes (in mg) - :param fixed_lead_time: Optionally use a fixed lead time for all quotes (in days) - :param stop_after: Stop after given number of rows, defaults to ``None`` - :param orig_name_is_hippo_id: Set to ``True`` if ``orig_name_col`` is the original HIPPO :class:``hippo.compound.Compound`` ID, defaults to ``False`` - :param delete_unavailable: Delete existing Enamine database quotes for compounds that are unavailable in the quote being loaded - :param overwrite_existing_quotes: Delete existing Enamine database quotes for compounds that are available in the quote being loaded - :param dry_run: Stop before any database modification, return first quote data to be inserted - :param currency: Specify currency if non-standard price column - :returns: An :class:`.IngredientSet` of the quoted molecules - """ - - df = pd.read_excel(path) - - def unexpected_column(key: str, value: str | float) -> str: - """Generate assertion message""" - return ( - f"Unexpected Excel format ({key}='{value}') \n\nfirst row:\n{df.loc[0]}" - ) - - if smiles_col not in df.columns: - smiles_col = smiles_col.lower() - - assert smiles_col in df.columns, unexpected_column('smiles_col', smiles_col) - - if orig_name_col is not None: - assert orig_name_col in df.columns, unexpected_column( - 'orig_name_col', orig_name_col - ) - else: - orig_name_is_hippo_id = False - - assert entry_col in df.columns, unexpected_column('entry_col', entry_col) - - if fixed_purity is False: - assert purity_col in df.columns, unexpected_column('purity_col', purity_col) - - if fixed_amount is None: - assert amount_col in df.columns, unexpected_column('amount_col', amount_col) - - if fixed_lead_time is False and lead_time_col is not None: - assert lead_time_col in df.columns, unexpected_column( - 'lead_time_col', lead_time_col - ) - - if not allow_no_catalogue_col: - assert catalogue_col in df.columns, unexpected_column( - 'catalogue_col', catalogue_col - ) - elif catalogue_col not in df.columns: - catalogue_col = None - - assert ( - 'Price, EUR' in df.columns - or 'Price, USD' in df.columns - or price_col in df.columns - ), unexpected_column('Price', '') - - if price_col is None: - price_cols = [c for c in df.columns if c.startswith('Price')] - assert len(price_cols) == 1 - price_col = price_cols[0] - - currency = currency or price_col.split(', ')[-1] - - ingredients = IngredientSet(self.db) - - if len(df) > 100: - generator = mrich.track( - df.iterrows(), prefix='Loading quotes...', total=len(df) - ) - else: - generator = df.iterrows() - - for i, row in generator: - smiles = row[smiles_col] - - if debug: - mrich.debug('smiles', smiles) - - if not isinstance(smiles, str): - if debug: - mrich.debug('SKIPPING smiles!=str', smiles) - continue - - compound = self.register_compound(smiles=smiles) - - if orig_name_is_hippo_id: - if pd.isna(row[orig_name_col]): - if warn_nan_orig_name: - mrich.warning(f'row {i} has NaN {orig_name_col}') - continue - - expected_id = int(row[orig_name_col]) - - if expected_id != compound.id: - mrich.error('Compound registration mismatch:') - mrich.var('expected_id', expected_id) - mrich.var('new_id', compound.id) - mrich.var('original_smiles', self.compounds[expected_id].smiles) - mrich.var('new_smiles', smiles) - - if catalogue_col and (catalogue := row[catalogue_col]) in [ - 'No starting material', - 'Out of stock', - 'Unavailable', - ]: - if not dry_run and delete_unavailable: - mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) - - self.db.delete_where( - table='quote', - key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", - ) - - continue - - if (price := row[price_col]) == 0.0: - if not dry_run and delete_unavailable: - mrich.warning(f"Deleting '{supplier_name}' quotes for", compound) - - self.db.delete_where( - table='quote', - key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", - ) - - if debug: - mrich.debug('Skipping NULL price', compound, i) - - continue - - if fixed_amount is None: - amount = row[amount_col] - else: - amount = fixed_amount - - if fixed_purity is False: - purity = row[purity_col] / 100 - else: - purity = fixed_purity - - if fixed_lead_time is False: - if not isinstance(row[lead_time_col], str): - continue - if 'week' in row[lead_time_col]: - lead_time = int(row[lead_time_col].split()[0].split('-')[-1]) * 5 - else: - raise NotImplementedError - else: - lead_time = fixed_lead_time - - quote_data = dict( - compound=compound, - supplier=supplier_name, - catalogue=catalogue if catalogue_col else None, - entry=row[entry_col], - amount=amount, - purity=purity, - lead_time=lead_time, - price=price, - currency=currency, - smiles=smiles, - ) - - if debug: - mrich.print(quote_data) - - if dry_run: - mrich.warning('Dry-run, stopping before any database modifications') - return quote_data - - if overwrite_existing_quotes: - self.db.delete_where( - table='quote', - key=f"quote_supplier = '{supplier_name}' AND quote_compound = {compound.id}", - ) - - q_id = self.db.insert_quote(**quote_data) - - if debug: - mrich.debug('inserted quote', q_id) - - ingredients.add( - compound_id=compound.id, - amount=amount, - quoted_amount=amount, - quote_id=q_id, - supplier='Enamine', - max_lead_time=None, - ) - - if stop_after and stop_after == i: - break - - return ingredients - - def add_mcule_quote( - self, - path: str | Path, - ): - """ - Load an MCule quote provided as an excel file - - :param path: Path to the excel file - :returns: An :class:`.IngredientSet` of the quoted molecules - """ - - ### get lead time from suppliers sheet - - sheet_name: str = 'List of suppliers' - df = pd.read_excel(path, sheet_name=sheet_name) - - supplier_col = 'Supplier' - lead_time_col = 'Delivery time (working days)' - - assert supplier_col in df.columns, 'Unexpected Excel format (supplier_col)' - assert lead_time_col in df.columns, 'Unexpected Excel format (lead_time_col)' - - lead_time_lookup = { - row[supplier_col]: row[lead_time_col] for i, row in df.iterrows() - } - - ### parse individual compound quotes - - sheet_name: str = 'List of products' - df = pd.read_excel(path, sheet_name=sheet_name) - - # return df - - smiles_col = 'Quoted product SMILES' - entry_col = 'Query Mcule ID' - purity_col = 'Guaranteed purity (%)' - amount_col = 'Quoted Amount (mg)' - catalogue_col = 'Supplier' - lead_time_col = 'Lead time' - price_col = 'Product price (USD)' - currency = 'USD' - - assert smiles_col in df.columns, 'Unexpected Excel format (smiles_col)' - assert entry_col in df.columns, 'Unexpected Excel format (entry_col)' - assert purity_col in df.columns, 'Unexpected Excel format (purity_col)' - assert amount_col in df.columns, 'Unexpected Excel format (amount_col)' - assert catalogue_col in df.columns, 'Unexpected Excel format (catalogue_col)' - assert price_col in df.columns, 'Unexpected Excel format (price_col)' - - ingredients = IngredientSet(self.db) - - for i, row in mrich.track(df.iterrows(), prefix='Loading quotes...'): - smiles = row[smiles_col] - - if not isinstance(smiles, str): - break - - compound = self.register_compound(smiles=smiles) - - # if (catalogue := row[catalogue_col]) == 'No starting material': - # continue - - catalogue = row[catalogue_col] - lead_time = lead_time_lookup[catalogue] - - # if (price := row[price_col]) == 0.0: - # continue - - # if not isinstance(row[lead_time_col], str): - # continue - - # if 'week' in row[lead_time_col]: - # lead_time = int(row[lead_time_col].split()[0].split('-')[-1])*5 - # else: - # raise NotImplementedError - - quote_data = dict( - compound=compound, - supplier='MCule', - catalogue=catalogue, - entry=row[entry_col], - amount=row[amount_col], - purity=row[purity_col] / 100, - lead_time=lead_time, - price=row[price_col], - currency=currency, - smiles=smiles, - ) - - q_id = self.db.insert_quote(**quote_data, commit=False) - - ingredients.add( - compound_id=compound.id, - amount=row[amount_col], - quote_id=q_id, - supplier='MCule', - max_lead_time=None, - ) - - self.db.commit() - - return ingredients - - def add_soakdb_compounds( - self, - path: 'str | Path', - smiles_col: str = 'CompoundSMILES', - alias_col: str = 'CompoundCode', - update_aliases: bool = True, - soak_count_to_metadata: bool = True, - sanitisation_verbosity: bool = False, - stop_after: int | None = None, - ) -> 'CompoundSet': - """Registers compounds with aliases and metadata from a SoakDB file - - :param path: Path to SoakDB CSV or SQLite file - :returns: :class:`.CompoundSet` of registered/matched compounds - """ - - from json import dumps - - path = Path(path) - - match ext := path.name.split('.')[-1]: - case 'csv': - df = pd.read_csv(path) - case 'sqlite': - raise NotImplementedError - case _: - print(ext) - raise ValueError( - f"Could not determine file type from extension, use '.csv' or '.sqlite' {path}" - ) - - unique = df[df['CompoundSMILES'] != '-'].drop_duplicates( - subset=[smiles_col, alias_col] - ) - - smiles_alias_tuples = [] - for j, (i, row) in enumerate(unique.iterrows()): - smiles = row[smiles_col] - alias = row[alias_col] - - if pd.isna(smiles): - continue - - if pd.isna(alias): - continue - - smiles_alias_tuples.append((smiles, alias)) - - if stop_after and j > stop_after: - break - - mrich.var('#unique compounds', len(smiles_alias_tuples)) - - old_smiles = [s for s, a in smiles_alias_tuples] - - mrich.debug('Registering compounds...') - inchikey_new_smiles_tuples = self.register_compounds( - smiles=old_smiles, sanitisation_verbosity=sanitisation_verbosity - ) - - inchikey_old_smiles_lookup = { - inchikey: old_s - for old_s, (inchikey, new_s) in zip( - old_smiles, inchikey_new_smiles_tuples, strict=False - ) - } - - alias_lookup = {s: a for s, a in smiles_alias_tuples} - alias_dicts = [ - dict(compound_inchikey=inchikey, compound_alias=alias_lookup[old_s]) - for old_s, (inchikey, new_s) in zip( - old_smiles, inchikey_new_smiles_tuples, strict=False - ) - ] - - if update_aliases: - match self.db.engine: - case 'sqlite3': - sql = """ - UPDATE OR IGNORE compound - SET compound_alias = :compound_alias - WHERE compound_inchikey = :compound_inchikey; - """ - case 'psycopg': - sql = """ - UPDATE hippo.compound - SET compound_alias = %(compound_alias)s - WHERE compound_inchikey = %(compound_inchikey)s - ON CONFLICT DO NOTHING; - """ - - mrich.debug('Updating aliases...') - self.db.executemany(sql, alias_dicts) - self.db.commit() - - inchikeys = [d['compound_inchikey'] for d in alias_dicts] - - inchikey_id_lookup = self.db.get_compound_inchikey_id_dict(inchikeys) - - cset = self.compounds[ - [inchikey_id_lookup[d['compound_inchikey']] for d in alias_dicts] - ] - - cset.add_tag('soaks') - - metadata_lookup = self.db.get_id_metadata_dict(table='compound', ids=cset.ids) - - if soak_count_to_metadata: - mrich.debug('Getting soak counts...') - for inchikey in inchikeys: - old_s = inchikey_old_smiles_lookup[inchikey] - c_id = inchikey_id_lookup[inchikey] - metadata_lookup[c_id]['SoakDB count'] = len(df[df[smiles_col] == old_s]) - - match self.db.engine: - case 'sqlite3': - sql = """ - UPDATE compound - SET compound_metadata = ? - WHERE compound_id = ?; - """ - case 'psycopg': - sql = """ - UPDATE hippo.compound - SET compound_metadata = %s - WHERE compound_id = %s; - """ - - mrich.debug('Updating metadata...') - self.db.executemany( - sql, [(dumps(m), i) for i, m in metadata_lookup.items()] - ) - - self.db.commit() - - return cset - - ### REGISTRATION - - def register_compound( - self, - *, - smiles: str, - scaffolds: list[Compound] | list[int] | None = None, - tags: None | list = None, - metadata: None | dict = None, - return_compound: bool = True, - commit: bool = True, - alias: str | None = None, - return_duplicate: bool = False, - register_scaffold_if_duplicate: bool = True, - radical: str = 'warning', - debug: bool = False, - ) -> Compound: - """Use a smiles string to add a compound to the database. If it already exists return the compound - - :param smiles: The SMILES string of the compound - :param bases: A list of :class:`.Compound` objects or IDs that this compound is based on, defaults to ``None`` - :param tags: A list of tags to assign to this compound, defaults to ``None`` - :param metadata: A dictionary of metadata to assign to this compound, defaults to ``None`` - :param return_compound: return the :class:`.Compound` object instead of the integer ID, defaults to ``True`` - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param alias: The string alias of this compound, defaults to ``None`` - :param return_duplicate: If ``True`` returns a boolean indicating if this compound previously existed, defaults to ``False`` - :param register_scaffold_if_duplicate: If this compound exists in the :class:`.Database` modify it's ``base`` property, defaults to ``True`` - :param radical: Define the behaviour for dealing with radical atoms in the SMILES. See :class:`.sanitise_smiles`. Defaults to ``'warning'`` - :param debug: Increase verbosity of output, defaults to ``False`` - :returns: The registered/existing :class:`.Compound` object or its ID (depending on ``return_compound``), and optionally a boolean to indicate duplication see ``return_duplicate`` - """ - - assert smiles - assert isinstance(smiles, str), f'Non-string {smiles=}' - - try: - smiles = sanitise_smiles( - smiles, sanitisation_failed='error', radical=radical, verbosity=debug - ) - except SanitisationError as e: - mrich.error(f'Could not sanitise {smiles=}') - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f'Could not sanitise {smiles=}') - return None - - if scaffolds: - scaffolds = [b.id if isinstance(b, Compound) else b for b in scaffolds] - - inchikey = inchikey_from_smiles(smiles) - - if debug: - mrich.var('inchikey', inchikey) - - compound_id = self.db.insert_compound( - smiles=smiles, - inchikey=inchikey, - tags=tags, - metadata=metadata, - warn_duplicate=False, - commit=False, - alias=alias, - ) - - duplicate = not bool(compound_id) - - def _return( - compound: 'Compound', - duplicate: bool, - return_compound: bool, - return_duplicate: bool, - ): - """Run on exit""" - if commit: - self.db.commit() - if not return_compound and not isinstance(compound, int): - compound = compound.id - if return_duplicate: - return compound, duplicate - else: - return compound - - def check_smiles(compound_id: int, smiles: str) -> None: - """Check smiles""" - assert compound_id - db_smiles = self.db.select_where( - table='compound', query='compound_smiles', key='id', value=compound_id - ) - (db_smiles,) = db_smiles - if db_smiles != smiles: - mrich.warning( - f'SMILES changed during compound registration: {smiles} --> {db_smiles}' - ) - - def insert_scaffolds( - scaffolds: 'list[Compound] | list[int]', compound_id: int - ) -> None: - """Insert scaffolds""" - scaffolds = [b for b in scaffolds if b is not None] or [] - for scaffold in scaffolds: - self.db.insert_scaffold( - scaffold=scaffold, - superstructure=compound_id, - warn_duplicate=False, - commit=False, - ) - - if return_compound or metadata or alias or tags: - if not compound_id: - compound = self.compounds[inchikey] - - check_smiles(compound.id, smiles) - - else: - compound = self.compounds[compound_id] - - if metadata: - compound.metadata.update(metadata) - - if alias: - compound.alias = alias - - if tags: - for tag in tags: - compound.tags.add(tag, commit=False) - - if scaffolds and not (not register_scaffold_if_duplicate and duplicate): - insert_scaffolds(scaffolds, compound.id) - - return _return(compound, duplicate, return_compound, return_duplicate) - - else: - if not compound_id: - assert inchikey - - compound_id = self.db.get_compound_id(inchikey=inchikey) - - check_smiles(compound_id, smiles) - - if scaffolds and not (not register_scaffold_if_duplicate and duplicate): - insert_scaffolds(scaffolds, compound_id) - - return _return(compound_id, duplicate, return_compound, return_duplicate) - - def register_compounds( - self, - *, - smiles: list[str], - radical: str = 'warning', - sanitisation_verbosity: bool = True, - debug: bool = False, - ) -> list[tuple[str, str]]: - """Insert many compounds at once - - :param smiles: list of smiles strings - :returns: list of sanitised inchikey and smiles string pairs - - """ - - if debug: - mrich.var('#smiles', len(smiles)) - - n_before = self.num_compounds - - values = self.db.register_compounds( - smiles=smiles, - radical=radical, - sanitisation_verbosity=sanitisation_verbosity, - debug=debug, - ) - - diff = self.num_compounds - n_before - - if diff: - mrich.success(f'Inserted {diff} new compounds') - else: - mrich.warning(f'Inserted {diff} new compounds') - - return values - - def register_reaction( - self, - *, - type: str, - product: Compound | int, - reactants: list[Compound | int], - commit: bool = True, - product_yield: float = 1.0, - check_chemistry: bool = False, - ) -> Reaction: - """Add a :class:`.Reaction` to the :class:`.Database`. If it already exists return the existing one - - :param type: string indicating the type of reaction - :param product: The :class:`.Compound` object or ID of the product - :param reactants: A list of :class:`.Compound` objects or IDs of the reactants - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param product_yield: The fraction of product yielded from this reaction ``0 < product_yield <= 1.0``, defaults to ``1.0`` - :param check_chemistry: check the reaction chemistry, defaults to ``True`` - :returns: The registered :class:`.Reaction` - """ - - ### CHECK REACTION VALIDITY - - if check_chemistry: - from .chem import ( - InvalidChemistryError, - check_chemistry, - ) - - if not isinstance(product, Compound): - product = self.db.get_compound(id=product) - - if not isinstance(reactants, CompoundSet): - reactants = CompoundSet(self.db, reactants) - - valid = check_chemistry(type, reactants, product) - - if not valid: - raise InvalidChemistryError(f'{type=}, {reactants.ids=}, {product.id=}') - - ### CHECK FOR DUPLICATES - - if isinstance(product, Compound): - product = product.id - - reactant_ids = set(v.id if isinstance(v, Compound) else v for v in reactants) - - match self.db.engine: - case 'sqlite3': - sql = """ - SELECT reactant_reaction, reactant_compound - FROM reactant INNER JOIN reaction - ON reactant.reactant_reaction = reaction.reaction_id - WHERE reaction_type="{type}" - AND reaction_product = {product} - """ - case 'psycopg': - sql = """ - SELECT reactant_reaction, reactant_compound - FROM hippo.reactant AS reactant INNER JOIN hippo.reaction AS reaction - ON reactant.reactant_reaction = reaction.reaction_id - WHERE reaction_type="{type}" - AND reaction_product = {product} - """ - - sql = sql.format(type=type, product=product) - - pairs = self.db.execute(sql).fetchall() - - if pairs: - reax_dict = {} - for reaction_id, reactant_id in pairs: - if reaction_id not in reax_dict: - reax_dict[reaction_id] = set() - reax_dict[reaction_id].add(reactant_id) - - for reaction_id, reactants in reax_dict.items(): - if reactants == reactant_ids: - return self.reactions[reaction_id] - - ### INSERT A NEW REACTION - - assert product_yield > 0 and product_yield <= 1.0, ( - f'{product_yield=} out of range (0,1)' - ) - - reaction_id = self.db.insert_reaction( - type=type, product=product, commit=commit, product_yield=product_yield - ) - - ### INSERT REACTANTS - - for reactant in reactant_ids: - self.db.insert_reactant( - compound=reactant, reaction=reaction_id, commit=commit - ) - - return self.reactions[reaction_id] - - def register_reactions( - self, - *, - types: list[str], - product_ids: list[list[int]], - reactant_id_lists: list[list[int]], - ): - """Insert many reactions at once - - :param types: list of reaction type strings - :param reactant_id_lists: list of reactant compound id lists - :param product_ids: list of product compound ids - :returns: list of reaction ids - """ - - assert len(types) == len(reactant_id_lists) == len(product_ids) - - # assert not any(not isinstance(t, str) for t in types) - # assert not any(not isinstance(t, int) for t in product_ids) - # assert not any(not any(not isinstance(i, int) for i in t) for t in reactant_id_lists) - - types = [str(t) for t in types] - product_ids = [int(i) for i in product_ids] - reactant_id_lists = [set(int(i) for i in r) for r in reactant_id_lists] - - n_before = self.num_reactions - - # get possible duplicates - existing = self.db.get_reaction_map_from_products(product_ids) - - non_duplicates = {} - - existing_count = 0 - - for reaction_type, product_id, reactant_ids in zip( - types, product_ids, reactant_id_lists, strict=False - ): - key = (reaction_type, product_id) - - reactant_ids = set(reactant_ids) - - possible_matches = {k: v for k, v in existing.items() if k == key} - - assert len(possible_matches) < 2 - - if possible_matches: - possible_matches = list(possible_matches.values())[0] - - if any(reactant_ids == v for v in possible_matches.values()): - existing_count += 1 - continue - - non_duplicates[key] = reactant_ids - - if existing_count: - mrich.warning('Skipped', existing_count, 'existing reactions') - - if not non_duplicates: - mrich.warning('All reactions are duplicates') - return None - - # insert reaction records - - match self.db.engine: - case 'sqlite3': - sql = """ - INSERT INTO reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(?1, ?2, 1) - RETURNING reaction_id - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES(%s, %s, 1) - RETURNING reaction_id - """ - - payload = list(non_duplicates.keys()) - - records = self.db.executemany(sql, payload) - reaction_ids = [r_id for (r_id,) in records] - self.db.commit() - - # insert reactant records - - match self.db.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(1.0, ?1, ?2) - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES(1.0, %s, %s) - ON CONFLICT DO NOTHING; - """ - - payload = [] - for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( - reaction_ids, non_duplicates.items(), strict=False - ): - for reactant_id in reactant_ids: - payload.append((reaction_id, reactant_id)) - - self.db.executemany(sql, payload) - self.db.commit() - - diff = self.num_reactions - n_before - - # delete orphaned reactions - - sql = f""" - SELECT reaction_id FROM {self.db.SQL_SCHEMA_PREFIX}reaction - LEFT JOIN reactant ON reaction_id = reactant_reaction - WHERE reactant_compound IS NULL - """ - - records = self.db.execute(sql).fetchall() - orphaned_str_ids = str(tuple(r for (r,) in records)).replace(',)', ')') - - self.db.execute( - f'DELETE FROM {self.db.SQL_SCHEMA_PREFIX}reaction WHERE reaction_id IN {orphaned_str_ids}' - ) - - if diff: - mrich.success(f'Inserted {diff} new reactions') - else: - mrich.warning(f'Inserted {diff} new reactions') - - return reaction_ids - - def register_target( - self, - name: str, - warn_duplicate: bool = True, - ) -> Target: - """ - Register a new protein :class:`` to the Database - - :param param1: this is a first param - :param param2: this is a second param - :returns: this is a description of what is returned - :raises keyError: raises an exception - """ - - target_id = self.db.insert_target(name=name, warn_duplicate=warn_duplicate) - - if not target_id: - target_id = self.db.get_target_id(name=name) - - return self.db.get_target(id=target_id) - - def register_pose( - self, - *, - compound: Compound | int, - target: str, - path: str, - inchikey: str | None = None, - alias: str | None = None, - reference: int | None = None, - tags: None | list = None, - metadata: None | dict = None, - inspirations: None | list[int | Pose] = None, - return_pose: bool = True, - energy_score: float | None = None, - distance_score: float | None = None, - commit: bool = True, - overwrite_metadata: bool = True, - warn_duplicate: bool = True, - check_RMSD: bool = False, - RMSD_tolerance: float = 1.0, - split_PDB: bool = False, - duplicate_alias: str = 'modify', - resolve_path: bool = True, - load_mol: bool = False, - ) -> Pose: - """Add a :class:`.Pose` to the :class:`.Database`. If it already exists return the pose - - :param compound: The :class:`.Compound` object or ID that this :class:`.Pose` is a conformer of - :param target: The :class:`.Target` name or ID - :param path: Path to the :class:`.Pose`'s conformer file (.pdb or .mol) - :param alias: The string alias of this :class:`.Pose`, defaults to ``None`` - :param reference: Reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param tags: A list of tags to assign to this compound, defaults to ``None`` - :param metadata: A dictionary of metadata to assign to this compound, defaults to ``None`` - :param inspirations: a list of inspiration :class:`.Pose` objects or ID's, defaults to ``None`` - :param energy_score: assign an energy score to this :class:`.Pose`, defaults to ``None`` - :param distance_score: assign a distance score to this :class:`.Pose`, defaults to ``None`` - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :param overwrite_metadata: If a duplicate is found, overwrite its metadata, defaults to ``True`` - :param warn_duplicate: Warn if a duplicate :class:`.Pose` exists, defaults to ``True`` - :param check_RMSD: Check the RMSD against existing :class:`.Pose`, defaults to ``False`` - :param RMSD_tolerance: Tolerance for ``check_RMSD`` in Angstrom, defaults to ``1.0`` - :param split_PDB: Register a :class:`.Pose` for every ligand residue in the PDB, defaults to ``False`` - :param duplicate_alias: In the case of a duplicate, define the behaviour for the ``alias`` property, defaults to ``'modify'`` which appends ``_copy`` to the alias. Set to ``error`` to raise an Exception. - :param resolve_path: Resolve to an absoltue path, default = True. - :param load_mol: Parse the input file and load the ligand rdkit.Chem.Mol - :returns: The registered/existing :class:`.Pose` object or its ID (depending on ``return_pose``) - """ - - assert duplicate_alias in ['error', 'modify', 'skip'] - - from molparse import parse - - if split_PDB: - sys = parse(path, verbosity=False, alternative_site_warnings=False) - - lig_residues = [] - - for res in sys.ligand_residues: - lig_residues += res.split_by_site() - - if len(lig_residues) > 1: - assert not energy_score - assert not distance_score - - mrich.warning(f'Splitting ligands in PDB: {path}') - - results = [] - for i, res in enumerate(lig_residues): - file = str(path).replace('.pdb', f'_hippo_{i}.pdb') - - split_sys = sys.protein_system - - for atom in res.atoms: - split_sys.add_atom(atom) - - mrich.writing(file) - split_sys.write(file, verbosity=False) - - result = self.register_pose( - compound=compound, - target=target, - path=file, - inchikey=inchikey, - alias=alias, - reference=reference, - tags=tags, - metadata=metadata, - inspirations=inspirations, - return_pose=return_pose, - commit=commit, - overwrite_metadata=overwrite_metadata, - warn_duplicate=warn_duplicate, - check_RMSD=check_RMSD, - RMSD_tolerance=RMSD_tolerance, - split_PDB=False, - load_mol=load_mol, - ) - - results.append(result) - - return results - - if isinstance(compound, int): - compound_id = compound - else: - compound_id = compound.id - - if check_RMSD: - # check if the compound has existing poses - other_pose_ids = self.db.select_id_where( - table='pose', - key='compound', - value=compound_id, - none='quiet', - multiple=True, - ) - - if other_pose_ids: - other_poses = PoseSet(self.db, [i for (i,) in other_pose_ids]) - - from numpy import array - from numpy.linalg import norm - from rdkit.Chem import MolFromMolFile - - mol = MolFromMolFile(str(path.resolve())) - - c1 = mol.GetConformer() - atoms1 = [a for a in mol.GetAtoms()] - symbols1 = [a.GetSymbol() for a in atoms1] - positions1 = [c1.GetAtomPosition(i) for i, _ in enumerate(atoms1)] - - for pose in other_poses: - c2 = pose.mol.GetConformer() - atoms2 = [a for a in pose.mol.GetAtoms()] - symbols2 = [a.GetSymbol() for a in atoms2] - positions2 = [c2.GetAtomPosition(i) for i, _ in enumerate(atoms2)] - - for s1, p1 in zip(symbols1, positions1, strict=False): - for s2, p2 in zip(symbols2, positions2, strict=False): - if s2 != s1: - continue - if norm(array(p2 - p1)) <= RMSD_tolerance: - # this atom (1) is within tolerance - break - else: - # this atom (1) is outside of tolerance - break - else: - # all atoms within tolerance --> too similar - mrich.warning(f'Found similar {pose=}') - if return_pose: - return pose - else: - return pose.id - - pose_data = dict( - compound=compound, - inchikey=inchikey, - alias=alias, - target=target, - path=path, - tags=tags, - metadata=metadata, - reference=reference, - warn_duplicate=warn_duplicate, - commit=commit, - energy_score=energy_score, - distance_score=distance_score, - ) - - pose_id = self.db.insert_pose(**pose_data, resolve_path=resolve_path) - - # if no pose_id then there must be a duplicate - if not pose_id: - # constraint failed - if isinstance(path, Path): - path = path.resolve() - - # try getting by path - result = self.db.select_where( - table='pose', query='pose_id', key='path', value=str(path), none='quiet' - ) - - # try getting by alias - if not result: - result = self.db.select_where( - table='pose', query='pose_id', key='alias', value=alias - ) - - if result and duplicate_alias == 'error': - raise Exception('could not register pose with existing alias') - - elif result and duplicate_alias == 'modify': - new_alias = alias + '_copy' - - mrich.warning(f'Modifying alias={alias} --> {new_alias}') - - pose_data['alias'] = new_alias - pose_id = self.db.insert_pose(**pose_data) - - elif result and duplicate_alias == 'skip': - (pose_id,) = result - - else: - (pose_id,) = result - else: - (pose_id,) = result - - assert pose_id, (result, pose_id) - - if not pose_id: - mrich.var('compound', compound) - mrich.var('inchikey', inchikey) - mrich.var('alias', alias) - mrich.var('target', target) - mrich.var('path', path) - mrich.var('reference', reference) - mrich.var('tags', tags) - mrich.debug(f'{metadata=}') - mrich.debug(f'{inspirations=}') - - raise Exception - - if return_pose or (metadata and not overwrite_metadata) or load_mol: - pose = self.poses[pose_id] - - if metadata: - pose.metadata.update(metadata) - - if load_mol: - pose.mol - - else: - pose = pose_id - - if overwrite_metadata: - self.db.insert_metadata( - table='pose', id=pose_id, payload=metadata, commit=commit - ) - - inspirations = inspirations or [] - for inspiration in inspirations: - self.db.insert_inspiration( - original=inspiration, - derivative=pose, - warn_duplicate=False, - commit=commit, - ) - - return pose - - def register_route( - self, - *, - recipe: 'Recipe', - commit: bool = True, - ) -> int: - """ - Insert a single-product :class:`.Recipe` into the :class:`.Database`. - - :param recipe: The :class:`.Recipe` object to be registered - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :returns: The :class:`.Route` ID - """ - - return self.db.register_route(recipe=recipe, commit=commit) - - ### QUOTING - - def quote_compounds( - self, - ref_animal: 'HIPPO', - compounds: CompoundSet | None = None, - *, - debug: bool = False, - ) -> 'CompoundSet,CompoundSet': - """Transfer quotes from another reference :class:`.HIPPO` animal object (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from - :param compounds: A :class:`.CompoundSet` containing the compounds to be quoted - """ - - if compounds is not None: - inchikeys = compounds.inchikeys - - else: - inchikeys = self.compounds.inchikeys - - quote_fields = [ - 'quote_id', - 'quote_smiles', - 'quote_amount', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_lead_time', - 'quote_price', - 'quote_currency', - 'quote_purity', - 'quote_date', - 'quote_compound', - ] - - sql = f""" - SELECT {', '.join(quote_fields)} - FROM {self.db.SQL_SCHEMA_PREFIX}quote - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON quote_compound = compound_id - WHERE compound_inchikey IN {tuple(inchikeys)} - """ - - with mrich.loading('Querying reference database...'): - records = ref_animal.db.execute(sql).fetchall() - - quoted_compound_ids = set() - quote_count = self.db.count('quote') - - for record in mrich.track( - records, total=len(records), prefix='Inserting quotes' - ): - ( - quote_id, - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_date, - quote_compound, - ) = record - - try: - compound = self.compounds(smiles=quote_smiles) - except Exception as e: - mrich.error(e) - continue - - if debug: - mrich.debug('Inserting quote for', compound) - - try: - self.db.insert_quote( - compound=compound, - supplier=quote_supplier, - catalogue=quote_catalogue, - entry=quote_entry, - amount=quote_amount, - price=quote_price, - currency=quote_currency, - purity=quote_purity, - lead_time=quote_lead_time, - smiles=quote_smiles, - date=quote_date, - commit=False, - ) - except Exception as e: - mrich.error(e) - continue - - quoted_compound_ids.add(compound.id) - - self.db.commit() - - quoted_compounds = self.compounds[quoted_compound_ids] - - if compounds: - unquoted_compounds = compounds - quoted_compounds - else: - unquoted_compounds = self.compounds[:] - quoted_compounds - - mrich.var('#new quotes', self.db.count('quote') - quote_count) - mrich.var('#quoted_compounds', len(quoted_compounds)) - mrich.var('#unquoted_compounds', len(unquoted_compounds)) - - return quoted_compounds, unquoted_compounds - - def quote_reactants( - self, - ref_animal: 'HIPPO', - *, - unquoted_only: bool = False, - supplier: str = 'any', - debug: bool = False, - ) -> None: - """Get batch quotes for all reactants in the database - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - :param unquoted_only: Only request quotes for unquoted compouds, defaults to ``False`` - """ - - if unquoted_only: - compounds = self.reactants.get_unquoted(supplier=supplier) - else: - compounds = self.reactants - - mrich.var('#compounds', len(compounds)) - - self.quote_compounds(ref_animal=ref_animal, compounds=compounds, debug=debug) - - def quote_intermediates( - self, - ref_animal: 'HIPPO', - ) -> None: - """Get batch quotes for all reactants in the database - - :param ref_animal: The reference :class:`.HIPPO` animal to fetch quotes from (e.g. the one from https://github.com/mwinokan/EnamineCatalogs) - :param unquoted_only: Only request quotes for unquoted compouds, defaults to ``False`` - """ - - self.quote_compounds(quoter=quoter, compounds=self.intermediates) - - ### PLOTTING - - def plot_tag_statistics(self, *args, **kwargs) -> 'plotly.graph_objects.Figure': - """Plot an overview of the number of compounds and poses for each tag, see :func:`hippo.plotting.plot_tag_statistics`""" - - if not self.num_tags: - mrich.error('No tagged compounds or poses') - return - from .plotting import plot_tag_statistics - - return plot_tag_statistics(self, *args, **kwargs) - - def plot_compound_property(self, prop, **kwargs) -> 'plotly.graph_objects.Figure': - """Plot an arbitrary compound property across the whole dataset, see :func:`hippo.plotting.plot_compound_property`""" - from .plotting import plot_compound_property - - return plot_compound_property(self, prop, **kwargs) - - def plot_pose_property(self, prop, **kwargs) -> 'plotly.graph_objects.Figure': - """Plot an arbitrary pose property across the whole dataset, see :func:`hippo.plotting.plot_pose_property`""" - from .plotting import plot_pose_property - - return plot_pose_property(self, prop, **kwargs) - - def plot_interaction_punchcard( - self, poses=None, subtitle=None, opacity=1.0, **kwargs - ) -> 'plotly.graph_objects.Figure': - """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_interaction_punchcard`""" - from .plotting import plot_interaction_punchcard - - return plot_interaction_punchcard( - self, poses=poses, subtitle=subtitle, opacity=opacity, **kwargs - ) - - def plot_interaction_punchcard_by_tags( - self, tags: dict[str, str] | list[str], **kwargs - ) -> 'plotly.graph_objects.Figure': - """Plot an interaction punchcard for a set of poses associated to given tags, see :func:`hippo.plotting.plot_interaction_punchcard_by_tags`""" - from .plotting import plot_interaction_punchcard_by_tags - - return plot_interaction_punchcard_by_tags(self, tags=tags, **kwargs) - - def plot_residue_interactions( - self, residue_number: int, poses: str | None = None, **kwargs - ) -> 'plotly.graph_objects.Figure': - """Plot an interaction punchcard for a set of poses, see :func:`hippo.plotting.plot_residue_interactions`""" - from .plotting import plot_residue_interactions - - return plot_residue_interactions( - self, poses=poses, residue_number=residue_number, **kwargs - ) - - def plot_compound_availability( - self, compounds=None, **kwargs - ) -> 'plotly.graph_objects.Figure': - """Plot a bar chart of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" - from .plotting import plot_compound_availability - - return plot_compound_availability(self, compounds=compounds, **kwargs) - - def plot_compound_availability_venn( - self, compounds, **kwargs - ) -> 'plotly.graph_objects.Figure': - """Plot a venn diagram of compound availability by supplier/catalogue, see :func:`hippo.plotting.plot_compound_availability`""" - from .plotting import plot_compound_availability_venn - - return plot_compound_availability_venn(self, compounds=compounds, **kwargs) - - def plot_compound_price( - self, - min_amount, - compounds=None, - plot_lead_time=False, - style='histogram', - **kwargs, - ) -> 'plotly.graph_objects.Figure': - """Plot a bar chart of minimum compound price for a given minimum amount, see :func:`hippo.plotting.plot_compound_price`""" - from .plotting import plot_compound_price - - return plot_compound_price( - self, min_amount=min_amount, compounds=compounds, style=style, **kwargs - ) - - def plot_reaction_funnel(self, **kwargs) -> 'plotly.graph_objects.Figure': - """Plot a funnel chart of the reactants, intermediates, and products across the whole dataset, see :func:`hippo.plotting.plot_reaction_funnel`""" - from .plotting import plot_reaction_funnel - - return plot_reaction_funnel(self, **kwargs) - - def plot_pose_interactions( - self, pose: 'Pose', **kwargs - ) -> 'plotly.graph_objects.Figure': - """3d figure showing the interactions between a :class:`.Pose` and the protein. see :func:`hippo.plotting.plot_pose_interactions`""" - from .plotting import plot_pose_interactions - - return plot_pose_interactions(self, pose=pose, **kwargs) - - def get_scaffold_network( - self, - compounds: 'CompoundSet | None' = None, - scaffolds: 'CompoundSet | None' = None, - notebook: bool = True, - depth: int = 5, - scaffold_tag: str | None = None, - exclude_tag: str | None = None, - physics: bool = True, - arrows: bool = True, - ) -> 'pyvis.network.Network': - """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" - from .pyvis import get_scaffold_network - - return get_scaffold_network( - self, - compounds=compounds, - scaffolds=scaffolds, - notebook=notebook, - depth=depth, - scaffold_tag=scaffold_tag, - exclude_tag=exclude_tag, - physics=physics, - arrows=arrows, - ) - - ### COMPOUND DESIGN - - # def fragmenstein_merge(self, - # reference: Pose, - # hits: PoseSet, - # combination_size: int = 2, - # timeout=300, - # n_cores: int = 1, - # scratch_dir: str = "fragmenstein_scratch", - # require_outome: str = "acceptable", - # return_df: bool = False, - # bulkdock_csv: str = "", - # ) -> PoseSet: - - # mrich.var("reference", reference) - # mrich.var("combination_size", combination_size) - # mrich.var("require_outome", require_outome) - # mrich.var("n_cores", n_cores) - # mrich.var("timeout", timeout) - # mrich.var("scratch_dir", scratch_dir) - # mrich.var("protein_path", reference.apo_path) - # mrich.var("bulkdock_csv", bulkdock_csv) - - # from .fstein import setup_wictor_laboratory, pure_merge - - # lab = setup_wictor_laboratory( - # scratch_dir=scratch_dir, - # protein_path=reference.apo_path, - # ) - - # df = pure_merge( - # lab, - # hits.mols, - # n_cores=n_cores, - # timeout=timeout, - # combination_size=combination_size, - # ) - - # if not len(filtered): - # mrich.error(f"No merges") - # return None - - # if require_outome: - # filtered = df[df["outcome"] == require_outome] - - # if not len(filtered): - # mrich.error(f"No merges with 'outcome' == {require_outome}") - # if return_df: - # return None, df - # return None - - # df = filtered - - # # register the poses - # n = len(self.compounds) - # compound_ids = [] - # # inspirations = [] - # for i,row in df.iterrows(): - # smiles = row.smiles - # compound = self.register_compound(smiles=smiles, tags=["Fragmenstein pure merge"]) - # compound_ids.append(compound.id) - # # hit_mols = row.hit_names - # # print(row) - # # raise NotImplementedError - - # compounds = self.compounds[compound_ids] - - # d = len(self.compounds) - n - - # if d: - # mrich.success(f"Found and registered {d} new merges") - # else: - # mrich.success(f"Found and registered {d} new merges") - - # if return_df: - # return compounds, df - - # return compounds - - ### OTHER - - def summary(self) -> None: - """Print a text summary of this HIPPO""" - mrich.header(self) - mrich.var('db_path', self.db_path) - mrich.var('#compounds', self.num_compounds) - mrich.var('#poses', self.num_poses) - mrich.var('#reactions', self.num_reactions) - mrich.var('#tags', self.num_tags) - mrich.var('tags', self.tags.unique) - # mrich.var('#products', len(self.products)) - - def get_by_shorthand(self, key) -> 'Compound | Pose | Reaction': - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID - - :param key: shortname of the object, e.g. C100 for :class:`.Compound` with id=100 - :returns: :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` object - """ - - assert isinstance(key, str), f"'HIPPO' object has no attribute '{key}'" - assert len(key) > 1, f"'HIPPO' object has no attribute '{key}'" - - prefix = key[0] - index = key[1:] - - if prefix not in 'CPRTFIS': - raise AttributeError(f"'HIPPO' object has no attribute '{key}'") - - try: - index = int(index) - except ValueError: - mrich.error(f'Cannot convert {index} to integer') - return None - - match key[0]: - case 'C': - return self.compounds[index] - case 'P': - return self.poses[index] - case 'R': - return self.reactions[index] - case 'T': - return self.db.get_target(id=index) - case 'F': - return self.db.get_feature(id=index) - case 'I': - return self.db.get_interaction(id=index) - case 'S': - return self.db.get_subsite(id=index) - - mrich.error(f'Unsupported {prefix=}') - return None - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation of this HIPPO""" - return f'HIPPO("{self.name}")' - - def __repr__(self) -> str: - """Returns a command line representation of this HIPPO""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.clear}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - def __getitem__(self, key: str): - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID. See :meth:`.HIPPO.get_by_shorthand`""" - return self.get_by_shorthand(key) - - def __getattr__(self, key: str): - """Get a :class:`.Compound`, :class:`.Pose`, or :class:`.Reaction` by its ID. See :meth:`.HIPPO.get_by_shorthand`""" - return self.get_by_shorthand(key) - - -GENERATED_TAG_COLS = [ - 'ConformerSites alias', - 'CanonSites alias', - 'CrystalformSites alias', - 'Quatassemblies alias', - 'Crystalforms alias', - 'ConformerSites upload name', - 'CanonSites upload name', - 'CrystalformSites upload name', - 'Quatassemblies upload name', - 'Crystalforms upload name', - 'ConformerSites short tag', - 'CanonSites short tag', - 'CrystalformSites short tag', - 'Quatassemblies short tag', - 'Crystalforms short tag', - 'Centroid res', - 'Experiment code', - 'Pose', -] diff --git a/hippo/apsw.py b/hippo/apsw.py deleted file mode 100644 index 8d8d6b8..0000000 --- a/hippo/apsw.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Functions for interfacing with the apsw library""" - -import apsw - - -def executemany(path: 'Path', sql: str, payload: list[tuple]): - """Bulk execution with apsw""" - connection = apsw.Connection(str(path.resolve())) - result = list(connection.executemany(sql, payload)) - connection.close() - return result diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py new file mode 100644 index 0000000..24bec1d --- /dev/null +++ b/hippo/bootstrap.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +import django +import mrich +from django.conf import settings + +# fix path +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + + +def configure_django(db_config, manage_models: bool): + + if settings.configured: + return + + if manage_models: + # sqlite3 db, create and manage models + database = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': db_config, + } + else: + # postgres, existing installation, don't touch + # TODO: pass vars from dbconfig + database = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'designdb', + 'USER': 'postgres', + 'PASSWORD': 's_URzt7CWfWZ.AXD7RcF', + 'HOST': 'database', + 'PORT': '5432', + 'OPTIONS': { + # sets the schema + 'options': '-c search_path=rdkit,designdb' + }, + } + + settings.configure( + INSTALLED_APPS=[ + 'designdb.apps.DesigndbConfig', + ], + DATABASES={'default': database}, + SECRET_KEY='runtime', + DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', + TIME_ZONE='UTC', + USE_TZ=True, + MIGRATION_MODULES={'designdb': None}, + MANAGE_MODELS=manage_models, + ) + + django.setup() + + +def load_hippo( + target_name: str, + *, + db: str | Path | dict | None = None, + # copy_from: str | Path | None = None, + # overwrite_existing: bool = False, + # update_legacy: bool = False, +): + """Initialisation function for HIPPO object. + + User should not call HIPPO directly because the db needs to be initialised. + """ + + mrich.bold('Creating HIPPO animal') + mrich.var('target_name', target_name, color='arg') + + if db is None: + db = {} + + if isinstance(db, str): + # sqlite db + + db_path = Path(db) + + mrich.var('db_path', db_path, color='file') + + # if copy_from: + # self._db = Database.copy_from( + # source=copy_from, + # destination=db_path, + # animal=self, + # update_legacy=update_legacy, + # overwrite_existing=overwrite_existing, + # ) + # else: + # self._db = Database(db_path, animal=self, update_legacy=update_legacy) + + configure_django(db_path, manage_models=True) + + from django.apps import apps + from django.db import connection + + with connection.schema_editor() as schema_editor: + for model in apps.get_models(): + if model._meta.managed: + schema_editor.create_model(model) + + else: + # postgres db + # pass + + # self._db = PostgresDatabase(animal=self, **db) + configure_django(db, manage_models=False) + + # import .testmodule + from designdb.animal import HIPPO + + animal = HIPPO(target_name) + + mrich.success('Initialised animal', f'{target_name}') + return animal diff --git a/hippo/compound.py b/hippo/compound.py deleted file mode 100644 index 63e2fec..0000000 --- a/hippo/compound.py +++ /dev/null @@ -1,1301 +0,0 @@ -"""Classes for working with compounds""" - -import mcol -import mrich -from rdkit import Chem - -from .pose import Pose -from .quote import Quote -from .tags import TagSet - - -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: - self._mol = self.db.get_compound_mol(self.id) - 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'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 - - 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 - - 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) - - 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) - - 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 for C{self.id} with amount >= {min_amount} mg. Estimating price...' - ) - 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 - - 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 - - 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") - - 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 - - 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 .cset import CompoundSet - from .recipe import Recipe - - return Recipe.from_compounds( - CompoundSet(self.db, [self.id]), - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - quoted_only=quoted_only, - supplier=supplier, - **kwargs, - ) - - 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] - - 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] - - 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) - - 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, - ) - - 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, - ) - - 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) - - def draw_elabs(self): - """Draw elaborations""" - - from molparse.rdkit import draw_highlighted_mol - from rdkit.Chem import MolFromSmarts, rdRGroupDecomposition - - 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) - - 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) - - 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 - - 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() - - 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 pathlib import Path - - from fragmenstein import Wictor - - 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 - - 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 - - match self.db.engine: - case 'sqlite3': - 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 - """ - case 'psycopg': - sql = """ - SELECT pose_id, inspiration_original FROM hippo.compound - INNER JOIN hippo.scaffold ON compound_id = scaffold_base - INNER JOIN hippo.pose ON compound_id = pose_compound - INNER JOIN hippo.inspiration ON pose_id = inspiration_derivative - WHERE compound_id = %(compound_id)s - """ - - 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 - - def __str__(self) -> str: - """Unformatted string representation""" - return f'C{self.id}' - - 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}"' - - def __eq__(self, other) -> bool: - """Compare compounds""" - assert isinstance(other, Compound) - return self.id == other.id - - -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 - - 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 - - def get_quotes(self, **kwargs) -> list['Quote']: - """Wrapper for :meth:`.Compound.get_quotes()`""" - return self.compound.get_quotes(**kwargs) - - ### DUNDERS - - def __str__(self) -> str: - """Plain string representation""" - return f'{self.amount:.2f}mg of C{self._compound_id}' - - 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)}' - - def __eq__(self, other) -> bool: - """Equality operator""" - - if self.compound_id != other.compound_id: - return False - - return self.amount == other.amount - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.Compound`""" - return getattr(self.compound, key) diff --git a/hippo/db.py b/hippo/db.py deleted file mode 100644 index 90d6c9c..0000000 --- a/hippo/db.py +++ /dev/null @@ -1,5717 +0,0 @@ -"""SQLite database wrapper class""" - -import json -import sqlite3 -import time -from pathlib import Path -from sqlite3 import Error - -import mcol -import mrich - -from .compound import Compound -from .feature import Feature -from .metadata import MetaData -from .pose import Pose -from .quote import Quote -from .reaction import Reaction -from .recipe import Recipe, Route -from .target import Target -from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles, strip_sql - - -class Database: - """Wrapper to connect to the HIPPO sqlite database. - - .. attention:: - - :class:`.Database` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. - - """ - - TABLES = [ - 'compound' - 'inspiration' - 'scaffold' - 'reaction' - 'reactant' - 'pose' - 'tag' - 'quote' - 'target' - 'feature' - 'route' - 'component' - 'compound_pattern_bfp' - 'interaction' - 'subsite' - 'subsite_tag' - ] - - SQL_STRING_PLACEHOLDER = '?' - SQL_PK_DATATYPE = 'INTEGER' - SQL_SCHEMA_PREFIX = '' - - ERROR_UNIQUE_VIOLATION = sqlite3.IntegrityError - - SQL_CREATE_TABLE_COMPOUND = """ - CREATE TABLE compound( - compound_id INTEGER PRIMARY KEY, - compound_inchikey TEXT, - compound_alias TEXT, - compound_smiles TEXT, - compound_base INTEGER, - compound_mol MOL, - compound_pattern_bfp bits(2048), - compound_morgan_bfp bits(2048), - compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES compound(compound_id), - CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey) - CONSTRAINT UC_compound_alias UNIQUE (compound_alias) - CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) - ); - """ - - SQL_CREATE_TABLE_POSE = """ - CREATE TABLE pose( - pose_id INTEGER PRIMARY KEY, - pose_inchikey TEXT, - pose_alias TEXT, - pose_smiles TEXT, - pose_reference INTEGER, - pose_path TEXT, - pose_compound INTEGER, - pose_target INTEGER, - pose_mol BLOB, - pose_fingerprint BLOB, - pose_energy_score REAL, - pose_distance_score REAL, - pose_inspiration_score REAL, - pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES compound(compound_id), - CONSTRAINT UC_pose_alias UNIQUE (pose_alias) - CONSTRAINT UC_pose_path UNIQUE (pose_path) - ); - """ - - SQL_INSERT_COMPOUND = """ - INSERT INTO compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_pattern_bfp, - compound_morgan_bfp, - compound_alias - ) - VALUES( - :inchikey, - :smiles, - mol_from_smiles(:smiles), - mol_pattern_bfp(mol_from_smiles(:smiles), 2048), - mol_morgan_bfp(mol_from_smiles(:smiles), 2, 2048), - :alias - ) - """ - - SQL_BULK_INSERT_INTERACTIONS = """ - INSERT OR IGNORE INTO interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES(?,?,?,?,?,?,?,?,?,?) - """ - - POSE_FIELDS = [ - 'pose_id', - 'pose_inchikey', - 'pose_alias', - 'pose_smiles', - 'pose_reference', - 'pose_path', - 'pose_compound', - 'pose_target', - 'pose_mol', - 'pose_fingerprint', - 'pose_energy_score', - 'pose_distance_score', - 'pose_inspiration_score', - ] - - COMPOUND_PROPERTY_FUNCTIONS = { - 'num_heavy_atoms': 'mol_num_hvyatms', - 'formula': 'mol_formula', - 'num_rings': 'mol_num_rings', - 'molecular_weight': 'mol_amw', - } - - def __init__( - self, - path: Path, - animal: 'HIPPO', - update_legacy: bool = False, - auto_compute_bfps: bool = True, - create_blank: bool = True, - check_legacy: bool = True, - create_indexes: bool = True, - update_indexes: bool = True, - debug: bool = True, - ) -> None: - """Database initialisation""" - - self._in_memory = path == ':memory:' - assert isinstance(path, Path) or self.in_memory - - if debug: - mrich.debug('hippo.Database.__init__()') - - self._path = path - self._connection = None - self._cursor = None - self._animal = animal - self._auto_compute_bfps = auto_compute_bfps - self._engine = 'sqlite3' - - if debug: - mrich.debug(f'Database.path = {self.path}') - - if not self.in_memory: - try: - path = path.resolve(strict=True) - - except FileNotFoundError: - # create a blank database - - if create_blank: - self.connect(debug=debug) - self.create_blank_db() - else: - raise - - else: - # connect to existing database - self.connect(debug=debug) - else: - self.connect(debug=debug) - if create_blank: - self.create_blank_db() - - if check_legacy: - self.check_schema(update=update_legacy) - - if create_indexes: - self.create_indexes(update=update_indexes, debug=debug) - - def check_schema(self, update: bool = False) -> None: - """Check the database for legacy schema and optionally update - - :param update: update the legacy database? - """ - - if 'interaction' not in self.table_names: - if not update: - mrich.error('This is a legacy format database (hippo-db < 0.3.23)') - mrich.error('Existing fingerprints will not be compatible') - mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') - raise LegacyDatabaseError('hippo-db < 0.3.23') - else: - mrich.warning('This is a legacy format database (hippo-db < 0.3.23)') - mrich.warning('Clearing legacy fingerprints...') - self.create_table_interaction() - self.delete_interactions() - - if 'subsite' not in self.table_names or 'subsite_tag' not in self.table_names: - mrich.warning('This is a legacy format database (hippo-db < 0.3.24)') - self.create_table_subsite() - self.create_table_subsite_tag() - - if 'scaffold' not in self.table_names: - if not update: - mrich.error('This is a legacy format database (hippo-db < 0.3.25)') - mrich.error('Existing base-elab relationships will not be compatible') - mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') - raise LegacyDatabaseError('hippo-db < 0.3.25') - else: - mrich.warning('This is a legacy format database (hippo-db < 0.3.25)') - mrich.warning('Migrating compound_base values to scaffold table...') - self.create_table_scaffold() - self.migrate_legacy_scaffolds() - - if 'route' not in self.table_names: - self.create_table_route() - self.create_table_component() - - elif 'component_amount' not in self.column_names('component'): - if not update: - mrich.error('This is a legacy format database (hippo-db < 0.3.29)') - mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') - raise LegacyDatabaseError('hippo-db < 0.3.29') - else: - mrich.warning('This is a legacy format database (hippo-db < 0.3.29)') - mrich.warning('Updating legacy routes...') - - self.update_legacy_routes() - - if 'reaction_metadata' not in self.column_names('reaction'): - if not update: - mrich.error('This is a legacy format database (hippo-db < 0.3.32)') - mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') - raise LegacyDatabaseError('hippo-db < 0.3.32') - else: - mrich.warning('This is a legacy format database (hippo-db < 0.3.32)') - mrich.warning('Updating legacy reaction table...') - - self.update_legacy_reaction_metadata() - - if 'pose_inspiration_score' not in self.column_names('pose'): - if not update: - mrich.error('This is a legacy format database (hippo-db < 0.3.36)') - mrich.error('Re-initialise HIPPO object with update_legacy=True to fix') - raise LegacyDatabaseError('hippo-db < 0.3.36') - else: - mrich.warning('This is a legacy format database (hippo-db < 0.3.36)') - mrich.warning('Updating legacy pose table...') - - self.update_legacy_pose_inspiration_score() - - self.commit() - - def create_indexes(self, update: bool = True, debug: bool = True) -> None: - """Create and optionally update indexes""" - - INDEXES = [ - ('pose', 'pose_inchikey'), - ( - 'pose', - 'pose_smiles', - ), - ( - 'pose', - 'pose_reference', - ), - ( - 'pose', - 'pose_target', - ), - ( - 'inspiration', - 'inspiration_original', - ), - ( - 'inspiration', - 'inspiration_derivative', - ), - ( - 'scaffold', - 'scaffold_superstructure', - ), - ( - 'reaction', - 'reaction_type', - ), - ( - 'reaction', - 'reaction_product', - ), - ( - 'reactant', - 'reactant_compound', - ), - ( - 'tag', - 'tag_compound', - ), - ( - 'tag', - 'tag_pose', - ), - ( - 'quote', - 'quote_supplier', - ), - ( - 'quote', - 'quote_catalogue', - ), - ( - 'quote', - 'quote_entry', - ), - ( - 'quote', - 'quote_compound', - ), - ( - 'route', - 'route_product', - ), - # ("subsite", "subsite_name",), # not enough rows to matter? - ( - 'subsite_tag', - 'subsite_tag_pose', - ), - ( - 'interaction', - 'interaction_pose', - ), - # ("interaction", "interaction_type",), # mainly done on interaction_temp - ( - 'component', - 'component_route', - ), - ( - 'component', - 'component_ref', - ), - ( - 'component', - ('component_type', 'component_ref', 'component_route'), - ), - ] - - existing = set(self.index_names()) - - for table, column in INDEXES: - if isinstance(column, tuple): - name = ['index_', table, *(c.removeprefix(table) for c in column)] - name = ''.join(name) - col_str = f'({", ".join(column)})' - - else: - assert column.startswith(table) - name = f'index_{column}' - col_str = f'({column})' - - if name in existing: - continue - - if debug: - mrich.debug(f'Creating {name}') - - self.execute( - f'CREATE INDEX IF NOT EXISTS {name} ON {self.SQL_SCHEMA_PREFIX}{table} {col_str}' - ) - - if update: - if debug: - mrich.debug('Updating indexes') - self.execute('ANALYZE') - self.commit() - - @classmethod - def copy_from( - cls, - source: Path, - destination: Path, - animal: 'HIPPO', - update_legacy: bool = False, - overwrite_existing: bool = False, - pages: int = 10000, - ) -> None: - """Create a :class:`.Database` from an existing one""" - - source = Path(source) - - assert source.exists() - - if destination.exists(): - if overwrite_existing: - mrich.warning(f'Overwriting {destination}') - else: - mrich.error(f'Will not overwrite {destination}') - mrich.error('Set overwrite_existing=True to override') - raise Exception('Set overwrite_existing=True to override') - - mrich.print(f'Copying {source} --> {destination}') - - def progress(status, remaining, total): - """print progress""" - mrich.debug(f'Copied {total - remaining} of {total} pages...') - - src = sqlite3.connect(source) - dst = sqlite3.connect(destination) - with dst: - src.backup(dst, pages=pages, progress=progress) - dst.close() - src.close() - - self = cls.__new__(cls) - - self.__init__(path=destination, animal=animal, update_legacy=update_legacy) - - return self - - ### PROPERTIES - - @property - def path(self) -> Path: - """Returns the path to the database file""" - return self._path - - @property - def engine(self) -> str: - """Returns the Database engine""" - return self._engine - - @property - def in_memory(self) -> bool: - """Is this database stored in memory""" - return self._in_memory - - @property - def connection(self) -> 'sqlite3.connection': - """Returns a ``sqlite3.connection`` to the database""" - if not self._connection: - self.connect() - return self._connection - - @property - def cursor(self) -> 'sqlite3.cursor': - """Returns a ``sqlite3.cursor``""" - if not self._cursor: - self._cursor = self.connection.cursor() - return self._cursor - - @property - def total_changes(self) -> int: - """Return the total number of database rows that have been modified, inserted, or deleted since the database connection was opened.""" - return self.connection.total_changes - - @property - def table_names(self) -> list[str]: - """List of all the table names in the database""" - results = self.execute( - "SELECT name FROM sqlite_master WHERE type='table';" - ).fetchall() - return [n for (n,) in results] - - @property - def auto_compute_bfps(self) -> bool: - """Automatically compute compound binary fingerprints on insertion""" - return self._auto_compute_bfps - - @auto_compute_bfps.setter - def auto_compute_bfps(self, b: bool): - """Automatically compute compound binary fingerprints on insertion""" - self._auto_compute_bfps = b - - ### PUBLIC METHODS / API CALLS - - def close(self, debug: bool = False) -> None: - """Close the connection""" - if debug: - mrich.debug('hippo.Database.close()') - if self.connection: - self.connection.close() - if debug: - mrich.success(f'Closed connection to {self.path}') - - def backup( - self, - destination: Path | str | None = None, - pages: int = 10_000, - ) -> None: - """Create a backup of the database""" - return backup(self.path, destination, pages=pages) - - ### GENERAL SQL - - def connect(self, debug: bool = True) -> None: - """Connect to the database""" - - if debug: - mrich.debug('hippo.Database.connect()') - - conn = None - - try: - if sqlite3.threadsafety == 3: # Serialized, safe to use multithreading - conn = sqlite3.connect(self.path, check_same_thread=False) - else: - conn = sqlite3.connect(self.path) - - if debug: - mrich.debug(f'{sqlite3.sqlite_version=}') - - conn.enable_load_extension(True) - conn.load_extension('chemicalite') - conn.enable_load_extension(False) - - if debug: - mrich.success('Database connected @', f'[file]{self.path}') - - except sqlite3.OperationalError as e: - if 'cannot open shared object file' in str(e): - mrich.error('chemicalite package not installed correctly') - else: - mrich.error(e) - raise - - except Error as e: - mrich.error(e) - raise - - self._connection = conn - self._cursor = conn.cursor() - - def execute( - self, - sql: str, - payload: tuple | list | dict | None = None, - *, - retry: float | None = 1, - debug: bool = False, - ): - """Execute arbitrary SQL with retry if database is locked.""" - if debug: - mrich.debug(sql) - - while True: - try: - if payload: - return self.cursor.execute(sql, payload) - else: - return self.cursor.execute(sql) - except sqlite3.OperationalError as e: - if 'database is locked' in str(e) and retry: - with mrich.clock( - f'SQLite Database is locked, waiting {retry} second(s)...' - ): - time.sleep(retry) - mrich.print('[debug]SQLite Database was locked, retrying...') - continue # retry without recursion - elif 'syntax error' in str(e): - mrich.error(sql) - mrich.error(payload) - raise - else: - raise - except Exception: - # from .tools import strip_sql - # mrich.error(strip_sql(sql)) - raise - - def executemany( - self, sql, payload, *, retry: float | None = 1, batch_size: int = None - ) -> None: - """Execute arbitrary SQL - - :param sql: SQL query - :param retry: If truthy, keep trying to executemany every `retry` seconds if the Database is locked - :param payload: Payload for insertion, etc. (Default value = None) - - """ - - if 'RETURNING' in sql: - from .apsw import executemany - - return executemany(self.path, sql, payload) - - if batch_size and batch_size < len(payload): - from itertools import batched - - batches = list(batched(payload, batch_size)) - - n = len(batches) - - for i, batch in enumerate(mrich.track(batches, prefix='batch execution')): - mrich.set_progress_field('i', i) - mrich.set_progress_field('n', n) - - self.executemany(sql, batch, batch_size=None, retry=retry) - - return - - try: - return self.cursor.executemany(sql, payload) - except sqlite3.OperationalError as e: - if 'database is locked' in str(e) and retry: - mrich.print('[debug]SQLite Database was locked, waiting...') - time.sleep(retry) - return self.executemany(sql=sql, payload=payload, retry=retry) - else: - raise - except Exception: - mrich.print(sql) - mrich.print(payload[0]) - raise - - def commit(self, *, retry: float | None = 1) -> None: - """Commit changes to the database - - :param retry: If truthy, keep trying to execute every `retry` seconds if the Database is locked - """ - try: - self.connection.commit() - except sqlite3.OperationalError as e: - if 'database is locked' in str(e) and retry: - with mrich.clock( - f'SQLite Database is locked, waiting {retry} second(s)...' - ): - time.sleep(retry) - mrich.print('[debug]SQLite Database was locked, retrying...') - return self.commit() - else: - raise - - def rollback(self) -> None: - """rollback (not relevant for sqlite)""" - # self.connection.rollback() - pass - - def get_lastrowid(self) -> int: - """Get ID of last inserted row""" - return self.cursor.lastrowid - - ### CREATE TABLES - - def create_blank_db(self) -> None: - """Create a blank database""" - - with mrich.loading('Creating blank database...'): - self.create_table_compound() - self.create_table_pose() - self.create_table_inspiration() - self.create_table_reaction() - self.create_table_reactant() - self.create_table_tag() - self.create_table_quote() - self.create_table_target() - self.create_table_pattern_bfp() - self.create_table_feature() - self.create_table_route() - self.create_table_component() - self.create_table_interaction() - self.create_table_subsite() - self.create_table_subsite_tag() - self.create_table_scaffold() - self.commit() - - def create_table_compound(self) -> None: - """Create the compound table""" - mrich.debug('HIPPO.Database.create_table_compound()') - - sql = self.SQL_CREATE_TABLE_COMPOUND - - self.execute(sql) - - def create_table_inspiration(self) -> None: - """Create the inspiration table""" - mrich.debug('HIPPO.Database.create_table_inspiration()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}inspiration( - inspiration_original INTEGER, - inspiration_derivative INTEGER, - FOREIGN KEY (inspiration_original) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), - FOREIGN KEY (inspiration_derivative) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), - CONSTRAINT UC_inspiration UNIQUE (inspiration_original, inspiration_derivative) - ); - """ - - self.execute(sql) - - def create_table_scaffold(self) -> None: - """Create the scaffold table""" - mrich.debug('HIPPO.Database.create_table_scaffold()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}scaffold( - scaffold_base INTEGER, - scaffold_superstructure INTEGER, - FOREIGN KEY (scaffold_base) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), - FOREIGN KEY (scaffold_superstructure) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), - CONSTRAINT UC_scaffold UNIQUE (scaffold_base, scaffold_superstructure) - ); - """ - - self.execute(sql) - - def create_table_reaction(self) -> None: - """Create the reaction table""" - mrich.debug('HIPPO.Database.create_table_reaction()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reaction( - reaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - reaction_type TEXT, - reaction_product INTEGER, - reaction_product_yield REAL, - reaction_metadata TEXT, - FOREIGN KEY (reaction_product) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id) - ); - """ - - self.execute(sql) - - def create_table_reactant(self) -> None: - """Create the reactant table""" - mrich.debug('HIPPO.Database.create_table_reactant()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}reactant( - reactant_amount REAL, - reactant_reaction INTEGER, - reactant_compound INTEGER, - FOREIGN KEY (reactant_reaction) REFERENCES {self.SQL_SCHEMA_PREFIX}reaction(reaction_id), - FOREIGN KEY (reactant_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), - CONSTRAINT UC_reactant UNIQUE (reactant_reaction, reactant_compound) - ); - """ - - self.execute(sql) - - def create_table_pose(self) -> None: - """Create the pose table""" - mrich.debug('HIPPO.Database.create_table_pose()') - - sql = self.SQL_CREATE_TABLE_POSE - - self.execute(sql) - - def create_table_tag(self) -> None: - """Create the tag table""" - mrich.debug('HIPPO.Database.create_table_tag()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}tag( - tag_name TEXT, - tag_compound INTEGER, - tag_pose INTEGER, - FOREIGN KEY (tag_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), - FOREIGN KEY (tag_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), - CONSTRAINT UC_tag_compound UNIQUE (tag_name, tag_compound), - CONSTRAINT UC_tag_pose UNIQUE (tag_name, tag_pose) - ); - """ - - self.execute(sql) - - def create_table_quote(self) -> None: - """Create the quote table""" - mrich.debug('HIPPO.Database.create_table_quote()') - - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}quote( - quote_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - quote_smiles TEXT, - quote_amount REAL, - quote_supplier TEXT, - quote_catalogue TEXT, - quote_entry TEXT, - quote_lead_time INTEGER, - quote_price REAL, - quote_currency TEXT, - quote_purity REAL, - quote_date TEXT, - quote_compound INTEGER, - FOREIGN KEY (quote_compound) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id), - CONSTRAINT UC_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) - ); - """ - - self.execute(sql) - - def create_table_target(self) -> None: - """Create the target table""" - mrich.debug('HIPPO.Database.create_table_target()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}target( - target_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - target_name TEXT, - target_metadata TEXT, - CONSTRAINT UC_target UNIQUE (target_name) - ); - """ - - self.execute(sql) - - def create_table_feature(self) -> None: - """Create the feature table""" - mrich.debug('HIPPO.Database.create_table_feature()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}feature( - feature_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - feature_family TEXT, - feature_target INTEGER, - feature_chain_name TEXT, - feature_residue_name TEXT, - feature_residue_number INTEGER, - feature_atom_names TEXT, - CONSTRAINT UC_feature UNIQUE ( - feature_family, - feature_target, - feature_chain_name, - feature_residue_number, - feature_residue_name, - feature_atom_names - ) - ); - """ - - self.execute(sql) - - def create_table_route(self) -> None: - """Create the route table""" - mrich.debug('HIPPO.Database.create_table_route()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}route( - route_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - route_product INTEGER, - FOREIGN KEY (route_product) REFERENCES {self.SQL_SCHEMA_PREFIX}compound(compound_id) - ); - """ - - self.execute(sql) - - def create_table_component(self) -> None: - """Create the component table""" - mrich.debug('HIPPO.Database.create_table_component()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}component( - component_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - component_route INTEGER, - component_type INTEGER, - component_ref INTEGER, - component_amount REAL, - FOREIGN KEY (component_route) REFERENCES {self.SQL_SCHEMA_PREFIX}route(route_id), - CONSTRAINT UC_component UNIQUE (component_route, component_ref, component_type) - ); - """ - - self.execute(sql) - - def create_table_pattern_bfp(self) -> None: - """Create the pattern_bfp table""" - mrich.debug('HIPPO.Database.create_table_pattern_bfp()') - - sql = """ - CREATE VIRTUAL TABLE compound_pattern_bfp - USING rdtree(compound_id, fp bits(2048)) - """ - - self.execute(sql) - - def create_table_interaction( - self, table: str = 'interaction', debug: bool = True - ) -> None: - """Create an interaction table""" - - if debug: - mrich.debug(f'HIPPO.Database.create_table_interaction({table=})') - - sql = f""" - CREATE TABLE {self.SQL_SCHEMA_PREFIX}{table}( - interaction_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - interaction_feature INTEGER NOT NULL, - interaction_pose INTEGER NOT NULL, - interaction_type TEXT NOT NULL, - interaction_family TEXT NOT NULL, - interaction_atom_ids TEXT NOT NULL, - interaction_prot_coord TEXT NOT NULL, - interaction_lig_coord TEXT NOT NULL, - interaction_distance REAL NOT NULL, - interaction_angle REAL, - interaction_energy REAL, - FOREIGN KEY (interaction_feature) REFERENCES {self.SQL_SCHEMA_PREFIX}feature(feature_id), - FOREIGN KEY (interaction_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), - CONSTRAINT UC_interaction UNIQUE ( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids - ) - ); - """ - - self.execute(sql) - - def create_table_subsite(self) -> None: - """Create the subsite table""" - - mrich.debug('HIPPO.Database.create_table_subsite()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite( - subsite_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - subsite_target INTEGER NOT NULL, - subsite_name TEXT NOT NULL, - subsite_metadata TEXT, - FOREIGN KEY (subsite_target) REFERENCES {self.SQL_SCHEMA_PREFIX}target(target_id), - CONSTRAINT UC_subsite UNIQUE (subsite_target, subsite_name) - ); - """ - - self.execute(sql) - - def create_table_subsite_tag(self) -> None: - """Create the subsite_tag table""" - - mrich.debug('HIPPO.Database.create_table_subsite_tag()') - sql = f"""CREATE TABLE {self.SQL_SCHEMA_PREFIX}subsite_tag( - subsite_tag_id {self.SQL_PK_DATATYPE} PRIMARY KEY, - subsite_tag_ref INTEGER NOT NULL, - subsite_tag_pose INTEGER NOT NULL, - subsite_tag_metadata TEXT, - FOREIGN KEY (subsite_tag_ref) REFERENCES {self.SQL_SCHEMA_PREFIX}subsite(subsite_id), - FOREIGN KEY (subsite_tag_pose) REFERENCES {self.SQL_SCHEMA_PREFIX}pose(pose_id), - CONSTRAINT UC_subsite_tag UNIQUE (subsite_tag_ref, subsite_tag_pose) - ); - """ - - self.execute(sql) - - def sql_return_id_str(self, key: str) -> str: - """SQL suffix to return the lastrowid (for sqlite returns an empty string)""" - return '' - - ### INSERTION - - def insert_compound( - self, - *, - smiles: str, - alias: str | None = None, - tags: None | list[str] = None, - warn_duplicate: bool = True, - commit: bool = True, - metadata: None | dict = None, - inchikey: str = None, - ) -> int: - """Insert an entry into the compound table - - :param smiles: SMILES string - :param alias: optional alias for the compound (Default value = None) - :param tags: list of string tags, (Default value = None) - :param warn_duplicate: print a warning if the compound already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :param metadata: dictionary of metadata (Default value = None) - :param inchikey: provide an InChI-key, otherwise it's calculated from the SMILES, (Default value = None) - :returns: compound ID - - """ - - # generate the inchikey name - inchikey = inchikey or inchikey_from_smiles(smiles) - - try: - self.execute( - self.SQL_INSERT_COMPOUND, - dict(inchikey=inchikey, smiles=smiles, alias=alias), - ) - - except self.ERROR_UNIQUE_VIOLATION as e: - constraints = [ - 'compound_inchikey', - 'compound_smiles', - 'compound_pattern_bfp', - 'compound_morgan_bfp', - ] - - message = str(e) - - for constraint in constraints: - match self.engine: - case 'sqlite3': - test_str = f'UNIQUE constraint failed: compound.{constraint}' - case 'psycopg': - test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' - case _: - raise NotImplementedError - - if test_str in message: - if warn_duplicate: - mrich.warning( - f'Skipping compound with duplicate {constraint}, {smiles=}' - ) - self.rollback() - return None - - else: - mrich.error(e) - - return None - - except Exception as e: - mrich.error(e) - - compound_id = self.get_lastrowid() - - if commit: - self.commit() - - ### register the tags - - if tags: - for tag in tags: - self.insert_tag(name=tag, compound=compound_id, commit=commit) - - ### register the binary fingerprints - - if self.auto_compute_bfps: - result = self.insert_compound_pattern_bfp(compound_id, commit=commit) - - if not result: - mrich.error('Could not insert compound pattern bfp') - - ### insert metadata - - if metadata: - self.insert_metadata( - table='compound', id=compound_id, payload=metadata, commit=commit - ) - - return compound_id - - def insert_compound_pattern_bfp(self, compound_id: int, commit: bool = True) -> int: - """Insert a compound_pattern_bfp - - :param compound_id: ID of the associated compound - :param commit: commit the changes to the database (Default value = True) - :returns: binary fingerprint ID - - """ - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}compound_pattern_bfp(compound_id, fp) - VALUES(?1, ?2) - """ - - (bfp,) = self.select_where( - 'compound_pattern_bfp', 'compound', 'id', compound_id - ) - - try: - self.execute(sql, (compound_id, bfp)) - - except Exception as e: - mrich.error(e) - - bfp_id = self.cursor.lastrowid - if commit: - self.commit() - - return bfp_id - - def insert_pose( - self, - *, - compound: Compound | int, - target: Target | int | str, - path: str, - inchikey: str | None = None, - alias: str | None = None, - reference: int | Pose | None = None, - tags: None | list = None, - energy_score: float | None = None, - distance_score: float | None = None, - metadata: None | dict = None, - commit: bool = True, - warn_duplicate: bool = True, - resolve_path: bool = True, - ) -> int: - """Insert an entry into the pose table - - :param compound: associated :class:`.Compound` object or ID - :param target: protein :class:`.Target` name or ID - :param path: path to the molecular structure (.pdb/.mol) - :param inchikey: provide an InChI-key if available, (Default value = None) - :param alias: optional alias for the compound (Default value = None) - :param reference: reference :class:`.Pose` object or ID to use for the protein conformation (Default value = None) - :param tags: list of string tags, (Default value = None) - :param energy_score: optional score of the ligand's binding energy (Default value = None) - :param distance_score: optional score of the ligand's binding position (Default value = None) - :param metadata: dictionary of metadata (Default value = None) - :param commit: commit the changes to the database (Default value = True) - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param resolve_path: try resolving the path (Default value = True) - :returns: the pose ID - - """ - - if isinstance(compound, Compound): - compound = compound.id - - if isinstance(reference, Pose): - reference = reference.id - - if isinstance(target, Target): - target = target.id - - if isinstance(target, str): - target = self.get_target_id(name=target) - if not target: - raise ValueError(f'No such {target=}') - target_name = self.get_target_name(id=target) - - if resolve_path: - try: - path = Path(path) - path = path.resolve(strict=True) - path = str(path) - - except FileNotFoundError: - mrich.error(f'Path cannot be resolved: {mcol.file}{path}') - raise - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}pose( - pose_inchikey, - pose_alias, - pose_smiles, - pose_compound, - pose_target, - pose_path, - pose_reference, - pose_energy_score, - pose_distance_score - ) - VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER} - ) - {self.sql_return_id_str('pose')} - """ - - try: - self.execute( - sql, - ( - inchikey, - alias, - None, - compound, - target, - path, - reference, - energy_score, - distance_score, - ), - ) - - except self.ERROR_UNIQUE_VIOLATION as e: - constraints = [ - 'pose_path', - 'pose_alias', - ] - - message = str(e) - - for constraint in constraints: - match self.engine: - case 'sqlite3': - test_str = f'UNIQUE constraint failed: pose.{constraint}' - case 'psycopg': - test_str = f'duplicate key value violates unique constraint "uc_{constraint}"' - case _: - raise NotImplementedError - - if test_str in message: - if warn_duplicate: - mrich.warning( - f'Skipping pose with duplicate {constraint}, {alias=}, {path=}' - ) - self.rollback() - return None - - else: - mrich.error(e) - - return None - - except Exception as e: - mrich.error(e) - raise - - pose_id = self.get_lastrowid() - - if commit: - self.commit() - - if tags: - for tag in tags: - self.insert_tag(name=tag, pose=pose_id, commit=commit) - - if metadata: - self.insert_metadata( - table='pose', id=pose_id, payload=metadata, commit=commit - ) - - return pose_id - - def insert_tag( - self, - *, - name: str, - compound: int = None, - pose: int = None, - commit: bool = True, - ) -> None: - """Insert an entry into the tag table. - - .. attention:: - Exactly one of compound or pose arguments must have a value - - :param name: name of the tag - :param compound: associated :class:`.Compound` ID - :param pose: associated :class:`.Pose` ID - :param commit: commit the changes to the database (Default value = True) - """ - - assert bool(compound) ^ bool(pose), ( - 'Exactly one of compound or pose arguments must have a value' - ) - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}tag(tag_name, tag_compound, tag_pose) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (name, compound, pose)) - - except self.ERROR_UNIQUE_VIOLATION: - return None - - except Exception as e: - mrich.error(e) - - if commit: - self.commit() - - def insert_inspiration( - self, - *, - original: Pose | int, - derivative: Pose | int, - warn_duplicate: bool = True, - commit: bool = True, - ) -> int: - """Insert an entry into the inspiration table - - :param original: :class:`.Pose` object or ID of the original hit - :param derivative: :class:`.Pose` object or ID of the derivative hit - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :returns: the inspiration ID - - """ - - if isinstance(original, Pose): - original = original.id - if isinstance(derivative, Pose): - derivative = derivative.id - - assert isinstance(original, int), ( - 'Must pass an integer ID or Pose object (original)' - ) - assert isinstance(derivative, int), ( - 'Must pass an integer ID or Pose object (derivative)' - ) - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}inspiration(inspiration_original, inspiration_derivative) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (original, derivative)) - - except self.ERROR_UNIQUE_VIOLATION: - if warn_duplicate: - mrich.warning( - f'Skipping existing inspiration: {original=} {derivative=}' - ) - return None - - except Exception as e: - mrich.error(e) - - inspiration_id = self.cursor.lastrowid - - if commit: - self.commit() - return inspiration_id - - def insert_scaffold( - self, - *, - scaffold: Compound | int, - superstructure: Compound | int, - warn_duplicate: bool = True, - commit: bool = True, - ) -> int: - """Insert an entry into the scaffold table - - :param scaffold: :class:`.Compound` object or ID of the scaffold hit - :param superstructure: :class:`.Compound` object or ID of the superstructure hit - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :returns: the scaffold row ID - - """ - - if isinstance(scaffold, Compound): - scaffold = scaffold.id - if isinstance(superstructure, Compound): - superstructure = superstructure.id - - assert isinstance(scaffold, int), ( - f'Must pass an integer ID or Compound object (scaffold) {scaffold=} {type(scaffold)}' - ) - assert isinstance(superstructure, int), ( - f'Must pass an integer ID or Compound object (superstructure) {superstructure=} {type(superstructure)}' - ) - - if scaffold == superstructure: - # mrich.warning(f"Skipped self-referential scaffold assignment (C{scaffold})") - return None - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (scaffold, superstructure)) - - except self.ERROR_UNIQUE_VIOLATION: - if warn_duplicate: - mrich.warning( - f'Skipping existing scaffold: {scaffold=} {superstructure=}' - ) - return None - - except Exception as e: - mrich.error(e) - - scaffold_id = self.cursor.lastrowid - - if commit: - self.commit() - return scaffold_id - - def insert_reaction( - self, - *, - type: str, - product: Compound | int, - product_yield: float = 1.0, - commit: bool = True, - ) -> int: - """Insert an entry into the reaction table - - :param type: string to indicate the reaction type - :param product: :class:`.Compound` object or ID of the reaction product - :param product_yield: yield fraction of the reaction product (Default value = 1.0) - :param commit: commit the changes to the database (Default value = True) - :returns: the reaction ID - - """ - - if isinstance(product, Compound): - product = product.id - - # assert isinstance(product, Compound), f'incompatible {product=}' - assert isinstance(type, str), f'incompatible {type=}' - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}reaction(reaction_type, reaction_product, reaction_product_yield) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (type, product, product_yield)) - - except Exception as e: - mrich.error(e) - - reaction_id = self.cursor.lastrowid - if commit: - self.commit() - return reaction_id - - def insert_reactant( - self, - *, - compound: Compound | int, - reaction: Reaction | int, - amount: float = 1.0, - commit: bool = True, - ) -> int: - """Insert an entry into the reactant table - - :param compound: :class:`.Compound` object or ID of the reactant - :param reaction: :class:`.Reaction` object or ID of the reaction - :param amount: amount (in ``mg``) needed for each unit of product (Default value = 1.0) - :param commit: commit the changes to the database (Default value = True) - :returns: the reactant ID - - """ - - if isinstance(reaction, int): - reaction = self.get_reaction(id=reaction) - - if isinstance(compound, int): - compound = self.get_compound(id=compound) - - assert isinstance(compound, Compound), f'incompatible {compound=}' - assert isinstance(reaction, Reaction), f'incompatible {reaction=}' - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}reactant(reactant_amount, reactant_reaction, reactant_compound) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (amount, reaction.id, compound.id)) - - except self.ERROR_UNIQUE_VIOLATION: - mrich.warning(f'Skipping existing reactant: {reaction=} {compound=}') - - except Exception as e: - mrich.error(e) - - reactant_id = self.cursor.lastrowid - - if commit: - self.commit() - - return reactant_id - - def insert_quote( - self, - *, - compound: Compound | int, - supplier: str, - catalogue: str | None = None, - entry: str | None = None, - amount: float, - price: float, - currency: str | None = None, - purity: float | None = None, - lead_time: float, - smiles: str | None = None, - date: str | None = None, - commit: bool = True, - ) -> int | None: - """Insert an entry into the quote table - - :param compound: associated :class:`.Compound` object or ID - :param supplier: name of the supplier - :param catalogue: optional catalogue name - :param entry: name of the catalogue entry - :param amount: amount in `mg` - :param price: price of the compound - :param currency: currency string ``['GBP', 'EUR', 'USD', None]`` - :param purity: compound purity fraction - :param lead_time: lead time in days - :param smiles: quoted SMILES string (Default value = None) - :param commit: commit the changes to the database (Default value = True) - :returns: the quote ID - - """ - - if not isinstance(compound, int): - assert isinstance(compound, Compound), f'incompatible {compound=}' - compound = compound.id - - assert currency in ['GBP', 'EUR', 'USD', None], f'incompatible {currency=}' - - assert supplier in [ - 'MCule', - 'Enamine', - 'Stock', - 'Molport', - ], f'incompatible {supplier=}' - - smiles = smiles or '' - - payload = [ - smiles, - amount, - supplier, - catalogue, - entry, - lead_time, - price, - currency, - purity, - compound, - ] - - if date: - date_str = '?11' - payload.append(date) - else: - date_str = 'date()' - - match self.engine: - case 'sqlite3': - sql = f""" - INSERT OR REPLACE INTO quote( - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_compound, - quote_date - ) - VALUES( - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - ?, - {date_str} - ); - """ - case 'psycopg': - sql = f""" - INSERT OR REPLACE INTO hippo.quote( - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_compound, - quote_date - ) - VALUES( - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - %s, - {date_str} - ) - ON CONFLICT - DO UPDATE - hippo.quote.quote_smiles = EXCLUDED.quote_smiles, - hippo.quote.quote_amount = EXCLUDED.quote_amount, - hippo.quote.quote_supplier = EXCLUDED.quote_supplier, - hippo.quote.quote_catalogue = EXCLUDED.quote_catalogue, - hippo.quote.quote_entry = EXCLUDED.quote_entry, - hippo.quote.quote_lead_time = EXCLUDED.quote_lead_time, - hippo.quote.quote_price = EXCLUDED.quote_price, - hippo.quote.quote_currency = EXCLUDED.quote_currency, - hippo.quote.quote_purity = EXCLUDED.quote_purity, - hippo.quote.quote_compound = EXCLUDED.quote_compound, - hippo.quote.quote_date = EXCLUDED.quote_date; - """ - - try: - self.execute( - sql, - tuple(payload), - ) - - except sqlite3.InterfaceError as e: - mrich.error(e) - mrich.debug(payload) - raise - - except Exception as e: - mrich.error(e) - return None - - quote_id = self.cursor.lastrowid - if commit: - self.commit() - return quote_id - - def insert_target( - self, - *, - name: str, - warn_duplicate: bool = True, - ) -> int: - """Insert an entry into the target table - - :param name: name of the protein target - :returns: the target ID - - """ - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}target(target_name) - VALUES({self.SQL_STRING_PLACEHOLDER}) - {self.sql_return_id_str('target')} - """ - - try: - self.execute(sql, (name,)) - - except self.ERROR_UNIQUE_VIOLATION: - if warn_duplicate: - mrich.warning(f'Skipping existing target with {name=}') - self.rollback() - return None - - except Exception as e: - mrich.error(e) - raise - - target_id = self.get_lastrowid() - - self.commit() - return target_id - - def insert_feature( - self, - *, - family: str, - target: int, - chain_name: str, - residue_name: str, - residue_number: int, - atom_names: list[str], - warn_duplicate: bool = False, - commit: bool = True, - ) -> int: - """Insert an entry into the feature table - - :param family: feature type string - :param target: associated :class:`.Target` ID - :param chain_name: single character name of the chain - :param residue_name: 3-4 character string name of the residue - :param residue_number: integer residue number - :param atom_names: list of atom names - :param commit: commit the changes to the database (Default value = True) - :returns: feature ID - - """ - - assert len(chain_name) == 1 - assert len(residue_name) <= 4 - for a in atom_names: - assert len(a) <= 4 - - if isinstance(target, str): - target = self.get_target_id(name=target) - assert isinstance(target, int) - - from .prolif import FEATURE_FAMILIES - - if family: - assert family in FEATURE_FAMILIES, f'Unsupported {family=}' - else: - family = 'Unknown' - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, - feature_atom_names - ) - VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER} - ) - {self.sql_return_id_str('feature')} - """ - - atom_names = ' '.join(sorted(atom_names)) - - try: - self.execute( - sql, - (family, target, chain_name, residue_name, residue_number, atom_names), - ) - - except self.ERROR_UNIQUE_VIOLATION as e: - if warn_duplicate: - mrich.warning(str(e)) - mrich.var('family', family) - mrich.var('target', target) - mrich.var('chain_name', chain_name) - mrich.var('residue_name', residue_name) - mrich.var('residue_number', residue_number) - mrich.var('atom_names', atom_names) - - self.rollback() - - return None - - except Exception as e: - mrich.error(e) - - feature_id = self.get_lastrowid() - - if commit: - self.commit() - return feature_id - - def insert_features( - self, - dicts: list[dict], - commit: bool = True, - ) -> None: - """Bulk insert entries into the feature table""" - - from .prolif import FEATURE_FAMILIES - - FEATURE_FAMILIES = set(FEATURE_FAMILIES) - - payload = [] - - for d in dicts: - chain_name = d['chain_name'] - family = d['family'] - target = d['target'] - atom_names = d['atom_names'] - residue_name = d['residue_name'] - residue_number = d['residue_number'] - - assert len(chain_name) == 1 - assert len(residue_name) <= 4 - for a in atom_names: - assert len(a) <= 4 - assert isinstance(target, int) - - atom_names = ' '.join(sorted(atom_names)) - - if family: - assert family in FEATURE_FAMILIES, f'Unsupported {family=}' - else: - family = 'Unknown' - - payload.append( - (family, target, chain_name, residue_name, residue_number, atom_names) - ) - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, - feature_atom_names - ) - VALUES(?,?,?,?,?,?) - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, - feature_atom_names - ) - VALUES(%s,%s,%s,%s,%s,%s) - ON CONFLICT ON CONSTRAINT uc_feature DO NOTHING; - """ - - try: - self.executemany( - sql, - payload, - ) - - # except self.ERROR_UNIQUE_VIOLATION as e: - - # if warn_duplicate: - # mrich.warning(str(e)) - # mrich.var("family", family) - # mrich.var("target", target) - # mrich.var("chain_name", chain_name) - # mrich.var("residue_name", residue_name) - # mrich.var("residue_number", residue_number) - # mrich.var("atom_names", atom_names) - - # self.rollback() - - # return None - - except Exception as e: - mrich.error(e) - raise - - if commit: - self.commit() - - def insert_metadata( - self, - *, - table: str, - id: int, - payload: dict, - commit: bool = True, - ) -> None: - """Insert metadata into an an existing entry in the compound or pose tables - - :param table: table for insertions ``['pose', 'compound', 'subsite', 'subsite_tag']`` - :param id: associated entry ID - :param payload: metadata dictionary - :param commit: commit the changes to the database (Default value = True) - - """ - - payload = json.dumps(payload) - - self.update( - table=table, id=id, key=f'{table}_metadata', value=payload, commit=commit - ) - - def insert_route( - self, - *, - product_id: int, - commit: bool = True, - ) -> int: - """Insert an entry into the route table - - :param product_id: :class:`.Compound` ID of the product - :param commit: commit the changes to the database (Default value = True) - :returns: route ID - - """ - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}route(route_product) - VALUES({self.SQL_STRING_PLACEHOLDER}) - """ - - product_id = int(product_id) - - try: - self.execute(sql, (product_id,)) - - except Exception as e: - mrich.error(e) - - route_id = self.cursor.lastrowid - - if commit: - self.commit() - - return route_id - - def insert_component( - self, - *, - route: int, - ref: int, - component_type: int, - amount: float = 1.0, - commit: bool = True, - ) -> int: - """ - ================ ========== ============== - component_type table ref - ================ ========== ============== - 1 reaction reaction - 2 compound reactant - 3 compound intermediate - ================ ========== ============== - - :param route: associated :class:`.Route` ID - :param ref: ID of the :class:`.Reaction` or :class:`.Compound` - :param component_type: integer specifying the type of the component - :param commit: commit the changes to the database (Default value = True) - :returns: the component ID - - """ - - match self.engine: - case 'sqlite3': - sql = """ - INSERT INTO component(component_route, component_type, component_ref, component_amount) - VALUES(:component_route, :component_type, :component_ref, :component_amount) - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.component(component_route, component_type, component_ref, component_amount) - VALUES(%(component_route)s, %(component_type)s, %(component_ref)s, %(component_amount)s) - """ - - route = int(route) - ref = int(ref) - component_type = int(component_type) - - if component_type == 1: - component_amount = None - else: - component_amount = float(amount) - assert component_amount > 0 - - try: - self.execute( - sql, - dict( - component_route=route, - component_type=component_type, - component_ref=ref, - component_amount=component_amount, - ), - ) - - except self.ERROR_UNIQUE_VIOLATION: - mrich.warning( - f'Did not add existing component={ref} (type={component_type}) to {route=}' - ) - - self.rollback() - return None - - component_id = self.get_lastrowid() - - if commit: - self.commit() - - return component_id - - def insert_interaction( - self, - *, - feature: Feature | int, - pose: Pose | int, - type: str, - family: str, - atom_ids: list[int], - prot_coord: list[float], - lig_coord: list[float], - distance: float, - angle: float | None = None, - energy: float | None = None, - warn_duplicate: bool = True, - commit: bool = True, - table: str = 'interaction', - ) -> int: - """Insert an entry into the interaction table - - :param feature: associated :class:`.Feature` object or ID - :param pose: associated :class:`.Pose` object or ID - :param type: interaction type - :param family: ligand feature type - :param atom_ids: atom indices of ligand feature - :param prot_coord: ``[x,y,z]`` coordinate of protein feature - :param lig_coord: ``[x,y,z]`` coordinate of ligand feature - :param distance: interaction distance ``Angstrom`` - :param angle: optional interaction angle ``degrees`` - :param energy: energy score ``kcal/mol``, defaults to ``None`` - :param warn_duplicate: print a warning if the pose already exists (Default value = True) - :param commit: commit the changes to the database (Default value = True) - :param table: the name of the table to insert into (Default value = 'interaction') - :returns: the interaction ID - """ - - # validation - - if isinstance(feature, Feature): - feature = feature.id - - if isinstance(pose, Pose): - pose = pose.id - - from .prolif import FEATURE_FAMILIES - - if family: - assert family in FEATURE_FAMILIES, f'Unsupported {family=}' - else: - family = 'Unknown' - - # assert type in INTERACTION_TYPES.values(), f"Unsupported {type=}" - - assert isinstance(atom_ids, list), f'Unsupported {atom_ids=}' - assert not any([not isinstance(i, int) for i in atom_ids]), ( - f'Unsupported {atom_ids=}' - ) - atom_ids = json.dumps(atom_ids) - - prot_coord = list(prot_coord) if prot_coord is not None else [] - assert len(prot_coord) == 3 or not prot_coord, f'Unsupported {prot_coord=}' - assert not any([not isinstance(i, float) for i in prot_coord]), ( - f'Unsupported {prot_coord=}' - ) - prot_coord = json.dumps(prot_coord) - - lig_coord = list(lig_coord) if lig_coord is not None else [] - assert len(lig_coord) == 3 or not lig_coord, f'Unsupported {lig_coord=}' - assert not any([not isinstance(i, float) for i in lig_coord]), ( - f'Unsupported {lig_coord=}' - ) - lig_coord = json.dumps(lig_coord) - - try: - distance = float(distance) - except ValueError: - raise ValueError(f'Unsupported {distance=}') - - try: - if angle is not None: - angle = float(angle) - except ValueError: - raise ValueError(f'Unsupported {angle=}') - - if energy is not None: - try: - energy = float(energy) - except ValueError: - raise ValueError(f'Unsupported {energy=}') - - # insertion - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}{table}( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES( - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER}, - {self.SQL_STRING_PLACEHOLDER} - ); - """ - - try: - self.execute( - sql, - ( - feature, - pose, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ), - ) - - except self.ERROR_UNIQUE_VIOLATION as e: - mrich.error(e) - if warn_duplicate: - mrich.warning( - f'Skipping existing interaction: {feature=} {pose=} {family=} {atom_ids=}' - ) - return None - - except Exception as e: - mrich.error(e) - - interaction_id = self.cursor.lastrowid - - if commit: - self.commit() - - return interaction_id - - def insert_subsite(self, target: int, name: str, commit: bool = True) -> int: - """Insert an entry into the subsite table - - :param target: protein :class:`.Target` ID - :param name: name of the protein subsite/subsite - :returns: the subsite ID - - """ - - assert isinstance(target, int) - assert isinstance(name, str) - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite(subsite_target, subsite_name) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (target, name)) - - except self.ERROR_UNIQUE_VIOLATION: - mrich.warning(f'Skipping existing subsite for {target=} with {name=}') - return None - - except Exception as e: - mrich.error(e) - - subsite_id = self.cursor.lastrowid - if commit: - self.commit() - - return subsite_id - - def insert_subsite_tag( - self, - *, - pose_id: int, - name: str | None, - target: int | None = None, - subsite_id: int | None = None, - commit: bool = True, - ) -> int: - """Insert an entry into the subsite_tag table - - :param pose_id: :class:`.Pose` ID - :param name: name of the protein subsite/pocket - :param target: protein :class:`.Target` ID, defaults to querying pose table - :param target: protein Subsite ID, defaults to querying Subsite table - :returns: the Subsite ID - - """ - - if name is not None: - assert isinstance(name, str) - - assert isinstance(pose_id, int) - - if not target: - (target,) = self.select_where( - table='pose', key='id', value=pose_id, query='pose_target' - ) - - assert isinstance(target, int) - - if not subsite_id: - subsite_id = self.get_subsite_id(name=name, none='quiet') - - if not subsite_id: - subsite_id = self.insert_subsite(name=name, target=target) - - assert isinstance(subsite_id, int) - - sql = f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES({self.SQL_STRING_PLACEHOLDER}, {self.SQL_STRING_PLACEHOLDER}) - """ - - try: - self.execute(sql, (subsite_id, pose_id)) - - except self.ERROR_UNIQUE_VIOLATION: - mrich.warning( - f'Skipping existing subsite_tag for {subsite_id=} with {pose_id=}' - ) - return None - - except Exception as e: - mrich.error(e) - - subsite_tag_id = self.cursor.lastrowid - if commit: - self.commit() - - return subsite_tag_id - - def register_route( - self, - *, - recipe: 'Recipe', - commit: bool = True, - ) -> int: - """ - Insert a single-product :class:`.Recipe` into the :class:`.Database`. - - :param recipe: The :class:`.Recipe` object to be registered - :param commit: Commit the changes to the :class:`.Database`, defaults to ``True`` - :returns: The :class:`.Route` ID - """ - - assert recipe.num_products == 1 - - # register the route - route_id = self.insert_route(product_id=recipe.product.id, commit=False) - - assert route_id - - # reactions - for ref in recipe.reactions.ids: - self.insert_component( - component_type=1, ref=ref, route=route_id, commit=False - ) - - # reactants - for ref, amount in recipe.reactants.id_amount_pairs: - self.insert_component( - component_type=2, ref=ref, route=route_id, amount=amount, commit=False - ) - - # intermediates - for ref, amount in recipe.intermediates.id_amount_pairs: - self.insert_component( - component_type=3, ref=ref, route=route_id, amount=amount, commit=False - ) - - if commit: - self.commit() - - return route_id - - ### SELECTION - - def select( - self, - query: str, - table: str, - multiple: bool = False, - ) -> tuple | list[tuple]: - """Wrapper for the SQL SELECT query, in the following syntax: - - :: - - 'SELECT {query} FROM {table}' - - :param query: the columns to return - :param table: the table from which to select - :param multiple: fetch all results (Default value = False) - :returns: the result of the query - - """ - - sql = f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table}' - - try: - self.execute(sql) - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - if multiple: - result = self.cursor.fetchall() - else: - result = self.cursor.fetchone() - - return result - - def select_where( - self, - query: str, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = 'error', - sort: str = None, - debug: bool = False, - ) -> tuple | list[tuple]: - """Select entries where ``key == value`` - - Examples - ======== - - Find compound alias with matching ID: - - :: - - animal.db.select_where( - query='compound_alias', - table='compound', - key='id', - value='123', - ) - - # the above evaluates to: - 'SELECT compound_id FROM compound WHERE compound_id = 123' - - Find compound aliases with ID below 10 and order alphabetically: - - :: - - animal.db.select_where( - query='compound_alias', - table='compound', - key='compound_id < 10', - multiple=True, - sort='compound_alias', - ) - - # the above evaluates to: - 'SELECT compound_id FROM compound WHERE compound_id < 10 ORDER BY compound_alias' - - Parameters - ========== - - :param query: the columns to return - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided the key argument should contain the a SQL string to select entries - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param sort: optionally sort the output (Default value = None) - :returns: the result of the query - - """ - - if isinstance(value, str): - if "'" in value: - value = f'"{value}"' - else: - value = f"'{value}'" - - if value is not None: - where_str = f'{table}_{key}={value}' - else: - where_str = key - - if sort: - sql = f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str} ORDER BY {sort}' - else: - sql = ( - f'SELECT {query} FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str}' - ) - - if debug: - mrich.print(strip_sql(sql)) - - try: - self.execute(sql) - except sqlite3.OperationalError: - mrich.var('sql', strip_sql(sql)) - raise - - if multiple: - result = self.cursor.fetchall() - else: - result = self.cursor.fetchone() - - if not result and none == 'error': - mrich.error(f'No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}') - return None - elif not result and none == 'exception': - raise ValueError( - f'No entry in {self.SQL_SCHEMA_PREFIX}{table} with {where_str}' - ) - - # if not result: - # raise ValueError(f"No entry in {table} with {where_str}") - - return result - - def select_id_where( - self, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = 'error', - ) -> tuple | list[tuple]: - """Select ID's where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``{table}_id``. - - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the result of the query - - """ - return self.select_where( - query=f'{table}_id', - table=table, - key=key, - value=value, - multiple=multiple, - none=none, - ) - - def select_all_where( - self, - table: str, - key: str, - value: str | None = None, - multiple: bool = False, - none: str | None = 'error', - ) -> tuple | list[tuple]: - """Select entries where ``key==value``. Similar to :meth:`.select_where` except the query argument is always ``*``. - - :param table: the table from which to select - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param multiple: fetch all results (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the result of the query - - """ - return self.select_where( - query='*', table=table, key=key, value=value, multiple=multiple, none=none - ) - - ### DELETION - - def delete_where( - self, - table: str, - key: str, - value: str | None = None, - commit: bool = True, - ) -> None: - """Delete entries where ``key==value`` - - :param table: the table from which to delete - :param key: column name to match to value, if no ``value`` is provided this - :param value: the value to match (Default value = None) - :param commit: commit the changes (Default value = True) - - """ - - if value is not None: - if isinstance(value, str): - value = f"'{value}'" - - sql = f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {table}_{key}={value}' - - else: - sql = f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {key}' - - try: - result = self.execute(sql) - - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - if commit: - self.commit() - - def delete_tag( - self, - tag: str, - ) -> None: - """Delete all tag entries with the matching name - - :param tag: tag name to match - - """ - self.delete_where(table='tag', key='name', value=tag) - - def delete_interactions(self) -> None: - """Delete all calculated interactions and set pose_fingerprint appropriately""" - - self.delete_where(table='interaction', key='interaction_id > 0') - self.update_all(table='pose', key='pose_fingerprint', value=0) - - def delete_features(self) -> None: - """Delete all protein features""" - - self.delete_where(table='feature', key='feature_id > 0') - - def delete_reactions(self) -> None: - """Delete all reaction data""" - - tables = ['reaction', 'reactant', 'route', 'component'] - - for table in tables: - self.execute(f'DELETE FROM {self.SQL_SCHEMA_PREFIX}{table};') - self.commit() - - def delete_subsites(self) -> None: - """Delete all protein subsites""" - - self.delete_where(table='subsite', key='subsite_id > 0') - self.delete_where(table='subsite_tag', key='subsite_tag_id > 0') - - ### UPDATE - - def update( - self, - *, - table: str, - id: int, - key: str, - value, - commit: bool = True, - ) -> int: - """Update a field in a database entry with given ID - - :param table: the table which to update - :param id: the ID of the entry to update - :param key: column name to update - :param value: the value to insert - :param commit: commit the changes to the database (Default value = True) - :returns: the ID of the modified entry - - """ - - sql = f""" - UPDATE {self.SQL_SCHEMA_PREFIX}{table} - SET {key} = {self.SQL_STRING_PLACEHOLDER} - WHERE {table}_id = {id} - {self.sql_return_id_str(table)}; - """ - - try: - self.execute(sql, (value,)) - except self.ERROR_UNIQUE_VIOLATION: - mrich.var('sql', sql) - self.rollback() - raise - - id = self.get_lastrowid() - - if commit: - self.commit() - - return id - - def update_all( - self, - *, - table: str, - key: str, - value, - commit: bool = True, - ) -> None: - """Update all fields in a table column at once - - :param table: the table which to update - :param key: column name to update - :param value: the value to insert - :param commit: commit the changes to the database (Default value = True) - - """ - - sql = f""" - UPDATE {self.SQL_SCHEMA_PREFIX}{table} - SET {key} = ? - """ - - try: - self.execute(sql, (value,)) - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - if commit: - self.commit() - - def update_pose_mol(self, pose_id: int, mol: 'Chem.Mol') -> None: - """Update the molecule stored for a specific pose""" - - self.update(table='pose', id=pose_id, key='pose_mol', value=mol.ToBinary()) - - ### COPYING / MIGRATION - - def copy_temp_interactions(self, source_db: 'Database | None' = None) -> None: - """Copy the records from the 'temp_interaction' table to the 'interaction' table""" - - if source_db is not None: - sql = """ - SELECT - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - FROM temp_interaction - """ - - cursor = source_db.execute(sql) - records = cursor.fetchall() - - cursor = self.executemany(self.SQL_BULK_INSERT_INTERACTIONS, records) - - else: - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - SELECT {self.SQL_SCHEMA_PREFIX}interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - FROM temp_interaction - """ - - cursor = self.execute(sql) - - def copy_interactions_to_temp(self, pose_id: int) -> int: - """Copy the records from the 'interaction' table to the 'temp_interaction' table for a given pose_id - - :returns: ID of the last inserted :class:`.Interaction` - """ - - cursor = self.execute( - f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}temp_interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - SELECT interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - FROM {self.SQL_SCHEMA_PREFIX}interaction - WHERE interaction_pose = {pose_id} - """ - ) - - return cursor.lastrowid - - def migrate_legacy_scaffolds(self) -> int: - """Migrate legacy compound_scaffold records from the 'compound' table to the 'scaffold' table - - :returns: ID of the last inserted scaffold record - """ - - mrich.debug('HIPPO.Database.migrate_legacy_scaffolds()') - - cursor = self.execute( - f""" - INSERT INTO {self.SQL_SCHEMA_PREFIX}scaffold(scaffold_base, scaffold_superstructure) - SELECT compound_base, compound_id FROM compound - WHERE compound_base IS NOT NULL - """ - ) - - self.commit() - - return cursor.lastrowid - - def update_legacy_routes(self) -> None: - """Update legacy component entries""" - - # add column - - sql = f""" - ALTER TABLE {self.SQL_SCHEMA_PREFIX}component - ADD component_amount REAL; - """ - - self.execute(sql) - - # set values - - match self.engine: - case 'sqlite3': - sql = """ - UPDATE component - SET component_amount = :component_amount - WHERE component_type = :component_type; - """ - - case 'psycopg': - sql = """ - UPDATE hippo.component - SET component_amount = %(component_amount)s - WHERE component_type = %(component_type)s; - """ - - self.execute(sql, dict(component_amount=None, component_type=1)) - self.execute(sql, dict(component_amount=1.0, component_type=2)) - self.execute(sql, dict(component_amount=1.0, component_type=3)) - - def update_legacy_reaction_metadata(self) -> None: - """Add reaction_metadata column""" - - sql = f""" - ALTER TABLE {self.SQL_SCHEMA_PREFIX}reaction - ADD reaction_metadata TEXT; - """ - - self.execute(sql) - - def update_legacy_pose_inspiration_score(self) -> None: - """Add pose_inspiration_score column""" - - sql = f""" - ALTER TABLE {self.SQL_SCHEMA_PREFIX}pose - ADD pose_inspiration_score REAL; - """ - - self.execute(sql) - - def update_compound_pattern_bfp_table(self): - """Update the compound pattern BFP table""" - self.execute( - f""" - INSERT INTO compound_pattern_bfp - SELECT c.compound_id, c.compound_pattern_bfp FROM {self.SQL_SCHEMA_PREFIX}compound AS c - LEFT JOIN compound_pattern_bfp as fp - ON c.compound_id = fp.compound_id - WHERE fp.compound_id IS NULL - """ - ) - - ### BULK CLEANUP - - def prune_duplicate_routes(self) -> None: - """Remove duplicate routes from the database""" - - from collections import Counter - - sql = f""" - SELECT route_id, route_product, component_ref, component_type FROM {self.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.SQL_SCHEMA_PREFIX}component ON route_id = component_route - """ - - records = self.execute(sql).fetchall() - - routes = {} - - for route_id, route_product, component_ref, component_type in records: - if route_id not in routes: - routes[route_id] = (route_product, set()) - - key = (component_ref, component_type) - routes[route_id][1].add(key) - - routes = {key: (value[0], tuple(value[1])) for key, value in routes.items()} - - flat_routes = [value for key, value in routes.items()] - - mrich.var('#routes', len(flat_routes)) - - counter = Counter(flat_routes) - duplicates = {item: count for item, count in counter.items() if count > 1} - - mrich.var('products with duplicate routes', len(duplicates)) - - if not duplicates: - mrich.success('No duplicate routes found') - return None - - delete = set() - for dupe in duplicates: - matched_ids = [k for k, v in routes.items() if v == dupe] - mrich.print( - 'compound', dupe[0], 'has', len(matched_ids), 'duplicate routes' - ) - for route_id in matched_ids[1:]: - delete.add(route_id) - - str_ids = str(tuple(delete)).replace(',)', ')') - self.delete_where(table='component', key=f'component_route IN {str_ids}') - self.delete_where(table='route', key=f'route_id IN {str_ids}') - - mrich.success('Deleted', len(delete), 'duplicate routes') - - return delete - - def reinitialise_molecules(self): - """In the case where the Mol binaries in a database are throwing unpickling errors, run this to reinitialise them all from their smiles.""" - - mrich.var('#compounds', self.count('compound')) - - sql = f""" - UPDATE {self.SQL_SCHEMA_PREFIX}compound - SET compound_mol = {self.SQL_SCHEMA_PREFIX}mol_from_smiles(compound_smiles); - """ - - with mrich.loading('Reinitialising compounds...'): - self.execute(sql) - - mrich.success('compound_mol records updated') - - def fix_incorrect_pose_compound_assignments(self): - """Fix pose_compound values that reference incorrect chemical structures""" - - lookup = self.get_compound_id_smiles_dict() - lookup = {v: k for k, v in lookup.items()} - - count = self.count_where(table='pose', key='mol', value='NOT null') - - match self.engine: - case 'sqlite3': - sql = """ - SELECT pose_id, pose_compound, mol_to_smiles(mol_from_binary_mol(pose_mol)) - FROM pose - WHERE pose_mol IS NOT null - """ - case 'psycopg': - sql = """ - SELECT pose_id, pose_compound, hippo.mol_to_smiles(hippo.mol_from_pkl(pose_mol)) - FROM hippo.pose - WHERE pose_mol IS NOT null - """ - - c = self.execute(sql) - - fix = set() - fix_count = 0 - for pose_id, pose_compound, smiles in mrich.track(c, total=count): - try: - flat_smiles = sanitise_smiles(smiles) - except Exception: - mrich.error('Could not sanitise', pose_id, smiles) - - comp_id = lookup.get(flat_smiles) - - if not comp_id: - mrich.error('No matching compound', pose_id, smiles) - continue - - if comp_id != pose_compound: - fix.add((comp_id, pose_id)) - fix_count += 1 - mrich.set_progress_field('#fix', fix_count) - - mrich.var('#fix', len(fix)) - - sql = f""" - UPDATE {self.SQL_SCHEMA_PREFIX}pose - SET pose_compound = {self.SQL_STRING_PLACEHOLDER} - WHERE pose_id = {self.SQL_STRING_PLACEHOLDER} - """ - - self.executemany(sql, list(fix)) - self.commit() - - ### BULK REGISTRATION - - def register_compounds( - self, - *, - smiles: list[str], - radical: str = 'warning', - sanitisation_verbosity: bool = True, - sanitise: bool = True, - debug: bool = False, - ) -> list[tuple[str, str]]: - """Bulk register compounds""" - - values = [] - - if len(smiles) > 1000: - generator = mrich.track(smiles, prefix='Sanitising...') - else: - generator = smiles - - for s in generator: - if sanitise: - try: - new_smiles = sanitise_smiles( - s, - sanitisation_failed='error', - radical=radical, - verbosity=sanitisation_verbosity, - ) - except SanitisationError as e: - mrich.error(f'Could not sanitise {s=}') - mrich.error(str(e)) - continue - except AssertionError: - mrich.error(f'Could not sanitise {s=}') - continue - else: - new_smiles = s - - inchikey = inchikey_from_smiles(new_smiles) - values.append((inchikey, new_smiles)) - - if self.auto_compute_bfps: - sql = f""" - INSERT OR IGNORE INTO {self.SQL_SCHEMA_PREFIX}compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_pattern_bfp, - compound_morgan_bfp - ) - VALUES( - ?1, - ?2, - mol_from_smiles(?2), - mol_pattern_bfp(mol_from_smiles(?2), 2048), - mol_morgan_bfp(mol_from_smiles(?2), 2, 2048) - ) - """ - - self.executemany(sql, values) - - else: - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO compound(compound_inchikey, compound_smiles, compound_mol) - VALUES(?1, ?2, mol_from_smiles(?2)) - """ - - if debug: - mrich.debug('Inserting...') - - self.executemany(sql, values) - - case 'psycopg': - sql = """ - INSERT INTO hippo.compound(compound_inchikey, compound_smiles, compound_mol) - VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s) - ) - ON CONFLICT DO NOTHING; - """ - - self.executemany( - sql, [dict(inchikey=i, smiles=s) for i, s in values] - ) - - if self.auto_compute_bfps: - self.update_compound_pattern_bfp_table() - - self.commit() - - return values - - def register_poses(self, dicts: list[dict]) -> set[int]: - """Insert or ignore a bunch of poses, also returns a set of Pose IDs - - :param dicts: a list of dictionaries describing the poses to be inserted. See the expected format below: - - dicts = [ - dict( - alias=..., # string can be None - reference_id=..., # reference pose id - inchikey=..., # pre-computed inchikey - smiles=..., # SMILEs - path=..., # path to mol-file on disk, used for uniqueness check, can be a fake path - compound_id=..., # Compound database ID - target_id=..., # Target database ID - mol=..., # rdkit.Chem.Mol - energy_score=..., # float, can be None - distance_score=..., # float, can be None - metadata=..., # dictionary, can be empty - ) - ] - - """ - - from json import dumps - - ### POSES - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, - pose_metadata - ) - VALUES( - :inchikey, - :smiles, - :alias, - :reference, - :path, - :compound, - :target, - :mol, - :energy_score, - :distance_score, - :metadata - ) - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.pose( - pose_inchikey, - pose_smiles, - pose_alias, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_energy_score, - pose_distance_score, - pose_metadata - ) - VALUES( - %(inchikey)s, - %(smiles)s, - %(alias)s, - %(reference)s, - %(path)s, - %(compound)s, - %(target)s, - %(mol)s, - %(energy_score)s, - %(distance_score)s, - %(metadata)s - ) - ON CONFLICT DO NOTHING; - """ - - values = [] - for i, d in enumerate(dicts): - alias = d['alias'] - reference_id = d.get('reference_id') - if reference_id: - reference_id = int(reference_id) - - if not alias: - alias = None - - try: - values.append( - dict( - inchikey=str(d['inchikey']), - smiles=str(d['smiles']), - alias=alias, - reference=reference_id, - path=str(d['path']), - compound=int(d['compound_id']), - target=int(d['target_id']), - mol=d['mol'].ToBinary(), - energy_score=float(d['energy_score']), - distance_score=float(d['distance_score']), - metadata=dumps(d['metadata']), - ) - ) - except KeyError as e: - mrich.error('Skipping', i, str(e)) - - self.executemany(sql, values) - self.commit() - - ### INSPIRATIONS - - lookup = self.get_pose_path_id_dict() - - values = [] - pose_ids = set() - - for i, d in enumerate(dicts): - if 'inspiration_ids' not in d: - continue - derivative_id = lookup.get(str(d['path'])) - if not derivative_id: - mrich.error('Could not get derivative by path:', str(d['path'])) - continue - pose_ids.add(derivative_id) - for inspiration_id in d['inspiration_ids']: - values.append((inspiration_id, derivative_id)) - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO inspiration(inspiration_original, inspiration_derivative) - VALUES(?1, ?2) - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.inspiration(inspiration_original, inspiration_derivative) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - - self.executemany(sql, values) - self.commit() - - return pose_ids - - def calculate_all_scaffolds(self) -> None: - """Determine and insert records for all substructure/superstructure relationships in the Compound table""" - - n_before = self.count('scaffold') - - mrich.var('#compounds', self.count('compound')) - mrich.var('#scaffold defs', n_before) - - sql = """ - SELECT compound_id, compound_mol, compound_pattern_bfp - FROM compound - """ - - with mrich.loading('Fetching compounds...'): - records = self.execute(sql).fetchall() - - self.commit() - - sql = """ - INSERT OR IGNORE INTO scaffold - SELECT ?1, c.compound_id - FROM compound AS c, compound_pattern_bfp AS fp - WHERE c.compound_id = fp.compound_id - AND c.compound_id <> ?1 - AND mol_is_substruct(c.compound_mol, ?2) - AND fp.compound_id MATCH rdtree_subset(?3) - """ - - with mrich.loading('Calculating scaffolds...'): - t1 = time.time() - self.executemany(sql, records) - mrich.print('Took', f'{time.time() - t1:.1f}', 'seconds') - - self.commit() - - diff = self.count('scaffold') - n_before - - if diff: - mrich.success( - 'Found', diff, 'new substructure-superstructure relationships' - ) - else: - mrich.warning( - 'Found', diff, 'new substructure-superstructure relationships' - ) - - def calculate_all_murcko_scaffolds( - self, generic: bool = True - ) -> 'dict | (dict, dict)': - """Determine Murcko and optionally generic Murcko scaffolds for all Compounds in the Database and add relevant records. - - :param generic: Calculate generic (single bonds and all carbon) scaffolds as well - """ - - n_before = self.count('scaffold') - - mrich.var('#compounds', self.count('compound')) - mrich.var('#scaffold defs', n_before) - - from rdkit.Chem import MolFromSmiles, MolToSmiles - from rdkit.Chem.Scaffolds.MurckoScaffold import ( - MakeScaffoldGeneric, - MurckoScaffoldSmiles, - ) - - compound_records = self.select( - query='compound_id, compound_smiles', table='compound', multiple=True - ) - - ### CALCULATE SCAFFOLDS - - murcko_data = {} - generic_data = {} - generic_to_murcko = {} - for c_id, smiles in mrich.track(compound_records): - # murcko - - try: - murcko_smiles = sanitise_smiles(MurckoScaffoldSmiles(smiles)) - except KeyboardInterrupt: - raise - except: - mrich.error("can't make murcko:", smiles) - continue - - if murcko_smiles not in murcko_data: - murcko_data[murcko_smiles] = set() - - murcko_data[murcko_smiles].add(c_id) - - # generic - - if generic: - try: - generic_smiles = sanitise_smiles( - MolToSmiles(MakeScaffoldGeneric(MolFromSmiles(murcko_smiles))) - ) - except KeyboardInterrupt: - raise - except: - mrich.error("can't make generic:", murcko_smiles) - continue - - if generic_smiles not in generic_data: - generic_data[generic_smiles] = set() - - if generic_smiles not in generic_to_murcko: - generic_to_murcko[generic_smiles] = set() - - generic_data[generic_smiles].add(c_id) - generic_to_murcko[generic_smiles].add(murcko_smiles) - - mrich.var('#murcko scaffolds', len(murcko_data)) - mrich.var('#generic murcko scaffolds', len(generic_data)) - - ### REGISTER MURCKOS - - murcko_values = self.register_compounds( - smiles=murcko_data.keys(), sanitisation_verbosity=False, sanitise=False - ) - murcko_s2i = {s: i for i, s in murcko_values} - - ### REGISTER GENERICS - - if generic: - generic_values = self.register_compounds( - smiles=generic_data.keys(), sanitisation_verbosity=False, sanitise=False - ) - generic_s2i = {s: i for i, s in generic_values} - - ### TAG MURCKOS - - murcko_ids = self.select_id_where( - table='compound', - key=f'compound_inchikey IN {tuple(murcko_s2i.values())}', - multiple=True, - ) - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO tag(tag_name, tag_compound) - VALUES (?,?) - """ - case 'psycopg': - sql = """ - INSERT INTO hippo.tag(tag_name, tag_compound) - VALUES (%s,%s) - ON CONFLICT DO NOTHING; - """ - - self.executemany( - sql, - [('MurckoScaffold', i) for (i,) in murcko_ids], - ) - - ### TAG GENERICS - - if generic: - generic_ids = self.select_id_where( - table='compound', - key=f'compound_inchikey IN {tuple(generic_s2i.values())}', - multiple=True, - ) - - self.executemany( - sql, - [('GenericMurckoScaffold', i) for (i,) in generic_ids], - ) - - ### ADD MURCKO SCAFFOLD RELATIONS - - pairs = [] - - murcko_inchikey_lookup = self.get_compound_inchikey_id_dict(murcko_s2i.values()) - for murcko_smiles, c_ids in murcko_data.items(): - murcko_id = murcko_inchikey_lookup[murcko_s2i[murcko_smiles]] - for c_id in c_ids: - pairs.append((murcko_id, c_id)) - - pairs = [(a, b) for a, b in pairs if a != b] - - mrich.var('#murcko scaffold relations', len(pairs)) - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) - VALUES (?,?) - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.scaffold (scaffold_base, scaffold_superstructure) - VALUES (%s, %s) - ON CONFLICT DO NOTHING; - """ - - self.executemany( - sql, - pairs, - ) - - ### ADD GENERIC SCAFFOLD RELATIONS - - if generic: - pairs = [] - - generic_inchikey_lookup = self.get_compound_inchikey_id_dict( - generic_s2i.values() - ) - for generic_smiles, c_ids in generic_data.items(): - generic_id = generic_inchikey_lookup[generic_s2i[generic_smiles]] - for c_id in c_ids: - pairs.append((generic_id, c_id)) - - for generic_smiles, murcko_smiles_list in generic_to_murcko.items(): - generic_id = generic_inchikey_lookup[generic_s2i[generic_smiles]] - for murcko_smiles in murcko_smiles_list: - murcko_id = murcko_inchikey_lookup[murcko_s2i[murcko_smiles]] - pairs.append((generic_id, murcko_id)) - - pairs = [(a, b) for a, b in pairs if a != b] - - mrich.var('#generic murcko scaffold relations', len(pairs)) - - self.executemany( - 'INSERT OR IGNORE INTO scaffold (scaffold_base, scaffold_superstructure) VALUES (?,?)', - pairs, - ) - - self.commit() - - if generic: - return murcko_data, generic_data - else: - return murcko_data - - def set_derivative_subsites(self, commit: bool = True) -> None: - """Propagate all subsite assignments from inspirations to their derivatives""" - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - SELECT subsite_tag_ref, inspiration_derivative FROM subsite_tag - INNER JOIN inspiration ON subsite_tag_pose = inspiration_original - """ - - case 'psycopg': - sql = """ - INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) - SELECT subsite_tag_ref, inspiration_derivative FROM hippo.subsite_tag - INNER JOIN hippo.inspiration ON subsite_tag_pose = inspiration_original - ON CONFLICT DO NOTHING; - """ - - self.execute(sql) - - if commit: - self.commit() - - def set_subsites_from_metadata_field( - self, pose_str_ids: str, field='CanonSites alias' - ) -> None: - """Create and assign subsite entries from a metadata field - - :param pose_str_ids: pose_str_ids - :param field: the metadata field to use - - """ - - from json import loads - - records = self.select_where( - table='pose', - query='pose_id, pose_target, pose_metadata', - key=f'pose_id IN {pose_str_ids}', - multiple=True, - ) - - subsites = set() - subsite_tags = set() - - for pose_id, pose_target, metadata in records: - metadata = loads(metadata) - - key = metadata.get(field) - - if not key: - mrich.warning(field, 'not in metadata pose_id=', pose_id) - continue - - subsites.add((pose_target, key)) - subsite_tags.add((pose_target, key, pose_id)) - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO subsite(subsite_target, subsite_name) - VALUES(?, ?) - """ - case 'psycopg': - sql = strip_sql( - """ - INSERT INTO hippo.subsite(subsite_target, subsite_name) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - ) - - self.executemany(sql, sorted(list(subsites))) - - subsite_records = self.select( - table='subsite', - query='subsite_id, subsite_target, subsite_name', - multiple=True, - ) - - subsite_lookup = {(t, name): i for i, t, name in subsite_records} - - match self.engine: - case 'sqlite3': - sql = """ - INSERT OR IGNORE INTO subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(?, ?) - """ - case 'psycopg': - sql = strip_sql( - """ - INSERT INTO hippo.subsite_tag(subsite_tag_ref, subsite_tag_pose) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - ) - - subsite_tags = [ - (subsite_lookup[(t, name)], pose_id) for t, name, pose_id in subsite_tags - ] - - self.executemany(sql, subsite_tags) - - self.commit() - - ### GETTERS - - def get_compound( - self, - *, - id: int | None = None, - inchikey: str | None = None, - alias: str | None = None, - smiles: str | None = None, - none: str = 'error', - **kwargs, - ) -> Compound: - """Get a :class:`.Compound` using one of the following fields: ['id', 'inchikey', 'alias', 'smiles'] - - :param id: the ID to search for (Default value = None) - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :param smiles: the smiles to search for (Default value = None) - :returns: the :class:`.Compound` object - - """ - - if id is None: - id = self.get_compound_id( - inchikey=inchikey, smiles=smiles, alias=alias, none=none, **kwargs - ) - - if not id: - if none == 'error': - mrich.error(f'Invalid {id=}') - return None - - query = 'compound_id, compound_inchikey, compound_alias, compound_smiles' - entry = self.select_where( - query=query, table='compound', key='id', value=id, none=none, **kwargs - ) - compound = Compound(self._animal, self, *entry, metadata=None, mol=None) - return compound - - def get_compound_id( - self, - *, - inchikey: str | None = None, - alias: str | None = None, - smiles: str | None = None, - **kwargs, - ) -> int: - """Get a compound's ID using one of the following fields: ['inchikey', 'alias', 'smiles'] - - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :param smiles: the smiles to search for (Default value = None) - :returns: the :class:`.Compound` ID - - """ - - if inchikey: - entry = self.select_id_where( - table='compound', key='inchikey', value=inchikey, **kwargs - ) - - elif alias: - entry = self.select_id_where( - table='compound', key='alias', value=alias, **kwargs - ) - - elif smiles: - entry = self.select_id_where( - table='compound', key='smiles', value=smiles, **kwargs - ) - - else: - raise NotImplementedError - - if entry: - return entry[0] - - return None - - def get_compound_mol( - self, - compound_id: int, - ) -> 'Chem.Mol': - """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" - - from rdkit.Chem import Mol - - (bytestr,) = self.select_where( - query='mol_to_binary_mol(compound_mol)', - table='compound', - key='id', - value=compound_id, - ) - - return Mol(bytestr) - - def get_compound_computed_property( - self, - prop: str, - compound_id: int, - ) -> int | str: - """Use chemicalite to calculate a property from the stored binary molecule - - :param prop: the property to calculate [num_heavy_atoms, formula, num_rings] - :param compound_id: the compound ID to query - :returns: the value of the computed property - - """ - - function = self.COMPOUND_PROPERTY_FUNCTIONS[prop] - - if not isinstance(function, str): - function, extra = function - else: - extra = '' - - (val,) = self.select_where( - query=f'{function}(compound_mol{extra})', - table='compound', - key='id', - value=compound_id, - multiple=False, - ) - return val - - def get_pose( - self, - *, - id: int | None = None, - inchikey: str = None, - alias: str = None, - debug: bool = False, - ) -> Pose: - """Get a pose using one of the following fields: ['id', 'inchikey', 'alias'] - - :param id: the ID to search for (Default value = None) - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :returns: the :class:`.Pose` object - - """ - - if id is None: - id = self.get_pose_id(inchikey=inchikey, alias=alias) - - if isinstance(id, list): - from .pset import PoseSet - - return PoseSet(self, id) - - if not id: - mrich.error(f'Invalid {id=}') - return None - - query = ', '.join(self.POSE_FIELDS) - - entry = self.select_where(query=query, table='pose', key='id', value=id) - - if debug: - mrich.print(entry) - - pose = Pose(self, *entry) - return pose - - def get_poses( - self, - *, - ids: list[int], - ) -> list[Pose]: - """Get list of initialised :class:`.Pose` objects with given ID's""" - - query = ', '.join(self.POSE_FIELDS) - - str_ids = str(tuple(ids)).replace(',)', ')') - - records = self.select_where( - query=query, table='pose', key=f'pose_id IN {str_ids}', multiple=True - ) - - poses = [Pose(self, *entry) for entry in records] - - return poses - - def get_pose_id( - self, - *, - inchikey: str | None = None, - alias: str | None = None, - ) -> int: - """Get a pose's ID using one of the following fields: ['inchikey', 'alias', 'smiles'] - - :param table: the table from which to get the entry (Default value = 'pose') - :param inchikey: the InChi-Key to search for (Default value = None) - :param alias: the alias to search for (Default value = None) - :returns: the :class:`.Pose` ID - - """ - - if inchikey: - # inchikey might not be unique - entries = self.select_id_where( - table='pose', key='inchikey', value=inchikey, multiple=True - ) - if len(entries) != 1: - mrich.warning(f'Multiple poses with {inchikey=}') - return [i for (i,) in entries] - else: - entry = entries[0] - - elif alias: - entry = self.select_id_where(table='pose', key='alias', value=alias) - - else: - raise NotImplementedError - - if entry: - return entry[0] - - return None - - def get_reaction( - self, - *, - id: int | None = None, - none: str | None = None, - ) -> Reaction: - """Get a reaction using its ID - - :param id: the ID to search for (Default value = None) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the :class:`.Reaction` object - - """ - - if not id: - mrich.error(f'Invalid {id=}') - return None - - query = 'reaction_id, reaction_type, reaction_product, reaction_product_yield' - entry = self.select_where( - query=query, table='reaction', key='id', value=id, none=none - ) - - if not entry: - return None - - reaction = Reaction(self, *entry) - return reaction - - def get_quote( - self, - *, - id: int | None = None, - none: str | None = None, - ) -> Quote: - """Get a quote using its ID - - :param id: the ID to search for (Default value = None) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: the :class:`.Quote` object - - """ - - query = ', '.join( - [ - 'quote_compound', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_amount', - 'quote_price', - 'quote_currency', - 'quote_lead_time', - 'quote_purity', - 'quote_date', - 'quote_smiles', - 'quote_id', - ] - ) - - entry = self.select_where( - query=query, table='quote', key='id', value=id, none=none - ) - - return Quote( - db=self, - id=entry[11], - compound=entry[0], - supplier=entry[1], - catalogue=entry[2], - entry=entry[3], - amount=entry[4], - price=entry[5], - currency=entry[6], - lead_time=entry[7], - purity=entry[8], - date=entry[9], - smiles=entry[10], - ) - - def get_quote_df(self, ids: list[int]) -> 'pd.DataFrame': - """Get a pandas DataFrame representing quotes with given IDs""" - - from pandas import DataFrame - - str_ids = str(tuple(ids)).replace(',)', ')') - - query = ', '.join( - [ - 'quote_compound', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_amount', - 'quote_price', - 'quote_currency', - 'quote_lead_time', - 'quote_purity', - 'quote_date', - 'quote_smiles', - 'quote_id', - ] - ) - records = self.select_where( - query=query, - table='quote', - key=f'quote_id IN {str_ids}', - multiple=True, - ) - - data = [] - - for entry in records: - data.append( - dict( - id=entry[11], - compound=entry[0], - supplier=entry[1], - catalogue=entry[2], - entry=entry[3], - amount=entry[4], - price=entry[5], - currency=entry[6], - lead_time=entry[7], - purity=entry[8], - date=entry[9], - smiles=entry[10], - ) - ) - - return DataFrame(data) - - def get_metadata( - self, - *, - table: str, - id: int, - ) -> dict: - """Get metadata dictionary from a specific table and ID - - :param table: the table from which to get the entry - :param id: the ID to search for (Default value = None) - :returns: a dictionary of metadata - - """ - - (payload,) = self.select_where( - query=f'{table}_metadata', table=table, key='id', value=id - ) - - if payload: - payload = json.loads(payload) - - else: - payload = dict() - - metadata = MetaData(payload) - - metadata._db = self - metadata._id = id - metadata._table = table - - return metadata - - def get_target( - self, - *, - id: int, - ) -> Target: - """Get target with specific ID - - :param id: the ID of the target to retrieve - :returns: :class:`.Target` object - - """ - - return Target(db=self, id=id, name=self.get_target_name(id=id)) - - def get_target_name( - self, - *, - id: int, - ) -> str: - """Get the name of a target with given ID - - :param id: the ID of the target to retrieve - :returns: target name - - """ - - table = 'target' - (payload,) = self.select_where( - query=f'{table}_name', table=table, key='id', value=id - ) - return payload - - def get_target_id( - self, - *, - name: str, - ) -> int | None: - """Get target ID with a given name - - :param name: the protein target name - :returns: the :class:`.Target` ID - - """ - - table = 'target' - entry = self.select_id_where(table=table, key='name', value=name) - - if entry: - return entry[0] - - return None - - def get_feature( - self, - *, - id: int, - ) -> Feature: - """Get a protein interaction :class:`.Feature` with a given ID - - :param id: the protein interaction :class:`.Feature` ID to be retrieved - :returns: :class:`.Feature` object - - """ - - entry = self.select_all_where(table='feature', key='id', value=id) - - return Feature(*entry) - - def get_route( - self, - *, - id: int, - debug: bool = False, - ) -> Route: - """Fetch a :class:`.Route` object stored in the :class:`.Database`. - - :param id: the ID of the :class:`.Route` to be retrieved - :param debug: increase verbosity for debugging, defaults to False - :returns: :class:`.Route` object - - """ - - from .cset import CompoundSet, IngredientSet - from .rset import ReactionSet - - (product_id,) = self.select_where( - table='route', query='route_product', key='id', value=id - ) - - if debug: - mrich.var('product_id', product_id) - - triples = self.select_where( - table='component', - query='component_ref, component_type, component_amount', - key=f'component_route IS {id} ORDER BY component_id', - multiple=True, - ) - - reaction_ids = [] - reactant_ids = [] - reactant_amounts = [] - intermediate_ids = [] - intermediate_amounts = [] - - for ref, c_type, amount in triples: - match c_type: - case 1: - reaction_ids.append(ref) - case 2: - reactant_ids.append(ref) - reactant_amounts.append(amount) - case 3: - intermediate_ids.append(ref) - intermediate_amounts.append(amount) - case _: - raise ValueError(f'Unknown component type {c_type}') - - if debug: - mrich.var('pairs', pairs) - - products = CompoundSet(self, [product_id]) - reactants = CompoundSet(self, reactant_ids) - intermediates = CompoundSet(self, intermediate_ids) - - products = IngredientSet.from_compounds(compounds=products, amount=1) - reactants = IngredientSet.from_compounds( - compounds=reactants, amount=reactant_amounts - ) - intermediates = IngredientSet.from_compounds( - compounds=intermediates, amount=intermediate_amounts - ) - - reactions = ReactionSet(self, reaction_ids) - - recipe = Route( - self, - route_id=id, - product=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - ) - - if debug: - mrich.var('recipe', recipe) - - return recipe - - def get_route_products(self) -> 'CompoundSet | None': - """Get a :class:`.CompoundSet` of all route products""" - from .cset import CompoundSet - - records = self.execute( - f'SELECT DISTINCT route_product FROM {self.SQL_SCHEMA_PREFIX}route' - ).fetchall() - if not records: - return None - return CompoundSet(self, [i for (i,) in records]) - - def get_route_id_product_dict(self) -> dict[int, int]: - """Get a dictionary mapping route ID's to their product :class:`.Compound`""" - records = self.execute('SELECT route_id, route_product FROM route').fetchall() - return {route_id: route_product for route_id, route_product in records} - - def get_product_id_routes_dict(self) -> dict[int, set[int]]: - """Get a dictionary mapping product :class:`.Compound` to their route IDs""" - records = self.execute('SELECT route_id, route_product FROM route').fetchall() - - lookup = {} - - for route_id, route_product in records: - if route_product not in lookup: - lookup[route_product] = set() - - lookup[route_product].add(route_id) - - return lookup - - def get_route_id_reactant_ids_dict(self) -> dict[int, set[int]]: - """Get a dictionary mapping :class:`.Route` ID's to their reactant :class:`.Compound` IDs""" - - sql = """ - SELECT route_id, component_ref FROM route - INNER JOIN component - ON component_route = route_id - WHERE component_type = 2 - """ - - c = self.execute(sql) - - lookup = {} - for route_id, route_reactant in c: - lookup.setdefault(route_id, set()) - lookup[route_id].add(route_reactant) - - return lookup - - def get_compound_id_pose_ids_dict(self, cset: 'CompoundSet') -> dict[int, set]: - """Get a dictionary mapping :class:`.Compound` ID's to their associated :class:`.Pose` ID's""" - records = self.execute( - f'SELECT pose_compound, pose_id FROM {self.SQL_SCHEMA_PREFIX}pose WHERE pose_compound IN {cset.str_ids}' - ).fetchall() - - d = {} - - for comp_id, pose_id in records: - d[comp_id] = d.get(comp_id, set()) - d[comp_id].add(pose_id) - return d - - def get_compound_id_suppliers_dict( - self, cset: 'CompoundSet' - ) -> dict[int, set[str]]: - """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" - records = self.execute( - f'SELECT quote_compound, quote_supplier FROM {self.SQL_SCHEMA_PREFIX}quote WHERE quote_compound IN {cset.str_ids}' - ).fetchall() - - d = {} - - for comp_id, quote_supplier in records: - d[comp_id] = d.get(comp_id, set()) - d[comp_id].add(quote_supplier) - - return d - - def get_compound_id_smiles_dict( - self, - cset: 'CompoundSet | None' = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping :class:`.Compound` ID's to suppliers which stock it""" - - if cset: - sql = f'SELECT compound_id, compound_smiles FROM {self.SQL_SCHEMA_PREFIX}compound WHERE compound_id IN {cset.str_ids}' - - else: - sql = 'SELECT compound_id, compound_smiles FROM compound' - - c = self.execute(sql) - - d = {} - for comp_id, comp_smiles in c: - d[comp_id] = comp_smiles - - return d - - def get_compound_inchikey_id_dict(self, inchikeys: list[str]) -> dict[str, int]: - """Get a dictionary mapping :class:`.Compound` inchikeys to their ID's""" - - inchikey_str = str(tuple(inchikeys)).replace(',)', ')') - - records = self.select_where( - table='compound', - multiple=True, - query='compound_inchikey, compound_id', - key=f'compound_inchikey IN {inchikey_str}', - ) - - return { - compound_inchikey: compound_id for compound_inchikey, compound_id in records - } - - def get_compound_smiles_id_dict(self) -> dict[str, int]: - """Get a dictionary mapping :class:`.Compound` smiles to their ID's""" - - records = self.select( - table='compound', - query='compound_smiles, compound_id', - multiple=True, - ) - - return { - compound_smiles: compound_id for compound_smiles, compound_id in records - } - - def get_compound_id_inchikey_dict( - self, cset: 'CompoundSet | None' = None - ) -> dict[int, str]: - """Get a dictionary mapping :class:`.Compound` IDs to their inchikeys""" - - if cset: - records = self.select_where( - table='compound', - multiple=True, - query='compound_id, compound_inchikey', - key=f'compound_id IN {cset.str_ids}', - ) - - else: - records = self.select( - table='compound', - multiple=True, - query='compound_id, compound_inchikey', - ) - - return { - compound_id: compound_inchikey for compound_id, compound_inchikey in records - } - - def get_id_metadata_dict(self, *, table: str, ids: list[int]) -> dict[int, dict]: - """Get a dictionary mapping IDs to metadata dictionaries""" - from json import loads - - str_ids = str(tuple(ids)).replace(',)', ')') - records = self.select_where( - query=f'{table}_id, {table}_metadata', - table=table, - key=f'{table}_id IN {str_ids}', - multiple=True, - ) - return {i: (loads(m) if m is not None else {}) for i, m in records} - - def get_compound_cluster_dict( - self, - cset: 'CompoundSet | None' = None, - *, - fractions: bool = False, - max_scaffolds: int | None = None, - fraction_reference: 'CompoundSet | None' = None, - ) -> dict[tuple, set]: - """Create a dictionary grouping compounds by their scaffold/base cluster. - - :param cset: :class:`.CompoundSet` subset to query, defaults to all compounds - :param fractions: Calculate fractional populations for each cluster - :param max_scaffolds: Define the maximum number of compounds to use as cluster keys - :param fraction_reference: Use cset to build the cluster map and use fraction_reference to determine the fractional populations - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of superstructure :class:`.Compound` ID's. - """ - - if fractions: - assert cset is not None - - if (cset is not None and not fractions) or (fraction_reference is not None): - sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM {self.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {cset.str_ids} - """ - else: - sql = f""" - SELECT scaffold_superstructure, scaffold_base FROM {self.SQL_SCHEMA_PREFIX}scaffold - """ - - records = self.execute(sql).fetchall() - - lookup = {} - for superstructure, scaffold in records: - group = lookup.setdefault(superstructure, set()) - group.add(scaffold) - - clustered = {} - for superstructure, scaffolds in lookup.items(): - if max_scaffolds and len(scaffolds) > max_scaffolds: - continue - cluster = tuple(scaffolds) - group = clustered.setdefault(cluster, set()) - group.add(superstructure) - - if fractions: - if fraction_reference is not None: - cset = fraction_reference - - fractions = {} - for cluster, members in clustered.items(): - size = len(members) - present = sum(1 for c in members if c in cset) - fractions[cluster] = present / size - return fractions - - return clustered - - def get_compound_scaffold_dict(self) -> dict[int, set[int]]: - """Get a dictionary mapping scaffold_base compound ID's to a set of their superstructure IDs""" - - records = self.select( - table='scaffold', - query='scaffold_base, scaffold_superstructure', - multiple=True, - ) - - data = {} - for scaffold, elab in records: - if scaffold not in data: - data[base] = set() - data[base].add(elab) - - return data - - def get_compound_tag_dict( - self, - cset: 'CompoundSet | None' = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping compound ID's to their tags""" - - if cset: - raise NotImplementedError - - records = self.select( - query='tag_name, tag_compound', table='tag', multiple=True - ) - - data = {} - for tag_name, compound_id in records: - if compound_id not in data: - data[compound_id] = set() - data[compound_id].add(tag_name) - - # null IDS - comp_ids = self.select(table='compound', query='compound_id', multiple=True) - comp_ids = set(q for (q,) in comp_ids) - - null_ids = comp_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_tag_dict( - self, - pset: 'PoseSet | None' = None, - ) -> dict[int, set[str]]: - """Get a dictionary mapping pose ID's to their tags""" - - if pset: - records = self.select_where( - query='tag_name, tag_pose', - table='tag', - key=f'tag_pose IN {pset.str_ids}', - multiple=True, - ) - - else: - records = self.select( - query='tag_name, tag_pose', table='tag', multiple=True - ) - - data = {} - for tag_name, pose_id in records: - if pose_id not in data: - data[pose_id] = set() - data[pose_id].add(tag_name) - - # null IDS - - if pset: - null_ids = set(pset.ids) - set(data.keys()) - - else: - pose_ids = self.select(table='pose', query='pose_id', multiple=True) - pose_ids = set(q for (q,) in pose_ids) - - null_ids = pose_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_subsite_names_dict(self) -> dict[int, set[str]]: - """Get a dictionary mapping pose ID's to their subsite names""" - - lookup = { - i: n - for i, n in self.select( - query='subsite_id, subsite_name', table='subsite', multiple=True - ) - } - - records = self.select( - query='subsite_tag_ref, subsite_tag_pose', - table='subsite_tag', - multiple=True, - ) - - data = {} - for subsite_id, pose_id in records: - data.setdefault(pose_id, set()) - data[pose_id].add(lookup[subsite_id]) - - # null IDS - pose_ids = self.select(table='pose', query='pose_id', multiple=True) - pose_ids = set(q for (q,) in pose_ids) - - null_ids = pose_ids - set(data.keys()) - - for c_id in null_ids: - data[c_id] = set() - - return data - - def get_pose_id_interaction_ids_dict(self, pset: 'PoseSet') -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to their associated :class:`.Interaction` ID's""" - records = self.execute( - f'SELECT interaction_pose, interaction_id FROM {self.SQL_SCHEMA_PREFIX}interaction WHERE interaction_pose IN {pset.str_ids}' - ).fetchall() - - d = {} - - for pose_id, interaction_id in records: - d[pose_id] = d.get(pose_id, set()) - d[pose_id].add(interaction_id) - return d - - def get_pose_alias_id_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_alias in records: - d[pose_alias] = pose_id - - return d - - def get_pose_alias_path_dict(self, pset: 'PoseSet | None' = None) -> dict[str, str]: - """Get a dictionary mapping :class:`.Pose` aliases to paths""" - - if pset: - records = self.execute( - f""" - SELECT pose_alias, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_alias, pose_path FROM pose """ - ).fetchall() - - d = {} - for pose_alias, pose_path in records: - d[pose_alias] = pose_path - - return d - - def get_pose_id_alias_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_alias FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_alias IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - """SELECT pose_id, pose_alias FROM pose - WHERE pose_alias IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_alias in records: - d[pose_id] = pose_alias - - return d - - def get_pose_path_id_dict(self, pset: 'PoseSet | None' = None) -> dict[str, int]: - """Get a dictionary mapping :class:`.Pose` aliases to ID's""" - - if pset: - records = self.execute( - f""" - SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_path IS NOT NULL - AND pose_id IN {pset.str_ids}""" - ).fetchall() - - else: - records = self.execute( - f""" - SELECT pose_id, pose_path FROM {self.SQL_SCHEMA_PREFIX}pose - WHERE pose_path IS NOT NULL""" - ).fetchall() - - d = {} - for pose_id, pose_path in records: - d[pose_path] = pose_id - - return d - - def get_pose_id_obj_dict(self, pset: 'PoseSet') -> 'dict[id, Pose]': - """Get a dictionary mapping :class:`.Pose` ID's to their objects""" - - query = ', '.join( - [ - 'pose_id', - 'pose_inchikey', - 'pose_alias', - 'pose_smiles', - 'pose_reference', - 'pose_path', - 'pose_compound', - 'pose_target', - 'pose_mol', - 'pose_fingerprint', - 'pose_energy_score', - 'pose_distance_score', - ] - ) - - records = self.select_where( - query=query, table='pose', key=f'pose_id IN {pset.str_ids}', multiple=True - ) - - d = {} - for entry in records: - ( - pose_id, - pose_inchikey, - pose_alias, - pose_smiles, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - ) = entry - - d[pose_id] = Pose( - self, - pose_id, - pose_inchikey, - pose_alias, - pose_smiles, - pose_reference, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - ) - - return d - - def get_pose_id_interaction_tuples_dict(self, pset: 'PoseSet') -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to lists of `(interaction_type, feature_id)` tuples describing their interactions""" - - sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type FROM {self.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id - WHERE interaction_pose IN {pset.str_ids} - """ - - records = self.execute(sql).fetchall() - - ISETS = {} - for pose_id, feature_id, interaction_type in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_id)) - ISETS[pose_id] = values - - return ISETS - - def get_compound_id_inspiration_ids_dict(self) -> dict[int, set]: - """Get a dictionary mapping :class:`.Compound` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - - sql = f""" - SELECT compound_id, pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}compound - INNER JOIN {self.SQL_SCHEMA_PREFIX}pose ON compound_id = pose_compound - INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative - """ - - with mrich.spinner('Database.get_pose_id_interaction_ids_dict()'): - records = self.execute(sql).fetchall() - - d = {} - - for compound_id, pose_id, inspiration_id in records: - d[compound_id] = d.get(compound_id, set()) - d[compound_id].add(inspiration_id) - - return d - - def get_pose_id_inspiration_ids_dict( - self, - pset: 'PoseSet' = None, - ) -> dict[int, set]: - """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - - if pset: - sql = f""" - SELECT pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}pose - INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative - WHERE pose_id IN {pset.str_ids} - """ - - else: - sql = f""" - SELECT pose_id, inspiration_original FROM {self.SQL_SCHEMA_PREFIX}pose - INNER JOIN {self.SQL_SCHEMA_PREFIX}inspiration ON pose_id = inspiration_derivative - """ - - with mrich.spinner('Database.get_pose_id_interaction_ids_dict()'): - records = self.execute(sql).fetchall() - - d = {} - - for pose_id, inspiration_id in records: - d[pose_id] = d.get(pose_id, set()) - d[pose_id].add(inspiration_id) - - return d - - def get_inspiration_tuples(self) -> list[int, int]: - """Get a dictionary mapping :class:`.Pose` ID's to a set of :class:`Pose` ID's for the inspirations for the whole database""" - sql = f"""SELECT inspiration_original, inspiration_derivative FROM {self.SQL_SCHEMA_PREFIX}inspiration""" - return self.execute(sql).fetchall() - - def get_compound_id_obj_dict(self, cset: 'CompoundSet') -> 'dict[id, Compound]': - """Get a dictionary mapping :class:`.Compound` ID's to their objects""" - - query = 'compound_id, compound_inchikey, compound_alias, compound_smiles' - records = self.select_where( - query=query, - table='compound', - key=f'compound_id IN {cset.str_ids}', - multiple=True, - ) - - d = {} - for entry in records: - compound_id, compound_inchikey, compound_alias, compound_smiles = entry - d[compound_id] = Compound( - self._animal, - self, - compound_id, - compound_inchikey, - compound_alias, - compound_smiles, - metadata=None, - mol=None, - ) - return d - - def get_interaction(self, *, id: int, table: str = 'interaction') -> 'Interaction': - """Fetch the :class:`.Interaction` object with given ID - - :param id: the ID of the Interaction to retrieve - :returns: :class:`.Interaction` object - - """ - - from .interaction import Interaction - - result = self.select_all_where(table=table, key=f'interaction_id = {id}') - - ( - id, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) = result - - return Interaction( - db=self, - id=id, - feature_id=feature_id, - pose_id=pose_id, - type=type, - family=family, - atom_ids=atom_ids, - prot_coord=prot_coord, - lig_coord=lig_coord, - distance=distance, - angle=angle, - energy=energy, - table=table, - ) - - def get_reaction_map_from_products( - self, product_ids: list[int] - ) -> dict[tuple[str, int], set[int]]: - """Get a dictionary mapping (reaction_type, product_id) tuples to sets of reactant_ids""" - - str_ids = str(tuple(product_ids)).replace(',)', ')') - - records = self.execute( - f""" - SELECT reaction_type, reaction_product, reaction_id, reactant_compound - FROM {self.SQL_SCHEMA_PREFIX}reaction INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant - ON reaction_id = reactant_reaction - WHERE reaction_product IN {str_ids} - """ - ).fetchall() - - mapping = {} - for reaction_type, reaction_product, reaction_id, reactant_compound in records: - key = (reaction_type, reaction_product) - - if key not in mapping: - mapping[key] = {} - - if reaction_id not in mapping[key]: - mapping[key][reaction_id] = set() - - mapping[key][reaction_id].add(reactant_compound) - - return mapping - - def get_possible_reaction_ids( - self, - *, - compound_ids: list[int], - ) -> list[int]: - """Given a set of reactant :class:`.Compound` ID's, compute which :class:`.Reaction` objects are possible (all reactants present). - - :param compound_ids: the list of reactant :class:`.Compound` IDs - :returns: a list of :class:`.Reaction` ID's that are possible with the given reactants - - """ - - compound_ids_str = str(tuple(compound_ids)).replace(',)', ')') - - result = self.execute( - f""" - WITH possible_reactants AS - ( - SELECT reactant_reaction, CASE - WHEN reactant_compound IN {compound_ids_str} - THEN reactant_compound END AS [possible_reactant] - FROM {self.SQL_SCHEMA_PREFIX}reactant - ) - - , possible_reactions AS ( - SELECT reactant_reaction, COUNT( - CASE - WHEN possible_reactant IS NULL - THEN 1 END) AS [count_null] - FROM possible_reactants - GROUP BY reactant_reaction - ) - - SELECT reactant_reaction FROM possible_reactions - WHERE count_null = 0 - """ - ).fetchall() - - return [q for (q,) in result] - - def get_unsolved_reaction_tree( - self, - *, - product_ids: list[int], - debug: bool = False, - ) -> '(CompoundSet, ReactionSet)': - """Given a set of product :class:`.Compound` IDs, recursively solve for all the reactants (:class:`.CompoundSet`) and reactions (:class:`.ReactionSet`) that could be involved in their synthesis. N.B. This evaluates all synthesis branches. - - :param product_ids: list of product :class:`.Compound` IDs - :param debug: increase verbosity for debugging, defaults to False - :returns: a tuple of ``(reactants, reactions)`` - - """ - - from .cset import CompoundSet - from .rset import ReactionSet - - all_reactants = set() - all_reactions = set() - - # print(product_ids) - - # for product_id in product_ids: - # all_reactants.add(product_id) - - for i in range(300): - if debug: - mrich.var('recursive depth', i + 1) - - if debug: - mrich.var('#products', len(product_ids)) - - product_ids_str = str(tuple(product_ids)).replace(',)', ')') - - reaction_ids = self.select_where( - table='reaction', - query='DISTINCT reaction_id', - key=f'reaction_product in {product_ids_str}', - multiple=True, - none='quiet', - ) - - reaction_ids = [q for (q,) in reaction_ids] - - if not reaction_ids: - break - - for reaction_id in reaction_ids: - all_reactions.add(reaction_id) - - if debug: - mrich.var('#reactions', len(reaction_ids)) - - reaction_ids_str = str(tuple(reaction_ids)).replace(',)', ')') - - reactant_ids = self.select_where( - table='reactant', - query='DISTINCT reactant_compound', - key=f'reactant_reaction in {reaction_ids_str}', - multiple=True, - ) - - if debug: - mrich.var('#reactants', len(reactant_ids)) - - reactant_ids = [q for (q,) in reactant_ids] - - if not reactant_ids: - break - - for reactant_id in reactant_ids: - all_reactants.add(reactant_id) - - product_ids = reactant_ids - - # all intermediates - ids = self.execute( - f""" - SELECT DISTINCT reaction_product - FROM {self.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.SQL_SCHEMA_PREFIX}reactant - ON reaction_product = reactant_compound - """ - ).fetchall() - ids = [q for (q,) in ids] - intermediates = CompoundSet(self, ids) - - # remove intermediates - cset = CompoundSet(self, all_reactants) - all_reactants = cset - intermediates - - # reactions - all_reactions = ReactionSet(self, all_reactions) - - if debug: - mrich.var('#all_reactants', len(all_reactants)) - mrich.var('#all_reactions', len(all_reactions)) - - return all_reactants, all_reactions - - def get_reaction_price_estimate( - self, - *, - reaction: Reaction, - ) -> float: - """Estimate the price of a :class:`.Reaction` - - :param reaction: :class:`.Reaction` object - :returns: price estimate - - """ - - # get reactants for a given reaction - - mrich.warning('Price estimate does not account for branching!') - reactants, _ = self.get_unsolved_reaction_tree( - product_ids=reaction.reactant_ids - ) - - # how to make sure that there are no branching reactions?! - - # sum lowest unit price for each reactant - - (price,) = self.execute( - f""" - WITH unit_prices AS - ( - SELECT quote_compound, MIN(quote_price/quote_amount) AS unit_price - FROM {self.SQL_SCHEMA_PREFIX}quote - WHERE quote_compound IN {reactants.str_ids} - GROUP BY quote_compound - ) - SELECT SUM(unit_price) FROM unit_prices - """ - ).fetchone() - - return price - - def get_possible_reaction_product_ids( - self, - *, - reaction_ids: list[int], - ) -> list[int]: - """Given a set of :class:`.Reaction` IDs return the :class:`.Compound` IDs of their synthesis products - - :param reaction_ids: :class:`.Reaction` IDs - :returns: list of :class:`.Compound` IDs - - """ - reaction_ids_str = str(tuple(reaction_ids)).replace(',)', ')') - return [ - q - for (q,) in self.select_where( - query='DISTINCT reaction_product', - table='reaction', - key=f'reaction_id IN {reaction_ids_str}', - multiple=True, - ) - ] - - def get_subsite(self, *, id) -> 'Subsite': - """Get protein subsite with a given ID - - :param ID: the subsite ID - :returns: :class:`.Subsite` object - - """ - - from .subsite import Subsite - - results = self.select_where( - table='subsite', - key='id', - value=id, - multiple=False, - query='subsite_name, subsite_target', - ) - - if not results: - mrich.error(f'No subsite with {id=}') - return None - - name, target = results - - subsite = Subsite(db=self, id=id, name=name, target_id=target) - - return subsite - - def get_subsite_tag(self, *, id) -> 'SubsiteTag': - """Get subsite_tag with a given ID - - :param ID: the subsite_tag ID - :returns: :class:`.SubsiteTag` object - - """ - - from .subsite import SubsiteTag - - subsite_id, pose_id = self.select_where( - table='subsite_tag', - key='id', - value=id, - multiple=False, - query='subsite_tag_ref, subsite_tag_pose', - ) - - subsite_tag = SubsiteTag(db=self, id=id, subsite_id=subsite_id, pose_id=pose_id) - - return subsite_tag - - def get_subsite_id(self, *, name: str, **kwargs) -> int | None: - """Get protein Subsite ID with a given name - - :param name: the protein Subsite name - :returns: the Subsite ID - - """ - - table = 'Subsite' - entry = self.select_id_where(table=table, key='name', value=name, **kwargs) - - if entry: - return entry[0] - - return None - - def get_subsite_name(self, *, id: str, **kwargs) -> int | None: - """Get protein :class:`.Subsite` name with a given ID - - :param name: the protein :class:`.Subsite` ID - :returns: the :class:`.Subsite` ID - - """ - - table = 'subsite' - entry = self.select_where( - query='subsite_name', table=table, key='id', value=id, **kwargs - ) - - if entry: - return entry[0] - - return None - - def get_scaffold_similarity_dict( - self, scaffolds: 'CompoundSet | None' = None - ) -> list[dict]: - """Get a dictionary mapping scaffold :class:`.Compound` IDs to their superstructure's IDs""" - - sql = f""" - SELECT scaffold_base as a, scaffold_superstructure as b, bfp_tanimoto(c.fp, d.fp) AS t - FROM {self.SQL_SCHEMA_PREFIX}scaffold - INNER JOIN compound_pattern_bfp AS c ON a = c.compound_id - INNER JOIN compound_pattern_bfp AS d ON b = d.compound_id - """ - - if scaffolds: - sql += f' WHERE a IN {scaffolds.str_ids}' - - records = self.execute(sql).fetchall() - - data = [] - for a, b, s in mrich.track(records): - data.append(dict(scaffold_id=a, superstructure_id=b, similarity=s)) - - return data - - def get_reactant_product_tuples( - self, compound_ids: list | None = None, deduplicated: bool = True - ) -> set[tuple[int, int]]: - """Get tuples of (reactant, product) :class:`.Compound` IDs""" - - sql = f""" - SELECT reactant_compound, reaction_product - FROM {self.SQL_SCHEMA_PREFIX}reactant - INNER JOIN {self.SQL_SCHEMA_PREFIX}reaction - ON reactant_reaction = reaction_id - """ - - if compound_ids: - str_ids = str(tuple(compound_ids)).replace(',)', ')') - sql += ( - f'WHERE reactant_compound IN {str_ids} OR reaction_product IN {str_ids}' - ) - - records = self.execute(sql) - if deduplicated: - return set((a, b) for a, b in records) - else: - return [(a, b) for a, b in records] - - def get_scaffold_tuples( - self, compound_ids: list | None = None - ) -> set[tuple[int, int]]: - """Get tuples of (reactant, product) :class:`.Compound` IDs""" - - sql = f""" - SELECT scaffold_base, scaffold_superstructure - FROM {self.SQL_SCHEMA_PREFIX}scaffold - """ - - if compound_ids: - str_ids = str(tuple(compound_ids)).replace(',)', ')') - sql += f'WHERE scaffold_base IN {str_ids} OR scaffold_superstructure IN {str_ids}' - - records = self.execute(sql) - return set((a, b) for a, b in records) - - ### COMPOUND QUERY - - def query_substructure( - self, - query: str, - *, - fast: bool = True, - none: str = 'error', - smarts: bool = False, - ) -> 'CompoundSet': - """Search for compounds by substructure - - :param query: SMILES string of the substructure - :param fast: Use pattern binary fingerprint table to improve performance (Default value = True) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :returns: :class:`.CompoundSet` object - - """ - - if smarts: - func = 'mol_from_smarts' - else: - func = 'mol_from_smiles' - - # smiles - if isinstance(query, str): - if fast: - sql = f""" - SELECT compound.compound_id, compound.compound_inchikey - FROM {self.SQL_SCHEMA_PREFIX}compound, compound_pattern_bfp AS bfp - WHERE {self.SQL_SCHEMA_PREFIX}compound.compound_id = {self.SQL_SCHEMA_PREFIX}bfp.compound_id - AND mol_is_substruct(compound.compound_mol, {func}(?)) - """ - - else: - sql = f""" - SELECT compound_id, compound_inchikey FROM {self.SQL_SCHEMA_PREFIX}compound - WHERE mol_is_substruct(compound_mol, {func}(?)) - """ - - else: - raise NotImplementedError - - try: - self.execute(sql, (query,)) - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - result = self.cursor.fetchall() - - if not result and none == 'error': - mrich.error(f'No compounds with substructure {query}') - return None - elif not result: - return None - - from .cset import CompoundSet - - if not smarts: - smiles = query - try: - smiles = sanitise_smiles(smiles, sanitisation_failed='error') - except SanitisationError as e: - mrich.error(f'Could not sanitise {smiles=}') - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f'Could not sanitise {smiles=}') - return None - return c - inchikey = inchikey_from_smiles(smiles) - - ids = [i for i, key in result if key != inchikey] - return CompoundSet(self, ids) - - else: - return CompoundSet(self, [i for i, _ in result]) - - def query_most_similar( - self, - query: str, - subset: 'CompoundSet', - fp='pattern', - bits=2048, - morgan_radius=1, - return_similarity: bool = False, - none='error', - ) -> 'Compound | (Compound, float)': - """Search for the most similar compound by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` - - :param query: SMILES string - :param return_similarity: return a list of similarity values together with the :class:`.CompoundSet` (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param subset: optional subset of compounds to search - :returns: :class:`.Compound` and optionally a similarity values - """ - - if fp == 'pattern' and bits == 2048: - sql = f""" - WITH subset AS ( - SELECT compound_id, fp - FROM {self.SQL_SCHEMA_PREFIX}compound - JOIN compound_pattern_bfp USING (compound_id) - WHERE compound_id IN {subset.str_ids} - ) - - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), {bits}), fp) - AS similarity - FROM subset - ORDER BY similarity DESC - LIMIT 1 - """ - - elif fp == 'morgan': - sql = f""" - WITH subset AS ( - SELECT compound_id, mol_{fp}_bfp(compound_mol, {morgan_radius}, {bits}) AS fp - FROM {self.SQL_SCHEMA_PREFIX}compound - WHERE compound_id IN {subset.str_ids} - ) - - SELECT compound_id, - bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {morgan_radius}, {bits}), fp) - AS similarity - FROM subset - ORDER BY similarity DESC - LIMIT 1 - """ - - else: - sql = f""" - WITH subset AS ( - SELECT compound_id, mol_{fp}_bfp(compound_mol, {bits}) AS fp - FROM {self.SQL_SCHEMA_PREFIX}compound - WHERE compound_id IN {subset.str_ids} - ) - - SELECT compound_id, bfp_tanimoto(mol_{fp}_bfp(mol_from_smiles(?1), {bits}), fp) - AS similarity - FROM subset - ORDER BY similarity DESC - LIMIT 1 - """ - - try: - self.execute(sql, (query,)) - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - compound_id, similarity = self.cursor.fetchone() - - if return_similarity: - return self.get_compound(id=compound_id), similarity - - return self.get_compound(id=compound_id) - - def query_similarity( - self, - query: str, - threshold: float, - return_similarity: bool = False, - subset: 'CompoundSet' = None, - none='error', - ) -> 'CompoundSet | (CompoundSet, list[float])': - """Search compounds by tanimoto similarity of binary pattern fingerprints using the chemicalite function `mol_pattern_bfp` - - :param query: SMILES string - :param threshold: similarity threshold to exceed - :param return_similarity: return a list of similarity values together with the :class:`.CompoundSet` (Default value = False) - :param none: define the behaviour for no matches, any value other than ``'error'`` will silently return empty data (Default value = 'error') - :param subset: optional subset of compounds to search - :returns: :class:`.CompoundSet` and optionally a list of similarity values - - """ - - from .cset import CompoundSet - - # smiles - if subset: - if return_similarity: - sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM {self.SQL_SCHEMA_PREFIX}compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) - WHERE mfp.compound_id - MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - AND compound_id IN {subset.str_ids} - ORDER BY t DESC - """ - else: - sql = f""" - SELECT compound_id - FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id - MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - AND compound_id IN {subset.str_ids} - """ - - elif isinstance(query, str): - if return_similarity: - sql = f""" - SELECT compound_id, - bfp_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), - mol_pattern_bfp(compound.compound_mol, 2048)) as t - FROM {self.SQL_SCHEMA_PREFIX}compound - JOIN compound_pattern_bfp AS mfp - USING(compound_id) - WHERE mfp.compound_id - MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - ORDER BY t DESC - """ - else: - sql = """ - SELECT compound_id - FROM compound_pattern_bfp AS bfp - WHERE bfp.compound_id - MATCH rdtree_tanimoto(mol_pattern_bfp(mol_from_smiles(?1), 2048), ?2) - """ - else: - raise NotImplementedError - - try: - self.execute(sql, (query, threshold)) - except sqlite3.OperationalError: - mrich.var('sql', sql) - raise - - result = self.cursor.fetchall() - - if not result and none == 'error': - mrich.error(f'No compounds with similarity >= {threshold} to {query}') - return None - - if return_similarity: - ids, similarities = zip(*result, strict=False) - cset = CompoundSet(self, ids) - return cset, similarities - - ids = [r for (r,) in result] - cset = CompoundSet(self, ids) - - return cset - - def query_exact( - self, - query: str, - threshold: float = 0.989, - ) -> 'CompoundSet': - """Search for exact match compounds (default similarity > 0.989) - - :param query: SMILES string - :param threshold: similarity threshold to exceed - - """ - - return self.query_similarity(query, 0.989, return_similarity=False) - - ### LOOKUPS / MAPPING - - def create_metadata_id_map(self, *, table: str, key: str) -> dict[str, int]: - """Create a mapping between metadata[key] values to their respective parent record ID's - - :returns: dictionary mapping metadata[key] values to integer ID's - - """ - - pairs = self.execute( - f""" - SELECT {table}_id, {table}_metadata - FROM {self.SQL_SCHEMA_PREFIX}{table} - WHERE {table}_metadata LIKE '%"{key}": "%' - """ - ).fetchall() - from json import loads - - return dict( - sorted( - {loads(metadata)[key]: pose_id for pose_id, metadata in pairs}.items() - ) - ) - - ### COUNTING - - def count( - self, - table: str, - ) -> int: - """Count all entries in a table - - :param table: table to count entries from - - """ - - sql = f'SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table};' - self.execute(sql) - return self.cursor.fetchone()[0] - - def count_where( - self, - table: str, - key: str, - value=None, - ): - """Count all entries in a table where ``key==value`` - - :param table: table to count entries from - :param key: the key to match as ``{table}_{key} = {value}`` or the SQL string if ``value == None`` - :param value: the value to match (Default value = None) - - """ - - if isinstance(value, str): - if "'" in value: - value = f'"{value}"' - else: - value = f"'{value}'" - - if value is not None: - where_str = f'{table}_{key}={value}' - else: - where_str = key - - sql = f'SELECT COUNT(1) FROM {self.SQL_SCHEMA_PREFIX}{table} WHERE {where_str};' - self.execute(sql) - return self.cursor.fetchone()[0] - - ### ID SELECTION - - def min_id( - self, - table: str, - ) -> int: - """Get the minimal entry ID from a given table - - :param table: the database table to query - :returns: the smallest entry ID - - """ - - (id,) = self.select(table=table, query=f'MIN({table}_id)') - return id - - def max_id( - self, - table: str, - ) -> int: - """Get the maximal entry ID from a given table - - :param table: the database table to query - :returns: the largest entry ID - - """ - (id,) = self.select(table=table, query=f'MAX({table}_id)') - return id - - def slice_ids( - self, - *, - table: str, - start: int | None, - stop: int | None, - step: int = 1, - name: bool = False, - ) -> list[int]: - """Retrieve ID's matching a slice - - :param table: the database table to query - :param start: return IDs equal to or larger than this value - :param stop: return IDs smaller than this value - :param step: return IDs in increments of this value (Default value = 1) - :returns: matching IDs - - """ - - min_id = self.min_id(table) - max_id = self.max_id(table) - - start = start or min_id - stop = stop or max_id + 1 - step = step or 1 - - if not (start >= 0 and start <= max_id): - raise IndexError( - f'Slice {start=} outside of DB {table}_id range ({min_id}, {max_id})' - ) - - if not (stop >= 0 and stop <= max_id + 1): - raise IndexError( - f'Slice {stop=} outside of DB {table}_id range ({min_id}, {max_id})' - ) - - if step != 1: - raise NotImplementedError(f'Slice {step=} not supported') - - ids = self.select_where( - table=table, - query=f'{table}_id', - key=f'{table}_id >= {start} AND {table}_id < {stop}', - multiple=True, - ) - ids = [q for (q,) in ids] - - if name: - return ids, f'{table}s[{start}:{stop}]' - else: - return ids - - ### PRUNING - - def prune_reactions( - self, - reactions: 'ReactionSet', - ) -> list[Reaction]: - """Remove duplicate reactions - - :param reactions: :class:`.ReactionSet` - :returns: list of pruned :class:`.Reaction` objects - - """ - - pruned = [] - del_list = [] - - for i, reaction in enumerate(reactions): - matches = [r for r in pruned if r == reaction] - - if not matches: - pruned.append(reaction) - - else: - del_list.append(reaction) - - for reaction in del_list: - mrich.warning(f'Deleted duplicate {reaction=}') - self.delete_where('reaction', 'id', reaction.id) - - return pruned - - def remove_metadata_list_item( - self, - *, - table: str, - key: str, - value, - remove_empty: bool = True, - ) -> None: - """Remove a specific item from list-like values associated with a given key from all metadata entries in a given table - - :param table: the database table to query - :param key: the :class:`.Metadata` key to match - :param value: the value to remove from the list - :param remove_empty: remove the key from the metadata if the list is empty (Default value = True) - - """ - - # get id's with specific metadata key and value - value_str = json.dumps(value) - result = self.select_where( - query=f'{table}_id, {table}_metadata', - table=table, - key=f'{table}_metadata LIKE \'%"export": [%{value_str}%]%\'', - multiple=True, - none='quiet', - ) - - # loop over all matches - for id, metadata_str in result: - # read the metadata - metadata = json.loads(metadata_str) - - # modify the metadata - index = metadata[key].index(value) - metadata[key].pop(index) - - if remove_empty and not metadata[key]: - del metadata[key] - - # update the database - metadata_str = json.dumps(metadata) - self.update( - table=table, - id=id, - key=f'{table}_metadata', - value=metadata_str, - commit=False, - ) - - # only runs if non-zero matches - else: - # commit the changes - self.commit() - - ### TABLE INFO - - def print_table( - self, - table: str, - ) -> None: - """Print a table's entries - - :param table: the table to print - - """ - - mrich.print(self.table_df(table)) - - def table_df( - self, - table: str, - ) -> 'pandas.DataFrame': - """Get a DataFrame of a table - - :param table: the table to get - """ - - from pandas import DataFrame - - data = [] - - column_names = self.column_names(table) - - self.execute(f'SELECT * FROM {self.SQL_SCHEMA_PREFIX}{table}') - - for record in self.cursor: - d = {} - for key, value in zip(column_names, record, strict=False): - d[key] = value - data.append(d) - - df = DataFrame(data) - df = df.set_index(column_names[0]) - - return df - - def table_info( - self, - table: str, - ) -> list[tuple]: - """Print a table's schema - - :param table: the table to print - - """ - - self.execute(f'PRAGMA table_info({self.SQL_SCHEMA_PREFIX}{table})') - return self.cursor.fetchall() - - def column_names(self, table: str) -> list[str]: - """Get the column names of the given table""" - table_info = self.table_info(table) - return [i[1] for i in table_info] - - def index_names(self) -> list[str]: - """Get the index names""" - - cursor = self.execute( - """ - SELECT name - FROM sqlite_master - WHERE type = 'index'; - """ - ) - - return [n for (n,) in cursor] - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - if self.in_memory: - return 'Database [IN-MEMORY]' - else: - return f'Database @ {self.path.resolve()}' - - def __repr__(self): - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.clear}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - -class LegacyDatabaseError(Exception): - """This database is in a legacy format""" - - ... - - -def backup( - source: Path | str, - destination: Path | str | None = None, - pages: int = 10_000, -) -> 'Path': - """Create a backup of the database""" - - from .tools import dt_hash - - source = Path(source) - - if not destination: - destination = str(source.resolve()).replace('.sqlite', f'_{dt_hash()}.sqlite') - - destination = Path(destination) - - with mrich.spinner(f'Backing up {source}...'): - mrich.writing(destination) - - def progress(status, remaining, total): - """print progress""" - mrich.debug(f'Copied {total - remaining} of {total} pages...') - - src = sqlite3.connect(source) - dst = sqlite3.connect(destination) - with dst: - src.backup(dst, pages=pages, progress=progress) - - dst.close() - - return destination diff --git a/hippo/designdb/__init__.py b/hippo/designdb/__init__.py new file mode 100644 index 0000000..967b94b --- /dev/null +++ b/hippo/designdb/__init__.py @@ -0,0 +1,3 @@ +import logging + +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/hippo/designdb/admin.py b/hippo/designdb/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/hippo/designdb/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py new file mode 100644 index 0000000..64ca39d --- /dev/null +++ b/hippo/designdb/animal.py @@ -0,0 +1,404 @@ +"""Main animal class for HIPPO""" + +import logging +import re +from enum import Enum +from pathlib import Path + +import mrich +import pandas as pd +from django.db import transaction + +from .models import Pose, Target +from .services.ingestion import IngestionBatchResult, IngestionService +from .sets.pose import PoseSet +from .utils import make_warn_once_per_key + +logger = logging.getLogger(__name__) + + +class HIPPO: + """Entry-point class of the xchem-hippo package. + + Update: this is atm not being called directly by the user. + """ + + def __init__( + self, + target_name: str, + ) -> None: + + # TODO: user- or project based targets + self._target, _ = Target.objects.get_or_create(target_name=target_name) + + # TODO: the way this worked previously was it gave the HIPPO + # instance full access to the pose table. When working with + # multi-project central postgres db, this is almost certainly + # not what I want. How is it that I'm going to keep this + # updated? What does it mean upadte? Access to all objects + # along this target? + + # self._compounds = CompoundTable(self.db) + # self._poses = PoseSet(Pose.objects.all()) # <- NB! for testing + # self._tags = TagTable(self.db) + # self._reactions = ReactionTable(self.db) + + # ### in memory subsets + # self._reactants = None + # self._products = None + # self._intermediates = None + # self._scaffolds = None + # self._elabs = None + + # @property + # def name(self) -> str: + # """Returns the project name + + # :returns: project name + # """ + # return self._name + + @property + def target(self) -> Target: + """Returns the target instance""" + return self._target + + # actually expected to return all poses. filtering in PoseTable + # class i.e. get_by_target. + + # Looks like I need to implement this. PoseService with some + # manager- and instance mthods as helpers? + + # Actually it's more compplex than this: in the original code + # there's PoseTable, and then there's PoseSet for a selection + @property + def poses(self): + """Return pose instances for this target""" + return Pose.objects.filter(target=self._target) + + @property + def num_poses(self) -> int: + """Total number of Poses in the Database""" + return self.poses.count() + + def add_hits( + self, + *, + metadata_csv: str | Path, + aligned_directory: str | Path, + tags: list | None = None, + skip: list | None = None, + # debug: bool = False, + # load_pose_mols: bool = False, + ) -> pd.DataFrame: + """Crystallographic hits from a Fragalysis download or XChemAlign alignment. + + For a Fragalysis download `aligned_directory` and `metadata_csv` + should point to the `aligned_files` and `metadata.csv` at the + root of the extracted download. + For an XChemAlign dataset the `aligned_directory` + should point to the `aligned_files`. + + :param target_name: Name of this protein :class:`.Target` + :param metadata_csv: Path to the metadata.csv from the Fragalysis download + :param aligned_directory: Path to the aligned_files directory + from the Fragalysis download + :param skip: optional list of observation names to skip + :param debug: bool: (Default value = False) + :returns: a DataFrame of metadata + + """ + + ### Process arguments + # NB! meta not required when loading XCA data + assert metadata_csv, 'metadata.csv required' + + assert aligned_directory, 'aligned_directory must be provided' + skip = skip or [] + tags = tags or ['hits'] + + if not isinstance(aligned_directory, Path): + aligned_directory = Path(aligned_directory) + + mrich.var('aligned_directory', aligned_directory) + + ### Determine data format + + # TODO: as it appears that users are currently only loading + # fragalysis data, XCA format is not supported. Leaving the + # format checks here to print a message for user + + class DataFormat(Enum): + """DataFormat enum""" + + Fragalysis_v2 = 1 + XChemAlign_v2 = 2 + XChemAlign_v3 = 3 + + def __str__(self) -> str: + """name""" + return self.name + + subdirs = list(aligned_directory.glob('*')) + + SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') + SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') + + fragalysis_subdirs_present = any( + SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs + ) + xca_subdirs_present = any( + SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs + ) + assert fragalysis_subdirs_present ^ xca_subdirs_present, ( + 'Unexpected mixed data format' + ) + + if fragalysis_subdirs_present: + data_format = DataFormat.Fragalysis_v2 + else: + if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): + data_format = DataFormat.XChemAlign_v3 + else: + data_format = DataFormat.XChemAlign_v2 + + mrich.error( + 'Loading XChemAlign data currently not supported.' + + ' Contact developers to enable this feature' + ) + + mrich.var('data_format', data_format) + + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_filesystem( + root_path=aligned_directory, + target=self.target, + skip_records=skip, + compound_tag_list=tags, + metadata_file=metadata_csv, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + # looking at the code, it seems to be the same, there are no + # skips between observations and dirs_parsed declaratiosn + mrich.var('#valid observations', result.attempts) + + # n_poses = self.num_poses + # n_poses = Pose.objects.count() + + mrich.var('#directories parsed', result.attempts) + mrich.var('#compounds registered', result.compounds_created) + mrich.var('#poses registered', result.poses_created) + + def load_sdf( + self, + *, + path: str | Path, + reference: int | Pose | None = None, + inspirations: list[int] | PoseSet | None = None, + compound_tags: None | list[str] = None, + pose_tags: None | list[str] = None, + mol_col: str = 'ROMol', + name_col: str = 'ID', + inspiration_col: str = 'ref_mols', + reference_col: str = 'ref_pdb', + inspiration_map: None | dict = None, + convert_floats: bool = True, + skip_equal_dict: dict | None = None, + skip_not_equal_dict: dict | None = None, + ) -> None: + """Add posed virtual hits from an SDF into the database. + + :param target: Name of the protein :class:`.Target` + :param path: Path to the SDF + :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` + :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's + :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` + :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` + :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` + :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` + :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` + :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` + :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids + :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` + :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` + :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` + :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` + :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` + + All non-name columns are added to the Pose metadata. + N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. + """ + # TODO: original code reads sdf into data frame. I don't see + # much point for this in this function. get rid of it at some + # point + + if not isinstance(path, Path): + path = Path(path) + + skip_equal_dict = skip_equal_dict or {} + skip_not_equal_dict = skip_not_equal_dict or {} + + mrich.debug(f'{path=}') + + compound_tags = compound_tags or [] + pose_tags = pose_tags or [] + + if isinstance(inspirations, PoseSet): + inspiration_list = list(inspirations.ids) + elif isinstance(inspirations, list): + # TODO: potentially check types + inspiration_list = inspirations + else: + inspiration_list = [] + + if reference and isinstance(reference, Pose): + reference_id = reference.id + else: + reference_id = None + + if inspiration_map is None: + inspiration_map = {} + + warn = make_warn_once_per_key() + + try: + with transaction.atomic(): + result: IngestionBatchResult = IngestionService.ingest_sdf( + file_path=path, + target=self.target, + compound_tag_list=compound_tags, + pose_tag_list=pose_tags, + mol_col=mol_col, + name_col=name_col, + inspiration_col=inspiration_col, + inspirations=inspiration_list, + reference_col=reference_col, + reference=reference_id, + skip_equal=skip_equal_dict, + skip_not_equal=skip_not_equal_dict, + convert_floats=convert_floats, + field_warning=warn, + inspiration_map=inspiration_map, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + # It's not clear what the original code was trying to do. I'm + # going to issue warning when number of compounds and poses + # was less than the number of compounds in sdf (not all were + # successfully parsed) but that may not have been the original + # intention + if result.attempts == result.compounds_created: + f = mrich.success + else: + f = mrich.warning + + f(f'{result.compounds_created} new compounds from {path}') + + if result.attempts == result.poses_created: + f = mrich.success + else: + f = mrich.warning + + f(f'{result.poses_created} new poses from {path}') + + def add_syndirella_routes( + self, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + check_chemistry: bool = True, + register_routes: bool = True, + ) -> pd.DataFrame: + """Add routes found from syndirella --just_retro query""" + + try: + with transaction.atomic(): + result: IngestionBatchResult = ( + IngestionService.ingest_syndirella_routes( + pickle_path=pickle_path, + CAR_only=CAR_only, + pick_first=pick_first, + do_check_chemistry=check_chemistry, + register_routes=register_routes, + ) + ) + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc + + def add_syndirella_elabs( + self, + df_path: str | Path, + max_energy_score: float | None = 0.0, + max_distance_score: float | None = 2.0, + require_intra_geometry_pass: bool = True, + reject_flags: list[str] | None = None, + register_reactions: bool = True, + dry_run: bool = False, + scaffold_route: 'Route | None' = None, + scaffold_compound: 'Compound | None' = None, + pose_tags: list[str] | None = None, + product_tags: list[str] | None = None, + ) -> pd.DataFrame: + """ + Load Syndirella elaboration compounds and poses from a pickled DataFrame + + :param df_path: Path to the pickled DataFrame + :param max_energy_score: Filter out poses with `∆∆G` above this value + :param max_distance_score: Filter out poses with `comRMSD` above this value + :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values + :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) + :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing + :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing + :param dry_run: Don't insert new records into the database (for debugging/testing) + :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] + :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] + :returns: annotated DataFrame + """ + + reject_flags = reject_flags or [ + 'one_of_multiple_products', + 'selectivity_issue_contains_reaction_atoms_of_both_reactants', + ] + + pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] + product_tags = product_tags or ['syndirella_product'] + + df_path = Path(df_path) + mrich.h3(df_path.name) + mrich.reading(df_path) + df = pd.read_pickle(df_path) + + # testing + # df = pd.read_csv(df_path.replace('.pkl.gz', '.csv')) + + try: + with transaction.atomic(): + result: pd.DataFrame = IngestionService.ingest_syndirella_elabs( + df=df, + # TODO: check if target eists + target=self.target, + reject_flags=reject_flags, + pose_tag_list=pose_tags, + product_tag_list=pose_tags, + max_energy_score=max_energy_score, + max_distance_score=max_distance_score, + require_intra_geometry_pass=require_intra_geometry_pass, + register_reactions=register_reactions, + scaffold_route=scaffold_route, + scaffold_compound=scaffold_compound, + ) + return result + except Exception as exc: + logger.error(exc, exc_info=True) + # TODO: handle gracefully + raise Exception from exc diff --git a/hippo/designdb/apps.py b/hippo/designdb/apps.py new file mode 100644 index 0000000..f5477dd --- /dev/null +++ b/hippo/designdb/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class DesigndbConfig(AppConfig): + name = 'designdb' diff --git a/hippo/chem.py b/hippo/designdb/chem.py similarity index 99% rename from hippo/chem.py rename to hippo/designdb/chem.py index a0269e3..654072d 100644 --- a/hippo/chem.py +++ b/hippo/designdb/chem.py @@ -2,6 +2,8 @@ import mrich +from designdb.models import Compound + """ Checks @@ -155,7 +157,7 @@ def check_reaction_types(types: list[str]) -> None: def check_chemistry( reaction_type: str, reactants: 'CompoundSet', - product: 'Compound', + product: Compound, debug: bool = False, ) -> bool: """Check chemistry of given reaction""" diff --git a/hippo/designdb/ingredient.py b/hippo/designdb/ingredient.py new file mode 100644 index 0000000..8bc0860 --- /dev/null +++ b/hippo/designdb/ingredient.py @@ -0,0 +1,267 @@ +import mcol +import mrich +import pandas as pd +from django.db.models import Exists, OuterRef, Q + +from designdb.models import CataloguePrice, CataloguePriceCompoundJunction, Compound + + +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, + compound: Compound, # or CatalogueCompound? + amount: float, + quote: CataloguePrice, + max_lead_time: float | None = None, + supplier: str | None = None, + ): + """Ingredient initialisation""" + + self._compound = compound + self._quote = quote + self._amount = amount + self._max_lead_time = max_lead_time + self._supplier = supplier + + def __str__(self) -> str: + """Plain string representation""" + return f'{self.amount:.2f}mg of C{self._compound.id}' + + 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)}' + + def __eq__(self, other) -> bool: + """Equality operator""" + + if self.compound != other.compound: + return False + + return self.amount == other.amount + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.Compound`""" + return getattr(self.compound, key) + + @classmethod + def from_compound( + cls, + compound: Compound, + 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 + + quote = cls.get_quotes( + compound=compound, + pick_cheapest=True, + min_amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + none=quote_none, + ) + + else: + quote = None + + return Ingredient( + compound=compound, + amount=amount, + quote=quote, + supplier=supplier, + max_lead_time=max_lead_time, + ) + + @classmethod + def get_quotes( + cls, + compound: Compound, + 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, + ): + """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 + + """ + + qs = CataloguePrice.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunction.objects.filter( + compound=compound, + catalogue_price=OuterRef('pk'), + ), + ), + ).filter( + has_compound=True, + ) + + if supplier: + if isinstance(supplier, str): + qs = qs.filter(supplier=supplier) + else: + qs = qs.filter(supplier__in=supplier) + + if not qs.exists(): + return None + + if max_lead_time: + qs = qs.filter(lead_time__lte=max_lead_time) + + if min_amount: + qs = qs.filter(amount__gte=min_amount) + + if not qs.exists(): + mrich.debug( + f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + ) + + if pick_cheapest: + return qs.order_by('price').first() + + if df: + return pd.DataFrame(qs.values()).drop(columns='compound') + + return qs + + ### METHODS + + def get_cheapest_quote_id( + self, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + ) -> 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. + """ + + query = Q(compound=self.compound) + + if supplier: + query &= Q(quote_supplier=supplier) + + if min_amount: + query &= Q(quote_amount__gte=min_amount) + + if max_lead_time: + query &= Q(quote_lead_time__lte=max_lead_time) + + return CataloguePrice.objects.filter(query).order_by('quote_price').first() + + ### PROPERTIES + + @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(self) -> int: + """Returns the ID of the associated :class:`.Quote`""" + return self._quote + + @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 = self.get_cheapest_quote_id( + min_amount=a, + max_lead_time=self._max_lead_time, + supplier=self._supplier, + none='quiet', + ) + + self._quote = quote + + 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 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 diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py new file mode 100644 index 0000000..9ffdacd --- /dev/null +++ b/hippo/designdb/models.py @@ -0,0 +1,994 @@ +from pathlib import Path + +import mrich +# from django.db.models import indexes +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils import timezone +from rdkit import Chem + +_MANAGE_MODELS = settings.MANAGE_MODELS + + +# Custom field type for text fields that store json. Once switchint to +# postgres, replace +class JSONTextField(models.TextField): + def from_db_value(self, value, expression, connection): + import json + + return json.loads(value) if value else {} + + def get_prep_value(self, value): + import json + + if isinstance(value, dict): + return json.dumps(value) + return value + + +class RDKitMolField(models.TextField): + """ + Stores RDKit molecules as MolBlock text (SDF format) in DB, + but returns RDKit Mol objects in Python. + """ + + description = 'RDKit molecule stored as MolBlock text' + + # ------------------------- + # DB → Python (read path) + # ------------------------- + def from_db_value(self, value, expression, connection): + if not value: + return None + return Chem.MolFromMolBlock(value) + + # ------------------------- + # Python → DB (write path) + # ------------------------- + def get_prep_value(self, value): + if value is None: + return None + + # Already serialized + if isinstance(value, str): + return value + + # RDKit Mol → MolBlock + if isinstance(value, Chem.Mol): + return Chem.MolToMolBlock(value) + + raise TypeError( + f'RDKitMolField only accepts RDKit Mol or MolBlock string, got {type(value)}' + ) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + return name, path, args, kwargs + + +if settings.MANAGE_MODELS: + # sqlite3, rdkit field types not available + # shouldn't this be binary as well? + from django.db.models import BinaryField as BfpField + + from .models import RDKitMolField as MolField +else: + from django_rdkit.models import BfpField, MolField + + +class BaseModel(models.Model): + created_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + updated_on = models.DateTimeField(null=True, blank=True, default=timezone.now) + + class Meta: + abstract = True + managed = _MANAGE_MODELS + app_label = 'designdb' + default_related_name = '%(class)ss' + + +class Target(BaseModel): + id = models.BigAutoField(primary_key=True) + external_target_id = models.BigIntegerField(null=True, blank=True) + target_name = models.TextField() + target_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'targets' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target_name', + ], + name='uc_target', + ), + ] + indexes = [ + models.Index(fields=['target_name'], name='idx_target_name'), + models.Index(fields=['created_on'], name='idx_target_created'), + ] + + +# TODO: tautomer hashes +class Compound(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_inchikey = models.TextField(null=True, blank=True) + compound_alias = models.TextField(null=True, blank=True) + compound_smiles = models.TextField(null=True, blank=True) + compound_hash = models.TextField(null=False, blank=True, default='a') + + base_compound = models.ForeignKey( + 'self', + null=True, + blank=True, + on_delete=models.SET_NULL, + db_column='base_compound_id', + related_name='+', # add if needed + ) + + # compound_mol = models.TextField(null=True, blank=True) + # compound_pattern_bfp = models.TextField(null=True, blank=True) + # compound_morgan_bfp = models.TextField(null=True, blank=True) + compound_mol = MolField(null=True) + compound_pattern_bfp = BfpField(null=True) + compound_morgan_bfp = BfpField(null=True) + + compound_metadata = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + tags = models.ManyToManyField( + 'CompoundTag', + through='CompoundTagJunction', + related_name='compounds', + ) + + enumeration_methods = models.ManyToManyField( + 'EnumerationMethod', + through='CompoundEnumerationMethodJunction', + related_name='compounds', + ) + + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + scaffolds = models.ManyToManyField( + 'self', + through='Scaffold', + ) + + class Meta(BaseModel.Meta): + db_table = 'compounds' + constraints = [ + # I believe there were supposed to be changes to these + # models.UniqueConstraint( + # fields=[ + # 'compound_alias', + # ], + # name='uc_compound_alias', + # ), + models.UniqueConstraint( + fields=[ + 'compound_inchikey', + ], + name='uc_compound_inchikey', + ), + # tautomers mess this up + # models.UniqueConstraint( + # fields=[ + # 'compound_smiles', + # ], + # name='uc_compound_smiles', + # ), + ] + indexes = [ + # models.Index(fields=['base_compound'], name='idx_base_compound_id'), + models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), + # models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), + models.Index(fields=['created_on'], name='idx_compound_created'), + ] + + +class Subsite(BaseModel): + id = models.BigAutoField(primary_key=True) + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + subsite_name = models.TextField() + subsite_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsites' + constraints = [ + models.UniqueConstraint( + fields=[ + 'target', + 'subsite_name', + ], + name='uc_subsite', + ), + ] + indexes = [ + models.Index(fields=['target'], name='idx_subsite_target_id'), + models.Index(fields=['created_on'], name='idx_subsite_created'), + ] + + +class Pose(BaseModel): + id = models.BigAutoField(primary_key=True) + + pose_inchikey = models.TextField(null=True, blank=True) + pose_alias = models.TextField(null=True, blank=True) + pose_smiles = models.TextField(null=True, blank=True) + + pose_reference = models.IntegerField(null=True, blank=True) + pose_path = models.TextField(null=True, blank=True) + + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + # pose_mol = models.TextField(null=True, blank=True) + pose_mol = MolField(null=True) + # this is integer in the db.. pretty sure this cannot be the case? + pose_fingerprint = models.IntegerField(null=True, blank=True) + + # dicts dumped into that field, change to JSON? + # pose_metadata = models.TextField(null=True, blank=True) + pose_metadata = JSONTextField(null=True, blank=True) + # pose_metadata = models.JSONField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + methods = models.ManyToManyField( + 'PoseMethod', + through='PoseMethodJunction', + related_name='poses', + ) + tags = models.ManyToManyField( + 'PoseTag', + through='PoseTagJunction', + related_name='poses', + ) + # unlike others, this wasn't clearly defined as m2m. may not want + # to keep it + inspirations = models.ManyToManyField( + 'self', + through='Inspiration', + symmetrical=False, + ) + + subsites = models.ManyToManyField( + Subsite, + through='SubsiteTag', + ) + + class Meta(BaseModel.Meta): + db_table = 'poses' + # There are no constraints here, but they need to be unique, + # verified in code (rdkit.align_pose coords from + # file). Investigate adding coords to db and doing the search + # there + + # although.. would alias-target combo work? + indexes = [ + models.Index(fields=['compound'], name='idx_pose_compound_id'), + models.Index(fields=['target'], name='idx_pose_target_id'), + models.Index(fields=['pose_path'], name='idx_pose_path'), + models.Index(fields=['created_on'], name='idx_pose_created'), + ] + + @property + def mol_path(self) -> Path | None: + """Get Path to molecule file""" + path = Path(self.pose_path) + if path.name.endswith('.pdb'): + mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_ligand.mol' + ) + if not mol_path.exists(): + mol_path = path.parent / path.name.replace( + '_hippo.pdb', '.pdb' + ).replace('.pdb', '_ligand.sdf') + if not mol_path.exists(): + mrich.error('Could not find ligand mol/sdf:', mol_path) + return None + return mol_path + elif path.name.endswith('.mol'): + return path + else: + raise NotImplementedError + + @property + def apo_path(self) -> Path | None: + """Get path to apo protein file""" + path = Path(self.pose_path) + if path.name.endswith('.pdb'): + apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( + '.pdb', '_apo-desolv.pdb' + ) + if not apo_path.exists(): + return None + return apo_path + else: + raise NotImplementedError + + +class SubsiteTag(BaseModel): + id = models.BigAutoField(primary_key=True) + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + subsite = models.ForeignKey( + Subsite, + on_delete=models.RESTRICT, + db_column='subsite_id', + ) + + subsite_tag_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'subsite_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'subsite', + 'pose', + ], + name='uc_subsite_tag', + ), + ] + indexes = [ + models.Index(fields=['subsite'], name='idx_subsite_tag_subsite_id'), + models.Index(fields=['pose'], name='idx_subsite_tag_pose_id'), + models.Index(fields=['created_on'], name='idx_subsite_tag_created'), + ] + + +class PoseMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_method_name = models.TextField(null=True, blank=True) + pose_method_description = models.TextField(null=True, blank=True) + pose_method_version = models.TextField(null=True, blank=True) + pose_method_organization = models.TextField(null=True, blank=True) + pose_method_link = models.TextField(null=True, blank=True) + pose_method_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_method_name', + 'pose_method_version', + ], + name='uc_pose_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['pose_method_name'], name='idx_pose_method_name'), + models.Index(fields=['created_on'], name='idx_pose_method_created'), + ] + + +class PoseMethodJunction(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_method_id') + pose = models.ForeignKey( + 'Pose', + on_delete=models.CASCADE, + db_column='pose_id', + ) + + pose_method = models.ForeignKey( + 'PoseMethod', + on_delete=models.CASCADE, + db_column='pose_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_methods' + indexes = [ + models.Index( + fields=['pose_method'], name='idx_has_pose_methods_pose_method_id' + ), + models.Index( + fields=['created_on'], name='idx_idx_has_pose_methods_created' + ), + ] + + +class PoseTag(BaseModel): + id = models.BigAutoField(primary_key=True) + pose_tag_name = models.TextField() + pose_tag_description = models.TextField(null=True, blank=True) + pose_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'pose_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'pose_tag_name', + ], + name='uc_pose_tag', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_pose_tag_created'), + ] + + +class PoseTagJunction(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'pose_tag_id') + pose = models.ForeignKey( + Pose, + on_delete=models.CASCADE, + db_column='pose_id', + ) + pose_tag = models.ForeignKey( + PoseTag, + on_delete=models.CASCADE, + db_column='pose_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_pose_tags' + indexes = [ + models.Index(fields=['pose_tag'], name='idx_has_pose_tag_pose_tag_id'), + models.Index(fields=['created_on'], name='idx_has_pose_tag_created'), + ] + + +# this was missing.. is this a m2m table as well? really looks like it +class Inspiration(BaseModel): + id = models.BigAutoField(primary_key=True) + # original behaviour described in schema was SET_NULL but I don't + # see how that makes sense. if either original or derivative is + # deleted, you'll have orphaned entries + original_pose = models.ForeignKey( + Pose, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='original_pose_id', + related_name='+', + ) + derivative_pose = models.ForeignKey( + Pose, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='derivative_pose_id', + related_name='+', + ) + + class Meta(BaseModel.Meta): + db_table = 'inspirations' + constraints = [ + models.UniqueConstraint( + fields=[ + 'original_pose', + 'derivative_pose', + ], + name='uc_inspiration', + ) + ] + indexes = [ + models.Index( + fields=['original_pose'], name='idx_inspiration_original_pose_id' + ), + models.Index( + fields=['derivative_pose'], name='idx_inspiration_derivative_pose_id' + ), + models.Index(fields=['created_on'], name='idx_inspiration_created'), + ] + + +class Feature(BaseModel): + id = models.BigAutoField(primary_key=True) + feature_family = models.TextField(null=True, blank=True) + target = models.ForeignKey( + Target, + on_delete=models.RESTRICT, + db_column='target_id', + ) + + feature_chain_name = models.TextField(null=True, blank=True) + feature_residue_name = models.TextField(null=True, blank=True) + feature_residue_number = models.IntegerField(null=True, blank=True) + feature_atom_name = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'features' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature_family', + 'target', + 'feature_chain_name', + 'feature_residue_name', + 'feature_residue_number', + 'feature_atom_name', + ], + name='uc_feature', + ) + ] + indexes = [ + models.Index(fields=['target'], name='idx_feature_target_id'), + models.Index(fields=['created_on'], name='idx_feature_created'), + ] + + +class Interaction(BaseModel): + id = models.BigAutoField(primary_key=True) + feature = models.ForeignKey( + Feature, + on_delete=models.RESTRICT, + db_column='feature_id', + ) + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + ) + + interaction_type = models.TextField() + interaction_family = models.TextField() + interaction_atom_id = models.TextField() + + # could these be vectors? + interaction_prot_coord = models.TextField() + interaction_lig_coord = models.TextField() + + interaction_distance = models.FloatField() + interaction_angle = models.FloatField(null=True, blank=True) + interaction_energy = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'interactions' + constraints = [ + models.UniqueConstraint( + fields=[ + 'feature', + 'pose', + 'interaction_type', + 'interaction_family', + 'interaction_atom_id', + ], + name='uc_interaction', + ) + ] + indexes = [ + models.Index(fields=['feature_id'], name='idx_interaction_feature_id'), + models.Index(fields=['pose'], name='idx_interaction_pose_id'), + models.Index(fields=['created_on'], name='idx_interaction_created'), + ] + + +class CompoundTag(BaseModel): + id = models.BigAutoField(primary_key=True) + compound_tag_name = models.TextField() + compound_tag_description = models.TextField(null=True, blank=True) + compound_tag_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_tags' + constraints = [ + models.UniqueConstraint( + fields=[ + 'compound_tag_name', + ], + name='uc_compound_tag_name', + ) + ] + indexes = [ + models.Index(fields=['created_on'], name='idx_compound_tag_created'), + ] + + +class CompoundTagJunction(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'compound_tag_id') + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + compound_tag = models.ForeignKey( + CompoundTag, + on_delete=models.CASCADE, + db_column='compound_tag_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_compound_tags' + indexes = [ + models.Index( + fields=['compound_tag'], name='idx_has_compound_tag_compound_tag_id' + ), + models.Index(fields=['created_on'], name='idx_has_compound_tag_created'), + ] + + +class EnumerationMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + enum_name = models.TextField(null=True, blank=True) + enum_description = models.TextField(null=True, blank=True) + enum_version = models.TextField(null=True, blank=True) + enum_organization = models.TextField(null=True, blank=True) + enum_link = models.TextField(null=True, blank=True) + enum_note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'enumeration_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'enum_name', + 'enum_version', + ], + name='uc_enumeration_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['enum_name'], name='idx_enumeration_method_name'), + models.Index(fields=['created_on'], name='idx_enumeration_method_created'), + ] + + +class CompoundEnumerationMethodJunction(BaseModel): + pk = models.CompositePrimaryKey('compound_id', 'enumeration_method_id') + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + enumeration_method = models.ForeignKey( + EnumerationMethod, + on_delete=models.CASCADE, + db_column='enumeration_method_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'has_enumeration_methods' + indexes = [ + models.Index( + fields=['enumeration_method'], + name='idx_has_enumeration_methods_enumeration_method_id', + ), + models.Index( + fields=['created_on'], name='idx_has_enumeration_methods_created' + ), + ] + + +class ScoringMethod(BaseModel): + id = models.BigAutoField(primary_key=True) + method_name = models.TextField(null=True, blank=True) + method_description = models.TextField(null=True, blank=True) + method_version = models.TextField(null=True, blank=True) + method_organization = models.TextField(null=True, blank=True) + method_link = models.TextField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'scoring_methods' + constraints = [ + models.UniqueConstraint( + fields=[ + 'method_name', + 'method_version', + ], + name='uc_scoring_method', + nulls_distinct=False, + ) + ] + indexes = [ + models.Index(fields=['method_name'], name='idx_scoring_method_name'), + models.Index(fields=['created_on'], name='idx_scoring_method_created'), + ] + + +class ScoreValue(BaseModel): + pk = models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id') + pose = models.ForeignKey( + Pose, + on_delete=models.RESTRICT, + db_column='pose_id', + related_name='scores', + ) + + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + related_name='scores', + ) + + scoring_method = models.ForeignKey( + ScoringMethod, + on_delete=models.RESTRICT, + db_column='scoring_method_id', + related_name='scores', + ) + + score = models.JSONField() + + class Meta(BaseModel.Meta): + db_table = 'score_values' + indexes = [ + models.Index(fields=['pose'], name='idx_score_values_pose_id'), + models.Index(fields=['compound'], name='idx_score_values_compound_id'), + models.Index( + fields=['scoring_method'], name='idx_score_values_scoring_method_id' + ), + models.Index(fields=['created_on'], name='idx_score_values_created'), + ] + + +class Reaction(BaseModel): + id = models.BigAutoField(primary_key=True) + reaction_type = models.TextField(null=True, blank=True) + product_compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + reaction_product_yield = models.FloatField(null=True, blank=True) + reaction_metadata = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'reactions' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_reaction_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_reaction_created'), + ] + + +class Reactant(BaseModel): + id = models.BigAutoField(primary_key=True) + reactant_amount = models.FloatField(null=True, blank=True) + reaction = models.ForeignKey( + Reaction, + on_delete=models.CASCADE, + db_column='reaction_id', + ) + compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'reactants' + constraints = [ + models.UniqueConstraint( + fields=[ + 'reaction', + 'compound', + ], + name='uc_reactant', + ) + ] + indexes = [ + models.Index(fields=['reaction'], name='idx_reactant_reaction_id'), + models.Index(fields=['compound'], name='idx_reactant_compound_id'), + models.Index(fields=['created_on'], name='idx_reactant_created'), + ] + + +class CatalogueCompound(BaseModel): + id = models.BigAutoField(primary_key=True) + catalogue_smiles = models.TextField(null=False, blank=True) + catalogue_inchikey = models.TextField(null=False, blank=True) + catalogue_hash = models.TextField(null=False, blank=True) + rdkit_version = models.TextField(null=True, blank=True) + inchi_version = models.TextField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'catalogue_compounds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'catalogue_smiles', + ], + name='uq_catalogue_compounds_smiles', + ), + models.CheckConstraint( + condition=Q(catalogue_hash__isnull=False) & Q(catalogue_hash__gt=''), + name='ck_catalogue_compounds_hash_nonempty', + ), + ] + + +class CataloguePrice(BaseModel): + id = models.BigAutoField(primary_key=True) + catalogue_compound = models.ForeignKey( + CatalogueCompound, + null=True, + on_delete=models.CASCADE, + db_column='catalogue_id', + ) + vendor = models.TextField(null=False, blank=True) + supplier = models.TextField(null=True, blank=True) + supplier_id = models.TextField(null=False, blank=True) + amount = models.FloatField(null=True, blank=True) + price = models.FloatField(null=True, blank=True) + currency = models.TextField(null=True, blank=True) + purity = models.FloatField(null=True, blank=True) + lead_time = models.IntegerField(null=True, blank=True) + + compounds = models.ManyToManyField( + Compound, + through='CataloguePriceCompoundJunction', + related_name='prices', + ) + + class Meta(BaseModel.Meta): + db_table = 'catalogue_prices' + constraints = [ + models.UniqueConstraint( + fields=[ + 'catalogue_compound', + 'vendor', + 'supplier', + 'supplier_id', + 'amount', + ], + name='uc_catalogue_price', + ) + ] + + +class CataloguePriceCompoundJunction(BaseModel): + ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') + catalogue_price = models.ForeignKey( + CataloguePrice, + on_delete=models.CASCADE, + db_column='catalogue_price_id', + ) + compound = models.ForeignKey( + Compound, + on_delete=models.CASCADE, + db_column='compound_id', + ) + + match_hash = models.TextField(null=False, blank=True) + + # Not needed, remove + # catalogue_inchikey = models.TextField(null=False, blank=True) + # supplier = models.TextField(null=True, blank=True) + # amount = models.FloatField(null=True, blank=True) + # price = models.FloatField(null=True, blank=True) + # lead_time = models.IntegerField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'compound_catalogue_map' + constraints = [ + models.CheckConstraint( + condition=Q(match_hash__isnull=False) & Q(match_hash__gt=''), + name='ck_compound_catalogue_map_match_hash_nonempty', + ) + ] + + +class Scaffold(BaseModel): + id = models.BigAutoField(primary_key=True) + # same comment as with inspiratons. original schema says SET_NULL + # but doesn't seem right + base_compound = models.ForeignKey( + Compound, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='base_compound_id', + related_name='scaffold_bases', + ) + superstructure_compound = models.ForeignKey( + Compound, + # on_delete=models.SET_NULL, + on_delete=models.CASCADE, + db_column='superstructure_compound_id', + related_name='scaffold_superstructures', + ) + + class Meta(BaseModel.Meta): + db_table = 'scaffolds' + constraints = [ + models.UniqueConstraint( + fields=[ + 'base_compound', + 'superstructure_compound', + ], + name='uc_scaffold', + ) + ] + indexes = [ + models.Index( + fields=['base_compound'], name='idx_scaffold_base_compound_id' + ), + models.Index( + fields=['superstructure_compound'], + name='idx_scaffold_superstructure_compound_id', + ), + models.Index(fields=['created_on'], name='idx_scaffold_created'), + ] + + +class Route(BaseModel): + id = models.BigAutoField(primary_key=True) + product_compound = models.ForeignKey( + Compound, + on_delete=models.RESTRICT, + db_column='product_compound_id', + ) + + class Meta(BaseModel.Meta): + db_table = 'routes' + indexes = [ + models.Index( + fields=['product_compound'], name='idx_route_product_compound_id' + ), + models.Index(fields=['created_on'], name='idx_route_created'), + ] + + +class Component(BaseModel): + id = models.BigAutoField(primary_key=True) + route = models.ForeignKey( + Route, + on_delete=models.RESTRICT, + db_column='route_id', + ) + component_type = models.IntegerField(null=True, blank=True) + component_ref = models.IntegerField(null=True, blank=True) + component_amount = models.FloatField(null=True, blank=True) + + class Meta(BaseModel.Meta): + db_table = 'components' + constraints = [ + models.UniqueConstraint( + fields=[ + 'route', + 'component_ref', + 'component_type', + ], + name='uc_component', + ) + ] + indexes = [ + models.Index(fields=['route'], name='idx_component_route_id'), + models.Index(fields=['created_on'], name='idx_component_created'), + ] + + +# what follows is audit tables, indexes, materialised views (none), +# views, functions and triggers. I'm not sure I need them here, will create if do. + + +# these functions available in db +# CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol +# LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; + +# CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text +# LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; diff --git a/hippo/price.py b/hippo/designdb/price.py similarity index 99% rename from hippo/price.py rename to hippo/designdb/price.py index 88f74b2..397a605 100644 --- a/hippo/price.py +++ b/hippo/designdb/price.py @@ -78,7 +78,7 @@ def currency(self) -> str: @property def amount(self) -> float: """Amount""" - return self._amount + return self._amountb @property def is_null(self) -> bool: diff --git a/hippo/recipe.py b/hippo/designdb/recipe.py similarity index 99% rename from hippo/recipe.py rename to hippo/designdb/recipe.py index 5999e8c..5150c10 100644 --- a/hippo/recipe.py +++ b/hippo/designdb/recipe.py @@ -3,17 +3,16 @@ import mcol import mrich -from .compound import Ingredient +from designdb.models import Compound, Reaction +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet class Recipe: """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" - _db = None - def __init__( self, - db: 'Database', *, products: 'IngredientSet | None' = None, reactants: 'IngredientSet | None' = None, @@ -23,23 +22,20 @@ def __init__( ) -> None: """Recipe initialisation""" - from .cset import IngredientSet - from .rset import ReactionSet - if products is None: - products = IngredientSet(db) + products = IngredientSet() if reactants is None: - reactants = IngredientSet(db) + reactants = IngredientSet() if intermediates is None: - intermediates = IngredientSet(db) + intermediates = IngredientSet() if compounds is None: - compounds = IngredientSet(db) + compounds = IngredientSet() if reactions is None: - reactions = ReactionSet(db) + reactions = ReactionSet() # check typing assert isinstance(products, IngredientSet) @@ -53,7 +49,6 @@ def __init__( self._intermediates = intermediates self._reactions = reactions self._compounds = compounds - self._db = db self._hash = None self._score = None @@ -98,13 +93,8 @@ def from_reaction( """ - from .reaction import Reaction - assert isinstance(reaction, Reaction) - from .cset import IngredientSet - from .rset import ReactionSet - if debug: mrich.debug( f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' @@ -116,22 +106,18 @@ def from_reaction( assert reaction in permitted_reactions # raise NotImplementedError - db = reaction.db - recipe = cls.__new__(cls) recipe.__init__( - db, products=IngredientSet( - db, [ reaction.product.as_ingredient( amount=amount, get_quote=get_ingredient_quotes ) ], ), - reactants=IngredientSet(db, [], supplier=supplier), - intermediates=IngredientSet(db, []), - reactions=ReactionSet(db, [reaction.id], sort=False), + reactants=IngredientSet([], supplier=supplier), + intermediates=IngredientSet([]), + reactions=ReactionSet([reaction.id], sort=False), ) recipes = [recipe] @@ -171,7 +157,8 @@ def get_reactant_amount_pairs(reaction: 'Reaction') -> list[tuple[int, float]]: pairs = get_reactant_amount_pairs(reaction) for reactant, reactant_amount in pairs: - reactant = db.get_compound(id=reactant) + # reactant = db.get_compound(id=reactant) + reactant = Compound.objects.get(pk=reactant) if debug: mrich.debug(f'{reactant.id=}, {reactant_amount=}') @@ -286,8 +273,6 @@ def from_reactions( assert isinstance(reactions, ReactionSet) - db = reactions.db - if debug: mrich.debug('Recipe.from_reactions()') mrich.var('reactions', reactions) @@ -710,7 +695,6 @@ def from_json( # Create the object self = cls.__new__(cls) self.__init__( - db, products=products, reactants=reactants, intermediates=intermediates, @@ -722,11 +706,6 @@ def from_json( ### PROPERTIES - @property - def db(self) -> 'Database': - """Associated :class:`.Database:""" - return self._db - @property def products(self) -> 'IngredientSet': """Product :class:`.IngredientSet`""" @@ -813,7 +792,7 @@ def intermediates(self) -> 'IngredientSet': def intermediates(self, a: 'IngredientSet'): """Set the intermediates""" self._intermediates = a - self.__flag_modification() + # self.__flag_modification() @property def reactions(self) -> 'ReactionSet': diff --git a/hippo/designdb/route.py b/hippo/designdb/route.py new file mode 100644 index 0000000..47c3753 --- /dev/null +++ b/hippo/designdb/route.py @@ -0,0 +1,219 @@ +import json + +import mcol +import mrich + +from designdb.models import Component, Reaction, Route + +from .recipe import Recipe + + +# name conflict with route model. Trying to get rid of this entirely +class RouteObj(Recipe): + """A recipe with a single product, that is stored in the database""" + + def __init__( + self, + *, + route_id: int, + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', + ) -> None: + """Route initialisation""" + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + # check typing + assert isinstance(product, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(reactions, ReactionSet) + + assert len(product) == 1 + assert isinstance(route_id, int) + assert route_id + + self._id = route_id + self._products = product + self._product_id = product.ids[0] + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + + ### FACTORIES + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'Route': + """Load a serialised route from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + if data is None: + data = json.load(open(path)) + + self = cls.__new__(cls) + + self._id = data['id'] + + self._product_id = data['product_id'] + self._products = IngredientSet.from_compounds( + compounds=None, ids=[self._product_id] + ) # IngredientSet + + self._reactants = IngredientSet.from_json( + path=None, + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], + ) + self._intermediates = IngredientSet.from_json( + path=None, + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], + ) + self._reactions = ReactionSet( + Reaction.objects.filter(pk__in=data['reactions']['indices']) + ) # ReactionSet + + return self + + @classmethod + def get_route( + cls, + *, + id: int, + debug: bool = False, + ) -> 'RouteObj': + """Fetch a :class:`.Route` object stored in the :class:`.Database`. + + :param id: the ID of the :class:`.Route` to be retrieved + :param debug: increase verbosity for debugging, defaults to False + :returns: :class:`.Route` object + + """ + + # avoiding circular dependencies + from designdb.sets.compound import CompoundSet, IngredientSet + from designdb.sets.reaction import ReactionSet + + # multiples?? + route = Route.objects.get(pk=id) + + if debug: + mrich.var('product_id', route.product_compound) + + qs = Component.objects.filter(route=route).order_by('id') + + reaction_ids = [] + reactant_ids = [] + reactant_amounts = [] + intermediate_ids = [] + intermediate_amounts = [] + + # for ref, c_type, amount in triples: + for k in qs: + ref = k.component_ref + c_type = k.component_type + amount = k.component_amount + match c_type: + case 1: + reaction_ids.append(ref) + case 2: + reactant_ids.append(ref) + reactant_amounts.append(amount) + case 3: + intermediate_ids.append(ref) + intermediate_amounts.append(amount) + case _: + raise ValueError(f'Unknown component type {c_type}') + + if debug: + mrich.var('pairs', qs) + + products = CompoundSet([route.pk]) + reactants = CompoundSet(reactant_ids) + intermediates = CompoundSet(intermediate_ids) + + products = IngredientSet.from_compounds(compounds=products, amount=1) + reactants = IngredientSet.from_compounds( + compounds=reactants, amount=reactant_amounts + ) + intermediates = IngredientSet.from_compounds( + compounds=intermediates, amount=intermediate_amounts + ) + + reactions = ReactionSet(reaction_ids) + + recipe = RouteObj( + route_id=id, + product=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + ) + + if debug: + mrich.var('recipe', recipe) + + return recipe + + ### PROPERTIES + + @property + def product(self) -> 'Ingredient': + """Product ingredient""" + return self._products[0] + + @property + def product_compound(self) -> 'Compound': + """Product compound""" + return self.product.compound + + @property + def id(self) -> int: + """Route ID""" + return self._id + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.price + + ### METHODS + + def get_dict(self) -> dict: + """Serialisable dictionary""" + data = {} + + data['id'] = self.id + data['product_id'] = self.product.id + data['reactants'] = self.reactants.get_dict() + data['intermediates'] = self.intermediates.get_dict() + data['reactions'] = self.reactions.get_dict() + + return data + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'Route #{self.id}: {self.product_compound}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' diff --git a/hippo/designdb/services/__init__.py b/hippo/designdb/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py new file mode 100644 index 0000000..290c489 --- /dev/null +++ b/hippo/designdb/services/compound.py @@ -0,0 +1,136 @@ +import logging +import re + +import mrich +import rdkit +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from designdb.models import Compound, CompoundTag +from designdb.utils import inchikey_from_smiles, sanitise_smiles + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class CompoundBatchResult: + def __init__(self): + self.created = [] + self.errors = [] + + +class CompoundService: + @classmethod + def create( + cls, + *, + mol: Chem.rdchem.Mol, + smiles: str, + inchikey: str, + ) -> tuple[Compound, bool]: + + # TODO: new fields to consider, fingerprints and tautomer hashes + + # TODO and SQLITE_RELIC: inchikey is calculated by postgres in + # trigger. But I need to calculate it here as well, for + # queries. I feel like having two different calculation + # methods is not ideal. Are the versions guaranteed to be the + # same? And even if I do this in trigger, it's already here, + # why not just insert it? + + compound, created = Compound.objects.get_or_create( + compound_inchikey=inchikey, + # compound_smiles=smiles, + defaults={ + 'compound_mol': mol, + 'compound_smiles': smiles, + 'rdkit_version': rdkit.__version__, + 'inchi_version': Chem.inchi.GetInchiVersion(), + }, + ) + if not created and logger.level == logging.DEBUG: + mrich.warning( + f'Skipping compound {inchikey}, {smiles}, duplicate of {compound.pk}' + ) + + # there's a following block in the original code + # I don't understand what it is trying to achieve + # smiles and inchikey are both inserted, so compound existing + # but not reachable by inchikey should not happen. maybe this + # covers compounds loaded through different pathway? + + # compound_id = self.db.insert_compound( + # smiles=smiles, + # tags=tags, + # warn_duplicate=debug, + # commit=False, + # ) + + # if not compound_id: + # inchikey = inchikey_from_smiles(smiles) + # compound = self.compounds[inchikey] + + # if not compound: + # mrich.error( + # 'Compound exists in database but could not be found by inchikey' + # ) + # mrich.var('smiles', smiles) + # mrich.var('inchikey', inchikey) + # mrich.var('observation_shortname', name) + # raise Exception + + # else: + # count_compound_registered += 1 + # compound = self.compounds[compound_id] + + return compound, created + + @classmethod + def create_from_smiles( + cls, + smiles_list: list[str], + ) -> list[tuple[str, str]]: + result = [] + for smiles in smiles_list: + sane_smiles = sanitise_smiles( + smiles, verbosity=logger.level == logging.DEBUG + ) + mol = Chem.MolFromSmiles(sane_smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + cls.create( + mol=mol, + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + + result.append((sane_inchikey, sane_smiles)) + + return result + + +class CompoundTagService: + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + CompoundTag.objects.bulk_create( + [CompoundTag(compound_tag_name=k.strip()) for k in tag_list if k.strip()], + ignore_conflicts=True, + ) + tags = CompoundTag.objects.filter(compound_tag_name__in=tag_list) + return tags diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py new file mode 100644 index 0000000..dfae0c7 --- /dev/null +++ b/hippo/designdb/services/ingestion.py @@ -0,0 +1,1065 @@ +import logging +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import molparse as mp +import mrich +import pandas as pd +from numpy import isnan +from pandas import read_pickle +# from mypackage.services.compound import CompoundService +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools + +from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry +from designdb.ingredient import Ingredient +from designdb.models import Compound, Pose, Reactant, Reaction, Scaffold, Target +from designdb.recipe import Recipe +from designdb.route import RouteObj +from designdb.services.compound import CompoundService, CompoundTagService +from designdb.services.pose import PoseService, PoseTagService +from designdb.services.reaction import ReactionService +from designdb.services.route import RouteService +from designdb.services.score import ScoreService +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet +from designdb.utils import ( + SanitisationError, + inchikey_from_smiles, + remove_other_ligands, + sanitise_smiles, +) +from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode +from src.designdb.services.reaction import ReactionService + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +@dataclass +class FSRecord: + name: str + path: Path + sdf: Path + pdb: Path + + +def parse_sdf_pandas(sdf_path: Path) -> tuple[str, Chem.rdchem.Mol]: + df = PandasTools.LoadSDF( + str(sdf_path), molColName='ROMol', idName='ID', strictParsing=True + ) + # extract fields + longcode = df.ID[0] + mol = df.ROMol[0] + + return longcode, mol + + +def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: + logger.debug('Reading %s', pdb_path) + pdb = mp.parse(pdb_path, verbosity=0) + + # create the single ligand bound pdb + lig_residues = pdb.residues['LIG'] + if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues): + pdb = remove_other_ligands(pdb, residue, chain) + pdb.prune_alternative_sites('A', verbosity=0) + pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb') + # side effect: writes pdb into file + mp.write( + pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG + ) + else: + pose_path = str(pdb_path.resolve()) + + return pose_path + + +def iter_fs_fragalysis(root_path, skip_records): + assert skip_records is not None, '"None" passed instead as skip_records' + + for dset_path in list(sorted(root_path.glob('*'))): + if dset_path.name in skip_records: + continue + + sdfs = [] + for sdf_path in dset_path.glob('*.sdf'): + sdf_name = sdf_path.name + + if ( + '_ligand' in sdf_name + ): # Quick fix, _ligand.sdf are exactly the same as .sdf + # in aligned_directory. + continue + + # fragalysis SDF + if SDF_FRAGALYSIS_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + # fragalysis SDF from PDB id + elif PDBID_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + else: + mrich.warning( + sdf_name, + "doesn't not follow neither Fragalysis nor PDB ID patterns", + ) + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + pdbs = [ + p + for p in dset_path.glob('*.pdb') + if '_ligand' not in p.name + and '_apo' not in p.name + and '_hippo' not in p.name + ] + + if not len(pdbs) == 1: + mrich.error(dset_path.name, 'has invalid PDBs', pdbs) + continue + + record = FSRecord(name=dset_path.name, path=dset_path, sdf=sdfs[0], pdb=pdbs[0]) + + logger.debug('fs_frag record: %s', record) + + yield record + + +# unfinished, seems XCA data is not loaded now +def iter_fs_xca(root_path, skip): + for dset_path in sorted(root_path.glob('*[0-9][0-9][0-9][0-9]')): + if dset_path.name in skip: + continue + + sdfs = [] + + for sdf_path in sorted(dset_path.glob('*.sdf')): + sdf_name = sdf_path.name + + # TODO: switch between patterns?? + if SDF_XCAv2_PATTERN.match(sdf_name): + sdfs.append(sdf_path) + + if not sdfs: + mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) + continue + + for i, sdf in enumerate(sdfs): + subname = dset_path.name + chr(ord('a') + i) + + pdb = dset_path / sdf.name.replace('_ligand.sdf', '.pdb') + + if not pdb.exists(): + mrich.error(dset_path.name, 'is missing PDB', pdb) + continue + + record = FSRecord(name=subname, path=dset_path, sdf=sdf, pdb=pdb) + + logger.debug('fs_frag record: %s', record) + + yield record + + +def read_df(path: Path): + if path.name.endswith('.sdf'): + df = PandasTools.LoadSDF(str(path.resolve())) + else: + df = read_pickle(path) + + return df + + +def validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, +): + + # TODO: these are part of input validation and should be removed. or + # at least rewritten + assert mol_col in df.columns, f'{mol_col=} not in {df.columns}' + + if name_col: + assert name_col in df.columns, f'{name_col=} not in {df.columns}' + + if inspiration_col and not inspirations: + assert inspiration_col in df.columns, f'{inspiration_col=} not in {df.columns}' + + if not reference and reference_col: + assert reference_col in df.columns, f'{reference_col=} not in {df.columns}' + + +def preprocess_df( + df, + *, + skip_equal, + skip_not_equal, + name_col: str, +) -> list[dict[str, Any]]: + + mrich.var('SDF entries (pre-filter)', len(df)) + + df = df[df['ID'] != 'ver_1.2'] + + for k, v in skip_equal.items(): + df = df[df[k] == v] + + for k, v in skip_not_equal.items(): + df = df[df[k] != v] + + mrich.var('SDF entries (post-filter)', len(df)) + + df[name_col] = df[name_col].str.strip() + + records = df.to_dict(orient='records') + + return records + + +def metadata_from_record( + record: dict[str, str], + ignore_fields: list[str | None], + convert_floats: bool, + field_warning=None, +) -> dict[str, str | float]: + + result = {} + skip = { + 'smiles', + 'inchikey', + 'compound_id', + 'target_id', + 'reference_id', + 'path', + 'exports', + } + + skip = skip.union(set([k for k in ignore_fields if k])) + + for key, value in record.items(): + if key in skip: + continue + + if isinstance(value, float) and isnan(value): + continue + + if convert_floats: + try: + value = float(value) + except TypeError: + pass + except ValueError: + pass + + if not (isinstance(value, str) or isinstance(value, float)): + if field_warning: + field_warning(mrich.warning(f'Skipping metadata from column={key}.')) + continue + + result[key] = value + + return result + + +@dataclass +class IngestionBatchResult: + attempts: int = 0 + compounds_created: int = 0 + poses_created: int = 0 + + +class IngestionService: + @classmethod + def ingest_filesystem( + cls, + *, + root_path: Path, + target: Target, + skip_records: list[str], + compound_tag_list: list[str], + metadata_file: Path | str, + ) -> IngestionBatchResult: + + # this is now strictly for loading frag data. cannot switch inner funcs easily + result = IngestionBatchResult() + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tagger = PoseTagService(metadata_file, other_tags=compound_tag_list) + + # if needs xca paths, need to pass or select function + for fs_record in iter_fs_fragalysis(root_path, skip_records): + longcode, mol = parse_sdf_pandas(fs_record.sdf) + logger.debug(fs_record.name, longcode) + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = mp.rdkit.mol_to_smiles(mol) + sane_smiles = sanitise_smiles( + smiles, verbosity=logger.level == logging.DEBUG + ) + inchikey = inchikey_from_smiles(smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + # NB! different func if XCA data + try: + longcode_rec = parse_observation_longcode(longcode) + except UnsupportedFragalysisLongcodeError as exc: + # unhandled in original code. do what? + raise UnsupportedFragalysisLongcodeError from exc + + pose_path = parse_pdb_mp( + fs_record.pdb, longcode_rec.residue_number, longcode_rec.chain + ) + + compound, compound_created = CompoundService.create( + mol=mol, + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if compound_created: + result.compounds_created += 1 + + # pose_tags = PoseTagService.tags_from_list(pose_tag_set) + pose_tags, metadata = pose_tagger.tags_and_meta( + code=fs_record.name, + longcode=longcode, + ) + + metadata = {'fragalysis_longcode': longcode} + + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=mol, + alias=fs_record.name, + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + + # it seems fragalysis data is not expected to contain + # scores + + # in original code. what's that for? + # what I can think of is previously existing pose without mol + # if load_pose_mols: + # try: + # pose.mol + # except Exception as e: + # mrich.error('Could not load molecule', pose) + # mrich.error(e) + + return result + + @classmethod + def ingest_sdf( + cls, + *, + file_path: Path, + target, + compound_tag_list: list[str], + pose_tag_list: list[str], + mol_col: str, + name_col: str, + inspiration_col: str | None = None, + inspirations: list[int], + inspiration_map: dict[str, Pose], + reference: int | None, + reference_col: str, + skip_equal, + skip_not_equal, + convert_floats: bool = True, + field_warning=None, + ) -> IngestionBatchResult: + result = IngestionBatchResult() + + output_directory = Path(str(file_path.name).removesuffix('.sdf')) + output_directory.mkdir(parents=True, exist_ok=True) + + df = read_df(file_path) + validate_df( + df, + mol_col, + name_col, + inspiration_col, + inspirations, + reference_col, + reference, + ) + + compound_tags = CompoundTagService.tags_from_list(compound_tag_list) + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + + # I need to know here one of two things: + # - which scores to create + # - which fields in sdf to ignore + # I mean, probs shouldn't cats smiles, etc as scores + + # it's probably the latter, isn't it? then I don't actually + # need to init scores at all, especially with central + # deisgndb, the scoring method likely exists + + # scorer = ScoreService(['energy_score', 'distance_score']) + scorer = ScoreService() + + records = preprocess_df( + df, + skip_equal=skip_equal, + skip_not_equal=skip_not_equal, + name_col=name_col, + ) + + for r in records: + result.attempts += 1 + + # TODO: this is the original procedure how it was + # calculated in hippo. I'm not touching it now, but this + # could use a rewrite, it converts smiles back to mol and + # then to inchikey + smiles = r.get('smiles', None) + if not smiles: + smiles = mp.rdkit.mol_to_smiles(r[mol_col]) + try: + sane_smiles = sanitise_smiles( + smiles, + sanitisation_failed='error', + radical='warning', + verbosity=logger.level == logging.DEBUG, + ) + except SanitisationError as e: + mrich.error(f'Could not sanitise {smiles=}') + mrich.error(str(e)) + continue + except AssertionError: + mrich.error(f'Could not sanitise {smiles=}') + continue + + inchikey = inchikey_from_smiles(smiles) + sane_inchikey = inchikey_from_smiles(sane_smiles) + + compound, compound_created = CompoundService.create( + mol=r[mol_col], + smiles=sane_smiles, + inchikey=sane_inchikey, + ) + compound.tags.add(*compound_tags) + if compound_created: + result.compounds_created += 1 + + pose_inspirations = PoseService.get_inspirations( + inspirations, + inspiration_map.get(r[name_col], []), + r.get(inspiration_col, []) if inspiration_col else None, + target=target, + ) + + if not reference and reference_col: + reference = PoseService.get_reference(r[reference_col], target) + + metadata = metadata_from_record( + r, + ignore_fields=[inspiration_col, name_col, mol_col], + convert_floats=convert_floats, + field_warning=field_warning, + ) + + pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve() + pose, pose_created = PoseService.create( + compound=compound, + target=target, + mol=r[mol_col], + alias=r[name_col], + path=pose_path, + metadata=metadata, + inchikey=inchikey, + smiles=smiles, + reference=reference, + ) + if pose_created: + result.poses_created += 1 + + pose.tags.add(*pose_tags) + pose.inspirations.add(*Pose.objects.filter(pk__in=pose_inspirations)) + scorer.add_scores_from_record(pose=pose, record=r) + + return result + + # how is that without target?? + @classmethod + def ingest_syndirella_routes( + cls, + pickle_path: str | Path, + CAR_only: bool = True, + pick_first: bool = True, + do_check_chemistry: bool = True, + register_routes: bool = True, + ): + # this is pretty much a copy from the original method now + df = read_pickle(pickle_path) + + for i, row in mrich.track(df.iterrows(), total=len(df)): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) + + d = row.to_dict() + + # comp = self.compounds(smiles=d['smiles']) + + n_routes = 0 + for key in d: + if not key.startswith('route'): + continue + + if not key.endswith('_names'): + continue + + v = d[key] + + if isinstance(v, float) and pd.isna(v): + break + + n_routes += 1 + + if not n_routes: + # mrich.warning(comp, "#routes =", n_routes) + continue + + # routes = [] + for j in range(n_routes): + route_str = f'route{j}' + + route = d[route_str] + + if CAR_only and not d[route_str + '_CAR']: + continue + + reactions = ReactionSet() + reactants = IngredientSet() + intermediates = IngredientSet() + products = IngredientSet() + + # new models include Reaction, Reactant and + # Component. Should use these instead? + + try: + for k, reaction_struct in enumerate(route): + reaction_type = reaction_struct['name'] + + # product = self.compounds(smiles=reaction['productSmiles']) + # no error handling on sanitaiton, catchall at the end + # from original code + + smiles = reaction_struct['productSmiles'] + sane_smiles = sanitise_smiles( + smiles, + sanitisation_failed='error', + ) + + sane_inchikey = inchikey_from_smiles(sane_smiles) + product = Compound.objects.get(compound_inchikey=sane_inchikey) + + mrich.print(i, j, k, reaction_type, product) + + reaction, _ = Reaction.objects.get_or_create( + reaction_type=reaction_type, + product_compound=product, + ) + + rs = [] + print('reactant smiles', reaction_struct['reactantSmiles']) + for reactant_s in reaction_struct['reactantSmiles']: + reactant_comp, _ = Compound.objects.get_or_create( + compound_smiles=reactant_s, + ) + reactant, _ = Reactant.objects.get_or_create( + compound=reactant_comp, + reaction=reaction, + ) + rs.append(reactant.pk) + + if do_check_chemistry and not check_chemistry( + reaction_type, rs, product + ): + raise InvalidChemistryError( + f'{type=}, {rs=}, {product.id=}', + ) + + for r_id in rs: + if r_id in reactants: + intermediates.add(compound_id=r_id, amount=1) + else: + reactants.add(compound_id=r_id, amount=1) + + reactions.add(reaction) + + except InvalidChemistryError: + continue + except UnsupportedChemistryError: + mrich.warning('Skipping unsupported chemistry:', reaction_type) + continue + except Exception: + mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) + continue + + products.add(Ingredient.from_compound(product, amount=1)) + + recipe = Recipe( + reactions=reactions, + reactants=reactants, + intermediates=intermediates, + products=products, + ) + + if register_routes: + route, _ = RouteService.create_from_recipe( + recipe=recipe, + ) + mrich.success('registered route', route.pk) + + if pick_first: + break + + return df + + @classmethod + def ingest_syndirella_elabs( + cls, + *, + df: pd.DataFrame, + target: Target, + reject_flags: list[str], + pose_tag_list: list[str], + product_tag_list: list[str], + max_energy_score: float, + max_distance_score: float, + require_intra_geometry_pass: bool, + register_reactions: bool, + scaffold_route: RouteObj | None = None, + scaffold_compound: Compound | None = None, + ) -> pd.DataFrame: + + # work out number of reaction steps + num_steps = max( + [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] + ) + mrich.var('num_steps', num_steps) + + # add is_scaffold row + df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') + + ###### PREP ###### + + # flags + + present_flags = set() + for step in range(num_steps): + step += 1 + + for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): + for flag in flags: + present_flags.add(flag) + + if present_flags: + mrich.warning('Flags in DataFrame:', present_flags) + + for flag in reject_flags: + if flag in present_flags: + for step in range(num_steps): + step += 1 + matches = df[f'{step}_flag'].apply( + lambda x: flag in x if x is not None else False + ) + mrich.print( + 'Filtering out', + len(df[matches]), + 'rows from step', + step, + 'due to', + flag, + ) + df = df[~matches] + + # poses + + n_null_mol = len(df[df['path_to_mol'].isna()]) + if n_null_mol: + df = df[df['path_to_mol'].notna()] + mrich.var('#rows skipped due to null path_to_mol', n_null_mol) + + if not len(df): + mrich.warning('No valid rows') + return None + + # inspirations + inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) + # smth like {('z0637a', 'z1040a')} + + if len(inspiration_sets) != 1: + mrich.error('Varying inspirations not supported') + return df + + (inspiration_set,) = inspiration_sets + + inspirations = Pose.objects.filter( + pose_alias__in=inspiration_set, + target=target, + ) + + if inspirations.count() != len(inspiration_set): + print('target', target) + print('inspiration_set', inspiration_set) + print('inspiration comparison', inspirations.count(), len(inspiration_set)) + assert inspirations.count() == len(inspiration_set) + + # reference + template_paths = set(df['template'].to_list()) + assert len(template_paths) == 1, 'Multiple references not supported' + (template_path,) = template_paths + template_path = Path(template_path) + mrich.var('template_path', template_path) + base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') + # reference = self.poses[base_name] + + # TODO: error handling + reference = Pose.objects.get( + pose_alias=base_name, + target=target, + ) + + assert reference, 'Could not determine reference structure' + mrich.var('reference', reference) + + # that's nice but I need it before that + # target = reference.target + + # subset of rows + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + mrich.var('#scaffold entries', len(scaffold_df)) + mrich.var('#elab entries', len(elab_df)) + + if not len(scaffold_df) and not scaffold_route and not scaffold_compound: + mrich.error('No valid scaffold rows') + return None + + elif scaffold_route: + ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE + + assert scaffold_route.num_reactions == 1 + + product = scaffold_route.products[0].compound + reaction = scaffold_route.reactions[0] + + assert reaction.reactants.count() == 2 + + scaffold_dict = { + 'scaffold_smiles': product.compound_smiles, + '1_reaction': reaction.reaction_type, + # this is so hacky + '1_r1_smiles': reaction.reactants.first().compound.compound_smiles, + '1_r2_smiles': reaction.reactants.last().compound.compound_smiles, + '1_product_smiles': product.compound_smiles, + '1_product_name': 'scaffold', + '1_single_reactant_elab': False, + '1_num_atom_diff': 0, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + elif scaffold_compound: + ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT + + scaffold_dict = { + 'scaffold_smiles': scaffold_compound.smiles, + 'is_scaffold': True, + } + + scaffold_df = pd.DataFrame([scaffold_dict]) + + df = pd.concat([scaffold_df, df]) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # if dry_run: + # mrich.error('Not registering records (dry_run)') + # return df + + ###### ELABS ###### + + # bulk register compounds + + smiles_cols = [ + c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' + ] + + for smiles_col in smiles_cols: + inchikey_col = smiles_col.replace('_smiles', '_inchikey') + compound_id_col = smiles_col.replace('_smiles', '_compound_id') + + unique_smiles = df[smiles_col].dropna().unique() + + mrich.debug( + f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' + ) + + # radical? + values = CompoundService.create_from_smiles(unique_smiles) + + orig_smiles_to_inchikey = { + orig_smiles: inchikey + for orig_smiles, (inchikey, new_smiles) in zip( + unique_smiles, values, strict=False + ) + } + + df[inchikey_col] = df[smiles_col].apply( + lambda x: orig_smiles_to_inchikey.get(x) + ) + + # get associated IDs + compound_inchikey_id_dict = { + k.compound_inchikey: k.pk + for k in Compound.objects.filter(compound_smiles__in=unique_smiles) + } + df[compound_id_col] = df[inchikey_col].apply( + lambda x: compound_inchikey_id_dict.get(x) + ) + + # bulk register reactions + + if register_reactions: + for step in range(num_steps): + step += 1 + + mrich.debug(f'Registering reactions for step {step}') + + reaction_dicts = [] + + for reaction_name, r1_id, r2_id, product_id in df[ + [ + f'{step}_reaction', + f'{step}_r1_compound_id', + f'{step}_r2_compound_id', + f'{step}_product_compound_id', + ] + ].values: + # skip invalid rows + if pd.isna(r1_id) or pd.isna(product_id): + mrich.warning("Can't insert reactions for missing scaffold") + continue + + # reactant IDs + + reactant_ids = set() + reactant_ids.add(int(r1_id)) + + if not pd.isna(r2_id): + reactant_ids.add(int(r2_id)) + + product_id = int(product_id) + + # registration data + + reaction_dicts.append( + dict( + reaction_name=reaction_name, + reactant_ids=reactant_ids, + product_id=int(product_id), + ) + ) + + # why is this outside of loop? + _ = ReactionService.create_from_lists( + reaction_types=[d['reaction_name'] for d in reaction_dicts], + product_ids=[d['product_id'] for d in reaction_dicts], + reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], + ) + + scaffold_df = df[df['is_scaffold']] + elab_df = df[~df['is_scaffold']] + + # tag product compounds: + + product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) + products = Compound.objects.filter(pk__in=product_ids) + product_tags = CompoundTagService.tags_from_list(product_tag_list) + for compound in products: + compound.tags.add(*product_tags) + + # bulk register scaffold relationships + + for step in range(num_steps): + step += 1 + + for role in ['r1', 'r2', 'product']: + key = f'{step}_{role}_compound_id' + + mrich.debug(f'Registering scaffold relatonships for {key}') + + if step == num_steps and role == 'product' and scaffold_compound: + scaffold_id = scaffold_compound.id + + else: + scaffold_ids = list(scaffold_df[key].dropna().unique()) + + if not scaffold_ids: + mrich.warning( + "Can't insert scaffold relationships due to missing", + key, + 'for all scaffold rows', + ) + continue + + if len(scaffold_ids) > 1: + mrich.error('Multiple scaffold row values in', key) + return scaffold_df + + scaffold_id = scaffold_ids[0] + + # original code didn't do dropna? how? filter in later step? + superstructure_ids = [ + i for i in elab_df[key].dropna().unique() if i != scaffold_id + ] + + # comp service? + for superstructure_id in superstructure_ids: + base = Compound.objects.get(pk=scaffold_id) + superstructure = Compound.objects.get(pk=int(superstructure_id)) + Scaffold.objects.get_or_create( + base_compound=base, + superstructure_compound=superstructure, + ) + + # filter poses + + ok = df + + try: + if require_intra_geometry_pass: + mrich.var( + '#poses !intra_geometry_pass', + len(df[df['intra_geometry_pass'] == False]), + ) + ok = ok[ok['intra_geometry_pass'] == True] + + if max_energy_score is not None: + mrich.var( + f'#poses ∆∆G > {max_energy_score}', + len(df[df['∆∆G'] > max_energy_score]), + ) + ok = ok[ok['∆∆G'] <= max_energy_score] + + if max_distance_score is not None: + mrich.var( + f'#poses comRMSD > {max_distance_score}', + len(df[df['comRMSD'] > max_energy_score]), + ) + ok = ok[ok['comRMSD'] <= max_distance_score] + + except Exception as e: + mrich.error('Problem filtering dataframe') + mrich.error(e) + return df + + mrich.var('#acceptable poses', len(ok)) + + if not len(ok): + mrich.warning('No valid poses') + return None + + # bulk register poses + + pose_ids = [] + scorer = ScoreService() + for _, row in ok.iterrows(): + path = Path(row.path_to_mol).resolve() + print('comp id in row', row[f'{num_steps}_product_compound_id']) + + # closed for testing + if not path.exists(): + mrich.warning('Skipping pose w/ non-exising file:', path) + continue + + if pd.isna(row[f'{num_steps}_product_compound_id']): + continue + + pose, created = PoseService.create_from_record( + compound_id=int(row[f'{num_steps}_product_compound_id']), + target_id=int(target.id), + reference=int(reference.id), + path=str(path), + ) + if created: + scores = { + 'energy_score': float(row['∆∆G']), + 'distance_score': float(row['comRMSD']), + } + pose_ids.append(pose.id) + scorer.add_scores_from_record(pose=pose, record=scores) + + if not pose_ids: + mrich.warning('No valid poses') + return None + + poses = Pose.objects.filter(pk__in=pose_ids) + mrich.success('Registered', poses.count(), 'new poses') + + # query relevant poses (also previously registered) + paths = poses.values_list('path', flat=True) + + # what the hell is this?? + records = Pose.objects.filter( + path__in=paths, + ) + for pose in records: + # pose.inspirations.add(*Pose.objects.filter(pk__in=inspiration.ids)) + pose.inspirations.add(*inspirations.queryset) + + # if pose_tags: + pose_tags = PoseTagService.tags_from_list(pose_tag_list) + for pose in poses: + pose.tags.add(*pose_tags) + + return df + + +# def create_compound(...): +# assert connection.in_atomic_block diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py new file mode 100644 index 0000000..5e93a4e --- /dev/null +++ b/hippo/designdb/services/pose.py @@ -0,0 +1,208 @@ +import json +import logging +import re +from collections.abc import Iterable +from pathlib import Path + +import mrich +import pandas as pd +import rdkit +from django.db.models import Q +# from mypackage.services.compound import CompoundService +from rdkit import Chem + +# from rdkit.Chem import inchi +from designdb.models import Compound, Pose, PoseTag, Target +from designdb.utils import normalize_string_list +from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class PoseService: + @classmethod + def create( + cls, + *, + compound: Compound, + target: Target, + mol: Chem.rdchem.Mol, + alias: str, + path: str, + metadata: dict[str, str], + inchikey: str, + smiles: str, + reference: int | None = None, + ): + + try: + pose = Pose.objects.get( + target=target, + compound=compound, + pose_alias=alias, + ) + # default is to overwrite metadata. what about other props? + # also, shoulnd't this be JSON? + pose.metadata = metadata + pose.save() + created = False + except Pose.DoesNotExist: + pose = Pose( + compound=compound, + target=target, + pose_alias=alias, + pose_path=path, + pose_inchikey=inchikey, # SQLITE_RELIC + pose_smiles=smiles, # SQLITE_RELIC + pose_metadata=json.dumps(metadata), + pose_mol=mol, + rdkit_version=rdkit.__version__, + inchi_version=Chem.inchi.GetInchiVersion(), + pose_reference=reference, + ) + pose.save() + created = True + # except MultipleObjectsReturned: + # pass + + return pose, created + + @classmethod + def create_from_record( + cls, + *, + compound_id: int, + target_id: int, + path: str, + reference: int | None = None, + ): + target = Target.objects.get(pk=target_id) + compound = Compound.objects.get(pk=compound_id) + pose, created = Pose.objects.get_or_create( + compound=compound, + target=target, + pose_path=path, + reference=reference, + ) + return pose, created + + # this is parsing input, maybe in ingestion? + @staticmethod + def get_inspirations(*args, target: Target | None = None): + parsed = [] + for el in args: + if isinstance(el, str): + parsed.extend(normalize_string_list(el)) + elif isinstance(el, Iterable) and not isinstance(el, dict): + parsed.extend(el) + else: + logger.warning( + 'Unsupported inspiration collection received: %s', + type(el), + ) + + # inputs can be pk or name + pks = [] + aliases = [] + + for val in parsed: + try: + pks.append(int(val)) + except ValueError: + # assume string alias + aliases.append(val) + + qs = Pose.objects.filter( + Q(pk__in=pks) | Q(pose_alias__in=aliases, target=target) + ) + + return qs + + @staticmethod + def get_reference(reference, target) -> int: + try: + reference = int(reference) + # should I check if exist here as well? + except ValueError: + try: + reference = Pose.objects.get( + pose_alias=reference, + target=target, + ).pk + except Pose.DoesNotExist as exp: + logger.error('Pose %s does not exist', reference) + raise Pose.DoesNotExist from exp + + return reference + + +class PoseTagService: + def __init__(self, metadata_file: Path | str, other_tags: list[str] | None = None): + self._df = pd.read_csv(metadata_file) + self._curated_tag_cols = [ + c + for c in self._df.columns + if c not in META_IGNORE_COLS + GENERATED_TAG_COLS + ] + # any other tags to be added + if other_tags: + self._other_tags = [k.strip() for k in other_tags if k.strip()] + else: + self._other_tags = [] + + mrich.var('curated_tag_cols', self._curated_tag_cols) + + @staticmethod + def tags_from_list(tag_list: list[str]): + assert tag_list is not None, '"None" passed as tag_list' + + PoseTag.objects.bulk_create( + [PoseTag(pose_tag_name=k.strip()) for k in tag_list if k.strip()], + ignore_conflicts=True, + ) + tags = PoseTag.objects.filter(pose_tag_name__in=tag_list) + return tags + + # might be a good idea to break meta and tags apart + def tags_and_meta( + self, + *, + code: str, + longcode: str, + ) -> tuple[list[PoseTag], dict[str, str]]: + meta_row = self._df[self._df['Code'] == code] + if not len(meta_row): + meta_row = self._df[self._df['Long code'] == longcode] + + # TODO: another unhandled exception, apprently not having + # meta_row is an option + + metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} + + for tag in GENERATED_TAG_COLS: + if tag in meta_row.columns: + metadata[tag] = meta_row[tag].values[0] + + pose_tag_set = set(self._other_tags) + + for tag in self._curated_tag_cols: + if meta_row[tag].values[0]: + pose_tag_set.add(tag) + + tags = PoseTagService.tags_from_list(pose_tag_set) + + return tags, metadata diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py new file mode 100644 index 0000000..cf54029 --- /dev/null +++ b/hippo/designdb/services/reaction.py @@ -0,0 +1,109 @@ +import logging + +import mrich + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import Compound, Reactant, Reaction + +logger = logging.getLogger(__name__) + + +class ReactionService: + @classmethod + def create_from_lists( + cls, + *, + reaction_types: list[str], + product_ids: list[int], + reactant_id_lists: list[set[int]], + ) -> list[int]: + # insert reaction + + # insert reactant + + reaction_ids = [] + non_duplicates = {} + + # not entirely sure how the original query was meant to work + qs = Reactant.objects.filter(compound__pk__in=product_ids) + existing = {} + for r in qs: + reaction_type = r.reaction.reaction_type + reaction_product = r.reaction.product_compound.pk + reaction_id = r.reaction.pk + reactant_compound = r.compound.pk + + key = (reaction_type, reaction_product) + + if key not in existing: + existing[key] = {} + + if reaction_id not in existing[key]: + existing[key][reaction_id] = set() + + existing[key][reaction_id].add(reactant_compound) + + existing_count = 0 + + # why is strict false?? + for reaction_type, product_id, reactant_ids in zip( + reaction_types, product_ids, reactant_id_lists, strict=False + ): + key = (reaction_type, product_id) + + possible_matches = {k: v for k, v in existing.items() if k == key} + + assert len(possible_matches) < 2 + + if possible_matches: + possible_matches = list(possible_matches.values())[0] + + if any(reactant_ids == v for v in possible_matches.values()): + existing_count += 1 + continue + + non_duplicates[key] = reactant_ids + + if existing_count: + mrich.warning('Skipped', existing_count, 'existing reactions') + + if not non_duplicates: + mrich.warning('All reactions are duplicates') + return None + + for reaction_type, product_id in non_duplicates.keys(): + compound = Compound.objects.get(pk=product_id) + # if I understand the original procedure correctly, it + # should have already weeded out the duplicates + reaction, _ = Reaction.objects.get_or_create( + reaction_type=reaction_type, + product_compound=compound, + reaction_product_yield=1.0, + ) + reaction_ids.append(reaction.pk) + + payload = [] + for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( + reaction_ids, non_duplicates.items(), strict=False + ): + for reactant_id in reactant_ids: + payload.append((reaction_id, reactant_id)) + + for reaction_id, reactant_id in payload: + reaction = Reaction.objects.get(pk=reaction_id) + compound = Compound.objects.get(pk=reactant_id) + reaction, _ = Reactant.objects.get_or_create( + reaction=reaction, + compound=compound, + reactant_amount=1.0, + ) + + # delete orphaned reactions, srsly?? + Reaction.objects.filter( + pk__in=Reactant.objects.filter( + compound__isnull=True, + ).values('reaction'), + ).delete() + + return reaction_ids diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py new file mode 100644 index 0000000..52d74fe --- /dev/null +++ b/hippo/designdb/services/route.py @@ -0,0 +1,80 @@ +# from mypackage.services.compound import CompoundService + +# from rdkit.Chem import inchi +from designdb.models import Component, Route +from designdb.recipe import Recipe + + +class RouteService: + @classmethod + def create_from_recipe( + cls, + *, + recipe: Recipe, + ) -> tuple[Route, bool]: + + route, created = Route.objects.get_or_create( + product_compound=recipe.product.compound + ) + + # are you joking?? reactants and intermediates are all of the + # sudden components + + # reactions + components = [] + components.extend( + [ + Component(route=route, component_type=1, component_ref=ref.pk) + for ref in recipe.reactions + ], + ) + + # this part needs data from ingredient df, which I don't have + # and is not implemented + + # reactants + # for ref, amount in recipe.reactants.id_amount_pairs: + # self.insert_component( + # component_type=2, ref=ref, route=route_id, amount=amount, commit=False + # ) + + components.extend( + [ + Component( + route=route, + component_type=1, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.reactants.id_amount_pairs + ], + ) + + # # intermediates + # for ref, amount in recipe.intermediates.id_amount_pairs: + # self.insert_component( + # component_type=3, ref=ref, route=route_id, amount=amount, commit=False + # ) + + components.extend( + [ + Component( + route=route, + component_type=1, + component_ref=ref, + component_amount=amount, + ) + for ref, amount in recipe.intermediates.id_amount_pairs + ], + ) + + Component.objects.bulk_create(components, ignore_conflicts=True) + + return route, created + + # @property + # def id_amount_pairs(self) -> list[tuple]: + # """Get a list of compound ID and amount pairs""" + # return [ + # (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + # ] diff --git a/hippo/designdb/services/score.py b/hippo/designdb/services/score.py new file mode 100644 index 0000000..f80a817 --- /dev/null +++ b/hippo/designdb/services/score.py @@ -0,0 +1,74 @@ +import logging +import re + +# from mypackage.services.compound import CompoundService +# from rdkit.Chem import inchi +from designdb.models import Pose, ScoreValue, ScoringMethod + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) + + +class ScoreService: + def __init__(self, scoring_method_list: list[str] | None = None): + self._scoring_method_list = scoring_method_list + # self._score_map = {} + self._scoring_method_cache = {} + + # unused, but I imagine this could take various arguments, + # like include or exclude list + + # if self._scoring_method_list: + # for m in self._scoring_method_list: + # sm, _ = ScoringMethod.objects.get_or_create( + # method_name=m, + # ) + # self._score_map[sm.method_name] = sm + + def add_scores_from_record( + self, + *, + pose: Pose, + record: dict[str, str | float], + ): + + # FIXME: this because don't know how to select + scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} + + for method_name, score_value in scores.items(): + try: + method = self.scoring_methods[method_name] + except KeyError: + # there's so many more fields, should I really be creating them? + method, _ = ScoringMethod.objects.get_or_create( + method_name=method_name, + ) + + score = ScoreValue( + pose=pose, + compound=pose.compound, + scoring_method=method, + score=score_value, + ) + score.save() + + # def bulk_scores(poses: list[pose], record: dict[str, str | float]): + # # potentially lots of scores, can do bulk insertion all at once + # pass + + @property + def scoring_methods(self) -> dict[str, ScoringMethod]: + return self._scoring_method_cache diff --git a/hippo/designdb/sets/__init__.py b/hippo/designdb/sets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hippo/cset.py b/hippo/designdb/sets/compound.py similarity index 72% rename from hippo/cset.py rename to hippo/designdb/sets/compound.py index df60da6..9955cc8 100644 --- a/hippo/cset.py +++ b/hippo/designdb/sets/compound.py @@ -1,53 +1,72 @@ -"""Classes for working with sets of compounds""" - +import json from collections.abc import Callable +from pathlib import Path import mcol import mrich -from numpy import int64, isnan, mean - -from .compound import Compound, Ingredient -from .db import Database -from .recipe import Recipe +import pandas as pd +from django.db.models import Exists, OuterRef, Q +from pandas import DataFrame, concat, isna +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import Mol + +from designdb.ingredient import Ingredient +from designdb.models import ( + CataloguePrice, + Compound, + CompoundTag, + CompoundTagJunction, + Reactant, + Reaction, +) +from designdb.price import Price -class CompoundTable: - """Class representing all :class:`.Compound` objects in the 'compound' table of the :class:`.Database`. +class CompoundSet: + """Object representing a subset of the 'compound' table in the :class:`.Database`. .. attention:: - :class:`.CompoundTable` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. Use as an iterable ================== - Iterate through :class:`.Compound` objects in the table: + Iterate through :class:`.Compound` objects in the set: :: - for compound in animal.compounds: + cset = animal.compounds[:100] + + for compound in cset: ... + Check membership + ================ - Selecting compounds in the table - ================================ + To determine if a :class:`.Compound` is present in the set: + + :: + + is_member = compound in cset + + Selecting compounds in the set + ============================== - The :class:`.CompoundTable` can be indexed with :class:`.Compound` IDs, names, aliases, or list/sets/tuples/slices thereof: + The :class:`.CompoundSet` can be indexed like standard Python lists by their indices :: - ctable = animal.compounds + cset = animal.compounds[1:100] # indexing individual compounds - comp = ctable[13] # using the ID - comp = ctable["BSYNRYMUTXBXSQ-UHFFFAOYSA-N"] # using the InChIKey - comp = ctable["aspirin"] # using the alias + comp = cset[0] # get the first compound + comp = cset[1] # get the second compound + comp = cset[-1] # get the last compound - # getting a subset of compounds - cset = ctable[13,15,18] # using IDs (tuple) - cset = ctable[[13,15,18]] # using IDs (list) - cset = ctable[set(13,15,18)] # using IDs (set) - cset = ctable[13:18] # using a slice + # getting a subset of compounds using a slice + cset2 = cset[13:18] # using a slice Tags and scaffold compounds can also be used to filter: @@ -58,299 +77,240 @@ class CompoundTable: """ - _table = 'compound' - _name = 'all compounds' - def __init__( self, - db: Database, + queryset=None, + *, + sort: bool = True, + name: str | None = None, ) -> None: - """CompoundTable initialisation""" + """CompoundSet initialisation""" - self._db = db + if queryset: + if isinstance(queryset, list): + self._queryset = Compound.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = Compound.objects.none() - ### PROPERTIES + if sort: + self._queryset = self._queryset.order_by('pk') - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db + self._name = name - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table + ### DUNDERS - @property - def names(self) -> list[str]: - """Returns the names of child compounds""" - result = self.db.select(table=self.table, query='compound_name', multiple=True) - return [q for (q,) in result] + def __len__(self) -> int: + """The number of compounds in this set""" + return self._queryset.count() - @property - def name(self) -> None | str: - """Optional name of this compound set""" - return self._name + def __iter__(self): + """Iterate through compounds in this set""" + return iter(self._queryset) - @property - def ids(self) -> list[int]: - """Returns the IDs of child compounds""" - result = self.db.select(table=self.table, query='compound_id', multiple=True) - return [q for (q,) in result] + def __getitem__( + self, + key: int | slice, + ) -> 'Compound | CompoundSet': + """Get compounds or subsets thereof from this set - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(',)', ')') + :param key: integer index or slice of indices - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of all compounds""" - result = self.db.select( - query='compound_inchikey', - table='compound', - multiple=True, - ) - return [q for (q,) in result] + """ + match key: + case int(): + index = self.indices[key] + try: + return Compound.objects.get(id=index) + except Compound.DoesNotExist: + raise Compound.DoesNotExist from exc - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key='tag_compound IS NOT NULL', - multiple=True, - ) - return set(v for (v,) in values) + case slice(): + return CompoundSet(Compound.objects.filter(pk__in=key)) - @property - def reactants(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are used as a reactants""" - # ids = self.db.select(table='reactant', query='DISTINCT reactant_compound', multiple=True) + case _: + raise NotImplementedError - sql = f""" - SELECT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction - ON reactant_compound = reaction_product - WHERE reaction_product IS NULL - """ + def __sub__( + self, + other: 'Compound | CompoundSet | IngredientSet', + ) -> 'CompoundSet': + """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - ids = self.db.execute(sql).fetchall() - ids = [q for (q,) in ids] - from .cset import CompoundSet + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return CompoundSet( + Compound.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + sort=False, + ) - cset = CompoundSet(self.db, ids) - cset._name = 'all reactants' - return cset + def __add__( + self, + other: 'Compound | CompoundSet | IngredientSet | int', + ) -> 'CompoundSet': + """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - @property - def products(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a product of a reaction but not a reactant""" + match other: + case Compound(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) - sql = f""" - SELECT reaction_product - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON reaction_product = reactant_compound - WHERE reactant_compound IS NULL - """ + case int(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) - ids = self.db.execute(sql).fetchall() - ids = [q for (q,) in ids] - from .cset import CompoundSet + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) - cset = CompoundSet(self.db, ids) - cset._name = 'all products' - return cset + case IngredientSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other._queryset) + ), + sort=False, + ) - @property - def intermediates(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are products and reactants""" + case _: + raise NotImplementedError - sql = f""" - SELECT DISTINCT reaction_product - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON reaction_product = reactant_compound - """ + def __and__(self, other: 'CompoundSet'): + """AND set operation, returns only compounds in both sets""" - ids = self.db.execute(sql).fetchall() - ids = [q for (q,) in ids] - from .cset import CompoundSet + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) - cset = CompoundSet(self.db, ids) - cset._name = 'all intermediates' - return cset + case _: + raise NotImplementedError - @property - def num_reactants(self) -> int: - """Returns the number of reactants (see :meth:`CompoundTable.reactants`)""" - return len(self.reactants) + def __or__(self, other: 'CompoundSet'): + """OR set operation, returns union of both sets""" - @property - def num_intermediates(self) -> int: - """Returns the number of intermediates (see :meth:`CompoundTable.intermediates`)""" - return len(self.intermediates) + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) - @property - def num_products(self) -> int: - """Returns the number of products (see :meth:`CompoundTable.products`)""" - return len(self.products) + case _: + raise NotImplementedError - @property - def elabs(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - ids = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key='scaffold_superstructure IS NOT NULL', - multiple=True, - none='quiet', - ) + def __xor__(self, other: 'CompoundSet'): + """Exclusive OR set operation, returns all compounds in either set but not both""" - if not ids: - return None + match other: + case CompoundSet(): + return CompoundSet( + Compound.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) - ids = [q for (q,) in ids] - from .cset import CompoundSet + case _: + raise NotImplementedError - cset = CompoundSet(self.db, ids) - cset._name = 'all elaborations' - return cset + def __str__(self) -> str: + """Unformatted string representation""" - @property - def scaffolds(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are the basis for a set of elaborations""" - ids = self.db.select_where( - query='DISTINCT scaffold_base', - table='scaffold', - key='scaffold_base IS NOT NULL', - multiple=True, - none='quiet', - ) - ids = [q for (q,) in ids] - from .cset import CompoundSet + if self.name: + s = f'{self.name}: ' + else: + s = '' - cset = CompoundSet(self.db, ids) - cset._name = 'all scaffolds' - return cset + s += f'{{C × {len(self)}}}' - @property - def num_elabs(self) -> int: - """Returns the number of compounds that are a an elaboration of an existing scaffold""" - return len(self.elabs) + return s - @property - def num_scaffolds(self) -> int: - """Returns the number of compounds that are the basis for a set of elaborations""" - return len(self.scaffolds) + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - ### METHODS + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __contains__(self, other: Compound | int): + """Check if compound or ingredient is a member of this set""" + match other: + case Compound(): + ik = other.pk + case int(): + pk = other + + return self._queryset.filter(pk=pk).exists() + + ### FILTERING def get_by_tag( self, tag: str, inverse: bool = False, ) -> 'CompoundSet': - """Get all child compounds with a certain tag - - :param tag: tag to filter by - - """ - - if not inverse: - values = self.db.select_where( - query='tag_compound', table='tag', key='name', value=tag, multiple=True - ) - - else: - values = self.db.select_where( - query='tag_compound', table='tag', key='name', value=tag, multiple=True - ) - - if not values: - return self - - ids = [v for (v,) in values if v] - - values = self.db.select_where( - query='compound_id', - table='compound', - key=f'compound_id NOT IN {str(tuple(ids))}', - multiple=True, - ) - - if not values: - return None + """Get all child compounds with a certain tag""" - ids = [v for (v,) in values if v] - cset = self[ids] + self._queryset = self._queryset.annotate( + has_tag=Exists( + CompoundTagJunction.objects.filter( + pose=OuterRef('pk'), + pose_tag__pose_tag_name=tag, + ), + ), + ) if inverse: - cset._name = f'compounds not tagged {tag}' + return CompoundSet(self._queryset.filter(has_tag=False)) else: - cset._name = f'compounds tagged {tag}' - return cset + return CompoundSet(self._queryset.filter(has_tag=True)) - def get_by_metadata( - self, - key: str, - value: str | None = None, - ): - """Get all child compounds by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': + """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient :param key: metadata key :param value: metadata value (Default value = None) - """ - results = self.db.select( - query='compound_id, compound_metadata', table='compound', multiple=True - ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - name = f'compounds with {key} in metadata' - else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f'compounds with metadata[{key}] == {value}' - - cset = self[ids] - cset._name = name - return cset - - def get_by_metadata_substring_match( - self, - substring: str, - ) -> 'CompoundSet': - """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" - - assert substring - assert isinstance(substring, str) - - compound_ids = self.db.select_where( - table='compound', - query='compound_id', - key=f"""compound_metadata LIKE '%{substring}%'""", - multiple=True, - ) - - if not compound_ids: - mrich.error(f'No compounds with metadata substring: {substring}') - return None - compound_ids = [i for (i,) in compound_ids] + q = Q(compound_metadata__has_key=key) + if value: + q = Q(compound_metadata__key=value) - name = f"compounds with '{substring}' in metadata" + qs = Compound.objects.filter(q) - cset = self[compound_ids] - cset._name = name - - return cset + return CompoundSet(qs) def get_by_scaffold( self, scaffold: Compound | int, + none: str = 'error', ) -> 'CompoundSet': """Get all compounds that elaborate the given scaffold compound @@ -365,1032 +325,291 @@ def get_by_scaffold( values = self.db.select_where( query='scaffold_superstructure', table='scaffold', - key='base', - value=scaffold, + key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', multiple=True, + none=none, ) ids = [v for (v,) in values if v] - cset = self[ids] - cset._name = f'elaborations of C{scaffold}' - return cset - def get_by_smiles(self, smiles: str, **kwargs) -> 'Compound | None': - """Get a member compound by its smiles""" - - from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles - - assert isinstance(smiles, str), f'Non-string {smiles=}' - try: - smiles = sanitise_smiles(smiles, sanitisation_failed='error') - except SanitisationError as e: - mrich.error(f'Could not sanitise {smiles=}') - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f'Could not sanitise {smiles=}') + if not ids: return None - return c - inchikey = inchikey_from_smiles(smiles) - return self.db.get_compound(inchikey=inchikey, **kwargs) + return CompoundSet(self.db, ids) - def summary(self) -> None: - """Print a summary of this compound set""" - mrich.header('CompoundTable()') - mrich.var('#compounds', len(self)) - # mrich.var('#poses', self.num_poses) - mrich.var('tags', self.tags) - mrich.var('#scaffolds', self.num_scaffolds) - mrich.var('#elabs', self.num_elabs) - mrich.var('#reactants', self.num_reactants) - mrich.var('#intermediates', self.num_intermediates) - mrich.var('#products', self.num_products) + def get_all_possible_reactants( + self, + debug: bool = False, + ) -> 'CompoundSet': + """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - def draw(self) -> None: - """2D grid of drawings of molecules in this set - - .. attention:: - - This method instantiates a :class:`.CompoundSet` containing all compounds, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. + :param debug: Increased verbosity for debugging (Default value = False) """ - return self[self.ids].draw() - - def interactive(self) -> None: - """Interactive widget to navigate compounds in the table - - .. attention:: - This method instantiates a :class:`.CompoundSet` containing all compounds, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. + qs = Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ) - """ - self[self.ids].interactive() + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) - def plot_tsnee(self, **kwargs) -> 'go.Figure': - """Plot a tanimoto similarity plot of these compounds. See :func:`hippo.plotting.plot_compound_tsnee`""" - return self[:].plot_tsnee(**kwargs) + seen |= new + frontier = new - def write_smiles_csv(self, file: str) -> None: - """Write a CSV of the smiles contained in this set to a file + return CompoundSet(Compound.objects.filter(pk__in=seen)) - :param file: path of the CSV file + def get_all_possible_reactions( + self, + debug: bool = False, + ) -> 'ReactionSet': + """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - """ - from pandas import DataFrame + :param debug: Increased verbosity for debugging (Default value = False) - sql = f""" - SELECT compound_id, compound_smiles - FROM {self.db.SQL_SCHEMA_PREFIX}compound - ORDER BY compound_id """ + qs = Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ) - records = self.db.execute(sql).fetchall() - - data = [dict(id=id, smiles=smiles) for id, smiles in records] + seen = set(qs.values_list('id', flat=True)) + frontier = set(seen) + + while frontier: + new = ( + set( + Compound.objects.filter( + pk__in=Reactant.objects.filter( + reaction__in=self._queryset, + ), + ).values_list('pk', flat=True) + ) + - seen + ) - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) + seen |= new + frontier = new - ### DUNDERS + return Reaction.objects.filter(product__compound__in=seen) - def __call__( - self, - *, - tag: str = None, - scaffold: int | Compound = None, - smiles: str | None = None, - ids: list | set | None = None, - sort: bool = True, - **kwargs, - ) -> 'CompoundSet | Compound | None': - """Filter compounds by a given tag, scaffold, or it's SMILES string. See :meth:`.CompoundTable.get_by_tag` and :meth:`.CompoundTable.get_by_scaffold` + def get_risk_diversity(self, debug: bool = False) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in this set - :param tag: optional tag to filter by - :param scaffold: optional :class:`.Compound` ID or object to filter by - :param smiles: optional SMILES string to filter by - :param ids: optional set of :class:`.Compound` ID's - :param sort: sort :class:`.Compound` ID's - :returns: :class:`.CompoundSet` if searching by tag or scaffold, else :class:`.Compound` object + :returns: average of the standard deviations of number of atoms added for each scaffold """ - if tag: - return self.get_by_tag(tag, **kwargs) - elif scaffold: - return self.get_by_scaffold(scaffold, **kwargs) - elif smiles: - return self.get_by_smiles(smiles, **kwargs) - elif ids: - return CompoundSet(self.db, indices=list(ids), sort=sort) - else: - mrich.error('Must provide one of tag, scaffold, or smiles arguments') - return None - - def __getitem__( - self, - key: int | str | tuple | list | set | slice, - ) -> Compound: - """Get a member :class:`.Pose` object or subset :class:`.PoseSet` thereof. + variances = self.db.execute( + f""" + WITH nums AS ( + SELECT scaffold_base AS base, scaffold_superstructure AS elab, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id + WHERE scaffold_superstructure IN {self.str_ids} + ), - :param key: Can be an integer ID, negative integer index, alias or inchikey string, list/set/tuple of IDs, or slice of IDs + means AS ( + SELECT base, AVG(diff) AS mean FROM nums + GROUP BY base + ) + SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums + LEFT JOIN means + ON nums.base = means.base + GROUP BY nums.base """ + ).fetchall() - from numpy import ndarray - from pandas import Index, Series - - match key: - # case int(): - case key if isinstance(key, int) or isinstance(key, int64): - if key == 0: - return self.__getitem__(key=1) - - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) - - else: - return self.db.get_compound(id=key) - - case str(): - comp = self.db.get_compound(inchikey=key, none='quiet') - if not comp: - comp = self.db.get_compound(alias=key) - return comp - - case key if ( - isinstance(key, list) - or isinstance(key, tuple) - or isinstance(key, set) - or isinstance(key, ndarray) - or isinstance(key, Index) - or isinstance(key, Series) - ): - if isinstance(key, Index): - assert key.nlevels == 1 - - if isinstance(key, ndarray): - assert len(key.shape) == 1 + if not variances: + return None - indices = [] - for i in key: - if isinstance(i, int) or isinstance(i, int64): - index = i - elif isinstance(i, float): - index = int(i) - elif isinstance(i, str): - index = self.db.get_compound_id(inchikey=i) - else: - raise NotImplementedError + variances = [v for (v,) in variances] - assert index - indices.append(index) + if debug: + mrich.debug(f'{variances=}') - return CompoundSet(self.db, indices) + return mean(variances) - case slice(): - ids = self.db.slice_ids( - table=self.table, start=key.start, stop=key.stop, step=key.step - ) - return self[ids] + def count_by_tag( + self, + tag: str, + ) -> 'CompoundSet': + """Count all child compounds with a certain tag - case _: - mrich.error( - f'Unsupported type for CompoundTable.__getitem__(): {key=} {type(key)}' - ) + :param tag: tag to filter by - return None + """ + return self._queryset.annotate( + has_tag=Exists( + CompoundTag.objects.filter( + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, + ), + ), + ).count() - def __str__(self) -> str: - """Unformatted string representation""" + ### CONSOLE / NOTEBOOK OUTPUT - if self.name: - s = f'{self.name}: ' - else: - s = '' + def draw(self) -> None: + """Draw a grid of all contained molecules. - s += f'{{C × {len(self)}}}' + .. attention:: - return s + This method is only intended for use within a Jupyter Notebook. - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + """ - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' + from molparse.rdkit import draw_grid - def __len__(self) -> int: - """Total number of compounds""" - return self.db.count(self.table) + data = [(str(c), c.mol) for c in self] - def __iter__(self): - """Iterate through all compounds""" - return iter(self[i + 1] for i in range(len(self))) + mols = [d[1] for d in data] + labels = [d[0] for d in data] + display(draw_grid(mols, labels=labels)) -class CompoundSet: - """Object representing a subset of the 'compound' table in the :class:`.Database`. + def grid(self) -> None: + """Draw a grid of all contained molecules. - .. attention:: + .. attention:: - :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + This method is only intended for use within a Jupyter Notebook. - Use as an iterable - ================== + """ - Iterate through :class:`.Compound` objects in the set: + self.draw() - :: + def summary(self, return_df: bool = False) -> None: + """Print a summary of this compound set""" - cset = animal.compounds[:100] + mrich.header(self) - for compound in cset: - ... + from pandas import DataFrame - Check membership - ================ + sql = f""" + SELECT tag_name, + COUNT(DISTINCT tag_compound) + FROM {self.db.SQL_SCHEMA_PREFIX}tag + WHERE tag_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ - To determine if a :class:`.Compound` is present in the set: + cursor = self.db.execute(sql) - :: + data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - is_member = compound in cset + df = DataFrame(data) + df = df.set_index('tag') - Selecting compounds in the set - ============================== + # poses - The :class:`.CompoundSet` can be indexed like standard Python lists by their indices + sql = f""" + SELECT tag_name, + COUNT(DISTINCT tag_pose) + FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose + ON pose_id = tag_pose + WHERE pose_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ - :: + cursor = self.db.execute(sql) - cset = animal.compounds[1:100] + for tag, count in cursor.fetchall(): + df.loc[tag, 'num_poses'] = count - # indexing individual compounds - comp = cset[0] # get the first compound - comp = cset[1] # get the second compound - comp = cset[-1] # get the last compound + # compounds with poses - # getting a subset of compounds using a slice - cset2 = cset[13:18] # using a slice + sql = f""" + SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose + ON tag_pose = pose_id + WHERE pose_compound IN {self.str_ids} + GROUP BY tag_name + ORDER BY tag_name + """ - Tags and scaffold compounds can also be used to filter: + cursor = self.db.execute(sql) - :: + for tag, count in cursor.fetchall(): + df.loc[tag, 'num_posed_compounds'] = count - cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' - cset = animal.compounds(scaffold=comp) # select elaborations of comp + df.loc['TOTAL', 'num_compounds'] = len(self) + df.loc['TOTAL', 'num_poses'] = self.num_poses + df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) - """ + df = df.fillna(0) + df = df.astype(int) - _table = 'compound' + if return_df: + return df + else: + mrich.print(df) - def __init__( + def interactive( self, - db: Database, - indices: list = None, - sort: bool = True, - name: str | None = None, + function: Callable | None = None, ) -> None: - """CompoundSet initialisation""" + """Creates a ipywidget to interactively navigate this PoseSet.""" - self._db = db + from IPython.display import display + from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, + ) - indices = indices or [] + if function: - if not isinstance(indices, list): - indices = list(indices) + def widget(i): + """interactive function widget""" + compound = self[i] + display(compound) + function(compound) - indices = [int(i) for i in indices] + return interactive( + widget, + i=BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ), + ) - if sort: - self._indices = sorted(list(set(indices))) else: - self._indices = list(indices) - - self._name = name - self._total_changes = db.total_changes - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Associated :class:`.Database` object""" - return self._db - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def indices(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self.indices - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of compounds in this set""" - result = self.db.select_where( - query='compound_alias', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def smiles(self) -> list[str]: - """Returns the smiles of child compounds""" - result = self.db.select_where( - query='compound_smiles', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def mols(self) -> 'list[Chem.Mol]': - """Returns the molecules of child compounds""" - from rdkit.Chem import Mol - - result = self.db.select_where( - query='mol_to_binary_mol(compound_mol)', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [Mol(q) for (q,) in result] - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of compounds in this set""" - result = self.db.select_where( - query='compound_inchikey', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key=f'tag_compound in {self.str_ids}', - multiple=True, - ) - if not values: - return set() - return set(v for (v,) in values) - - @property - def num_poses(self) -> int: - """Count the poses associated to this set of compounds""" - - return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') - - @property - def poses(self) -> 'PoseSet': - """Get the poses associated to this set of compounds""" - from .pset import PoseSet - - ids = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_compound in {self.str_ids}', - multiple=True, - none='warning', - ) - - if not ids: - return PoseSet(self.db, {}) - - ids = [v for (v,) in ids] - return PoseSet(self.db, ids) - - @property - def best_placed_poses(self) -> 'PoseSet': - """Get the best placed pose for each compound in this set""" - from .pset import PoseSet - - query = self.db.select_where( - table='pose', - query='pose_id, MIN(pose_distance_score)', - key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', - multiple=True, - ) - ids = [i for i, s in query] - return PoseSet(self.db, ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def num_heavy_atoms(self) -> int: - """Get the total number of heavy atoms""" - return sum([c.num_heavy_atoms for c in self]) - - @property - def num_rings(self): - """Get the total number of molecular rings""" - return sum([c.num_rings for c in self]) - - @property - def formula(self) -> str: - """Get the combined chemical formula for all compounds""" - from molparse.atomtypes import atomtype_dict_to_formula - - return atomtype_dict_to_formula(self.atomtype_dict) - - @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 combine_atomtype_dicts - - atomtype_dicts = [c.atomtype_dict for c in self] - return combine_atomtype_dicts(atomtype_dicts) - - @property - def num_atoms_added(self) -> list[int]: - """Calculate the number of atoms added w.r.t the scaffold - - :returns: list of number of atoms added values - - """ - - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - - query = self.db.execute(sql).fetchall() - - lookup = {k: v for k, v in query} - - return [lookup[i] for i in self.indices] - - @property - def avg_num_atoms_added(self) -> float: - """Calculate the average number of atoms added w.r.t the scaffold - - :returns: average number of atoms added values for compounds which have a scaffold - - """ - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - INNER JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - - (avg,) = self.db.execute().fetchone() - - return avg - - @property - def risk_diversity(self) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - return self.get_risk_diversity() - - @property - def elaboration_balance(self) -> float: - """Measure of how evenly elaborations are distributed across scaffolds in this set""" - - sql = f""" - SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - GROUP BY scaffold_base - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] - - from hirsch import hirsch - - return hirsch(counts) - - # return -std(counts) - - @property - def num_scaffolds_elaborated(self) -> int: - """Count the number of scaffold compounds that have at least one elaboration in this set - - :returns: number of scaffold compounds - - """ - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - - return count - - @property - def scaffolds(self) -> 'CompoundSet': - """Get the scaffold compounds that have at least one elaboration in this set - - :returns: :class:`.CompoundSet` - - """ - return CompoundSet(self.db, self.scaffold_ids) - - @property - def scaffold_ids(self) -> list[int]: - """Return a list of :class:`.Compound` ID's for scaffolds of this set""" - scaffold_ids = self.db.execute( - f""" - SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchall() - return [i for (i,) in scaffold_ids] - - @property - def num_scaffolds(self) -> int: - """Return a count of scaffolds of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elabs(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - - ids = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', - multiple=True, - none='quiet', - ) - - if not ids: - return None - - ids = [q for (q,) in ids] - from .cset import CompoundSet - - return CompoundSet(self.db, ids) - - @property - def num_elabs(self) -> int: - """Return a count of elaborations of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_base IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elab_df(self) -> 'pd.DataFrame': - """Get a DataFrame summarising the elaborations in this CompoundSet""" - from pandas import DataFrame - - cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) - - data = [] - for scaffold, elabs in cluster_dict.items(): - scaffold = self.db.get_compound(id=scaffold[0]) - elabs = CompoundSet(self.db, indices=elabs) - data.append( - dict( - scaffold_id=scaffold.id, - scaffold_compound=scaffold, - elabs=elabs, - num_elabs=len(elabs), - ) - ) - - return DataFrame(data) - - @property - def id_num_poses_dict(self) -> dict[int, int]: - """Get a dictionary mapping compound ids to the number of poses""" - - sql = f""" - SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_compound IN {self.str_ids} - GROUP BY pose_compound - """ - - records = self.db.execute(sql) - - assert records - - lookup = {k: v for k, v in records} - - for id in self.ids: - if id not in lookup: - lookup[id] = 0 - - return lookup - - @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 - - @property - def reaction_ids(self) -> list[int]: - """Returns a list of :class:`.Reaction` IDs that result in members of this set""" - records = self.db.select_where( - table='reaction', - query='reaction_id', - key=f'reaction_product IN {self.str_ids}', - multiple=True, - ) - if not records: - return None - return [r for (r,) in records] - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> 'CompoundSet': - """Get all child compounds with a certain tag""" - - values = self.db.select_where( - query='tag_compound', - table='tag', - key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', - multiple=True, - ) - - if inverse: - matches = set(v for (v,) in values) - ids = [i for i in self.ids if i not in matches] - else: - ids = [v for (v,) in values] - - return CompoundSet(self.db, ids) - - def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': - """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key - :param value: metadata value (Default value = None) - """ - - results = self.db.select_where( - query='compound_id, compound_metadata', - table='compound', - key=f'compound_id IN {self.str_ids}', - multiple=True, - ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - return CompoundSet(self.db, ids) - - def get_by_metadata_substring_match( - self, - substring: str, - ) -> 'CompoundSet': - """Get :class:`.CompoundSet` of poses with metadata JSON containing substring""" - - assert substring - assert isinstance(substring, str) - - compound_ids = self.db.select_where( - table='compound', - query='compound_id', - key=f"""compound_metadata LIKE '%{substring}%' AND compound_id IN {self.str_ids}""", - multiple=True, - ) - - if not compound_ids: - mrich.error(f'No compounds with metadata substring: {substring}') - return None - - compound_ids = [i for (i,) in compound_ids] - - name = f"compounds with '{substring}' in metadata" - - cset = CompoundSet(self.db, compound_ids) - cset._name = name - - return cset - - def get_by_scaffold( - self, - scaffold: Compound | int, - none: str = 'error', - ) -> 'CompoundSet': - """Get all compounds that elaborate the given scaffold compound - - :param scaffold: :class:`.Compound` object or ID to search by - - """ - - if not isinstance(scaffold, int): - assert scaffold._table == 'compound' - scaffold = scaffold.id - - values = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', - multiple=True, - none=none, - ) - ids = [v for (v,) in values if v] - - if not ids: - return None - return CompoundSet(self.db, ids) - - def get_all_possible_reactants( - self, - debug: bool = False, - ) -> 'CompoundSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - all_reactants, all_reactions = self.db.get_unsolved_reaction_tree( - product_ids=self.ids, debug=debug - ) - return all_reactants - - def get_all_possible_reactions( - self, - debug: bool = False, - ) -> 'ReactionSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - all_reactants, all_reactions = self.db.get_unsolved_reaction_tree( - product_ids=self.ids, debug=debug - ) - return all_reactions - - def get_risk_diversity(self, debug: bool = False) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - variances = self.db.execute( - f""" - WITH nums AS ( - SELECT scaffold_base AS base, scaffold_superstructure AS elab, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id - WHERE scaffold_superstructure IN {self.str_ids} - ), - - means AS ( - SELECT base, AVG(diff) AS mean FROM nums - GROUP BY base - ) - - SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums - LEFT JOIN means - ON nums.base = means.base - GROUP BY nums.base - """ - ).fetchall() - - if not variances: - return None - - variances = [v for (v,) in variances] - - if debug: - mrich.debug(f'{variances=}') - - return mean(variances) - - def count_by_tag( - self, - tag: str, - ) -> 'CompoundSet': - """Count all child compounds with a certain tag - - :param tag: tag to filter by - - """ - (count,) = self.db.select_where( - query='COUNT(tag_compound)', - table='tag', - key=f'tag_name = "{tag}" AND tag_compound IN {self.str_ids}', - multiple=False, - ) - return count - - ### CONSOLE / NOTEBOOK OUTPUT - - def draw(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - from molparse.rdkit import draw_grid - - data = [(str(c), c.mol) for c in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] - - display(draw_grid(mols, labels=labels)) - - def grid(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - self.draw() - - def summary(self, return_df: bool = False) -> None: - """Print a summary of this compound set""" - - mrich.header(self) - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index('tag') - - # poses - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_pose) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON pose_id = tag_pose - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_poses'] = count - - # compounds with poses - - sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON tag_pose = pose_id - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_posed_compounds'] = count - - df.loc['TOTAL', 'num_compounds'] = len(self) - df.loc['TOTAL', 'num_poses'] = self.num_poses - df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) - - df = df.fillna(0) - df = df.astype(int) - - if return_df: - return df - else: - mrich.print(df) - - def interactive( - self, - function: Callable | None = None, - ) -> None: - """Creates a ipywidget to interactively navigate this PoseSet.""" - - from IPython.display import display - from ipywidgets import ( - BoundedIntText, - Checkbox, - GridBox, - Layout, - VBox, - interactive, - interactive_output, - ) - - if function: - - def widget(i): - """interactive function widget""" - compound = self[i] - display(compound) - function(compound) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Comp (/{len(self)}):', - disabled=False, - ), - ) - - else: - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Comp (/{len(self)}):', - disabled=False, - ) + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Comp (/{len(self)}):', + disabled=False, + ) b = Checkbox(description='Name', value=True) c = Checkbox(description='Summary', value=False) @@ -1521,21 +740,6 @@ def tag_summary(self) -> 'pd.DataFrame': ### OTHER METHODS - def add(self, compound: Compound | int) -> None: - """Add a compound to this set - - :param compound: compound to be added - - """ - - if isinstance(compound, Compound): - compound = compound.id - - if compound not in self.ids: - from bisect import insort - - insort(self.ids, compound) - def get_recipes( self, amount: float = 1, @@ -1551,6 +755,9 @@ def get_recipes( See :meth:`.Recipe.from_compounds` """ + # avoiding circular imports + from designdb.recipe import Recipe + return Recipe.from_compounds( self, amount=amount, @@ -1707,11 +914,6 @@ def get_df( """ - from json import loads - - from pandas import DataFrame - from rdkit.Chem import Mol - data = [] query = ['compound_id'] @@ -2070,9 +1272,8 @@ def write_CAR_csv( """ - from pathlib import Path - - from pandas import DataFrame + # avoiding circular imports + from designdb.recipe import Recipe file = str(Path(file).resolve()) @@ -2157,292 +1358,524 @@ def add_tag( self.db.commit() - def plot_tsnee(self, **kwargs) -> 'go.Figure': - """Plot a tanimoto similarity plot of these compounds""" - from .plotting import plot_compound_tsnee + def plot_tsnee(self, **kwargs) -> 'go.Figure': + """Plot a tanimoto similarity plot of these compounds""" + from .plotting import plot_compound_tsnee + + return plot_compound_tsnee(self, **kwargs) + + def as_ingredientset( + self, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Get an :class:`.IngredientSet` for these compounds""" + return IngredientSet.from_compounds( + compounds=self, amount=amount, supplier=supplier + ) + + def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': + """Split this set into subsets clustered by scaffold compound""" + + cluster_dict = self.db.get_compound_cluster_dict(cset=self) + + subsets = {} + for cluster, elabs in cluster_dict.items(): + cluster = CompoundSet(self.db, list(cluster)) + subsets[cluster] = CompoundSet(self.db, list(elabs)) + + return subsets + + def despaghettify( + self, + register_missing_routes: bool = True, + supplier='Enamine', + ) -> 'CompoundSet': + """Reduce this set to only compounds that elaborate a single reactant at a time. + Requires routes to be present in the database.""" + + if register_missing_routes: + mrich.debug('registering_missing_routes...') + route_lookup = self.register_missing_routes( + missing_only=True, supplier=supplier + ) + + mrich.debug('clustering by scaffold...') + clustered = self.split_by_scaffolds() + + n = len(clustered) + mrich.var('#clusters', n) + + mrich.debug('getting route lookup...') + route_lookup = self.db.get_product_id_routes_dict() + + mrich.debug('getting reactant lookup...') + reactant_lookup = self.db.get_route_id_reactant_ids_dict() + + keep = set() + for i, (cluster, elabs) in enumerate(clustered.items()): + for scaffold in cluster: + mrich.debug( + f'{i}/{n}', + 'scaffold:', + scaffold.id, + '#elabs:', + len(elabs), + '#kept:', + len(keep), + ) + + route_ids = route_lookup.get(scaffold.id) + + if not route_ids: + mrich.error(f'scaffold {scaffold} has no routes') + continue + + elif len(route_ids) > 1: + mrich.warning(f'scaffold {scaffold} has multiple routes') + + for route_id in route_ids: + scaffold_reactants = reactant_lookup[route_id] + + for elab in elabs: + route_ids = route_lookup.get(elab.id, set()) + + if len(route_ids) != 1: + mrich.error(f'elab {elab.id} has {route_ids=}') + continue + + reactants = reactant_lookup[list(route_ids)[0]] + + common = scaffold_reactants & reactants + + if len(common) == len(scaffold_reactants) - 1: + keep.add(elab.id) + + return CompoundSet(self.db, keep) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate missing routes to compounds in this set""" + + if missing_only: + from .cset import CompoundSet + + records = self.db.select_where( + table='route', + key=f'route_product IN {self.str_ids}', + query='route_product', + multiple=True, + ) + existing = set(i for (i,) in records) + missing = set(self.ids) - existing + return CompoundSet(self.db, missing).register_missing_routes( + missing_only=False, supplier=supplier + ) + + mrich.var('#compounds', len(self)) + + for i, c in mrich.track(enumerate(self), total=len(self)): + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + route = self.db.register_route(recipe=recipe) + + mrich.print(f'registered {route=}') + + self.db.prune_duplicate_routes() + + ### PROPERTIES + + @property + def queryset(self): + """Associated :class:`.Database` object""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self._queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of compounds in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def names(self) -> list[str]: + """Returns the aliases of compounds in this set""" + result = self.db.select_where( + query='compound_alias', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def smiles(self) -> list[str]: + """Returns the smiles of child compounds""" + result = self.db.select_where( + query='compound_smiles', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def mols(self) -> 'list[Chem.Mol]': + """Returns the molecules of child compounds""" + from rdkit.Chem import Mol + + result = self.db.select_where( + query='mol_to_binary_mol(compound_mol)', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [Mol(q) for (q,) in result] + + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of compounds in this set""" + result = self.db.select_where( + query='compound_inchikey', + table='compound', + key=f'compound_id in {self.str_ids}', + multiple=True, + ) + return [q for (q,) in result] + + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this compound set""" + values = self.db.select_where( + table='tag', + query='DISTINCT tag_name', + key=f'tag_compound in {self.str_ids}', + multiple=True, + ) + if not values: + return set() + return set(v for (v,) in values) + + @property + def num_poses(self) -> int: + """Count the poses associated to this set of compounds""" + + return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') - return plot_compound_tsnee(self, **kwargs) + @property + def poses(self) -> 'PoseSet': + """Get the poses associated to this set of compounds""" + from .pset import PoseSet - def as_ingredientset( - self, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Get an :class:`.IngredientSet` for these compounds""" - return IngredientSet.from_compounds( - compounds=self, amount=amount, supplier=supplier + ids = self.db.select_where( + query='pose_id', + table='pose', + key=f'pose_compound in {self.str_ids}', + multiple=True, + none='warning', ) - def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': - """Split this set into subsets clustered by scaffold compound""" - - cluster_dict = self.db.get_compound_cluster_dict(cset=self) + if not ids: + return PoseSet(self.db, {}) - subsets = {} - for cluster, elabs in cluster_dict.items(): - cluster = CompoundSet(self.db, list(cluster)) - subsets[cluster] = CompoundSet(self.db, list(elabs)) + ids = [v for (v,) in ids] + return PoseSet(self.db, ids) - return subsets + @property + def best_placed_poses(self) -> 'PoseSet': + """Get the best placed pose for each compound in this set""" + from .pset import PoseSet - def despaghettify( - self, - register_missing_routes: bool = True, - supplier='Enamine', - ) -> 'CompoundSet': - """Reduce this set to only compounds that elaborate a single reactant at a time. - Requires routes to be present in the database.""" + query = self.db.select_where( + table='pose', + query='pose_id, MIN(pose_distance_score)', + key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', + multiple=True, + ) + ids = [i for i, s in query] + return PoseSet(self.db, ids) - if register_missing_routes: - mrich.debug('registering_missing_routes...') - route_lookup = self.register_missing_routes( - missing_only=True, supplier=supplier - ) + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + return str(tuple(self.ids)).replace(',)', ')') - mrich.debug('clustering by scaffold...') - clustered = self.split_by_scaffolds() + @property + def num_heavy_atoms(self) -> int: + """Get the total number of heavy atoms""" + return sum([c.num_heavy_atoms for c in self]) - n = len(clustered) - mrich.var('#clusters', n) + @property + def num_rings(self): + """Get the total number of molecular rings""" + return sum([c.num_rings for c in self]) - mrich.debug('getting route lookup...') - route_lookup = self.db.get_product_id_routes_dict() + @property + def formula(self) -> str: + """Get the combined chemical formula for all compounds""" + from molparse.atomtypes import atomtype_dict_to_formula - mrich.debug('getting reactant lookup...') - reactant_lookup = self.db.get_route_id_reactant_ids_dict() + return atomtype_dict_to_formula(self.atomtype_dict) - keep = set() - for i, (cluster, elabs) in enumerate(clustered.items()): - for scaffold in cluster: - mrich.debug( - f'{i}/{n}', - 'scaffold:', - scaffold.id, - '#elabs:', - len(elabs), - '#kept:', - len(keep), - ) + @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 combine_atomtype_dicts - route_ids = route_lookup.get(scaffold.id) + atomtype_dicts = [c.atomtype_dict for c in self] + return combine_atomtype_dicts(atomtype_dicts) - if not route_ids: - mrich.error(f'scaffold {scaffold} has no routes') - continue + @property + def num_atoms_added(self) -> list[int]: + """Calculate the number of atoms added w.r.t the scaffold - elif len(route_ids) > 1: - mrich.warning(f'scaffold {scaffold} has multiple routes') + :returns: list of number of atoms added values - for route_id in route_ids: - scaffold_reactants = reactant_lookup[route_id] + """ - for elab in elabs: - route_ids = route_lookup.get(elab.id, set()) + sql = f""" + WITH nums AS ( + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + WHERE A.compound_base = B.compound_id + AND A.compound_id IN {self.str_ids} + ) - if len(route_ids) != 1: - mrich.error(f'elab {elab.id} has {route_ids=}') - continue + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound + LEFT JOIN nums + ON comp_id = compound_id + WHERE compound_id IN {self.str_ids} + """ - reactants = reactant_lookup[list(route_ids)[0]] + query = self.db.execute(sql).fetchall() - common = scaffold_reactants & reactants + lookup = {k: v for k, v in query} - if len(common) == len(scaffold_reactants) - 1: - keep.add(elab.id) + return [lookup[i] for i in self.indices] - return CompoundSet(self.db, keep) + @property + def avg_num_atoms_added(self) -> float: + """Calculate the average number of atoms added w.r.t the scaffold - def register_missing_routes( - self, missing_only: bool = True, supplier: str = 'Enamine' - ) -> None: - """Calculate missing routes to compounds in this set""" + :returns: average number of atoms added values for compounds which have a scaffold - if missing_only: - from .cset import CompoundSet + """ + sql = f""" + WITH nums AS ( + SELECT + A.compound_id AS comp_id, + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + WHERE A.compound_base = B.compound_id + AND A.compound_id IN {self.str_ids} + ) - records = self.db.select_where( - table='route', - key=f'route_product IN {self.str_ids}', - query='route_product', - multiple=True, - ) - existing = set(i for (i,) in records) - missing = set(self.ids) - existing - return CompoundSet(self.db, missing).register_missing_routes( - missing_only=False, supplier=supplier - ) + SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound + INNER JOIN nums + ON comp_id = compound_id + WHERE compound_id IN {self.str_ids} + """ - mrich.var('#compounds', len(self)) + (avg,) = self.db.execute().fetchone() - for i, c in mrich.track(enumerate(self), total=len(self)): - try: - reactions = c.reactions - except Exception as e: - mrich.error(f"Error getting {c}'s reactions", e) - continue + return avg - for reaction in reactions: - try: - recipes = reaction.get_recipes(supplier=supplier) - except Exception as e: - mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) - continue + @property + def risk_diversity(self) -> float: + """Calculate the average spread of risk (#atoms added) for each scaffold in this set - for recipe in recipes: - route = self.db.register_route(recipe=recipe) + :returns: average of the standard deviations of number of atoms added for each scaffold - mrich.print(f'registered {route=}') + """ - self.db.prune_duplicate_routes() + return self.get_risk_diversity() - ### DUNDERS + @property + def elaboration_balance(self) -> float: + """Measure of how evenly elaborations are distributed across scaffolds in this set""" - def __len__(self) -> int: - """The number of compounds in this set""" - return len(self.indices) + sql = f""" + SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + GROUP BY scaffold_base + """ - def __iter__(self): - """Iterate through compounds in this set""" - return iter(self.db.get_compound(id=i) for i in self.indices) + counts = self.db.execute(sql).fetchall() - def __getitem__( - self, - key: int | slice, - ) -> 'Compound | CompoundSet': - """Get compounds or subsets thereof from this set + counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] - :param key: integer index or slice of indices + from hirsch import hirsch - """ - match key: - case int(): - index = self.indices[key] - return self.db.get_compound(id=index) + return hirsch(counts) - case slice(): - indices = self.indices[key] - return CompoundSet(self.db, indices) + # return -std(counts) - case _: - raise NotImplementedError + @property + def num_scaffolds_elaborated(self) -> int: + """Count the number of scaffold compounds that have at least one elaboration in this set - def __sub__( - self, - other: 'Compound | CompoundSet | IngredientSet', - ) -> 'CompoundSet': - """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + :returns: number of scaffold compounds - match other: - case Compound(): - ids = set(self.ids) - set([other.id]) - return CompoundSet(self.db, ids) + """ - case CompoundSet(): - ids = set(self.ids) - set(other.ids) - return CompoundSet(self.db, ids) + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchone() - case IngredientSet(): - mrich.warning( - 'Subtracting IngredientSet from CompoundSet. Ignoring quote/amount data' - ) - ids = set(self.ids) - set([int(i) for i in other.compound_ids]) - return CompoundSet(self.db, ids) + return count - case _: - raise NotImplementedError + @property + def scaffolds(self) -> 'CompoundSet': + """Get the scaffold compounds that have at least one elaboration in this set - def __add__( - self, - other: 'Compound | CompoundSet | IngredientSet | int', - ) -> 'CompoundSet': - """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + :returns: :class:`.CompoundSet` - match other: - case Compound(): - ids = set(self.ids) - ids.add(other.id) - return CompoundSet(self.db, ids) + """ + return CompoundSet(self.db, self.scaffold_ids) - case int(): - ids = set(self.ids) - ids.add(other) - return CompoundSet(self.db, ids) + @property + def scaffold_ids(self) -> list[int]: + """Return a list of :class:`.Compound` ID's for scaffolds of this set""" + scaffold_ids = self.db.execute( + f""" + SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchall() + return [i for (i,) in scaffold_ids] - case CompoundSet(): - ids = set(self.ids) | set(other.ids) - return CompoundSet(self.db, ids) + @property + def num_scaffolds(self) -> int: + """Return a count of scaffolds of this set""" + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_superstructure IN {self.str_ids} + """ + ).fetchone() + return count - case IngredientSet(): - ids = set(self.ids) | set(other.compound_ids) - return CompoundSet(self.db, ids) + @property + def elabs(self) -> 'CompoundSet': + """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - case _: - raise NotImplementedError + ids = self.db.select_where( + query='scaffold_superstructure', + table='scaffold', + key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', + multiple=True, + none='quiet', + ) - def __and__(self, other: 'CompoundSet'): - """AND set operation, returns only compounds in both sets""" + if not ids: + return None - match other: - case CompoundSet(): - ids = set(self.ids) & set(other.ids) - return CompoundSet(self.db, ids) + ids = [q for (q,) in ids] + from .cset import CompoundSet - case _: - raise NotImplementedError + return CompoundSet(self.db, ids) - def __or__(self, other: 'CompoundSet'): - """OR set operation, returns union of both sets""" + @property + def num_elabs(self) -> int: + """Return a count of elaborations of this set""" + (count,) = self.db.execute( + f""" + SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + WHERE scaffold_base IN {self.str_ids} + """ + ).fetchone() + return count - match other: - case CompoundSet(): - ids = set(self.ids) | set(other.ids) - return CompoundSet(self.db, ids) + @property + def elab_df(self) -> 'pd.DataFrame': + """Get a DataFrame summarising the elaborations in this CompoundSet""" + from pandas import DataFrame - case _: - raise NotImplementedError + cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) - def __xor__(self, other: 'CompoundSet'): - """Exclusive OR set operation, returns all compounds in either set but not both""" + data = [] + for scaffold, elabs in cluster_dict.items(): + scaffold = self.db.get_compound(id=scaffold[0]) + elabs = CompoundSet(self.db, indices=elabs) + data.append( + dict( + scaffold_id=scaffold.id, + scaffold_compound=scaffold, + elabs=elabs, + num_elabs=len(elabs), + ) + ) - match other: - case CompoundSet(): - ids = set(self.ids) ^ set(other.ids) - return CompoundSet(self.db, ids) + return DataFrame(data) - case _: - raise NotImplementedError + @property + def id_num_poses_dict(self) -> dict[int, int]: + """Get a dictionary mapping compound ids to the number of poses""" - def __str__(self) -> str: - """Unformatted string representation""" + sql = f""" + SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose + WHERE pose_compound IN {self.str_ids} + GROUP BY pose_compound + """ - if self.name: - s = f'{self.name}: ' - else: - s = '' + records = self.db.execute(sql) - s += f'{{C × {len(self)}}}' + assert records - return s + lookup = {k: v for k, v in records} - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + for id in self.ids: + if id not in lookup: + lookup[id] = 0 - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' + return lookup - def __contains__(self, other: Compound | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other + @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 - return id in set(self.ids) + @property + def reaction_ids(self) -> list[int]: + """Returns a list of :class:`.Reaction` IDs that result in members of this set""" + records = self.db.select_where( + table='reaction', + query='reaction_id', + key=f'reaction_product IN {self.str_ids}', + multiple=True, + ) + if not records: + return None + return [r for (r,) in records] class IngredientSet: @@ -2480,19 +1913,14 @@ class IngredientSet: def __init__( self, - db: 'Database', ingredients: 'None | list[Ingredient]' = None, supplier: str | list | None = None, debug: bool = False, ) -> None: """IngredientSet initialisation""" - from pandas import DataFrame - ingredients = ingredients or [] - self._db = db - self._data = DataFrame(columns=self._columns, dtype=object) if debug: @@ -2509,10 +1937,103 @@ def __init__( if debug: mrich.debug(self._data) + ### DUNDERS + + def __len__(self): + """The number of ingredients in this set""" + return len(self._data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Ingredient × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __add__(self, other): + """Add another :class:`.IngredientSet` this set""" + + for i, row in other._data.iterrows(): + self.add( + compound_id=row.compound_id, + amount=row.amount, + quote_id=row.quote_id, + supplier=row.supplier, + max_lead_time=row.max_lead_time, + quoted_amount=row.quoted_amount, + ) + + return self + + def __getitem__(self, key: int) -> 'Ingredient': + """Get a member by it's index""" + match key: + case int(): + series = self.df.loc[key] + return self._get_ingredient(series) + + case _: + raise NotImplementedError + + def __iter__(self): + """Iterate through the ingredients""" + return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) + + def __call__( + self, + *, + compound_id: int | None = None, + tag: str | None = None, + ) -> 'IngredientSet | Ingredient | CompoundSet': + """Get members based on a compound_id or tag""" + + if compound_id: + # get the ingredient with the matching compound ID + matches = self.df[self.df['compound_id'] == compound_id] + + if len(matches) == 0: + return None + + elif len(matches) != 1: + mrich.warning(f'Multiple ingredients in set with {compound_id=}') + # print(matches) + + return IngredientSet( + self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] + ) + + return self._get_ingredient(matches.iloc[0]) + + # elif tag: + # return self.compounds(tag=tag) + + else: + raise NotImplementedError + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.CompoundSet`""" + return getattr(self.compounds, key) + + def __contains__(self, other: Compound | Ingredient | int): + """Check if compound or ingredient is a member of this set""" + match other: + case Compound(): + id = other.id + case Ingredient(): + id = other.compound_id + case int(): + id = other + + return id in set(self.compound_ids) + @classmethod def from_ingredient_df( cls, - db: 'Database', df: 'DataFrame', supplier: str | list | None = None, ) -> 'IngredientSet': @@ -2531,7 +2052,6 @@ def from_ingredient_df( raise Exception(f'{col} not in df.columns') df[col] = None - self._db = db self._data = df.copy() self._supplier = supplier @@ -2540,7 +2060,6 @@ def from_ingredient_df( @classmethod def from_json( cls, - db: 'Database', path: None | str, supplier: str | list | None = None, data: None | dict = None, @@ -2555,23 +2074,18 @@ def from_json( """ if not data: - import json - data = json.load(open(path)) - from pandas import DataFrame - df = DataFrame(columns=cls._columns, dtype=object) for col in cls._columns: df[col] = data[col] - return cls.from_ingredient_df(db=db, df=df, supplier=supplier) + return cls.from_ingredient_df(df=df, supplier=supplier) @classmethod def from_ingredient_dicts( cls, - db: 'Database', dicts: list[dict], supplier: str | list | None = None, ) -> 'IngredientSet': @@ -2582,10 +2096,9 @@ def from_ingredient_dicts( :param supplier: supplier to use for all quoting, (Default value = ``None``) """ - from pandas import DataFrame df = DataFrame(dicts, dtype=object) - return cls.from_ingredient_df(db=db, df=df, supplier=supplier) + return cls.from_ingredient_df(df=df, supplier=supplier) @classmethod def from_compounds( @@ -2593,7 +2106,6 @@ def from_compounds( *, compounds: 'CompoundSet | None' = None, ids: list[int] | None = None, - db: 'Database | None' = None, amount: float | list[float] = 1, supplier: str | list | None = None, ) -> 'IngredientSet': @@ -2607,14 +2119,9 @@ def from_compounds( """ - from pandas import DataFrame - if not ids: ids = compounds.ids - if not db: - db = compounds.db - df = DataFrame( dict( compound_id=ids, @@ -2627,108 +2134,7 @@ def from_compounds( dtype=object, ) - return cls.from_ingredient_df(db, df) - - ### PROPERTIES - - @property - def df(self) -> 'DataFrame': - """Access the raw DataFrame""" - return self._data - - @property - def db(self) -> 'Database': - """Linked HIPPO Database""" - return self._db - - @property - def price_df(self) -> 'DataFrame': - """DataFrame including prices""" - df = self.df.copy() - tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] - df['price'] = [t[0] for t in tuples] - df['lead_time'] = [t[1] for t in tuples] - df['quote_supplier'] = [t[2] for t in tuples] - return df - - @property - def price(self) -> 'Price': - """Total price of these ingredients""" - return self.get_price() - - @property - def supplier(self) -> str | list[str]: - """Supplier(s)""" - return self._supplier - - @supplier.setter - def supplier(self, s): - if isinstance(s, list) or isinstance(s, tuple): - for x in s: - assert isinstance(x, str) - else: - assert isinstance(s, str) - - self._supplier = s - self.df['supplier'] = [s] * len(self) - - @property - def smiles(self) -> list[str]: - """SMILES for all ingredients""" - compound_ids = list(self.df['compound_id']) - result = self.db.select_where( - query='compound_smiles', - table='compound', - key=f'compound_id in {tuple(compound_ids)}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def inchikeys(self) -> list[str]: - """InChI-keys for all ingredients""" - compound_ids = list(self.df['compound_id']) - result = self.db.select_where( - query='compound_inchikey', - table='compound', - key=f'compound_id in {tuple(compound_ids)}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def compound_ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return list(self.df['compound_id'].values) - - @property - def ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return self.compound_ids - - @property - def id_amount_pairs(self) -> list[tuple]: - """Get a list of compound ID and amount pairs""" - return [ - (id, amount) for id, amount in self.df[['compound_id', 'amount']].values - ] - - @property - def str_compound_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.df['compound_id'].values)).replace(',)', ')') - - @property - def compounds(self) -> 'CompoundSet': - """:class:`.CompoundSet` of all compounds in this set""" - return CompoundSet(self.db, self.compound_ids) - - @property - def quote_ids(self) -> list[int]: - """Get a list of quote ID's""" - from pandas import isna - - return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] + return cls.from_ingredient_df(df) ### METHODS @@ -2741,8 +2147,6 @@ def get_price( """ - from .price import Price - pairs = {i: q for i, q in enumerate(self.df['quote_id'])} quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] @@ -2751,29 +2155,20 @@ def get_price( mrich.debug('quote_ids', quote_ids) if quote_ids: - quote_id_str = str(tuple(quote_ids)).replace(',)', ')') + qs = CataloguePrice.objects.filter(pk__in=quote_ids) if supplier: - result = self.db.select_where( - query='quote_price, quote_currency', - table='quote', - key=f'quote_id in {quote_id_str} AND quote_supplier = "{supplier}"', - multiple=True, - none=none, - ) - else: - result = self.db.select_where( - query='quote_price, quote_currency', - table='quote', - key=f'quote_id in {quote_id_str}', - multiple=True, - none=none, - ) + qs = qs.filter(quote_supplier=supplier) - if result: - prices = [Price(a, b) for a, b in result] + if qs.exists(): + prices = [ + Price( + amount=k.quote_amount, + currency=k.quote_currency, + ) + for k in qs + ] quoted = sum(prices, Price.null()) - else: quoted = Price.null() self.df['quote_id'] = None @@ -2850,14 +2245,13 @@ def add( """ - from pandas import DataFrame, concat - if ingredient: - assert ingredient._table == 'ingredient' - compound_id = ingredient.compound_id + compound_id = ingredient.compound.pk amount = ingredient.amount if (q := ingredient.quote) and not ingredient.quote_id: + # I don't understand the logic for this. it's always + # true now. what was the meaning of storing id? mrich.warning(f'Losing quote! {ingredient.quote=}') supplier = ingredient.supplier @@ -2952,8 +2346,7 @@ def _get_ingredient( q_id = None return Ingredient( - db=self._db, - compound=series['compound_id'], + compound=Compound.objects.get(pk=series['compound_id']), amount=series['amount'], quote=q_id, supplier=series['supplier'], @@ -2962,9 +2355,7 @@ def _get_ingredient( def copy(self) -> 'IngredientSet': """Return a copy of this :class:`.IngredientSet`""" - return IngredientSet.from_ingredient_df( - self.db, self.df, supplier=self.supplier - ) + return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) def draw(self) -> None: """Wrapper for :meth:`.CompoundSet.draw`""" @@ -2987,24 +2378,29 @@ def set_amounts( assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) - # update quotes - pairs = self.db.execute( - f""" - WITH matching_quotes AS ( - SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote - WHERE quote_compound IN {self.str_compound_ids} - AND quote_amount >= {amount} - GROUP BY quote_compound - ) - SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN matching_quotes ON quote_compound = compound_id - WHERE compound_id IN {self.str_compound_ids} - """ - ).fetchall() + # # update quotes + # pairs = self.db.execute( + # f""" + # WITH matching_quotes AS ( + # SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote + # WHERE quote_compound IN {self.str_compound_ids} + # AND quote_amount >= {amount} + # GROUP BY quote_compound + # ) + # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + # LEFT JOIN matching_quotes ON quote_compound = compound_id + # WHERE compound_id IN {self.str_compound_ids} + # """ + # ).fetchall() + + qs = CataloguePrice.objects.filter( + compound__pk__in=self.compound_ids, + quote_amount__gte=amount, + ) - for compound_id, quote_id in pairs: - match = self.df.index[self.df['compound_id'] == compound_id][0] - self.df.loc[match, 'quote_id'] = quote_id + for k in qs: + match = self.df.index[self.df['compound_id'] == k.compound.pk][0] + self.df.loc[match, 'quote_id'] = k.quote.pk def get_dict(self, data_orient: str = 'list') -> dict: """Get serialisable dictionary @@ -3013,7 +2409,6 @@ def get_dict(self, data_orient: str = 'list') -> dict: """ return dict( - db=str(self.db), supplier=self.supplier, data=self.df.to_dict(orient=data_orient), ) @@ -3028,96 +2423,89 @@ def shuffle(self) -> None: """Randomises the order of compounds in this set""" self._data = self.df.sample(frac=1).reset_index(drop=True) - ### DUNDERS - - def __len__(self): - """The number of ingredients in this set""" - return len(self._data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Ingredient × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - def __add__(self, other): - """Add another :class:`.IngredientSet` this set""" - - for i, row in other._data.iterrows(): - self.add( - compound_id=row.compound_id, - amount=row.amount, - quote_id=row.quote_id, - supplier=row.supplier, - max_lead_time=row.max_lead_time, - quoted_amount=row.quoted_amount, - ) + ### PROPERTIES - return self + @property + def df(self) -> 'DataFrame': + """Access the raw DataFrame""" + return self._data - def __getitem__(self, key: int) -> 'Ingredient': - """Get a member by it's index""" - match key: - case int(): - series = self.df.loc[key] - return self._get_ingredient(series) + @property + def price_df(self) -> 'DataFrame': + """DataFrame including prices""" + df = self.df.copy() + tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] + df['price'] = [t[0] for t in tuples] + df['lead_time'] = [t[1] for t in tuples] + df['quote_supplier'] = [t[2] for t in tuples] + return df - case _: - raise NotImplementedError + @property + def price(self) -> 'Price': + """Total price of these ingredients""" + return self.get_price() - def __iter__(self): - """Iterate through the ingredients""" - return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) + @property + def supplier(self) -> str | list[str]: + """Supplier(s)""" + return self._supplier - def __call__( - self, - *, - compound_id: int | None = None, - tag: str | None = None, - ) -> 'IngredientSet | Ingredient | CompoundSet': - """Get members based on a compound_id or tag""" + @supplier.setter + def supplier(self, s): + if isinstance(s, list) or isinstance(s, tuple): + for x in s: + assert isinstance(x, str) + else: + assert isinstance(s, str) - if compound_id: - # get the ingredient with the matching compound ID - matches = self.df[self.df['compound_id'] == compound_id] + self._supplier = s + self.df['supplier'] = [s] * len(self) - if len(matches) == 0: - return None + @property + def smiles(self) -> list[str]: + """SMILES for all ingredients""" + compound_ids = list(self.df['compound_id']) + return Compound.objects.filter( + pk__in=compound_ids, + ).values_list('compound_smiles', flat=True) - elif len(matches) != 1: - mrich.warning(f'Multiple ingredients in set with {compound_id=}') - # print(matches) + @property + def inchikeys(self) -> list[str]: + """InChI-keys for all ingredients""" + compound_ids = list(self.df['compound_id']) + return Compound.objects.filter( + pk__in=compound_ids, + ).values_list('compound_inchikeys', flat=True) - return IngredientSet( - self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] - ) + @property + def compound_ids(self) -> list[int]: + """Compound IDs for all ingredients""" + return list(self.df['compound_id'].values) - return self._get_ingredient(matches.iloc[0]) + @property + def ids(self) -> list[int]: + """Compound IDs for all ingredients""" + return self.compound_ids - # elif tag: - # return self.compounds(tag=tag) + @property + def id_amount_pairs(self) -> list[tuple]: + """Get a list of compound ID and amount pairs""" + return [ + (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + ] - else: - raise NotImplementedError + @property + def str_compound_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + return str(tuple(self.df['compound_id'].values)).replace(',)', ')') - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.CompoundSet`""" - return getattr(self.compounds, key) + @property + def compounds(self) -> 'CompoundSet': + """:class:`.CompoundSet` of all compounds in this set""" + return CompoundSet(self.compound_ids) - def __contains__(self, other: Compound | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other + @property + def quote_ids(self) -> list[int]: + """Get a list of quote ID's""" - return id in set(self.compound_ids) + return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/hippo/iset.py b/hippo/designdb/sets/interaction.py similarity index 98% rename from hippo/iset.py rename to hippo/designdb/sets/interaction.py index 4b88f74..9f75150 100644 --- a/hippo/iset.py +++ b/hippo/designdb/sets/interaction.py @@ -3,6 +3,8 @@ import mcol import mrich +from designdb.models import Interaction + class InteractionTable: """Class representing all :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. @@ -79,15 +81,10 @@ class InteractionSet: def __init__( self, - db: 'Database', indices: list = None, - table: str = 'interaction', ) -> None: """InteractionSet initialisation""" - self._db = db - self._table = table - indices = indices or [] if not isinstance(indices, list): @@ -97,6 +94,7 @@ def __init__( self._indices = sorted(list(set(indices))) self._df = None + self._qs = Interaction.objects.filter(pk__in=indices) ### FACTORIES @@ -156,8 +154,6 @@ def from_pose( @classmethod def all( cls, - db: 'Database', - table: str = 'interaction', ) -> 'InteractionSet': """Construct a :class:`.InteractionSet` for all interactions in the table. @@ -165,14 +161,10 @@ def all( """ - sql = f'SELECT interaction_id FROM {table}' - - ids = db.execute(sql).fetchall() - - ids = [i for (i,) in ids] - + # bit of a round-trip + ids = Interaction.objects.values_list('pk', flat=True) self = cls.__new__(cls) - self.__init__(db, ids, table=table) + self.__init__(ids) return self diff --git a/hippo/pset.py b/hippo/designdb/sets/pose.py similarity index 50% rename from hippo/pset.py rename to hippo/designdb/sets/pose.py index 9166deb..377f8f8 100644 --- a/hippo/pset.py +++ b/hippo/designdb/sets/pose.py @@ -1,1869 +1,915 @@ -"""Classes to work with sets of Poses""" - +import inspect +import json +import logging +import re +import shutil from collections.abc import Callable +from itertools import combinations +from os.path import relpath +from pathlib import Path +from pprint import pprint +from zipfile import ZipFile +import community as louvain import mcol +import molparse as mp import mrich - -from .db import Database -from .pose import Pose +import networkx as nx +import pandas as pd +from django.conf import settings +from django.db import IntegrityError +from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery +from IPython.display import display +from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive, + interactive_output, +) +from molparse.rdkit import draw_grid, draw_mols +from pandas import DataFrame +# from mypackage.services.compound import CompoundService +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools, SDWriter + +from designdb.models import ( + Compound, + Inspiration, + Interaction, + Pose, + PoseTag, + PoseTagJunction, + Subsite, + SubsiteTag, + Target, +) +from designdb.sets.interaction import InteractionSet +from designdb.utils import ScoreSubquery, normalize_string_list +from designdb.utils_frag import generate_header + +if settings.MANAGE_MODELS: + from designdb.utils import JsonGroupArray as ArrayAgg +else: + from django.contrib.postgres.aggregates import ArrayAgg + + +# from .validation.compound import ValidationError, validate_compound_data + +SDF_XCAv2_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' +) +SDF_XCAV3_PATTERN = re.compile( + r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' +) + + +SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') + + +logger = logging.getLogger(__name__) -class PoseTable: - """Class representing all :class:`.Pose` objects in the 'pose' table of the :class:`.Database`. +class PoseSet: + """Object representing a subset of the 'pose' table in the :class:`.Database`. .. attention:: - :class:`.PoseTable` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. Use as an iterable ================== - Iterate through :class:`.Pose` objects in the table: + Iterate through :class:`.Pose` objects in the set: :: - for pose in animal.poses: - ... + pset = animal.poses[:100] + for pose in pset: + ... - Selecting poses in the table - ============================ + Check membership + ================ - The :class:`.PoseTable` can be indexed with :class:`.Pose` IDs, names, aliases, or list/sets/tuples/slices thereof: + To determine if a :class:`.Pose` is present in the set: :: - ptable = animal.poses - - # indexing individual compounds - pose = ptable[13] # using the ID - pose = ptable["BSYNRYMUTXBXSQ-UHFFFAOYSA-N"] # using the InChIKey - pose = ptable["Ax0310a"] # using the alias + is_member = pose in cset - # getting a subset of compounds - pset = ptable[13,15,18] # using IDs (tuple) - pset = ptable[[13,15,18]] # using IDs (list) - pset = ptable[set(13,15,18)] # using IDs (set) - pset = ptable[13:18] # using a slice + Selecting compounds in the set + ============================== - Tags and target IDs can also be used to filter: + The :class:`.PoseSet` can be indexed like standard Python lists by their indices :: - pset = animal.poses(tag='hits') # select compounds tagged with 'hits' - pset = animal.poses(target=1) # select poses from the first target + pset = animal.poses[1:100] - """ + # indexing individual compounds + pose = pset[0] # get the first pose + pose = pset[1] # get the second pose + pose = pset[-1] # get the last pose - _table = 'pose' - _name = 'all poses' + # getting a subset of compounds using a slice + pset2 = pset[13:18] # using a slice + + """ def __init__( self, - db: Database, + queryset=None, + *, + sort: bool = True, + name: str | None = None, ) -> None: - """PoseTable initialisation""" - - self._db = db - self._interactions = None - - ### PROPERTIES + """PoseSet initialisation""" - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db + # let's have a queryset no matter what. + if queryset: + self._queryset = queryset + else: + self._queryset = Pose.objects.none() - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name + self._interactions = None + self._metadata_dict = None - @property - def names(self) -> list[str]: - """Returns the aliases of child poses""" - return [p.name for p in self] + ### DUNDERS - @property - def aliases(self) -> list[str]: - """Returns the aliases of child poses""" - result = self.db.select(table=self.table, query='pose_alias', multiple=True) - return [q for (q,) in result] + def __str__(self): + """Unformatted string representation""" + if self.name: + s = f'{self._name}: ' + else: + s = '' - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of child poses""" - result = self.db.select(table=self.table, query='pose_inchikey', multiple=True) - return [q for (q,) in result] + s += f'{{P × {len(self)}}}' - @property - def ids(self) -> list[int]: - """Returns the IDs of child poses""" - result = self.db.select(table=self.table, query='pose_id', multiple=True) - return [q for (q,) in result] + return s - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this pose set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key='tag_pose IS NOT NULL', - multiple=True, - ) - return set(v for (v,) in values) + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - @property - def num_fingerprinted(self) -> int: - """Count the number of fingerprinted poses""" - return self.db.count_where( - table='pose', - key='fingerprint', - value=1, - ) + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' - @property - def id_name_dict(self) -> dict[int, str]: - """Return a dictionary mapping pose ID's to their name""" + def __len__(self) -> int: + """The number of poses in this set""" + return self._queryset.count() - records = self.db.select( - table=self.table, query='pose_id, pose_inchikey, pose_alias', multiple=True - ) + def __iter__(self): + """Iterate through poses in this set""" + return iter(self._queryset) - lookup = {} - for i, inchikey, alias in records: - if alias: - lookup[i] = alias - else: - lookup[i] = inchikey + def __getitem__( + self, + key: int | slice, + ) -> 'Pose | PoseSet': + """Get poses or subsets thereof from this set - return lookup + :param key: integer index or slice of indices - @property - def interactions(self) -> 'InteractionSet': - """Get a :class:`.InteractionSet`""" - if self._interactions is None: - from .iset import InteractionSet + """ + match key: + case int(): + try: + pose = Pose.objects.get(pk=key) + except Pose.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise Pose.DoesNotExist from exc - self._interactions = InteractionSet.all(self.db) + return pose - return self._interactions + case slice(): + return PoseSet(Pose.objects.filter(pk__in=key)) - ### METHODS + case _: + raise NotImplementedError - def get_by_tag( + def __add__( self, - tag: str, - inverse: bool = False, + other: 'PoseSet', ) -> 'PoseSet': - """Get all child poses with a certain tag - - :param tag: tag to search for - :param inverse: invert the selection - :returns: a :class:`.PoseSet` of the subset - - """ - - if not inverse: - values = self.db.select_where( - query='tag_pose', table='tag', key='name', value=tag, multiple=True - ) - - else: - values = self.db.select_where( - query='tag_pose', table='tag', key='name', value=tag, multiple=True + """Add a :class:`.PoseSet` to this set""" + if isinstance(other, PoseSet): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, ) - - if not values: - return self - - ids = [v for (v,) in values if v] - - values = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_id NOT IN {self.str_ids}', - multiple=True, + elif isinstance(other, Pose): + return PoseSet( + Pose.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), + sort=False, ) - - if not values: - return None - - ids = [v for (v,) in values if v] - - pset = self[ids] - - if inverse: - pset._name = f'poses not tagged "{tag}"' else: - pset._name = f'poses tagged "{tag}"' - return pset + raise NotImplementedError - def get_by_target( + def __sub__( self, - *, - id: int, + other: 'PoseSet', ) -> 'PoseSet': - """Get all child poses with a certain :class:`.Target` ID: - - :param id: :class:`.Target` ID - :returns: a :class:`.PoseSet` of the subset - - """ - assert isinstance(id, int) - values = self.db.select_where( - query='pose_id', table='pose', key='target', value=id, multiple=True - ) - ids = [v for (v,) in values if v] - - target = self.db.get_target(id=id) - - pset = self[ids] - pset._name = f'poses for "{target}"' - return pset - - def get_by_smiles(self, smiles: str) -> 'Pose | PoseSet | None': - """Get a member pose by it's smiles""" - - from .tools import SanitisationError, inchikey_from_smiles, sanitise_smiles - - try: - flat_smiles = sanitise_smiles(smiles, sanitisation_failed='error') - except SanitisationError as e: - mrich.error(f'Could not sanitise {smiles=}') - mrich.error(str(e)) - return None - except AssertionError: - mrich.error(f'Could not sanitise {smiles=}') - return None - return c - - # get the compound - - flat_inchikey = inchikey_from_smiles(flat_smiles) - - comp_id = self.db.select_id_where( - table='compound', key='inchikey', value=flat_inchikey - ) - - if not comp_id: - return None - - (comp_id,) = comp_id - - # get the poses + """Substract a :class:`.PoseSet` from this set""" + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + case int(): + return PoseSet( + Pose.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + sort=False, + ) - pose_ids = self.db.select_id_where( - table='pose', key='compound', value=comp_id, multiple=True - ) + def __and__(self, other: 'PoseSet'): + """AND set operation, returns only poses in both sets""" - if not pose_ids: - return None + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) & Q(pk__in=other.queryset) + ), + sort=False, + ) - pose_ids = [i for (i,) in pose_ids] - pset = self[pose_ids] + case _: + raise NotImplementedError - # identify the pose + def __or__(self, other: 'PoseSet'): + """OR set operation, returns union of both sets""" - inchikey = inchikey_from_smiles(smiles) + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) - matches = set() - for pose in pset: - if pose.inchikey == inchikey: - matches.add(pose.id) - matches = list(matches) + case _: + raise NotImplementedError - if not matches: - mrich.error(f'Did not find pose matching stereochemistry (C{comp_id})') - return None + def __xor__(self, other: 'PoseSet'): + """Exclusive OR set operation, returns all poses in either set but not both""" - if len(matches) == 1: - return self[matches[0]] + match other: + case PoseSet(): + return PoseSet( + Pose.objects.filter( + Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) + & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) + ), + sort=False, + ) - return self[matches] + case _: + raise NotImplementedError - def get_by_subsite( + def __call__( self, *, - id: int, + tag: str = None, + target: int = None, + subsite: int = None, ) -> 'PoseSet': - """Get all child poses with a certain :class:`.Subsite` ID: + """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" - :param id: :class:`.Subsite` ID - :returns: a :class:`.PoseSet` of the subset + if tag: + return self.get_by_tag(tag) + elif target: + return self.get_by_target(target=Target.objects.get(pk=target)) + elif subsite: + return self.get_by_subsite(subsite=Subsite.objects.get(pk=subsite)) + else: + raise NotImplementedError - """ - assert isinstance(id, int) - values = self.db.select_where( - query='subsite_tag_pose', - table='subsite_tag', - key='ref', - value=id, - multiple=True, + @classmethod + def get_by_references(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + Pose.objects.filter(pk__in=poseset._queryset.values('pose_reference')) ) - ids = [v for (v,) in values if v] - subsite = self.db.get_subsite_name(id=id) + # there's a method get_by_inspiration + @classmethod + def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': + return PoseSet( + Pose.objects.filter( + pk__in=Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'original_pose', + ), + ), + ) - pset = self[ids] - pset._name = f'poses in "{subsite}"' - return pset + ### FILTERING - def get_by_metadata( + def get_by_tag( self, - key: str, - value: str | None = None, + tag: str, + inverse: bool = False, ) -> 'PoseSet': - """Get all child poses by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + """Get all child poses with a certain tag - :param key: metadata key to match - :param value: metadata value to match, if ``None`` any pose with the key present will be returned (Default value = None) - :returns: a :class:`.PoseSet` of the subset + :param tag: tag to filter by + :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) """ - results = self.db.select( - query='pose_id, pose_metadata', table='pose', multiple=True + self._queryset = self._queryset.annotate( + has_tag=Exists( + PoseTagJunction.objects.filter( + pose=OuterRef('pk'), + pose_tag__pose_tag_name=tag, + ), + ), ) - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - name = f'poses with {key} in metadata' + if inverse: + return PoseSet(self._queryset.filter(has_tag=False)) else: - if isinstance(value, str): - value = f'"{value}"' - ids = [i for i, d in results if d and f'"{key}": {value}' in d] - name = f'poses with metadata[{key}] == {value}' + return PoseSet(self._queryset.filter(has_tag=True)) - pset = self[ids] - pset._name = name - return pset - - def get_by_metadata_substring_match( - self, - substring: str, + def get_by_metadata( + self, key: str, value: str | None = None, debug: bool = False ) -> 'PoseSet': - """Get :class:`.PoseSet` of poses with metadata JSON containing substring""" + """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - assert substring - assert isinstance(substring, str) + :param key: metadata key to search for + :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) - pose_ids = self.db.select_where( + """ + results = self.db.select_where( + query='pose_id, pose_metadata', + key=f'pose_id IN {self.str_ids}', table='pose', - query='pose_id', - key=f"""pose_metadata LIKE '%{substring}%'""", multiple=True, ) - if not pose_ids: - mrich.error('No poses with export ') - return None - - pose_ids = [i for (i,) in pose_ids] - - name = f"poses with '{substring}' in metadata" + if value is None: + # metadata stored as string + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}"'), + ) - pset = self[pose_ids] - pset._name = name + else: + if isinstance(value, str): + value = f'"{value}"' - return pset + return PoseSet( + self._queryset.filter(pose_metadata__contains=f'"{key}: {value}"'), + ) - def draw( - self, - max_draw: int = 100, - ) -> None: - """Render the poses + def get_by_inspiration(self, inspiration: Pose, inverse: bool = False): + """Get all child poses with with this inspiration. - :param max_draw: show a warning if trying to draw more than this number of poses (Default value = 100) + :param inspiration: inspiration :class:`.Pose` ID or object + :param inverse: invert the selection (Default value = False) """ - if len(self) <= max_draw: - self[:].draw() - else: - mrich.warning( - f'Too many poses: {len(self)} > {max_draw=}. Increase max_draw or use animal.poses[:].draw()' - ) - - def summary(self) -> None: - """Print a summary of this pose set""" - mrich.header('PoseTable()') - mrich.var('#poses', len(self)) - mrich.var('tags', self.tags) + # not entirely sure which way the filtering should go + qs = ( + Inspiration.objects.filter( + derivative_pose=inspiration, + ).values('original_pose'), + ) - def interactive(self) -> None: - """Interactive widget to navigate poses in the table + if inverse: + return PoseSet(self._queryset.exclude(pk__in=qs)) + else: + return PoseSet(self._queryset.filter(pk__in=qs)) - .. attention:: - - This method instantiates a :class:`.PoseSet` containing all poses, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. + def get_df( + self, + smiles: bool = True, + inchikey: bool = True, + alias: bool = True, + name: bool = True, + compound_id: bool = False, + target_id: bool = False, + reference_id: bool = False, + reference_alias: bool = False, + path: bool = False, + mol: bool = False, + energy_score: bool = False, + distance_score: bool = False, + inspiration_score: bool = False, + metadata: bool = False, + expand_metadata: bool = True, + debug: bool = True, + inspiration_ids: bool = False, + inspiration_aliases: bool = False, + derivative_ids: bool = False, + tags: bool = False, + expand_tags: bool = False, + subsites: bool = False, + # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs + ) -> 'pandas.DataFrame': + """Get a DataFrame of the poses in this set. + :param smiles: include SMILES column (Default value = True) + :param inchikey: include InChIKey column (Default value = True) + :param alias: include alias column (Default value = True) + :param name: include name column (Default value = True) + :param compound_id: include :class:`.Compound` ID column (Default value = False) + :param reference_id: include reference :class:`.Pose` ID column (Default value = False) + :param target_id: include reference :class:`.Target` ID column (Default value = False) + :param path: include path column (Default value = False) + :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) + :param energy_score: include energy_score column (Default value = False) + :param distance_score: include distance_score column (Default value = False) + :param inspiration_score: include inspiration_score column (Default value = False) + :param metadata: include metadata in output (Default value = False) + :param expand_metadata: create separate column for each metadata key (Default value = True) + :param inspiration_ids: include inspiration :class:`.Pose` ID column + :param inspiration_aliases: include inspiration :class:`.Pose` alias column + :param derivative_ids: include derivative :class:`.Pose` ID column + :param tags: include tags column + :param subsites: include subsites column """ - self[self.ids].interactive() - - ### DUNDERS - - def __call__( - self, - *, - tag: str | None = None, - target: int | None = None, - subsite: int | None = None, - smiles: str | None = None, - ) -> 'PoseSet': - """Filter poses by a given tag, subsite ID, or target ID. See :meth:`.PoseTable.get_by_tag`, :meth:`.PoseTable.get_by_target`, amd :meth:`.PoseTable.get_by_subsite`""" - - if tag: - return self.get_by_tag(tag) - elif target: - return self.get_by_target(id=target) - elif subsite: - return self.get_by_subsite(id=subsite) - elif smiles: - return self.get_by_smiles(smiles=smiles) - else: - raise NotImplementedError + sig = inspect.signature(self.get_df) + flags = { + name: locals()[name] + for name in sig.parameters + if name not in ('self', 'debug', 'expand_tags', 'expand_metadata') + } + # need id in output + flags['id'] = True + + print('input flags', flags) + + # alias and name both point to same thing. prefer 'name' + if flags.get('name', False): + flags['alias'] = True + + # this is still not working right and I don't understand. What + # was the original code doing here? simply adding both fields, + # name and alias? + + # dict :: func arg: (col title, qs field lookup, queryset annotation) + # this is going to get out of hand with multiple scoring methods + fields = { + 'id': ('id', 'id', None), + 'smiles': ('smiles', 'pose_smiles', None), + 'inchikey': ('inchikey', 'pose_inchikey', None), + # 'alias': ('alias', 'pose_alias', None), + 'name': ('name', 'pose_alias', None), + 'compound_id': ('compound_id', 'compound__id', None), + 'target_id': ('target_id', 'target__id', None), + 'reference_id': ('reference_id', 'pose_reference', None), + 'reference_alias': ( + 'reference_alias', + 'reference_alias', + Subquery( + Pose.objects.filter( + pk=OuterRef('pose_reference'), + ).values('pose_alias')[0:1] + ), + ), + 'path': ('pose_path', 'pose_path', None), + 'mol': ('mol', 'pose_mol', None), + 'energy_score': ( + 'energy_score', + 'energy_score', + ScoreSubquery('energy_score'), + ), + 'distance_score': ( + 'distance_score', + 'distance_score', + ScoreSubquery('distance_score'), + ), + 'inspiration_score': ( + 'inspiration_score', + 'inspiration_score', + ScoreSubquery('inspiration_score'), + ), + 'metadata': ('metadata', 'pose_metadata', None), + 'inspiration_ids': ( + 'inspiration_ids', + 'inspiration_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'inspiration_aliases': ( + 'inspiration_aliases', + 'inspiration_aliases', + ArrayAgg('inspirations__pose_alias'), + # JsonGroupArray('inspirations__pose_alias'), + ), + 'derivative_ids': ( + 'derivative_ids', + 'derivative_ids', + ArrayAgg('inspirations__id'), + # JsonGroupArray('inspirations__id'), + ), + 'tags': ( + 'tags', + 'tag_names', + ArrayAgg('tags__pose_tag_name'), + # JsonGroupArray('tags__pose_tag_name'), + ), + 'subsites': ( + 'subsites', + 'subsites_names', + ArrayAgg( + 'subsites__subsite_name', + filter=Q(subsites__isnull=False), + ), + # JsonGroupArray('subsites__subsite_name', filter=Q(subsites__isnull=False),), + ), + } - def __getitem__( - self, - key: int | str | tuple | list | set | slice, - ) -> Pose: - """Get a member :class:`.Pose` object or subset :class:`.PoseSet` thereof. + annotations = { + v[1]: v[2] for k, v in fields.items() if flags.get(k, False) and v[2] + } + values = [v[1] for k, v in fields.items() if flags.get(k, False)] + columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} - :param key: Can be an integer ID, negative integer index, alias or inchikey string, list/set/tuple of IDs, or slice of IDs + print('df values', values) + print('df columns', columns) + qs = self._queryset.annotate(**annotations).values(*values) - """ + print('queryset', self._queryset.count(), self._queryset) - from numpy import int64, ndarray - from pandas import Series + df = pd.DataFrame(qs) + print(df) + print('df columns from df before', df.columns) + df = df.rename(columns=columns) + print('df columns from df after', df.columns) + df = df.set_index('id') - match key: - case int(): - if key == 0: - return self.__getitem__(key=1) + if alias: + df['alias'] = df.name + + if metadata and expand_metadata: + # TODO: code specific to my current situation. have to + # parse string to json (does postgres handle this + # automatically?) + # expanded = pd.json_normalize( + # df["metadata"].apply(lambda x: json.loads(x) if x else {}), + # ) + expanded = pd.json_normalize(df['metadata']) + # dropping columns is due to confusion with scores. can't be + # permanent solution, for now, drop the common ones + expanded = expanded.drop( + columns=set(expanded.columns).intersection(set(df.columns)), + ) - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) + df = df.drop(columns=['metadata']).join(expanded) - else: - return self.db.get_pose(id=key) + if tags and expand_tags: + # surprisingly manual compared to expand_metadata, but + # kept running into problems + df['tags'] = df['tags'].apply(normalize_string_list) + # get all unique tags + all_tags = sorted(set(tag for tags in df['tags'] for tag in tags)) - case str(): - pose = self.db.get_pose(alias=key) - if not pose: - pose = self.db.get_pose(inchikey=key) - return pose + # build boolean columns + for tag in all_tags: + df[tag] = df['tags'].apply(lambda tags: tag in tags) - case key if ( - isinstance(key, list) - or isinstance(key, tuple) - or isinstance(key, set) - or isinstance(key, Series) - or isinstance(key, ndarray) - ): - indices = [] - for i in key: - if isinstance(i, int): - index = i - elif isinstance(i, int64): - index = int(i) - elif isinstance(i, str): - index = self.db.get_pose_id(alias=i) - if not index: - index = self.db.get_pose_id(inchikey=i) - else: - raise NotImplementedError(type(i)) - - assert index - indices.append(index) - - return PoseSet(self.db, indices) + df = df.drop(columns=['tags']) - case slice(): - ids, name = self.db.slice_ids( - table=self.table, - start=key.start, - stop=key.stop, - step=key.step, - name=True, - ) - pset = self[ids] - pset._name = name - return pset + # custom aggreagte field is giving me string, parse to list + for col in [ + 'inspiration_aliases', + ]: + if col in df.columns: + df[col] = df[col].apply(normalize_string_list) - case _: - mrich.error( - f'Unsupported type for PoseTable.__getitem__(): {type(key)}' - ) + return df - return None + def get_by_reference( + self, + ref_id: int, + ) -> 'PoseSet | None': + """Get poses with a certain reference id - def __str__(self): - """Unformatted string representation""" - if self.name: - s = f'{self.name}: ' - else: - s = '' + :param ref_id: reference :class:`.Pose` ID - s += f'{{P × {len(self)}}}' + """ + qs = self._queryset.filter(pose_reference=ref_id) + if not qs.exists(): + # odd, but keeping now + return None - return s + return PoseSet(qs) - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + def get_by_compound( + self, + *, + compound: 'int | Compound | CompoundSet', + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' + :param compound: :class:`.Compound` object or ID + :returns: a :class:`.PoseSet` of the selection - def __len__(self) -> int: - """Total number of compounds""" - return self.db.count(self.table) + """ + if isinstance(compound, int): + return PoseSet(self._queryset.filter(compound__id=compound)) + elif isinstance(compound, Compound): + return PoseSet(self._queryset.filter(compound=compound)) + else: + # possible crash point: assuming CompoundSet but not + # testing type, still trying to fiugre out circular + # imports + return PoseSet(self._queryset.filter(compound__in=compound.queryset)) - def __iter__(self): - """Iterate through all compounds""" - return iter(self[i] for i in self.ids) + def get_by_target( + self, + *, + target: Target, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. + :param id: :class:`.Target` ID + :returns: a :class:`.PoseSet` of the selection -class PoseSet: - """Object representing a subset of the 'pose' table in the :class:`.Database`. + """ + # where would you need this method?? do you ever create sets + # of poses from different targets? + return PoseSet(self._queryset.filter(target=target)) - .. attention:: + def get_by_subsite( + self, + *, + subsite: Subsite, + ) -> 'PoseSet | None': + """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. - :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :param id: :class:`.Subsite` ID + :returns: a :class:`.PoseSet` of the selection - Use as an iterable - ================== + """ + qs = self._queryset.filter( + id__in=SubsiteTag.objects.filter( + subsite=subsite, + ).values('pose'), + ) - Iterate through :class:`.Pose` objects in the set: + if self.name: + name = f'{self.name} & subsite={subsite.pk}' + else: + name = None - :: + return PoseSet(qs, name=name) - pset = animal.poses[:100] + # def get_best_placed_poses_per_compound(self): + # """Choose the best placed pose (best distance_score) grouped by compound""" - for pose in pset: - ... + # sql = f""" + # SELECT pose_id, MIN(pose_distance_score) + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # GROUP BY pose_compound + # """ - Check membership - ================ + # cursor = self.db.execute(sql) - To determine if a :class:`.Pose` is present in the set: + # ids = [i for i, _ in cursor] - :: + # return PoseSet(self._queryset) - is_member = pose in cset + # def filter( + # self, + # function=None, + # *, + # key: str = None, + # value: str = None, + # operator='=', + # inverse: bool = False, + # ): + # """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values - Selecting compounds in the set - ============================== + # :param function: callable object + # :param key: database field for 'pose' table ('pose_' prefix not needed) + # :param value: value to compare to + # :param operator: comparison operator (default = "=") + # :param inverse: invert the selection (Default value = False) - The :class:`.PoseSet` can be indexed like standard Python lists by their indices + # """ - :: + # if function: + # ids = set() + # for pose in self: + # value = function(pose) + # # mrich.debug(f'{pose=} {value=}') + # if value and not inverse: + # ids.add(pose.id) + # elif not value and inverse: + # ids.add(pose.id) - pset = animal.poses[1:100] + # return PoseSet(self.db, ids) - # indexing individual compounds - pose = pset[0] # get the first pose - pose = pset[1] # get the second pose - pose = pset[-1] # get the last pose + # sql = f""" + # SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # AND pose_{key} {operator} {value} + # """ - # getting a subset of compounds using a slice - pset2 = pset[13:18] # using a slice + # cursor = self.db.execute(sql) - """ + # ids = [i for (i,) in cursor] - _table = 'pose' + # return PoseSet(self.db, ids) - def __init__( + def add_tag( self, - db: Database, - indices: list = None, - *, - sort: bool = True, - name: str | None = None, + tag: str, ) -> None: - """PoseSet initialisation""" + """Add this tag to every member of the set""" - self._db = db + assert isinstance(tag, str) - indices = indices or [] + pose_tag = PoseTag(pose_tag_name=tag) + pose_tag.save() - if not isinstance(indices, list): - indices = list(indices) + PoseTagJunction.objects.bulk_create( + [PoseTagJunction(pose=pose, pose_tag=pose_tag) for pose in self._queryset], + ignore_conflicts=True, + ) - assert all(isinstance(i, int) for i in indices) + mrich.print(f'Tagged {self} w/ "{tag}"') - if sort: - self._indices = sorted(list(set(indices))) - else: - # remove duplicates but keep order - self._indices = dict() - for i in indices: - if i not in self._indices: - self._indices[i] = i - self._indices = list(self._indices.keys()) + # refetch in case was evaluated + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - self._interactions = None - self._metadata_dict = None + # NB! I'm now realizing this is potentially a huge + # problem. with every evaluation and refretch some attributes + # may be lost. how can this be kept clean? - self._name = name + # unused? the original method didn't save object + def append_to_metadata( + self, + key, + value, + ) -> None: + """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries - ### PROPERTIES + :param key: the :class:`.Metadata` key to match + :param value: the value to append to the list - @property - def db(self) -> 'Database': - """Returns the associated :class:`.Database`""" - return self._db + """ + for pose in self._queryset: + # metadata = json.loads(pose.payload) + metadata = pose.pose_metadata + try: + metadata.append(key, value) + except AttributeError: + mrich.error(f'Could not append to metadata {key=}. Not a list?') - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table + pose.save() + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - @property - def indices(self) -> list[int]: - """Returns the ids of poses in this set""" - return self._indices + def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: + """Create and assign subsite entries from a metadata field - @property - def ids(self) -> list[int]: - """Returns the ids of poses in this set""" - return self._indices + :param field: the metadata field to use - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name + """ + for pose in self._queryset: + metadata = json.loads(pose.payload) + key = metadata.get(field) + if not key: + mrich.warning(field, 'not in metadata pose_id=', pose_id) + continue + + # I'm still not entirely clear can you really have + # posesets from different target, if not, and it really + # seems that not, this should be a single subsite + subsite, _ = Subsite.get_or_create(target=pose.target, subsite_name=key) + subsite_tag = SubsiteTag(subsite=subsite, pose=pose) + subsite_tag.save() + + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + + # TODO: implement scores + # def calculate_inspiration_scores( + # self, + # alpha: float = 0.95, + # beta: float = 0.05, + # score_type: str = 'combo', + # ) -> 'pd.DataFrame': + # """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky + + # :param alpha: Tversky alpha parameter + # :param beta: Tversky beta parameter + # :param score_type: Score type to add to database, choose from "combo", "shape", "colour" + # :returns: Pandas DataFrame with molecules and scores + # """ + + # from mocassin.mocassin import calculate_mocassin_tversky + + # df = self.get_df( + # alias=False, + # smiles=False, + # inchikey=False, + # inspiration_ids=True, + # mol=True, + # ) + + # inspirations = {p.id: p for p in self.inspirations} + + # df['inspiration_mols'] = df['inspiration_ids'].apply( + # lambda x: [inspirations[i].mol for i in x] + # ) + + # n = len(df) + + # for j, (i, row) in mrich.track( + # enumerate(df.iterrows()), prefix='MoCASSIn', total=n + # ): + # mrich.set_progress_field('j', j) + # mrich.set_progress_field('n', n) + + # try: + # combo, shape, colour = calculate_mocassin_tversky( + # row['inspiration_mols'], + # row['mol'], + # alpha=0.95, + # beta=0.05, + # ) + # df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo + # df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape + # df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour + # except Exception as e: + # mrich.error(e) + + # tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() + + # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" + + # mrich.debug('Updating pose_inspiration_score values') + # self.db.executemany(sql, [(b, a) for a, b in tuples]) + # self.db.commit() + + # return df - @property - def names(self) -> list[str]: - """Returns the aliases of poses in this set""" - return [p.name for p in self] + ### SPLITTING - @property - def aliases(self) -> list[str]: - """Returns the aliases of child poses""" - return [ - self.db.select_where( - table=self.table, query='pose_alias', key='id', value=i, multiple=False - )[0] - for i in self.indices - ] + def split_by_reference(self) -> 'dict[int,PoseSet]': + """Split this :class:`.PoseSet` into subsets grouped by reference ID - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of child poses""" - return [ - self.db.select_where( - table=self.table, - query='pose_inchikey', - key='id', - value=i, - multiple=False, - )[0] - for i in self.indices - ] + :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values - @property - def id_name_dict(self) -> dict: - """Return a dictionary mapping pose ID's to their name""" + """ + sets = {} + for ref_id in self.reference_ids: + sets[ref_id] = self.get_by_reference(ref_id) + return sets - records = self.db.select_where( - table=self.table, - query='pose_id, pose_inchikey, pose_alias', - key=f'pose_id IN {self.str_ids}', - multiple=True, - ) + def split_by_inspirations( + self, + single_set: bool = False, + ) -> 'dict[PoseSet,PoseSet] | PoseSet': + """Split this :class:`.PoseSet` into subsets grouped by inspirations - lookup = {} - for i, inchikey, alias in records: - if alias: - lookup[i] = alias - else: - lookup[i] = inchikey + :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) + :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values - return lookup + """ - @property - def smiles(self) -> list[str]: - """Returns the smiles of poses in this set""" - pairs = self.db.select_where( - table=self.table, - query='pose_id, pose_smiles', - key=f'pose_id IN {self.str_ids}', - multiple=True, - ) + sets = {} - results = [] - for pose_id, smiles in pairs: - if smiles is None: - pose = self.db.get_pose(id=pose_id) - smiles = pose.smiles + for pose in self._queryset: + insp_ids = list(pose.inspirations.distinct().values_list('pk', flat=True)) + key = tuple(insp_ids) + sets.setdefault(key, set()) + sets[key].add(pose.pk) - results.append(smiles) + mrich.var('#unique inspiration combinations', len(sets)) - return results + if single_set: + return PoseSet( + Pose.objects.filter( + pk__in=[id for s in sets.values() for id in s.ids], + sort=False, + ) + ) - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this pose set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key=f'tag_pose in {self.str_ids}', - multiple=True, - ) - return set(v for (v,) in values) - - @property - def compounds(self) -> 'CompoundSet': - """Get the compounds associated to this set of poses""" - from .cset import CompoundSet - - ids = self.db.select_where( - table='pose', - query='DISTINCT pose_compound', - key=f'pose_id in {self.str_ids}', - multiple=True, - ) - ids = [v for (v,) in ids] - return CompoundSet(self.db, ids) - - @property - def mols(self) -> 'list[rdkit.Chem.mol]': - """Get the rdkit Molecules contained in this set""" - return [p.mol for p in self] - - @property - def num_compounds(self) -> int: - """Count the compounds associated to this set of poses""" - return len(self.compounds) - - @property - def df(self) -> 'pandas.DataFrame': - """Get a DataFrame of the poses in this set""" - return self.get_df(mol=True) - - @property - def references(self) -> 'PoseSet': - """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" - return PoseSet(self.db, self.reference_ids) - - @property - def reference_ids(self) -> set[int]: - """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" - values = self.db.select_where( - table='pose', - query='DISTINCT pose_reference', - key=f'pose_reference IS NOT NULL and pose_id in {self.str_ids}', - value=None, - multiple=True, - ) - return set(v for (v,) in values) - - @property - def inspiration_sets(self) -> list[set[int]]: - """Return a list of unique sets of inspiration :class:`.Pose` IDs""" - - sql = f""" - SELECT inspiration_derivative, inspiration_original FROM {self.db.SQL_SCHEMA_PREFIX}inspiration - WHERE inspiration_derivative IN {self.str_ids} - """ - - pairs = self.db.execute(sql).fetchall() - - data = {} - for derivative, original in pairs: - if derivative not in data: - data[derivative] = set() - data[derivative].add(original) - - data = {k: tuple(sorted(list(v))) for k, v in data.items()} - - unique = set(data.values()) - - return unique - - @property - def num_inspiration_sets(self) -> int: - """Return the number of unique sets of inspirations""" - return len(self.inspiration_sets) - - @property - def num_inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - (count,) = self.db.select_where( - table='inspiration', - query='COUNT(DISTINCT inspiration_original)', - key=f'inspiration_derivative IN {self.str_ids}', - ) - - return count - - @property - def inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - records = self.db.select_where( - table='inspiration', - query='DISTINCT inspiration_original', - key=f'inspiration_derivative IN {self.str_ids}', - multiple=True, - ) - - if not records: - return None - - return PoseSet(self.db, [i for (i,) in records]) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def targets(self) -> 'list[Target]': - """Returns the :class:`.Target` objects of poses in this set""" - return [self.db.get_target(id=q) for q in self.target_ids] - - @property - def target_names(self) -> list[str]: - """Returns the :class:`.Target` objects of poses in this set""" - return [self.db.get_target_name(id=q) for q in self.target_ids] - - @property - def target_ids(self) -> list[int]: - """Returns the :class:`.Target` objects ID's of poses in this set""" - result = self.db.select_where( - table=self.table, - query='DISTINCT pose_target', - key=f'pose_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def best_placed_pose(self) -> Pose: - """Returns the pose with the best distance_score in this subset""" - return self.db.get_pose(id=self.best_placed_pose_id) - - @property - def best_placed_pose_id(self) -> int: - """Get the id of the pose with the best distance_score in this subset""" - - if len(self) == 1: - return self.ids[0] - - query = 'pose_id, MIN(pose_distance_score)' - query = self.db.select_where( - table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False - ) - return query[0] - - @property - def interactions(self) -> 'InteractionSet': - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" - if self._interactions is None: - from .iset import InteractionSet - - self._interactions = InteractionSet.from_pose(self) - return self._interactions - - @property - def pose_id_metadata_dict(self) -> dict[int, dict]: - """Get a dictionary mapping pose_ids to metadata dicts""" - if self._metadata_dict is None: - metadata_lookup = self.db.get_id_metadata_dict(table='pose', ids=self.ids) - metadata = {} - for pose_id in self.ids: - metadata[pose_id] = metadata_lookup[pose_id] - self._metadata_dict = metadata - return self._metadata_dict - - def get_interaction_overlaps(self, return_pairs: bool = False) -> int: - """Count the number of member pose pairs which share at least one but not all interactions""" - - from itertools import combinations - - sql = f""" - SELECT DISTINCT interaction_pose, feature_id, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - # mrich.print(sql) - - records = self.db.execute(sql).fetchall() - - ISETS = {} - for pose_id, feature_id, interaction_type in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_id)) - ISETS[pose_id] = values - - ids = [i for i in self.ids if i in ISETS] - - count = 0 - - pairs = set() - - for pose_j, pose_k in combinations(ids, 2): - iset_j = ISETS[pose_j] - iset_k = ISETS[pose_k] - - intersection = iset_j & iset_k - diff1 = iset_j - iset_k - diff2 = iset_k - iset_j - - if intersection and diff1 and diff2: - count += 1 - pairs.add((pose_j, pose_k)) - - if return_pairs: - return [PoseSet(self.db, [a, b]) for a, b in pairs] - - return count - - def get_interaction_clusters(self) -> 'dict[int, PoseSet]': - """Cluster poses based on shared interactions.""" - - from itertools import combinations - - import community as louvain - import networkx as nx - - # get interaction records - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - ISETS = {} - for ( - pose_id, - feature_residue_name, - feature_residue_number, - interaction_type, - ) in records: - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_residue_name, feature_residue_number)) - ISETS[pose_id] = values - - pairs = combinations(ISETS.keys(), 2) - - # construct overlap dictionary - - OVERLAPS = {} - for id1, id2 in pairs: - iset1 = ISETS[id1] - iset2 = ISETS[id2] - OVERLAPS[(id1, id2)] = len(iset1 & iset2) - - # make the graph - G = nx.Graph() - - for (id1, id2), count in OVERLAPS.items(): - G.add_edge(id1, id2, weight=count) - - # partition the graph - - partition = louvain.best_partition(G, weight='weight') - - # find the clusters - - clusters = {} - for node, cluster_id in partition.items(): - clusters.setdefault(cluster_id, set()).add(node) - - # create the PoseSets - - psets = { - i: PoseSet(self.db, ids, name=f'Cluster {i}') - for i, ids in enumerate(clusters.values()) - } - - all_ids = set(sum((pset.ids for pset in psets.values()), [])) - - # calculate modal interactions - - for i, cluster in psets.items(): - mrich.var(cluster.name, len(cluster), unit='poses') - - df = cluster.interactions.df - - unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ - 'pose_id' - ].nunique() - - max_count = unique_counts.max() - max_pairs = unique_counts[unique_counts == max_count] - - for ( - interaction_type, - residue_name, - residue_number, - ) in max_pairs.index.values: - mrich.print(interaction_type, 'w/', residue_name, residue_number) - - # unclustered - unclustered = set(i for i in self.ids if i not in all_ids) - psets[None] = PoseSet(self.db, unclustered, name='Unclustered') - - return psets - - @property - def num_fingerprinted(self) -> int: - """Count the number of fingerprinted poses in this set""" - return self.db.count_where( - table='pose', key=f'pose_id IN {self.str_ids} AND pose_fingerprint = 1' - ) - - @property - def fraction_fingerprinted(self) -> float: - """Return the fraction of fingerprinted poses in this set""" - return self.num_fingerprinted / len(self) - - @property - def num_subsites(self) -> int: - """Count the number of subsites that poses in this set come into contact with""" - (count,) = self.db.select_where( - query='COUNT(DISTINCT subsite_tag_ref)', - table='subsite_tag', - key=f'subsite_tag_pose IN {self.str_ids}', - none='quiet', - ) - if count is None: - count = 0 - return count - - @property - def subsite_balance(self) -> float: - """Measure of how evenly subsite counts are distributed across poses in this set""" - - from numpy import std - - sql = f""" - SELECT COUNT(DISTINCT subsite_tag_ref) - FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag - WHERE subsite_tag_pose IN {self.str_ids} - GROUP BY subsite_tag_pose - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] - - return -std(counts) - - @property - def subsite_ids(self) -> set[int]: - """Return a list of subsite id's of member poses""" - - sql = f""" - SELECT DISTINCT subsite_tag_ref - FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag - WHERE subsite_tag_pose IN {self.str_ids} - """ - - subsite_ids = self.db.execute(sql).fetchall() - - if not subsite_ids: - return set() - - subsite_ids = set([i for (i,) in subsite_ids]) - - return subsite_ids - - @property - def avg_energy_score(self) -> float: - """Average energy score of poses in this set""" - - from numpy import mean - - sql = f""" - SELECT pose_energy_score - FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {self.str_ids} - """ - - scores = self.db.execute(sql).fetchall() - return mean([s for (s,) in scores if s is not None]) - - @property - def avg_distance_score(self) -> float: - """Average distance score of poses in this set""" - - from numpy import mean - - sql = f""" - SELECT pose_distance_score - FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {self.str_ids} - """ - - scores = self.db.execute(sql).fetchall() - - return mean([s for (s,) in scores if s is not None]) - - @property - def derivatives(self) -> 'PoseSet': - """Get the :class:`.PoseSet` of derivatives""" - - ids = self.db.select_where( - table='inspiration', - query='inspiration_derivative', - key=f'inspiration_original IN {self.str_ids}', - multiple=True, - none='quiet', - ) - if not ids: - return None - ids = [i for (i,) in ids] - pset = PoseSet(self.db, ids, name=f'derivatives of {self}') - return pset - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> 'PoseSet': - """Get all child poses with a certain tag - - :param tag: tag to filter by - :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) - - """ - values = self.db.select_where( - query='tag_pose', table='tag', key='name', value=tag, multiple=True - ) - if inverse: - matches = [v for (v,) in values if v] - ids = [i for i in self.ids if i not in matches] - else: - ids = [v for (v,) in values if v and v in self.ids] - return PoseSet(self.db, ids) - - def get_by_metadata( - self, key: str, value: str | None = None, debug: bool = False - ) -> 'PoseSet': - """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key to search for - :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) - - """ - results = self.db.select_where( - query='pose_id, pose_metadata', - key=f'pose_id IN {self.str_ids}', - table='pose', - multiple=True, - ) - - if value is None: - ids = [i for i, d in results if d and f'"{key}":' in d] - - else: - if isinstance(value, str): - value = f'"{value}"' - - ids = [] - - for i, d in results: - if not d: - continue - - if debug: - mrich.print(i, d, f'"{key}": {value}' in d) - - if f'"{key}": {value}' in d: - ids.append(i) - else: - continue - - if debug: - break - - return PoseSet(self.db, ids) - - def get_by_inspiration(self, inspiration: int | Pose, inverse: bool = False): - """Get all child poses with with this inspiration. - - :param inspiration: inspiration :class:`.Pose` ID or object - :param inverse: invert the selection (Default value = False) - - """ - - ids = set() - - for pose in self: - if not inverse: - for pose_inspiration in pose.inspirations: - if pose_inspiration == inspiration: - ids.add(pose.id) - break - - elif inverse: - for pose_inspiration in pose.inspirations: - if pose_inspiration == inspiration: - break - else: - ids.add(pose.id) - - return PoseSet(self.db, ids) - - def get_df( - self, - smiles: bool = True, - inchikey: bool = True, - alias: bool = True, - name: bool = True, - compound_id: bool = False, - target_id: bool = False, - reference_id: bool = False, - reference_alias: bool = False, - path: bool = False, - mol: bool = False, - energy_score: bool = False, - distance_score: bool = False, - inspiration_score: bool = False, - metadata: bool = False, - expand_metadata: bool = True, - debug: bool = True, - inspiration_ids: bool = False, - inspiration_aliases: bool = False, - derivative_ids: bool = False, - tags: bool = False, - expand_tags: bool = False, - subsites: bool = False, - # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs - ) -> 'pandas.DataFrame': - """Get a DataFrame of the poses in this set. - - :param smiles: include SMILES column (Default value = True) - :param inchikey: include InChIKey column (Default value = True) - :param alias: include alias column (Default value = True) - :param name: include name column (Default value = True) - :param compound_id: include :class:`.Compound` ID column (Default value = False) - :param reference_id: include reference :class:`.Pose` ID column (Default value = False) - :param target_id: include reference :class:`.Target` ID column (Default value = False) - :param path: include path column (Default value = False) - :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) - :param energy_score: include energy_score column (Default value = False) - :param distance_score: include distance_score column (Default value = False) - :param inspiration_score: include inspiration_score column (Default value = False) - :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param derivative_ids: include derivative :class:`.Pose` ID column - :param tags: include tags column - :param subsites: include subsites column - """ - - from json import loads - - from pandas import DataFrame - from rdkit.Chem import Mol - - get_alias = alias - - if name: - alias = True - - query = ['pose_id'] - - if smiles: - query.append('pose_smiles') - - if inchikey: - query.append('pose_inchikey') - - if alias: - query.append('pose_alias') - - if reference_id or reference_alias: - query.append('pose_reference') - - if path: - query.append('pose_path') - - if compound_id: - query.append('pose_compound') - - if target_id: - query.append('pose_target') - - if mol: - query.append('pose_mol') - - if energy_score: - query.append('pose_energy_score') - - if distance_score: - query.append('pose_distance_score') - - if inspiration_score: - query.append('pose_inspiration_score') - - if metadata: - query.append('pose_metadata') - - query = ', '.join(query) - - sql = f""" - SELECT {query} - FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {self.str_ids} - """ - - if debug: - # print(sql) - mrich.debug('querying...') - records = self.db.execute(sql).fetchall() - - if debug: - generator = mrich.track(records) - else: - generator = records - - data = [] - for row in generator: - row = list(row) - - d = dict(id=row.pop(0)) - - if smiles: - d['smiles'] = row.pop(0) - - if inchikey: - d['inchikey'] = row.pop(0) - - if alias: - d['alias'] = row.pop(0) - - if reference_id or reference_alias: - d['reference_id'] = row.pop(0) - - if path: - d['path'] = row.pop(0) - - if compound_id: - d['compound_id'] = row.pop(0) - - if target_id: - d['target_id'] = row.pop(0) - - if mol: - mol_bytes = row.pop(0) - if mol_bytes: - d['mol'] = Mol(mol_bytes) - - if energy_score: - d['energy_score'] = row.pop(0) - - if distance_score: - d['distance_score'] = row.pop(0) - - if inspiration_score: - d['inspiration_score'] = row.pop(0) - - if metadata and (meta_str := row.pop(0)): - meta_dict = loads(meta_str) or {} - - if expand_metadata: - for k, v in meta_dict.items(): - d[k] = v - - else: - d['metadata'] = meta_dict - - data.append(d) - - df = DataFrame(data) - - if inspiration_ids or derivative_ids or inspiration_aliases: - if debug: - mrich.debug('adding inspiration column(s)') - - tuples = self.db.get_inspiration_tuples() - - if inspiration_ids or inspiration_aliases: - lookup = {} - for inspiration, derivative in tuples: - lookup.setdefault(derivative, set()) - lookup[derivative].add(inspiration) - df['inspiration_ids'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if derivative_ids: - lookup = {} - for inspiration, derivative in tuples: - lookup.setdefault(inspiration, set()) - lookup[inspiration].add(derivative) - df['derivative_ids'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if inspiration_aliases: - inspirations = PoseSet( - self.db, set.union(*list(df['inspiration_ids'].values)) - ) - lookup = self.db.get_pose_id_alias_dict(pset=inspirations) - df['inspiration_aliases'] = df['inspiration_ids'].apply( - lambda x: {lookup[i] for i in x} - ) - if not inspiration_ids: - df = df.drop(columns=['inspiration_ids']) - - if reference_alias: - references = PoseSet( - self.db, - set([int(x) for x in df['reference_id'].values if x is not None]), - ) - - if references: - lookup = self.db.get_pose_id_alias_dict(pset=references) - df['reference_alias'] = df['reference_id'].apply(lambda x: lookup[x]) - else: - df['reference_alias'] = None - - if not reference_id: - df = df.drop(columns=['reference_id']) - - if tags: - if debug: - mrich.debug('adding tag column') - lookup = self.db.get_pose_tag_dict() - - if not expand_tags: - df['tags'] = df['id'].apply(lambda x: lookup.get(x, set())) - - else: - for i, row in df.iterrows(): - for tag in lookup.get(row['id'], set()): - df.loc[i, tag] = True - - if subsites: - if debug: - mrich.debug('adding subsite column') - lookup = self.db.get_pose_subsite_names_dict() - df['subsites'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if name: - df['name'] = df.apply(lambda row: row['alias'] or f'P{row["id"]}', axis=1) - if not get_alias: - df = df.drop(columns=['alias']) - - df = df.set_index('id') - - ### Fill missing smiles entries - - smiles_missing = smiles and 'smiles' in df.columns and df['smiles'].isna().any() - inchikey_missing = ( - inchikey and 'inchikey' in df.columns and df['inchikey'].isna().any() - ) - - if smiles_missing or inchikey_missing: - mrich.error('None in smiles/inchikey column') - - empty = df[df['smiles'].isna()] - empty_poses = PoseSet(self.db, set(empty.index)) - - for pose in mrich.track( - empty_poses, prefix=f'generating smiles/inchikeys ({len(empty)} poses)' - ): - pose.smiles - - records = self.db.select_where( - table='pose', - query='pose_id, pose_smiles, pose_inchikey, pose_mol', - key=f'pose_id IN {empty_poses.str_ids}', - multiple=True, - ) - - for pose_id, pose_smiles, pose_inchikey, pose_mol in records: - df.loc[pose_id, 'smiles'] = pose_smiles - df.loc[pose_id, 'inchikey'] = pose_inchikey - df.loc[pose_id, 'mol'] = Mol(pose_mol) - - assert not df['smiles'].isna().any() - assert not df['inchikey'].isna().any() - - ### Fill missing molecule entries - - if mol and df['mol'].isna().any(): - empty = df[df['mol'].isna()] - - mrich.warning(len(empty), "rows have empty 'mol'") - empty_poses = PoseSet(self.db, set(empty.index)) - - for pose in mrich.track(empty_poses, prefix='generating Mols'): - pose.mol - - records = self.db.select_where( - table='pose', - query='pose_id, pose_mol', - key=f'pose_id IN {empty_poses.str_ids}', - multiple=True, - ) - - for pose_id, pose_mol in records: - df.loc[pose_id, 'mol'] = Mol(pose_mol) - - assert not len(df[df['mol'].isna()]) - - return df - - def get_by_reference( - self, - ref_id: int, - ) -> 'PoseSet | None': - """Get poses with a certain reference id - - :param ref_id: reference :class:`.Pose` ID - - """ - values = self.db.select_where( - table='pose', - query='pose_id', - key=f'pose_reference={ref_id} AND pose_id in {self.str_ids}', - multiple=True, - ) - if not values: - return None - return PoseSet(self.db, [v for (v,) in values]) - - def get_by_compound( - self, - *, - compound: 'int | Compound | CompoundSet', - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. - - :param compound: :class:`.Compound` object or ID - :returns: a :class:`.PoseSet` of the selection - - """ - from .compound import Compound - from .cset import CompoundSet - - if isinstance(compound, CompoundSet): - values = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_compound IN {compound.str_ids} AND pose_id in {self.str_ids}', - multiple=True, - none='quiet', - ) - - else: - if isinstance(compound, Compound): - compound = compound.id - - values = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_compound={compound} AND pose_id in {self.str_ids}', - multiple=True, - none='quiet', - ) - - if not values: - return None - ids = [v for (v,) in values if v] - return PoseSet(self.db, [v for (v,) in values]) - - def get_by_target( - self, - *, - id: int, - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. - - :param id: :class:`.Target` ID - :returns: a :class:`.PoseSet` of the selection - - """ - assert isinstance(id, int) - values = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_target is {id} AND pose_id in {self.str_ids}', - multiple=True, - none='quiet', - ) - ids = [v for (v,) in values if v] - if not ids: - return None - return PoseSet(self.db, ids) - - def get_by_subsite( - self, - *, - id: int, - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. - - :param id: :class:`.Subsite` ID - :returns: a :class:`.PoseSet` of the selection - - """ - assert isinstance(id, int) - values = self.db.select_where( - query='subsite_tag_pose', - table='subsite_tag', - key=f'subsite_tag_ref is {id} AND subsite_tag_pose in {self.str_ids}', - multiple=True, - none='quiet', - ) - ids = [v for (v,) in values if v] - if not ids: - return None - - if self.name: - name = f'{self.name} & subsite={id}' - else: - name = None - - return PoseSet(self.db, ids, name=name) - - def get_best_placed_poses_per_compound(self): - """Choose the best placed pose (best distance_score) grouped by compound""" - - sql = f""" - SELECT pose_id, MIN(pose_distance_score) - FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {self.str_ids} - GROUP BY pose_compound - """ - - cursor = self.db.execute(sql) - - ids = [i for i, _ in cursor] - - return PoseSet(self.db, ids) - - def filter( - self, - function=None, - *, - key: str = None, - value: str = None, - operator='=', - inverse: bool = False, - ): - """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values - - :param function: callable object - :param key: database field for 'pose' table ('pose_' prefix not needed) - :param value: value to compare to - :param operator: comparison operator (default = "=") - :param inverse: invert the selection (Default value = False) - - """ - - if function: - ids = set() - for pose in self: - value = function(pose) - # mrich.debug(f'{pose=} {value=}') - if value and not inverse: - ids.add(pose.id) - elif not value and inverse: - ids.add(pose.id) - - return PoseSet(self.db, ids) - - sql = f""" - SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_id IN {self.str_ids} - AND pose_{key} {operator} {value} - """ - - cursor = self.db.execute(sql) - - ids = [i for (i,) in cursor] - - return PoseSet(self.db, ids) - - ### BULK SETTING - - @property - def reference(self): - """Bulk set the references for poses in this set""" - raise NotImplementedError( - 'This attribute only allows setting, ``PoseSet.reference = ...``' - ) - - @reference.setter - def reference(self, r) -> None: - """Bulk set the references for poses in this set""" - if not isinstance(r, int): - assert r._table == 'pose' - r = r.id - - for i in self.indices: - self.db.update( - table='pose', id=i, key='pose_reference', value=r, commit=False - ) - - self.db.commit() - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - - assert isinstance(tag, str) - - for i in self.indices: - self.db.insert_tag(name=tag, pose=i, commit=False) - - mrich.print(f'Tagged {self} w/ "{tag}"') - - self.db.commit() - - def append_to_metadata( - self, - key, - value, - ) -> None: - """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries - - :param key: the :class:`.Metadata` key to match - :param value: the value to append to the list - - """ - for id in self.indices: - metadata = self.db.get_metadata(table='pose', id=id) - try: - metadata.append(key, value) - except AttributeError: - mrich.error(f'Could not append to metadata {key=}. Not a list?') - - def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: - """Create and assign subsite entries from a metadata field - - :param field: the metadata field to use - - """ - - self.db.set_subsites_from_metadata_field(pose_str_ids=self.str_ids, field=field) - - def calculate_inspiration_scores( - self, - alpha: float = 0.95, - beta: float = 0.05, - score_type: str = 'combo', - ) -> 'pd.DataFrame': - """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky - - :param alpha: Tversky alpha parameter - :param beta: Tversky beta parameter - :param score_type: Score type to add to database, choose from "combo", "shape", "colour" - :returns: Pandas DataFrame with molecules and scores - """ - - from mocassin.mocassin import calculate_mocassin_tversky - - df = self.get_df( - alias=False, - smiles=False, - inchikey=False, - inspiration_ids=True, - mol=True, - ) - - inspirations = {p.id: p for p in self.inspirations} - - df['inspiration_mols'] = df['inspiration_ids'].apply( - lambda x: [inspirations[i].mol for i in x] - ) - - n = len(df) - - for j, (i, row) in mrich.track( - enumerate(df.iterrows()), prefix='MoCASSIn', total=n - ): - mrich.set_progress_field('j', j) - mrich.set_progress_field('n', n) - - try: - combo, shape, colour = calculate_mocassin_tversky( - row['inspiration_mols'], - row['mol'], - alpha=0.95, - beta=0.05, - ) - df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo - df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape - df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour - except Exception as e: - mrich.error(e) - - tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() - - sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" - - mrich.debug('Updating pose_inspiration_score values') - self.db.executemany(sql, [(b, a) for a, b in tuples]) - self.db.commit() - - return df - - ### SPLITTING - - def split_by_reference(self) -> 'dict[int,PoseSet]': - """Split this :class:`.PoseSet` into subsets grouped by reference ID - - :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values - - """ - sets = {} - for ref_id in self.reference_ids: - sets[ref_id] = self.get_by_reference(ref_id) - return sets - - def split_by_inspirations( - self, - single_set: bool = False, - ) -> 'dict[PoseSet,PoseSet] | PoseSet': - """Split this :class:`.PoseSet` into subsets grouped by inspirations - - :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) - :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values - - """ - - sets = {} - - lookup = self.db.get_pose_id_inspiration_ids_dict(pset=self) - - for pose_id, insp_ids in lookup.items(): - key = tuple(insp_ids) - sets.setdefault(key, set()) - sets[key].add(pose_id) - - mrich.var('#unique inspiration combinations', len(sets)) - - if single_set: - return PoseSet(self.db, sum([s.ids for s in sets.values()], []), sort=False) + self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) return { - PoseSet(self.db, insp_ids): PoseSet(self.db, pose_ids) + PoseSet(Pose.objects.filter(pk__in=insp_ids)): PoseSet( + Pose.objects.filter(pk__in=pose_ids) + ) for insp_ids, pose_ids in sets.items() } @@ -1886,9 +932,6 @@ def write_sdf( :param fragalysis_inspirations: create inspirations column "ref_mols" """ - import json - from pathlib import Path - df = self.get_df( mol=True, inspiration_ids=inspiration_ids, @@ -1897,14 +940,12 @@ def write_sdf( **kwargs, ) + print('what do I have for name col', name_col) + print(df.columns) + if name_col not in ['name', 'alias', 'inchikey', 'id']: # try getting name from metadata - records = self.db.select_where( - table='pose', - query='pose_id, pose_metadata', - key=f'pose_id IN {self.str_ids}', - multiple=True, - ) + records = self._queryset.values('id', 'pose_metadata') longcode_lookup = {} for i, d in records: @@ -1921,17 +962,15 @@ def write_sdf( df[name_col] = values - df.rename(inplace=True, columns={name_col: '_Name', 'mol': 'ROMol'}) + df = df.rename(columns={name_col: '_Name', 'mol': 'ROMol'}) mrich.writing(out_path) - from rdkit.Chem import PandasTools - PandasTools.WriteSDF(df, out_path, 'ROMol', '_Name', list(df.columns)) # keep record of export value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) self.append_to_metadata(key='exports', value=value) def to_fragalysis( @@ -1979,35 +1018,27 @@ def to_fragalysis( """ - from pathlib import Path - - from rdkit.Chem import PandasTools, SDWriter - - from .fragalysis import generate_header - assert out_path.endswith('.sdf') _name_col = '_Name' mol_col = 'ROMol' + mol_col = 'mol' # make sure references are defined: + logger.debug('entering') mrich.debug(len(self), 'poses in set') poses = None if skip_no_reference: - values = self.db.select_where( - table='pose', - query='DISTINCT pose_id', - key=f'pose_reference IS NOT NULL and pose_id in {self.str_ids}', - multiple=True, - none='error', - ) + values = self._queryset.filter(pose_reference__isnull=False) - if not values: + if not values.exists(): + mrich.debug('no references, quitting') + logger.warning('no references, quitting') return - poses = PoseSet(self.db, [i for (i,) in values]) + poses = PoseSet(values) mrich.debug(len(poses), 'remaining after skipping null reference') @@ -2015,28 +1046,34 @@ def to_fragalysis( if not poses: poses = self - values = self.db.select_where( - table='inspiration', - query='DISTINCT inspiration_derivative', - key=f'inspiration_derivative IN {poses.str_ids}', - multiple=True, - none='error', + values = Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'derivative_pose', ) - if not values: + if not values.exists(): + rich.debug('no inspirations, quitting') + logger.warning('no inspirations, quitting') return - poses = PoseSet(self.db, [i for (i,) in values]) + poses = PoseSet(Pose.objects.filter(pk__in=values)) mrich.debug(len(poses), 'remaining after skipping null inspirations') if not poses: - poses = PoseSet(self.db, self.ids) + # huh? + poses = PoseSet(self._queryset) mrich.var('#poses', len(poses)) - + logger.debug('about to create df') # get the dataframe of poses + # TODO: this should not go through the df + + # Scope issue - this code expect access to all poses in the db + self._queryset = Pose.objects.all() + pose_df = poses.get_df( mol=True, inspiration_ids=True, @@ -2062,17 +1099,18 @@ def to_fragalysis( # fix inspirations and reference column (comma separated aliases) - lookup = self.db.get_pose_id_alias_dict() + lookup = {k.pk: k.pose_alias for k in self._queryset} inspiration_strs = [] - for i, row in pose_df.iterrows(): - strs = [] - for i in row['inspiration_ids']: - alias = lookup.get(i) - if not alias: - continue - strs.append(alias) - inspiration_strs.append(','.join(strs)) + # for i, row in pose_df.iterrows(): + # strs = [] + # for i in normalize_string_list(row['inspiration_ids']): + # # this is what it did in original code + # alias = self._queryset.get(pk=i).pose_alias + # if not alias: + # continue + # strs.append(alias) + # inspiration_strs.append(','.join(strs)) # comma separate subsites if subsites: @@ -2080,6 +1118,7 @@ def to_fragalysis( def fix_subsites(subsite_list: list[str]): """Fix subsites""" if not subsite_list: + logger.warning('no subsite list') return 'None' return ','.join(subsite_list) @@ -2088,7 +1127,8 @@ def fix_subsites(subsite_list: list[str]): if tags: pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(x)) - pose_df['ref_mols'] = inspiration_strs + # pose_df['ref_mols'] = inspiration_strs + pose_df['ref_mols'] = 'inspiration_strs' pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup[x]) # add compound identifier column (inchikey?) @@ -2188,8 +1228,6 @@ def fix_subsites(subsite_list: list[str]): mrich.var('out_path', out_path) if generate_pdbs: - from zipfile import ZipFile - # output subdirectory out_key = Path(out_path).name.removesuffix('.sdf') pdb_dir = Path(out_path).parent / Path(out_key) @@ -2216,9 +1254,6 @@ def fix_subsites(subsite_list: list[str]): mrich.writing(f'{out_key}_pdbs.zip') if copy_reference_pdbs: - import shutil - from zipfile import ZipFile - # output subdirectory out_key = Path(out_path).name.removesuffix('.sdf') pdb_dir = Path(out_path).parent / Path(out_key) @@ -2226,7 +1261,8 @@ def fix_subsites(subsite_list: list[str]): zip_path = Path(out_path).parent / f'{out_key}_refs.zip' references = self.references - lookup = self.db.get_pose_alias_path_dict(references) + # lookup = self.db.get_pose_alias_path_dict(references) + lookup = {k.pose_alias: k.pose_path for k in self._queryset} zips = set() for ref_alias in pose_df['ref_pdb'].values: @@ -2260,7 +1296,8 @@ def fix_subsites(subsite_list: list[str]): df_cols = set(pose_df.columns) header = generate_header( - self[0], + # self[0], # <- what does that do?? + self._queryset.first(), method=method, ref_url=ref_url, submitter_name=submitter_name, @@ -2306,7 +1343,10 @@ def fix_subsites(subsite_list: list[str]): # keep record of export value = str(Path(out_path).resolve()) - self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + + # FIXME + # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) + self.append_to_metadata(key='exports', value=value) return pose_df @@ -2327,8 +1367,8 @@ def to_pymol(self, prefix: str | None = None) -> None: from pathlib import Path for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): - ref_pose = self.db.get_pose(id=ref_id) - ref_name = ref_pose.name or ref_id + ref_pose = Pose.objects.get(id=ref_id) + ref_name = ref_pose.pose_alias or ref_id # create the subdirectory ref_dir = Path(f'{prefix}ref_{ref_name}') @@ -2396,9 +1436,6 @@ def to_knitwork( """ - from os.path import relpath - from pathlib import Path - out_path = Path(out_path).resolve() path_root = Path(path_root).resolve() mrich.var('out_path', out_path) @@ -2410,9 +1447,9 @@ def to_knitwork( with open(out_path, 'w') as f: mrich.writing(out_path) - for pose in self: - assert pose.alias - assert 'hits' in pose.tags + for pose in self._queryset: + assert pose.pose_alias + assert pose.tags.filter(pose_tag_name='hits').exists() if aligned_files_dir: mol = str(pose.mol_path) @@ -2433,7 +1470,7 @@ def to_knitwork( mol = relpath(pose.mol_path, path_root) pdb = relpath(pose.apo_path, path_root) - data = [pose.alias, pose.compound.smiles, mol, pdb] + data = [pose.pose_alias, pose.compound.compound_smiles, mol, pdb] f.write(','.join(data)) f.write('\n') @@ -2443,8 +1480,6 @@ def to_syndirella( ) -> 'DataFrame': """Create syndirella inputs""" - from pathlib import Path - out_key = Path('.') / out_key out_dir = out_key.parent @@ -2455,8 +1490,6 @@ def to_syndirella( out_dir.mkdir(parents=True, exist_ok=True) - import shutil - ### Prepare Syndirella CSV data df = self.get_df( @@ -2501,7 +1534,11 @@ def to_syndirella( for j, inspiration in enumerate(row['inspiration_aliases']): df.loc[i, f'hit{j + 1}'] = inspiration - all_inspirations = set.union(*list(df['inspiration_aliases'].values)) + # this from original code. looking at the data type I have, + # this cannot possibly work. did I get something wrong filling + # the df? + # all_inspirations = set.union(*list(df['inspiration_aliases'].values)) + all_inspirations = set().union(*df['inspiration_aliases']) df = df.drop(columns=['name', 'inspiration_aliases']) @@ -2513,13 +1550,13 @@ def to_syndirella( templates = df['template'].unique() - records = self.db.select_id_where( - table='pose', - key=f'pose_alias IN {str(tuple(templates)).replace(",)", ")")}', - multiple=True, + # records = self._queryset.filter(pose_alias__in=templates) + records = Pose.objects.filter( + target__in=self.targets, + pose_alias__in=templates, ) - templates = PoseSet(self.db, [i for (i,) in records]) + templates = PoseSet(records) for ref in templates: template = template_dir / ref.apo_path.name @@ -2528,14 +1565,14 @@ def to_syndirella( shutil.copy(ref.apo_path, template) ### Inspirations - - records = self.db.select_id_where( - table='pose', - key=f'pose_alias IN {str(tuple(all_inspirations)).replace(",)", ")")}', - multiple=True, + print('all inspirations', all_inspirations) + # records = self._queryset.filter(pose_alias__in=all_inspirations) + # isn't this overwriting the one few lines above?? + records = Pose.objects.filter( + target__in=self.targets, pose_alias__in=all_inspirations ) - all_inspirations = PoseSet(self.db, [i for (i,) in records]) + all_inspirations = PoseSet(records) ### Write CSV @@ -2579,19 +1616,6 @@ def interactive( """ - from pprint import pprint - - from IPython.display import display - from ipywidgets import ( - BoundedIntText, - Checkbox, - GridBox, - Layout, - VBox, - interactive, - interactive_output, - ) - if method: def widget(i): @@ -2677,7 +1701,7 @@ def widget( metadata: bool = True, ): """Default widget""" - pose = self[i] + pose = self._queryset.get(pk=i) if name: print(repr(pose)) @@ -2697,254 +1721,523 @@ def widget( mrich.title('Metadata:') pprint(pose.metadata) - out = interactive_output( - widget, - { - 'i': a, - 'name': b, - 'summary': c, - 'grid': d, - 'draw2d': e, - 'draw': f, - 'metadata': g, - 'tags': h, - 'subsites': i, - }, - ) + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'grid': d, + 'draw2d': e, + 'draw': f, + 'metadata': g, + 'tags': h, + 'subsites': i, + }, + ) + + display(ui, out) + + def summary(self) -> None: + """Print a summary of this pose set""" + mrich.header('PoseSet()') + mrich.var('#poses', len(self)) + mrich.var('#compounds', self.num_compounds) + mrich.var('tags', self.tags) + + def draw(self) -> None: + """Render this pose set with Py3Dmol""" + + mols = [p.mol for p in self] + + drawing = draw_mols(mols) + # display(drawing) + + def grid(self) -> None: + """Draw a grid of all contained molecules""" + + data = [(p.name, p.compound.mol) for p in self] + + mols = [d[1] for d in data] + labels = [d[0] for d in data] + + drawing = draw_grid(mols, labels=labels) + display(drawing) + + # TODO: disabled, the field subsite_tag_ref doesn't exist anymore, + # don't know what the query is doing + # def subsite_summary(self) -> 'pd.DataFrame': + # """Print a table counting poses by subsite""" + + # sql = f""" + # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite + # INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag + # ON subsite_id = subsite_tag_ref + # WHERE subsite_tag_pose IN {self.str_ids} + # GROUP BY subsite_name + # """ + + # cursor = self.db.execute(sql) + + # df = DataFrame( + # [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] + # ) + + # df = df.set_index('id') + + # df = df.sort_values(by='num_poses', ascending=False) + + # mrich.print(df) + + # return df + + def get_interaction_overlaps(self, return_pairs: bool = False) -> int: + """Count the number of member pose pairs which share at least one but not all interactions""" + + records = Interaction.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_id = r['feature'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_id)) + ISETS[pose_id] = values + + ids = [i for i in self.ids if i in ISETS] + + count = 0 + + pairs = set() + + for pose_j, pose_k in combinations(ids, 2): + iset_j = ISETS[pose_j] + iset_k = ISETS[pose_k] + + intersection = iset_j & iset_k + diff1 = iset_j - iset_k + diff2 = iset_k - iset_j + + if intersection and diff1 and diff2: + count += 1 + pairs.add((pose_j, pose_k)) + + if return_pairs: + return [PoseSet(Pose.objects.filter(pk__in[a, b])) for a, b in pairs] + + return count + + def get_interaction_clusters(self) -> 'dict[int, PoseSet]': + """Cluster poses based on shared interactions.""" + + # get interaction records + + sql = f""" + SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}interaction + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature + ON interaction_feature = feature_id + WHERE interaction_pose IN {self.str_ids} + """ + + records = self.db.execute(sql).fetchall() + records = Interaction.objects.filter( + pose__in=self._queryset, + ).values( + 'pose', + 'feature__feature_residue_name', + 'feature__feature_residue_number', + 'interaction_type', + ) + + ISETS = {} + for r in records: + pose_id = r['pose'] + feature_residue_name = r['feature_residue_name'] + feature_residue_number = r['feature_residue_number'] + interaction_type = r['interaction_type'] + values = ISETS.get(pose_id, set()) + values.add((interaction_type, feature_residue_name, feature_residue_number)) + ISETS[pose_id] = values + + pairs = combinations(ISETS.keys(), 2) + + # construct overlap dictionary + + OVERLAPS = {} + for id1, id2 in pairs: + iset1 = ISETS[id1] + iset2 = ISETS[id2] + OVERLAPS[(id1, id2)] = len(iset1 & iset2) + + # make the graph + G = nx.Graph() + + for (id1, id2), count in OVERLAPS.items(): + G.add_edge(id1, id2, weight=count) + + # partition the graph + + partition = louvain.best_partition(G, weight='weight') + + # find the clusters + + clusters = {} + for node, cluster_id in partition.items(): + clusters.setdefault(cluster_id, set()).add(node) + + # create the PoseSets + + psets = { + i: PoseSet(Pose.objects.filter(pk__in=ids), name=f'Cluster {i}') + for i, ids in enumerate(clusters.values()) + } + + all_ids = set(sum((pset.ids for pset in psets.values()), [])) + + # calculate modal interactions + + for i, cluster in psets.items(): + mrich.var(cluster.name, len(cluster), unit='poses') + + df = cluster.interactions.df + + unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ + 'pose_id' + ].nunique() + + max_count = unique_counts.max() + max_pairs = unique_counts[unique_counts == max_count] + + for ( + interaction_type, + residue_name, + residue_number, + ) in max_pairs.index.values: + mrich.print(interaction_type, 'w/', residue_name, residue_number) + + # unclustered + unclustered = set(i for i in self.ids if i not in all_ids) + psets[None] = PoseSet( + Pose.objects.filter(pk__in=unclustered), name='Unclustered' + ) + + return psets + + ### PROPERTIES + + @property + def queryset(self) -> QuerySet[Pose]: + """Returns the ids of poses in this set""" + return self._queryset + + @property + def indices(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.queryset.values_list('id', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of poses in this set""" + return self.indices + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name - display(ui, out) + @property + def names(self) -> list[str]: + """Returns the aliases of poses in this set""" + return self._queryset.values_list('pose_alias', flat=True) - def summary(self) -> None: - """Print a summary of this pose set""" - mrich.header('PoseSet()') - mrich.var('#poses', len(self)) - mrich.var('#compounds', self.num_compounds) - mrich.var('tags', self.tags) + @property + def aliases(self) -> list[str]: + """Returns the aliases of child poses""" + return self._queryset.values_list('pose_alias', flat=True) - def draw(self) -> None: - """Render this pose set with Py3Dmol""" + @property + def inchikeys(self) -> list[str]: + """Returns the inchikeys of child poses""" + return self._queryset.values_list('pose_inchikey', flat=True) - from molparse.rdkit import draw_mols + @property + def id_name_dict(self) -> dict: + """Return a dictionary mapping pose ID's to their name""" + return {p.pk: p.pose_alias for p in Pose.objects.all()} - mols = [p.mol for p in self] + @property + def smiles(self) -> list[str]: + """Returns the smiles of poses in this set""" + return self._queryset.values_list('pose_smiles', flat=True) - drawing = draw_mols(mols) - # display(drawing) + @property + def tags(self) -> set[str]: + """Returns the set of unique tags present in this pose set""" + return self._queryset.values_list('tags__pose_tag_name', flat=True).distinct() - def grid(self) -> None: - """Draw a grid of all contained molecules""" - from molparse.rdkit import draw_grid + @property + def num_fingerprinted(self) -> int: + """Count the number of fingerprinted poses""" + # that's one field suspect not in use + return self._queryset.filter(pose_fingerprint=1).count() + + # seems unused and causes circular dependency + # @property + # def compounds(self) -> 'CompoundSet': + # """Get the compounds associated to this set of poses""" + # from .cset import CompoundSet + + # ids = self.db.select_where( + # table='pose', + # query='DISTINCT pose_compound', + # key=f'pose_id in {self.str_ids}', + # multiple=True, + # ) + # ids = [v for (v,) in ids] + # return CompoundSet(self.db, ids) - data = [(p.name, p.compound.mol) for p in self] + @property + def mols(self) -> list[Chem.rdchem.Mol]: + """Get the rdkit Molecules contained in this set""" + return self._queryset.values_list('pose_mol', flat=True) - mols = [d[1] for d in data] - labels = [d[0] for d in data] + @property + def num_compounds(self) -> int: + """Count the compounds associated to this set of poses""" + return self._queryset.values('compound').distinct().count() - drawing = draw_grid(mols, labels=labels) - display(drawing) + @property + def df(self) -> pd.DataFrame: + """Get a DataFrame of the poses in this set""" + return self.get_df(mol=True) - def subsite_summary(self) -> 'pd.DataFrame': - """Print a table counting poses by subsite""" + @property + def references(self) -> 'PoseSet': + """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" + # TODO: call through proper factory method + return self.get_by_references(self) - from pandas import DataFrame + @property + def reference_ids(self) -> set[int]: + """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" + return self.get_by_references(self).values_list('pk', flat=True) - sql = f""" - SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag - ON subsite_id = subsite_tag_ref - WHERE subsite_tag_pose IN {self.str_ids} - GROUP BY subsite_name - """ + @property + def inspiration_sets(self) -> list[set[int]]: + """Return a list of unique sets of inspiration :class:`.Pose` IDs""" - cursor = self.db.execute(sql) + pairs = Inspiration.objects.filter(derivative_pose__in=self._queryset) + data = {} + for p in pairs: + if p.derivative_pose not in data: + data[p.derivative_pose] = set() + data[p.derivative_pose].add(p.original_pose) - df = DataFrame( - [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] - ) + data = {k: tuple(sorted(list(v))) for k, v in data.items()} - df = df.set_index('id') + unique = set(data.values()) - df = df.sort_values(by='num_poses', ascending=False) + return unique - mrich.print(df) + @property + def num_inspiration_sets(self) -> int: + """Return the number of unique sets of inspirations""" + return len(self.inspiration_sets) - return df + @property + def num_inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + # fmt: off + return Inspiration.objects.filter( + derivative_pose__in=self._queryset, + ).values( + 'original_pose', + ).distinct().count() + # fmt: on - ### PRIVATE + @property + def inspirations(self) -> int: + """Return the number of unique inspirations for poses in this set""" + return self.get_by_inspirations(self._queryset) - def _delete(self, *, force: bool = False) -> None: - """Delete poses in this set""" + # @property + # def str_ids(self) -> str: + # """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" + # return str(tuple(self.ids)).replace(',)', ')') - if not force: - mrich.warning('Deleting Poses is risky! Set force=True to continue') - return + @property + def targets(self) -> QuerySet[Target]: + """Returns the :class:`.Target` objects of poses in this set""" + return Target.objects.filter(pk__in=self._queryset.values('target')) - str_ids = self.str_ids + @property + def target_names(self) -> list[str]: + """Returns the :class:`.Target` objects of poses in this set""" + return self.targets.values_list('target_name', flat=True) - # delete the poses in this set - self.db.delete_where( - table=self.table, key=f'pose_id IN {str_ids}', commit=False - ) + @property + def target_ids(self) -> list[int]: + """Returns the :class:`.Target` objects ID's of poses in this set""" + return self.targets.values_list('id', flat=True) - # check for other references to this pose - self.db.delete_where(table='tag', key=f'tag_pose IN {str_ids}', commit=False) - self.db.delete_where( - table='inspiration', - key=f'inspiration_original IN {str_ids}', - commit=False, - ) - self.db.delete_where( - table='inspiration', - key=f'inspiration_derivative IN {str_ids}', - commit=False, - ) - self.db.delete_where( - table='subsite_tag', - key=f'subsite_tag_pose IN {str_ids}', - commit=False, - ) - self.db.delete_where( - table='interaction', - key=f'interaction_pose IN {str_ids}', - commit=False, - ) + @property + def best_placed_pose(self) -> Pose: + """Returns the pose with the best distance_score in this subset""" + return self._queryset.get(pk=self.best_placed_pose_id) - self.db.execute( - f""" - UPDATE {self.db.SQL_SCHEMA_PREFIX}pose - SET pose_reference = NULL - WHERE pose_id IN {str_ids} - """ - ) + @property + def best_placed_pose_id(self) -> int: + """Get the id of the pose with the best distance_score in this subset""" - self.db.commit() + # if len(self) == 1: + # return self.ids[0] - ### DUNDERS + # query = 'pose_id, MIN(pose_distance_score)' + # query = self.db.select_where( + # table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False + # ) + # return query[0] - def __str__(self): - """Unformatted string representation""" - if self.name: - s = f'{self.name}: ' - else: - s = '' + # TODO: scoring not implemented yet + return self.queryset.first().pk - s += f'{{P × {len(self)}}}' + @property + def interactions(self) -> 'InteractionSet': + """Get a :class:`.InteractionSet` for this :class:`.Pose`""" + if self._interactions is None: + self._interactions = InteractionSet.from_pose(self) + return self._interactions - return s + @property + def pose_id_metadata_dict(self) -> dict[int, dict]: + """Get a dictionary mapping pose_ids to metadata dicts""" + if self._metadata_dict is None: + metadata = {} + for p in self._queryset: + metadata[p.pk] = p.pose_metadata + self._metadata_dict = metadata + return self._metadata_dict - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + @property + def fraction_fingerprinted(self) -> float: + """Return the fraction of fingerprinted poses in this set""" + return self.num_fingerprinted / len(self) - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' + @property + def num_subsites(self) -> int: + """Count the number of subsites that poses in this set come into contact with""" + return Subsite.objects.filter(pose__in=self._queryset).distinct().count() - def __len__(self) -> int: - """The number of poses in this set""" - return len(self.indices) + @property + def subsite_balance(self) -> float: + """Measure of how evenly subsite counts are distributed across poses in this set""" + # TODO: subsites not implemented yet + # from numpy import std - def __iter__(self): - """Iterate through poses in this set""" - return iter(self.db.get_pose(id=i) for i in self.indices) + # sql = f""" + # SELECT COUNT(DISTINCT subsite_tag_ref) + # FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag + # WHERE subsite_tag_pose IN {self.str_ids} + # GROUP BY subsite_tag_pose + # """ - def __getitem__( - self, - key: int | slice, - ) -> 'Pose | PoseSet': - """Get poses or subsets thereof from this set + # counts = self.db.execute(sql).fetchall() - :param key: integer index or slice of indices + # counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] - """ - match key: - case int(): - try: - index = self.indices[key] - except IndexError: - mrich.error(f'list index out of range: {key=} for {self}') - raise - return self.db.get_pose(id=index) + # return -std(counts) + return 4 - case slice(): - ids = self.indices[key] - return PoseSet(self.db, ids) + @property + def subsite_ids(self) -> set[int]: + """Return a list of subsite id's of member poses""" + return Subsite.objects.filter( + pk__in=SubsiteTag.objects.filter( + pose__in=self._queryset, + ).values(subsite), + ).values_list('pk', flat=True) - case _: - raise NotImplementedError + @property + def avg_energy_score(self) -> float: + """Average energy score of poses in this set""" + # TODO: scores not implemented + # from numpy import mean - def __add__( - self, - other: 'PoseSet', - ) -> 'PoseSet': - """Add a :class:`.PoseSet` to this set""" - if isinstance(other, PoseSet): - return PoseSet(self.db, self.ids + other.ids, sort=False) - elif isinstance(other, Pose): - return PoseSet(self.db, self.ids + [other.id], sort=False) - else: - raise NotImplementedError + # sql = f""" + # SELECT pose_energy_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ - def __sub__( - self, - other: 'PoseSet', - ) -> 'PoseSet': - """Substract a :class:`.PoseSet` from this set""" - match other: - case PoseSet(): - ids = set(self.ids) - set(other.ids) - return PoseSet(self.db, ids, sort=False) - case int(): - # assert other in set(self.ids) - return PoseSet(self.db, [i for i in self.ids if i != other], sort=False) + # scores = self.db.execute(sql).fetchall() + # return mean([s for (s,) in scores if s is not None]) + return 4 - def __and__(self, other: 'PoseSet'): - """AND set operation, returns only poses in both sets""" + @property + def avg_distance_score(self) -> float: + """Average distance score of poses in this set""" + # TODO: scores not implemented yet + # from numpy import mean - match other: - case PoseSet(): - ids = set(self.ids) & set(other.ids) - return PoseSet(self.db, ids) + # sql = f""" + # SELECT pose_distance_score + # FROM {self.db.SQL_SCHEMA_PREFIX}pose + # WHERE pose_id IN {self.str_ids} + # """ - case _: - raise NotImplementedError + # scores = self.db.execute(sql).fetchall() - def __or__(self, other: 'PoseSet'): - """OR set operation, returns union of both sets""" + # return mean([s for (s,) in scores if s is not None]) + return 4 - match other: - case PoseSet(): - ids = set(self.ids) | set(other.ids) - return PoseSet(self.db, ids) + @property + def derivatives(self) -> 'PoseSet': + """Get the :class:`.PoseSet` of derivatives""" + return PoseSet( + Pose.objects.filter( + pk__in=Inspiration.objects.filter( + original_pose__in=self._queryset, + ).values( + 'derivative_pose', + ), + ), + ) - case _: - raise NotImplementedError + @property + def reference(self): + """Bulk set the references for poses in this set""" + raise NotImplementedError( + 'This attribute only allows setting, ``PoseSet.reference = ...``' + ) - def __xor__(self, other: 'PoseSet'): - """Exclusive OR set operation, returns all poses in either set but not both""" + @reference.setter + def reference(self, r) -> None: + """Bulk set the references for poses in this set""" + self._queryset.update(pose_reference=r) - match other: - case PoseSet(): - ids = set(self.ids) ^ set(other.ids) - return PoseSet(self.db, ids) + ### PRIVATE - case _: - raise NotImplementedError + def _delete(self, *, force: bool = False) -> None: + """Delete poses in this set""" - def __call__( - self, - *, - tag: str = None, - target: int = None, - subsite: int = None, - ) -> 'PoseSet': - """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" + if not force: + mrich.warning('Deleting Poses is risky! Set force=True to continue') + return - if tag: - return self.get_by_tag(tag) - elif target: - return self.get_by_target(id=target) - elif subsite: - return self.get_by_subsite(id=subsite) - else: - raise NotImplementedError + try: + with transaction.atomic(): + Inspiration.objects.filter(original_pose__in=self._queryset).delete() + Inspiration.objects.filter(derivative_pose__in=self._queryset).delete() + SubsiteTag.objects.filter(pose__in=self._queryset).delete() + Interaction.objects.filter(pose__in=self._queryset).delete() + self._queryset.delete() + except IntegrityError as exc: + mrich.error(exc) diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py new file mode 100644 index 0000000..8756d31 --- /dev/null +++ b/hippo/designdb/sets/reaction.py @@ -0,0 +1,362 @@ +"""Classes for working with sets of :class:`.Reaction` objects""" + +import mcol +import mrich +import pandas as pd +from django.db.models import Q +from hippo.recipe import Recipe +from IPython.display import display +from ipywidgets import BoundedIntText, Checkbox, GridBox, Layout, VBox, interactive_output + +from designdb.models import Compound, Reactant, Reaction +from designdb.sets.compound import CompoundSet + + +class ReactionSet: + """Object representing a subset of the 'reaction' table in the :class:`.Database`. + + .. attention:: + + :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + + Use as an iterable + ================== + + Iterate through :class:`.Reaction` objects in the set: + + :: + + rset = animal.reactions[:100] + + for reaction in rset: + ... + + Check membership + ================ + + To determine if a :class:`.Reaction` is present in the set: + + :: + + is_member = reaction in cset + + Selecting compounds in the set + ============================== + + The :class:`.ReactionSet` can be indexed like standard Python lists by their indices + + :: + + rset = animal.reactions[1:100] + + # indexing individual compounds + reaction = rset[0] # get the first reaction + reaction = rset[1] # get the second reaction + reaction = rset[-1] # get the last reaction + + # getting a subset of compounds using a slice + rset2 = rset[13:18] # using a slice + + """ + + def __init__( + self, + queryset=None, + *, + sort: bool = True, + name: str | None = None, + ) -> None: + """ReactionSet initialisation""" + + if queryset: + if isinstance(queryset, list): + self._queryset = Reaction.objects.filter(pk__in=queryset) + else: + self._queryset = queryset + else: + self._queryset = Reaction.objects.none() + + self._name = name + if sort: + self._queryset = self._queryset.order_by('pk') + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.name: + s = f'{self.name}: ' + else: + s = '' + + s += f'{{R × {len(self)}}}' + + return s + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __len__(self) -> int: + """Number of member :class:`.Reaction` objects""" + return self._queryset.count() + + def __iter__(self): + """Iterate through member :class:`.Reaction` objects""" + return iter(self._queryset) + + def __getitem__(self, key) -> 'Reaction | ReactionSet': + """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" + + match key: + case int(): + try: + # reaction = Reaction.objects.get(pk=key) + reaction = self._queryset[key] + except Reaction.DoesNotExist as exc: + mrich.error(f'list index out of range: {key=} for {self}') + raise Reaction.DoesNotExist from exc + + return reaction + + case slice(): + return ReactionSet(Reaction.objects.filter(pk__in=key)) + + case _: + mrich.error( + f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' + ) + + return None + + def __add__(self, other: 'ReactionSet') -> 'ReactionSet': + """Add a :class:`.ReactionSet` to this one""" + if other: + return ReactionSet( + Reaction.objects.filter( + Q(pk__in=self._queryset) | Q(pk__in=other.queryset) + ), + sort=False, + ) + + def __sub__( + self, + other: 'ReactionSet', + ) -> 'ReactionSet': + """Substract a :class:`.ReactionSet` from this set""" + match other: + case ReactionSet(): + return ReactionSet( + Reaction.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) + ), + sort=False, + ) + + ### METHODS + + def add(self, r: Reaction) -> None: + """Add a :class:`.Reaction` to this set + + :param r: :class:`.Reaction` to be added + + """ + assert isinstance(r, Reaction) + self._queryset = Reaction.objects.filter( + pk__in=list(self._queryset.values_list('pk', flat=True)) + [r.pk], + ) + + def interactive(self): + """Creates a ipywidget to interactively navigate this PoseSet.""" + + a = BoundedIntText( + value=0, + min=0, + max=len(self) - 1, + step=1, + description=f'Rs (/{len(self)}):', + disabled=False, + ) + + b = Checkbox(description='Name', value=True) + c = Checkbox(description='Summary', value=False) + d = Checkbox(description='Draw', value=True) + e = Checkbox(description='Check chemistry', value=False) + f = Checkbox(description='Reactant Quotes', value=False) + + ui1 = GridBox( + [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') + ) + ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) + ui = VBox([a, ui1, ui2]) + + def widget( + i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False + ): + """ + + :param i: + :param name: (Default value = True) + :param summary: (Default value = True) + :param draw: (Default value = True) + :param check_chemistry: (Default value = True) + :param reactants: (Default value = False) + + """ + reaction = self[i] + if name: + print(repr(reaction)) + if summary: + reaction.summary(draw=False) + if draw: + reaction.draw() + if check_chemistry: + reaction.check_chemistry(debug=True) + if reactants: + for comp in reaction.reactants: + # if summary: + # comp.summary(draw=False) + # elif name: + print(repr(comp)) + + quotes = comp.get_quotes(df=True) + display(quotes) + + # break + + # if draw: + # comp.draw() + + out = interactive_output( + widget, + { + 'i': a, + 'name': b, + 'summary': c, + 'draw': d, + 'check_chemistry': e, + 'reactants': f, + }, + ) + + display(ui, out) + + def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: + """Construct a pandas.DataFrame of this ReactionSet + + :param smiles: Include smiles column (Default value = True) + :param mols: Include `rdkit.Chem.Mol` column (Default value = True) + :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: + + """ + + mrich.debug('Using slower Reaction.dict rather than direct SQL query...') + + data = [] + for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): + data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) + + return pd.DataFrame(data) + + def copy(self) -> 'ReactionSet': + """Return a copy of this set""" + return ReactionSet(self._queryset.all(), sort=False, name=self.name) + + def get_recipes( + self, amounts: float | list[float] = 1.0, **kwargs + ) -> Recipe | list[Recipe]: + """Get the :class:`.Recipe` object(s) from this set of recipes + + :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) + :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: + + """ + # avoiding circular imports + from designdb.recipe import Recipe + + return Recipe.from_reactions(reactions=self, amounts=1, **kwargs) + + def summary(self) -> None: + """Print a summary of the Reactions""" + + mrich.header(self) + for reaction in self: + print(repr(reaction)) + + ### PROPERTIES + + @property + def name(self) -> str | None: + """Returns the name of set""" + return self._name + + @property + def indices(self) -> list[int]: + """Returns the ids of reactions in this set""" + return self._queryset.values_list('pk', flat=True) + + @property + def ids(self) -> list[int]: + """Returns the ids of reactions in this set""" + return self._indices + + @property + def types(self) -> list[str]: + """Returns the types of reactions in this set""" + return self._queryset.values('reaction_type').distinct() + + @property + def num_types(self) -> int: + """Returns the number of reaction types in this set""" + return self._queryset.values('reaction_type').distinct().count() + + @property + def products(self) -> CompoundSet: + """Get all product compounds that can be synthesised with these reactions (no intermediates)""" + + qs = Compound.objects.filter( + pk__in=self._queryset.values('product_compound'), + ).exclude( + pk__in=self.intermediates.queryset.values('pk'), + ) + cset = CompoundSet(qs) + if self.name: + cset._name = f'products of {self}' + return cset + + @property + def intermediates(self) -> CompoundSet: + """Get all intermediate compounds that can be synthesised with these reactions""" + + # NB! not 100% sure about this queryset + qs = Compound.objects.filter( + Q( + pk__in=Reactant.objects.values('compound'), + ) + & Q(pk__in=self._queryset.values('product_compound')), + ) + cset = CompoundSet(qs) + + if self.name: + cset._name = f'intermediates of {self}' + return cset + + @property + def reactants(self) -> 'CompoundSet': + """Get all reactant compounds that are used by these reactions""" + + qs = Reactant.objects.filter( + reaction__in=self._queryset, + ).values('compound') + cset = CompoundSet(qs) + if self.name: + cset._name = f'reactants of {self}' + return cset + + @property + def get_dict(self) -> dict[str]: + """Serializable dictionary""" + return dict(indices=self.indices) diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py new file mode 100644 index 0000000..e1fa364 --- /dev/null +++ b/hippo/designdb/sets/route.py @@ -0,0 +1,427 @@ +import json + +import mcol +import mrich + +from designdb.models import Component, Route +from designdb.sets.compound import CompoundSet + + +class RouteSet: + """A set of Route objects""" + + def __init__(self, routes: 'list[Route]') -> None: + """RouteSet initialisation""" + + data = {} + for route in routes: + # assert isinstance(route, Route) + data[route.id] = route + + self._data = data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + ### FACTORIES + + @classmethod + def from_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of :class:`.Route` IDs + + :param db: database to link + :param ids: :class:`.Route` database IDs + :param progress: show progress bar + """ + + # this gets stuck + # if progress: + # ids = mrich.track(ids, prefix='Getting routes') + + # avoiding circular reference + # avoiding name conflict + from designdb.route import RouteObj + + routes = [RouteObj.get_route(id=r) for r in ids] + + # self = cls.__new__(cls) + return RouteSet(routes) + + @classmethod + def from_product_ids(cls, ids: list | set, progress: bool = True): + """Generate a routeset from a set of product :class:`.Compound` IDs + + :param db: database to link + :param ids: :class:`.Compound` database IDs + """ + + # str_ids = str(tuple(ids)).replace(',)', ')') + + # records = db.select_where( + # table='route', + # query='route_id', + # key=f'route_product IN {str_ids}', + # multiple=True, + # ) + records = Route.objects.filter( + product_compound__pk__in=ids, + ) + + # route_ids = [i for (i,) in records] + + return cls.from_ids(records.values_list('id', flat=True), progress=progress) + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': + """Load a serialised routeset from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + self = cls.__new__(cls) + + if data is None: + data = json.load(open(path)) + + new_data = {} + for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): + route_id = d['id'] + new_data[route_id] = Route.from_json(db=db, path=None, data=d) + + self._data = new_data + self._cluster_map = None + self._permitted_clusters = None + self._current_cluster = None + + return self + + ### PROPERTIES + + @property + def data(self) -> 'dict[int, Route]': + """Get internal data dictionary""" + return self._data + + @property + def db(self): + """Get associated database""" + return self._db + + @property + def routes(self) -> 'list[Route]': + """Get route objects""" + return self.data.values() + + @property + def product_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the products""" + return Route.objects.values_list('product_compound__id', flat=True).distinct() + + @property + def reactant_ids(self) -> list[int]: + """Get the :class:`.Compound` ID's of the reactants""" + + return Component.objects.filter( + route__in=self.ids, + component_type=2, + ).values_list('component_ref', flat=True) + + @property + def products(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route products""" + return CompoundSet(self.product_ids) + + @property + def reactants(self) -> 'CompoundSet': + """Return a :class:`.CompoundSet` of all the route reactants""" + return CompoundSet(self.reactant_ids) + + @property + def str_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.Route` ID's""" + return str(tuple(self.ids)).replace(',)', ')') + + @property + def ids(self) -> list[int]: + """Return the :class:`.Route` IDs""" + return self.data.keys() + + @property + def cluster_map(self) -> dict[tuple, set]: + """Create a dictionary grouping routes by their scaffold/base cluster. + + :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. + """ + + if self._cluster_map is None: + # get route mapping + pairs = self.db.select_where( + query='route_product, route_id', + key=f'route_id IN {self.str_ids}', + table='route', + multiple=True, + ) + + route_map = {route_product: route_id for route_product, route_id in pairs} + + # group compounds by cluster + compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) + + # create the map + self._cluster_map = {} + for cluster, compounds in compound_clusters.items(): + self._cluster_map[cluster] = [] + for compound in compounds: + route_id = route_map.get(compound, None) + if not route_id: + continue + self._cluster_map[cluster].append(route_id) + + if not self._cluster_map[cluster]: + del self._cluster_map[cluster] + + return self._cluster_map + + ### METHODS + + def copy(self) -> 'RouteSet': + """Copy this RouteSet""" + return RouteSet(self.db, self.data.values()) + + def set_db_pointers(self, db: 'Database') -> None: + """ + + :param db: + + """ + self._db = db + for route in self.data.values(): + route._db = db + + # def clear_db_pointers(self): + # """ """ + # self._db = None + # for route in self.data.values(): + # route._db = None + + def get_dict(self): + """Get serialisable dictionary""" + + data = dict(db=str(self.db), routes={}) + + # populate with routes + for route_id, route in self.data.items(): + data['routes'][route_id] = route.get_dict() + + return data + + def prune_unavailable(self, suppliers: list[str]): + """Remove routes that don't have all reactants available from given suppliers""" + + suppliers_str = str(tuple(suppliers)).replace(',)', ')') + + sql = f""" + WITH possible_reactants AS ( + SELECT quote_compound, COUNT( + CASE + WHEN quote_supplier IN {suppliers_str} THEN 1 + END) AS [count_valid] + FROM {self.db.SQL_SCHEMA_PREFIX}quote + GROUP BY quote_compound + ), + + route_reactants AS ( + SELECT route_id, route_product, + COUNT( + CASE + WHEN count_valid = 0 THEN 1 + WHEN count_valid IS NULL THEN 1 + END) + AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id + LEFT JOIN possible_reactants ON quote_compound = component_ref + WHERE component_type = 2 + GROUP BY route_id + ) + + SELECT route_id FROM route_reactants + WHERE count_unavailable = 0 + AND route_id IN {self.str_ids} + """ + + route_ids = self.db.execute(sql).fetchall() + + route_ids = [i for (i,) in route_ids] + + mrich.var('#routes before pruning', len(self)) + mrich.var('#routes after pruning', len(route_ids)) + + return RouteSet.from_ids(self.db, route_ids) + + def pop_id(self) -> int: + """Pop the last route from the set and return it's id""" + route_id, route = self.data.popitem() + return route_id + + def pop(self) -> 'Route': + """Pop the last route from the set and return it's object""" + route_id, route = self.data.popitem() + return route + + def balanced_pop( + self, permitted_clusters: set[tuple] | None = None, debug: bool = False + ) -> 'Route': + """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" + + if not self._data: + mrich.print('RouteSet depleted') + return None + + if not self.cluster_map: + # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") + return self.pop() + + # store the permitted clusters (or all clusters) list as property + + if self._permitted_clusters is None: + if permitted_clusters: + permitted_clusters = set( + (cluster,) if isinstance(cluster, int) else cluster + for cluster in permitted_clusters + ) + + self._permitted_clusters = [] + for cluster in permitted_clusters: + if cluster not in self.cluster_map: + mrich.warning( + cluster, 'in permitted_clusters but not cluster_map' + ) + else: + self._permitted_clusters.append(cluster) + + else: + self._permitted_clusters = list(self.cluster_map.keys()) + + if self._current_cluster is None: + self._current_cluster = self._permitted_clusters[0] + + ### pop a Route + + if debug: + mrich.debug(f'Would pop Route from {self._current_cluster=}') + + cluster = self._current_cluster + + # pop the last route id from the given cluster + + try: + route_id = self.cluster_map[cluster].pop() + except IndexError: + mrich.print(self._permitted_clusters) + mrich.print(self.cluster_map) + raise + except AttributeError: + mrich.print(cluster) + mrich.print(self.cluster_map) + raise + except KeyError: + mrich.print('cluster', cluster) + mrich.print('self._permitted_clusters', self._permitted_clusters) + mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) + raise + + # clean up empty clusters + + if debug: + mrich.debug('Popped route', route_id) + + # get the Route object + + if route_id in self._data: + route = self._data[route_id] + del self._data[route_id] + else: + # if debug: + mrich.debug('Route not present') + return self.balanced_pop() + + ### increment cluster + + # def increment_cluster(cluster): + n = len(self._permitted_clusters) + if n > 1: + for i, cluster in enumerate(self._permitted_clusters): + if cluster == self._current_cluster: + if i == n - 1: + self._current_cluster = self._permitted_clusters[0] + else: + self._current_cluster = self._permitted_clusters[i + 1] + break + else: + raise IndexError('This should never be reached...') + + # increment_cluster() + + if not self.cluster_map[cluster]: + del self.cluster_map[cluster] + if not self.cluster_map: + mrich.debug('RouteSet.cluster_map depleted') + self._permitted_clusters = [ + c for c in self._permitted_clusters if c != cluster + ] + # if debug: + mrich.debug('Depleted cluster', cluster) + + if not self._permitted_clusters: + mrich.debug('Depleted all permitted clusters', cluster) + mrich.debug('Removing cluster restriction', cluster) + self._permitted_clusters = list(self.cluster_map.keys()) + self._current_cluster = None + + if debug: + mrich.debug('#Routes in set', len(self._data)) + + return route + + def shuffle(self): + """Randomly shuffle the routes in this set""" + import random + + items = list(self.data.items()) + random.shuffle(items) + self._data = dict(items) + + ### shuffle the cluster map as well + + for cluster, routes in self.cluster_map.items(): + random.shuffle(routes) + self.cluster_map[cluster] = routes + + ### DUNDERS + + def __len__(self) -> int: + """Number of routes in this set""" + return len(self.data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Route × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __iter__(self): + """Iterate over routes in this set""" + return iter(self.data.values()) + + def __getitem__(self, key): + """Get a specific route in this set""" + return list(self.data.values())[key] diff --git a/hippo/designdb/tests.py b/hippo/designdb/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/hippo/designdb/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/hippo/tools.py b/hippo/designdb/utils.py similarity index 72% rename from hippo/tools.py rename to hippo/designdb/utils.py index 2f91108..592b574 100644 --- a/hippo/tools.py +++ b/hippo/designdb/utils.py @@ -1,16 +1,23 @@ """Generic tools for use in the HIPPO package""" +import ast +import json import re from datetime import datetime from string import ascii_uppercase import mcol +import molparse as mp import mrich import numpy as np +from django.db.models import Aggregate, OuterRef, Subquery from molparse.rdkit import mol_from_smiles +from rdkit import Chem from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RemoveHs from rdkit.Chem.inchi import MolToInchiKey +from .models import Pose, ScoreValue + def strip_sql(sql) -> str: """Reduce unecessary whitespace in SQL""" @@ -41,9 +48,7 @@ def df_row_to_dict(df_row) -> dict: return data -def remove_other_ligands( - sys: 'molparse.System', residue_number: int, chain: str -) -> 'molparse.System': +def remove_other_ligands(sys: mp.System, residue_number: int, chain: str) -> mp.System: """Remove ligands other than the specified one""" ligand_residues = [r.number for r in sys['rLIG'] if r.number != residue_number] @@ -120,8 +125,10 @@ def sanitise_smiles( :param s: input smiles string :param verbosity: print smiles changes (Default value = False) - :param sanitisation_failed: behvaiour when sanitisation fails, choose from ["error", "warning", "quiet"] (Default value = 'error') - :param radical: behvaiour when radicals occur, choose from ["error", "warning", "remove"] (Default value = 'error') + :param sanitisation_failed: behvaiour when sanitisation fails, + choose from ["error", "warning", "quiet"] (Default value = 'error') + :param radical: behvaiour when radicals occur, choose from + ["error", "warning", "remove"] (Default value = 'error') :returns: SMILES string """ @@ -134,7 +141,7 @@ def sanitise_smiles( s = sorted(s.split('.'), key=lambda x: len(x))[-1] # flatten the smiles - stereo_smiles = s + # stereo_smiles = s smiles = s.replace('@', '') smiles = smiles.replace('/', '') smiles = smiles.replace('\\', '') @@ -197,14 +204,14 @@ def sanitise_smiles( return smiles -def sanitise_mol(m: 'rdkit.Chem.Mol') -> 'rdkit.Chem.Mol': +def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: """Sanitise by RDKit round-trip""" from rdkit.Chem import MolFromMolBlock, MolToMolBlock return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: 'Pose', b: 'Pose') -> float: +def pose_gap(a: Pose, b: Pose) -> float: """Calculate minimum distance between two :class:`.Pose` objects""" from molparse.rdkit import mol_to_AtomGroup @@ -257,3 +264,78 @@ class SanitisationError(Exception): """Something went wrong in Molecule/SMILES sanitisation""" ... + + +def make_warn_once_per_key(): + """Warn once per field type in sdf file. + + When attribute is defined but broken in all molecules, no need to + complain every time. + + Instatiate at the beginning of the loading process and pass where + needed. + + """ + warned = set() + + def warn(key, msg): + if key not in warned: + print(f'WARNING: {msg}') + warned.add(key) + + return warn + + +class ScoreSubquery(Subquery): + def __init__(self, scoring_method): + query = ScoreValue.objects.filter( + pose=OuterRef('pk'), + compound=OuterRef('compound'), + scoring_method__method_name=scoring_method, + ).values('score')[:1] + super().__init__(query) + + +# Don't understand the distinct here. Shouldn't have to use it. +# Workaround for missing ArrayAgg in sqlite, can get rid of when +# moving to postgres +class JsonGroupArray(Aggregate): + function = 'json_group_array' + # template = "%(function)s(%(expressions)s)" + template = '%(function)s(DISTINCT %(expressions)s)' + + +def normalize_string_list(x): + """Convert string representation of list to proper list""" + if not x: + return [] + if isinstance(x, list): + # return list(set(x)) + return x + if isinstance(x, str): + # try JSON first + try: + parsed = json.loads(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # fallback for python-style strings + try: + parsed = ast.literal_eval(x) + if isinstance(parsed, list): + # return list(set(parsed)) + return parsed + except Exception: + pass + + # ultimate fallback, comma-separated string + try: + splits = x.split(',') + if isinstance(splits, list): + return splits + except Exception: + pass + return [] diff --git a/hippo/fragalysis.py b/hippo/designdb/utils_frag.py similarity index 70% rename from hippo/fragalysis.py rename to hippo/designdb/utils_frag.py index 48e4aa5..de75c23 100644 --- a/hippo/fragalysis.py +++ b/hippo/designdb/utils_frag.py @@ -1,6 +1,44 @@ """Functions for interfacing with Fragalysis data""" +from dataclasses import dataclass, fields + import mrich +from rdkit import Chem + +GENERATED_TAG_COLS = [ + 'ConformerSites alias', + 'CanonSites alias', + 'CrystalformSites alias', + 'Quatassemblies alias', + 'Crystalforms alias', + 'ConformerSites upload name', + 'CanonSites upload name', + 'CrystalformSites upload name', + 'Quatassemblies upload name', + 'Crystalforms upload name', + 'ConformerSites short tag', + 'CanonSites short tag', + 'CrystalformSites short tag', + 'Quatassemblies short tag', + 'Crystalforms short tag', + 'Centroid res', + 'Experiment code', + 'Pose', +] + + +META_IGNORE_COLS = [ + 'Code', + 'Long code', + 'Compound code', + 'Smiles', + 'Downloaded', + 'Main status', + 'GOOD count', + 'MEDIOCRE count', + 'BAD count', + 'RefinementResolution', +] def generate_header( @@ -13,7 +51,7 @@ def generate_header( generation_date: str | None = None, extras=None, metadata: bool = True, -) -> 'Chem.Mol': +) -> Chem.rdchem.Mol: """Generate a header molecule for Fragalysis RHS upload""" extras = extras or {} @@ -23,7 +61,7 @@ def generate_header( from molparse.rdkit import mol_from_smiles from rdkit.Chem.AllChem import EmbedMolecule - header = mol_from_smiles(pose.compound.smiles) + header = mol_from_smiles(pose.compound.compound_smiles) header.SetProp('_Name', 'ver_1.2') EmbedMolecule(header) @@ -38,7 +76,7 @@ def generate_header( header.SetProp('method', method) if metadata: - for k, v in pose.metadata.items(): + for k, _ in pose.pose_metadata.items(): header.SetProp(k, str(k)) for k, v in extras.items(): @@ -47,7 +85,16 @@ def generate_header( return header -def parse_observation_longcode(longcode: str) -> dict[str]: +@dataclass +class LongcodeRecord: + target: str + crystal: str + chain: str + residue_number: int + version: int + + +def parse_observation_longcode(longcode: str) -> LongcodeRecord: """Parse a Fragalysis longcode and try to extract the following information: - Target name (target) @@ -80,10 +127,10 @@ def parse_observation_longcode(longcode: str) -> dict[str]: crystal = match.group(1) else: - target_name = None + target_name = '' crystal = cryst_str - return dict( + return LongcodeRecord( target=target_name, crystal=crystal, chain=chain, @@ -99,8 +146,6 @@ def find_observation_longcode_matches( dq = parse_observation_longcode(query) - keys = dq.keys() - if debug: mrich.var('allow_version_none', allow_version_none) mrich.var('dq', str(dq)) @@ -116,15 +161,15 @@ def find_observation_longcode_matches( dc = parse_observation_longcode(code) - for key in keys: + for key in fields(dq): if ( allow_version_none - and key == 'version' - and (dc[key] is None or dq[key] is None) + and key.name == 'version' + and (getattr(dc, key.name) is None or getattr(dq, key.name) is None) ): continue - if dc[key] != dq[key]: + if getattr(dc, key.name) != getattr(dq, key.name): break else: if debug: diff --git a/hippo/xca.py b/hippo/designdb/utils_xca.py similarity index 100% rename from hippo/xca.py rename to hippo/designdb/utils_xca.py diff --git a/hippo/designdb/views.py b/hippo/designdb/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/hippo/designdb/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/hippo/feature.py b/hippo/feature.py deleted file mode 100644 index b0e2ec3..0000000 --- a/hippo/feature.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Classes to work with pharmacophoric features""" - -from dataclasses import dataclass - -import mcol - -from .target import Target - - -@dataclass -class Feature: - """Pharmocophoric feature in a protein - - attributes: - id: Database ID - family: Feature family - chain_name: Protein chain name/letter - residue_name: Protein residue name - residue_number: Protein residue number - atom_names: Protein atom names (whitespace-delimited) - - """ - - id: int - family: str - target: Target - chain_name: str - residue_name: str - residue_number: int - atom_names: str - - def __str__(self) -> str: - """Unformatted string representation""" - return f'F{self.id}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self.family} {self.chain_name} {self.residue_name} {self.residue_number} [{self.atom_names}]' - - @property - def chain_res_name_number_str(self) -> str: - """Return a string representation of the feature""" - return f'{self.chain_name} {self.residue_name} {self.residue_number}' - - @property - def res_name_number_str(self) -> str: - """Return a string representation of the feature""" - return f'{self.residue_name} {self.residue_number}' - - @property - def res_number_name_tuple(self) -> str: - """Return a tuple representation of the feature""" - return (self.residue_number, self.residue_name) - - @property - def res_name_number_family_str(self) -> str: - """Return a string representation of the feature""" - return f'{self.residue_name} {self.residue_number} {self.family}' - - @property - def backbone(self) -> bool: - """Are any of the atoms referenced by this feature on the backbone?""" - from molparse.amino import BB_NAMES - - for atom_name in self.atom_names.split(','): - if atom_name in BB_NAMES: - return True - - return False - - @property - def sidechain(self) -> bool: - """Are any of the atoms referenced by this feature on the sidechain?""" - from molparse.amino import BB_NAMES - - for atom_name in self.atom_names.split(','): - if atom_name.startswith('H'): - continue - - if atom_name not in BB_NAMES: - return True - - return False diff --git a/hippo/interaction.py b/hippo/interaction.py deleted file mode 100644 index 6235636..0000000 --- a/hippo/interaction.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Classes for working with interactions""" - -import mcol -import mrich - - -class Interaction: - """A :class:`.Interaction` represents an interaction between an rdkit Feature on a :class:`.Pose` and a :class:`.Feature` on the protein :class:`.Target`. - - .. attention:: - - :class:`.Interaction` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. - - """ - - def __init__( - self, - db: 'Database', - id: int, - feature_id: int, - pose_id: int, - type: str, - family: str, - atom_ids: str, - prot_coord: str, - lig_coord: str, - distance: float, - angle: float, - energy: float | None, - table: str = 'interaction', - ) -> None: - """Interaction initialisation""" - - import json - - # from interaction table - self._id = id - self._feature_id = feature_id - self._pose_id = pose_id - self._type = type - self._family = family - self._atom_ids = json.loads(atom_ids) - self._prot_coord = json.loads(prot_coord) - self._lig_coord = json.loads(lig_coord) - self._distance = distance - self._angle = angle - self._energy = energy - - # placeholders - self._pose = None - self._feature = None - self._table = table - - self._db = db - - ### PROPERTIES - - @property - def id(self) -> int: - """Returns the interaction's database ID""" - return self._id - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def family(self) -> str: - """The Feature family""" - return self._family - - @property - def pose_id(self) -> int: - """Returns the associated :class:`.Pose`'s database ID""" - return self._pose_id - - @property - def pose(self) -> 'Pose': - """Returns the associated :class:`.Pose`'s object""" - if not self._pose: - self._pose = self.db.get_pose(id=self.pose_id) - return self._pose - - @property - def feature_id(self) -> int: - """Returns the associated :class:`.Feature`'s database ID""" - return self._feature_id - - @property - def feature(self) -> 'Feature': - """Returns the associated :class:`.Feature`'s object""" - if not self._feature: - self._feature = self.db.get_feature(id=self.feature_id) - return self._feature - - @property - def residue_name(self) -> str: - """Returns the associated :class:`.Feature`'s residue name""" - return self.feature.residue_name - - @property - def residue_number(self) -> int: - """Returns the associated :class:`.Feature`'s residue number""" - return self.feature.residue_number - - @property - def atom_ids(self) -> list[int]: - """Returns the indices of atoms making up the ligand feature""" - return self._atom_ids - - @property - def prot_coord(self) -> list[float]: - """Returns the cartesian position of the protein :class:`.Feature`""" - return self._prot_coord - - @property - def lig_coord(self) -> list[float]: - """Returns the cartesian position of the ligand feature""" - return self._lig_coord - - @property - def distance(self) -> float: - """Returns the euclidian distance of the interaction""" - return self._distance - - @property - def angle(self) -> float | None: - """Returns the interaction angle (only defined for π-stacking and π-cation interactions)""" - return self._angle - - @property - def energy(self) -> float | None: - """Returns the interaction energy, if defined""" - return self._energy - - @property - def family_str(self) -> str: - """String of the two feature families""" - return f'{repr(self.feature)} ~ {self.family}' - - @property - def type(self) -> str: - """Interaction type string""" - from molparse.rdkit.features import INTERACTION_TYPES - - return INTERACTION_TYPES[(self.feature.family, self.family)] - - @property - def description(self) -> str: - """One line description of this interaction""" - s = f'{self.type} [{self.feature.chain_res_name_number_str}] {self.distance:.1f} Å' - if self.angle: - s += f', {self.angle:.1f} degrees' - return s - - ### METHODS - - def summary(self) -> None: - """Print a summary of this interaction's properties""" - - mrich.header(f'Interaction {self.id}') - - mrich.var('feature', self.feature) - mrich.var('pose', self.pose) - mrich.var('family', self.family) - mrich.var('atom_ids', self.atom_ids) - mrich.var('prot_coord', self.prot_coord) - mrich.var('lig_coord', self.lig_coord) - mrich.var('distance', self.distance) - mrich.var('angle', self.angle) - mrich.var('energy', self.energy) - - ### DUNDERS - - def __str__(self) -> str: - """Plain string representation""" - return f'I{self.id}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich formatted string representation""" - return f'[bold underline]{self}' diff --git a/hippo/manage.py b/hippo/manage.py new file mode 100755 index 0000000..8333a56 --- /dev/null +++ b/hippo/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/hippo/metadata.py b/hippo/metadata.py deleted file mode 100644 index 3752224..0000000 --- a/hippo/metadata.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Class for working with database stored JSON metadata""" - -from collections import UserDict -from collections.abc import Mapping - - -class MetaData(UserDict): - """Metadata dictionary linked to a compound or pose in a HIPPO :class:`.Database` - - .. attention:: - - :class:`.Metadata` objects should not be created directly. Instead use the methods :meth:`.Compound.metadata` and :meth:`.Pose.metadata` - - """ - - def __init__( - self, - __dict: Mapping[str, str] | None, - ) -> None: - """Metadata initialisation""" - - super().__init__() - if __dict: - for key, value in __dict.items(): - super().__setitem__(key, value) - - self._db = None - self._table: str = None - self._id: str = None - - ### PROPERTIES - - @property - def table(self) -> str: - """name of the associated :class:`.Database` table""" - return self._table - - @property - def id(self) -> int: - """entry ID in the associated :class:`.Database` table""" - return self._id - - @property - def db(self) -> 'Database': - """associated :class:`.Database`""" - return self._db - - ### METHODS - - def _update_db( - self, - commit: bool = True, - ) -> None: - """Update the associated :class:`.Database` entry - - :param commit: commit the changes (Default value = True) - - """ - self._db.insert_metadata( - table=self._table, id=self._id, payload=self.data, commit=commit - ) - - def update( - self, - data: dict, - commit: bool = True, - ) -> None: - """Wrapper for dict.update() - - :param data: data with which to update the metadata - :param commit: commit the changes (Default value = True) - - """ - self.data.update(data) - self._update_db(commit=commit) - - def append( - self, - key: str, - value, - commit: bool = True, - ) -> None: - """Create or append to a list-like value with given key - - :param key: metadata dictionary key to be modified - :param value: value to be appended to list-like ``metadata[key]`` - :param commit: commit the changes (Default value = True) - - """ - if key not in self: - self.data[key] = [] - if value not in self.data[key]: - self.data[key].append(value) - self._update_db(commit=commit) - - ### DUNDERS - - def __setitem__( - self, - key: str, - item, - ) -> None: - """Set the value associated to a specific dictionary key""" - - self.data.__setitem__(key, item) - self._update_db() - - def __delitem__(self, key: str) -> None: - """Set a dictionary key""" - - self.data.__delitem__(key) - self._update_db() diff --git a/hippo/migration.py b/hippo/migration.py deleted file mode 100644 index 8f1cb3a..0000000 --- a/hippo/migration.py +++ /dev/null @@ -1,1384 +0,0 @@ -"""Functions to perform the SQLite -> PostgreSQL migration, called by :meth:`.PostgresDatabase.migrate_sqlite`""" - -import mrich - - -def migrate_compounds( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate compounds""" - - # source data - compound_records = source.select( - table='compound', - query='compound_id, compound_inchikey, compound_smiles', - multiple=True, - ) - - mrich.var('source: #compounds', len(compound_records)) - - if not compound_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, - compound_mol - ) - VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s) - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - compound_dicts = [ - dict(smiles=smiles, inchikey=inchikey) - for i, inchikey, smiles in compound_records - ] - - # do the insertion - if execute: - executemany(destination, 'compound', sql, compound_dicts, batch_size) - - # map to the destination records - destination_inchikey_map = destination.get_compound_inchikey_id_dict( - inchikeys=[inchikey for i, inchikey, smiles in compound_records] - ) - - compound_id_map = { - i: destination_inchikey_map[inchikey] - for i, inchikey, smiles in compound_records - } - - migration_data['compound_id_map'] = compound_id_map - - return migration_data - - -def migrate_scaffolds( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate scaffolds""" - - # source data - scaffold_records = source.select( - table='scaffold', - query='scaffold_base, scaffold_superstructure', - multiple=True, - ) - - if not scaffold_records: - return migration_data - - # map to new IDs - scaffold_records = [ - ( - migration_data['compound_id_map'][base_id], - migration_data['compound_id_map'][superstructure_id], - ) - for (base_id, superstructure_id) in scaffold_records - ] - - mrich.var('source: #scaffolds', len(scaffold_records)) - - # insert new data - - sql = """ - INSERT INTO hippo.scaffold(scaffold_base, scaffold_superstructure) - VALUES(%s, %s) - ON CONFLICT DO NOTHING; - """ - - if execute: - executemany(destination, 'scaffold', sql, scaffold_records, batch_size) - - return migration_data - - -def migrate_targets( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate targets""" - - # source data - target_records = source.select( - table='target', query='target_id, target_name', multiple=True - ) - - if not target_records: - return migration_data - - # do the insertion - for i, name in target_records: - destination.insert_target(name=name, warn_duplicate=False) - - # map to the destination records - destination_target_name_map = { - name: i - for i, name in destination.select( - table='target', query='target_id, target_name', multiple=True - ) - } - - target_id_map = {i: destination_target_name_map[name] for i, name in target_records} - - migration_data['target_id_map'] = target_id_map - - return migration_data - - -def migrate_poses( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate poses""" - - from rdkit.Chem import Mol - - pose_fields = [ - 'pose_id', - 'pose_inchikey', - 'pose_alias', - 'pose_smiles', - 'pose_path', - 'pose_compound', - 'pose_target', - 'pose_mol', - 'pose_fingerprint', - 'pose_energy_score', - 'pose_distance_score', - 'pose_inspiration_score', - 'pose_metadata', - ] - - # source data - pose_records = source.select( - table='pose', query=', '.join(pose_fields), multiple=True - ) - - if not pose_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.pose( - pose_inchikey, - pose_alias, - pose_smiles, - pose_path, - pose_compound, - pose_target, - pose_mol, - pose_fingerprint, - pose_energy_score, - pose_distance_score, - pose_inspiration_score, - pose_metadata - ) - VALUES( - %(inchikey)s, - %(alias)s, - %(smiles)s, - %(path)s, - %(compound)s, - %(target)s, - hippo.mol_from_pkl(%(mol)s), - %(fingerprint)s, - %(energy_score)s, - %(distance_score)s, - %(inspiration_score)s, - %(metadata)s - ) - ON CONFLICT DO NOTHING; - """ - - # massage the data - pose_dicts = [ - dict( - id=i, - inchikey=inchikey, - alias=alias, - smiles=smiles, - path=path, - compound=migration_data['compound_id_map'][compound_id], - target=migration_data['target_id_map'][target_id], - mol=Mol(mol).ToBinary() if mol else None, - fingerprint=fingerprint, - energy_score=energy_score, - distance_score=distance_score, - inspiration_score=inspiration_score, - metadata=metadata, - ) - for ( - i, - inchikey, - alias, - smiles, - path, - compound_id, - target_id, - mol, - fingerprint, - energy_score, - distance_score, - inspiration_score, - metadata, - ) in pose_records - ] - - mrich.var('source: #poses', len(pose_dicts)) - - ### THIS DEVELOPMENT WAS NOT COMPLETED, - ### TO IMPLEMENT WOULD REQUIRE FIRST INSERTING ALL - ### UPSTREAM REFERENCES AND INSPIRATIONS SO THEIR IDS - ### ARE IN THE POSE_ID_MAP - # pose_dicts = rename_pose_paths(pose_dicts, migration_data) - - # do the insertion - if execute: - executemany(destination, 'pose', sql, pose_dicts, batch_size) - - # map to the destination records - destination_pose_path_map = destination.get_pose_path_id_dict() - - # return destination_pose_path_map - - pose_id_map = {p['id']: destination_pose_path_map[p['path']] for p in pose_dicts} - - migration_data['pose_id_map'] = pose_id_map - - return migration_data - - -def migrate_pose_references( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate pose references""" - - # source data - reference_records = source.select( - table='pose', - query='pose_id, pose_reference', - multiple=True, - ) - - if not reference_records: - return migration_data - - # map to new IDs - reference_dicts = [ - dict( - pose=migration_data['pose_id_map'][pose_id], - reference=migration_data['pose_id_map'][reference_id], - ) - for pose_id, reference_id in reference_records - if reference_id - ] - - mrich.var('source: #references', len(reference_dicts)) - - # insert new data - - sql = """ - UPDATE hippo.pose - SET pose_reference = %(reference)s - WHERE pose_id = %(pose)s; - """ - - if execute: - destination.executemany(sql, reference_dicts, batch_size=batch_size) - - return migration_data - - -def migrate_inspirations( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate inspirations""" - - # source data - inspiration_records = source.select( - table='inspiration', - query='inspiration_original, inspiration_derivative', - multiple=True, - ) - - if not inspiration_records: - return migration_data - - # map to new IDs - inspiration_dicts = [ - dict( - original=migration_data['pose_id_map'][a], - derivative=migration_data['pose_id_map'][b], - ) - for a, b in inspiration_records - if b - ] - - mrich.var('source: #inspirations', len(inspiration_dicts)) - - # insert new data - - sql = """ - INSERT INTO hippo.inspiration( - inspiration_original, - inspiration_derivative - ) - VALUES ( - %(original)s, - %(derivative)s - ) - ON CONFLICT DO NOTHING; - """ - - if execute: - executemany(destination, 'inspiration', sql, inspiration_dicts, batch_size) - - return migration_data - - -def migrate_tags( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate reactions and reactants""" - - import re - - # unique tag names - - tag_names = source.select(table='tag', query='DISTINCT tag_name', multiple=True) - - tag_names = sorted([t for (t,) in tag_names]) - - if not tag_names: - return migration_data - - # rename tags based on regex - - tag_name_map = {} - for tag in tag_names: - for pattern, template in migration_data['tag_compound_id_regex']: - match = re.match(pattern, tag) - - if not match: - continue - - groups = match.groups() - - assert len(groups) == 1, ( - f'tag_compound_id_regex replacement not supported with multiple groups, {pattern=}' - ) - - groups = [g for g in groups] - - compound_id = int(groups[0]) - new_compound_id = migration_data['compound_id_map'][compound_id] - - replacement = template.format(new_compound_id=new_compound_id) - - new_tag = re.sub(pattern, replacement, tag) - - if new_tag != tag: - tag_name_map[tag] = new_tag - - break - - # source data - tag_records = source.select( - table='tag', - query='tag_name, tag_compound, tag_pose', - multiple=True, - ) - - mrich.var('source: #tags', len(tag_records)) - - if tag_name_map: - mrich.warning('renamed', len(tag_name_map), 'tags') - - # insertion query - sql = """ - INSERT INTO hippo.tag( - tag_name, - tag_compound, - tag_pose - ) - VALUES( - %(name)s, - %(compound)s, - %(pose)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - tag_dicts = [ - dict( - name=tag_name_map.get(name, name), - compound=( - migration_data['compound_id_map'][compound_id] if compound_id else None - ), - pose=migration_data['pose_id_map'][pose_id] if pose_id else None, - ) - for name, compound_id, pose_id in tag_records - ] - - # add unchanged tags - for tag in tag_names: - if tag not in tag_name_map: - tag_name_map[tag] = tag - - migration_data['tag_name_map'] = tag_name_map - - # do the insertion - if execute: - executemany(destination, 'tag', sql, tag_dicts, batch_size) - - return migration_data - - -def migrate_reactions_and_reactants( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, -) -> dict: - """migrate inspirations""" - - # get source reaction data - source_reaction_dicts, reactant_records = get_reaction_id_reaction_dict_map( - source, migration_data['compound_id_map'] - ) - mrich.var('source: #reactions', len(source_reaction_dicts)) - - if not source_reaction_dicts: - return migration_data - - # get destination reaction data - destination_reaction_dicts, _ = get_reaction_id_reaction_dict_map(destination) - mrich.var('destination: #reactions', len(destination_reaction_dicts)) - - # create keyed lookups - - source_reaction_lookup = { - ( - d['product'], - d['type'], - tuple(sorted(list(d['reactant_ids']))), - ): d['id'] - for d in source_reaction_dicts.values() - } - - destination_reaction_lookup = { - ( - d['product'], - d['type'], - tuple(sorted(list(d['reactant_ids']))), - ): d['id'] - for d in destination_reaction_dicts.values() - } - - # work out which source reactions are not in the destination and create a map for existing reactions - - reaction_id_map = {} - new_reaction_dicts = [] - - for key, reaction_id in list(source_reaction_lookup.items()): - if key in destination_reaction_lookup: - # EXISTING REACTION - reaction_id_map[reaction_id] = destination_reaction_lookup[key] - - else: - # NEW REACTION - new_reaction_dicts.append(source_reaction_dicts[reaction_id]) - - mrich.var('existing #reactions:', len(reaction_id_map)) - mrich.var('new #reactions:', len(new_reaction_dicts)) - - # reaction insertion query - sql = """ - INSERT INTO hippo.reaction( - reaction_type, - reaction_product, - reaction_product_yield - ) - VALUES( - %(type)s, - %(product)s, - %(product_yield)s - ) - ON CONFLICT DO NOTHING - RETURNING reaction_id; - """ - - # massage the data - reaction_dicts = [ - dict( - type=d['type'], - product=d['product'], - product_yield=d['product_yield'], - ) - for d in new_reaction_dicts - ] - - # do the insertion - inserted_reaction_ids = executemany( - destination, 'reaction', sql, reaction_dicts, batch_size - ) - - if inserted_reaction_ids: - inserted_reaction_ids = [i for (i,) in inserted_reaction_ids] - else: - inserted_reaction_ids = [] - - # add to the map - for reaction_dict, new_reaction_id in zip( - new_reaction_dicts, inserted_reaction_ids, strict=False - ): - reaction_id = reaction_dict['id'] - reaction_id_map[reaction_id] = new_reaction_id - - migration_data['reaction_id_map'] = reaction_id_map - - # reactant insertion query - sql = """ - INSERT INTO hippo.reactant( - reactant_amount, - reactant_reaction, - reactant_compound - ) - VALUES( - %(amount)s, - %(reaction)s, - %(compound)s - ) - ON CONFLICT DO NOTHING; - """ - - reactant_dicts = [ - dict( - amount=amount, - reaction=reaction_id_map[reaction_id], - compound=migration_data['compound_id_map'][compound_id], - ) - for amount, reaction_id, compound_id in reactant_records - ] - - mrich.var('source: #reactants', len(reactant_dicts)) - - # do the insertion - executemany(destination, 'reactant', sql, reactant_dicts, batch_size) - - return migration_data - - -def migrate_features( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate features""" - - # source data - feature_records = source.select( - table='feature', - query='feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names', - multiple=True, - ) - - mrich.var('source: #features', len(feature_records)) - - if not feature_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.feature( - feature_family, - feature_target, - feature_chain_name, - feature_residue_name, - feature_residue_number, - feature_atom_names - ) - VALUES( - %(family)s, - %(target)s, - %(chain_name)s, - %(residue_name)s, - %(residue_number)s, - %(atom_names)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - feature_dicts = [ - dict( - family=family, - target=migration_data['target_id_map'][target_id], - chain_name=chain_name, - residue_name=residue_name, - residue_number=residue_number, - atom_names=atom_names, - ) - for ( - i, - family, - target_id, - chain_name, - residue_name, - residue_number, - atom_names, - ) in feature_records - ] - - # do the insertion - if execute: - executemany(destination, 'feature', sql, feature_dicts, batch_size) - - # get destination values - feature_map = { - ( - family, - target_id, - chain_name, - residue_name, - residue_number, - atom_names, - ): i - for ( - i, - family, - target_id, - chain_name, - residue_name, - residue_number, - atom_names, - ) in destination.select( - table='feature', - query='feature_id, feature_family, feature_target, feature_chain_name, feature_residue_name, feature_residue_number, feature_atom_names', - multiple=True, - ) - } - - # map to the destination records - feature_id_map = { - i: feature_map[ - ( - family, - migration_data['target_id_map'][target_id], - chain_name, - residue_name, - residue_number, - atom_names, - ) - ] - for ( - i, - family, - target_id, - chain_name, - residue_name, - residue_number, - atom_names, - ) in feature_records - } - - migration_data['feature_id_map'] = feature_id_map - - return migration_data - - -def migrate_interactions( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate interactions""" - - interaction_fields = [ - 'interaction_id', - 'interaction_feature', - 'interaction_pose', - 'interaction_type', - 'interaction_family', - 'interaction_atom_ids', - 'interaction_prot_coord', - 'interaction_lig_coord', - 'interaction_distance', - 'interaction_angle', - 'interaction_energy', - ] - - # source data - interaction_records = source.select( - table='interaction', - query=', '.join(interaction_fields), - multiple=True, - ) - - mrich.var('source: #interactions', len(interaction_records)) - - if not interaction_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES( - %(feature)s, - %(pose)s, - %(type)s, - %(family)s, - %(atom_ids)s, - %(prot_coord)s, - %(lig_coord)s, - %(distance)s, - %(angle)s, - %(energy)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - interaction_dicts = [ - dict( - feature=migration_data['feature_id_map'][feature_id], - pose=migration_data['pose_id_map'][pose_id], - type=type, - family=family, - atom_ids=atom_ids, - prot_coord=prot_coord, - lig_coord=lig_coord, - distance=distance, - angle=angle, - energy=energy, - ) - for ( - i, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) in interaction_records - ] - - # do the insertion - if execute: - executemany(destination, 'interaction', sql, interaction_dicts, batch_size) - - # get destination values - interaction_map = { - ( - feature_id, - pose_id, - type, - family, - ): i - for ( - i, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) in destination.select( - table='interaction', - query=', '.join(interaction_fields), - multiple=True, - ) - } - - # map to the destination records - interaction_id_map = { - i: interaction_map[ - ( - migration_data['feature_id_map'][feature_id], - migration_data['pose_id_map'][pose_id], - type, - family, - ) - ] - for ( - i, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) in interaction_records - } - - migration_data['interaction_id_map'] = interaction_id_map - - return migration_data - - -def migrate_subsites( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate subsites and subsite_tags""" - - # source data - subsite_records = source.select( - table='subsite', - query='subsite_id, subsite_target, subsite_name, subsite_metadata', - multiple=True, - ) - - mrich.var('source: #subsites', len(subsite_records)) - - if not subsite_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.subsite( - subsite_target, - subsite_name, - subsite_metadata - ) - VALUES( - %(target)s, - %(name)s, - %(metadata)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - subsite_dicts = [ - dict( - target=migration_data['target_id_map'][target_id], - name=name, - metadata=metadata, - ) - for i, target_id, name, metadata in subsite_records - ] - - # do the insertion - if execute: - executemany(destination, 'subsite', sql, subsite_dicts, batch_size) - - # map to the destination records - subsite_map = { - (target_id, name): i - for i, target_id, name, metadata in destination.select( - table='subsite', - query='subsite_id, subsite_target, subsite_name, subsite_metadata', - multiple=True, - ) - } - - subsite_id_map = { - i: subsite_map[(migration_data['target_id_map'][target_id], name)] - for i, target_id, name, metadata in subsite_records - } - - migration_data['subsite_id_map'] = subsite_id_map - - ### subsite_tags - - # source data - subsite_tag_records = source.select( - table='subsite_tag', - query='subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata', - multiple=True, - ) - - mrich.var('source: #subsite_tags', len(subsite_tag_records)) - - # insertion query - sql = """ - INSERT INTO hippo.subsite_tag( - subsite_tag_ref, - subsite_tag_pose, - subsite_tag_metadata - ) - VALUES( - %(subsite)s, - %(pose)s, - %(metadata)s - ) - ON CONFLICT DO NOTHING; - """ - - # format the data - subsite_tag_dicts = [ - dict( - subsite=migration_data['subsite_id_map'][subsite_id], - pose=migration_data['pose_id_map'][pose_id], - metadata=metadata, - ) - for i, subsite_id, pose_id, metadata in subsite_tag_records - ] - - # do the insertion - if execute: - executemany(destination, 'subsite_tag', sql, subsite_tag_dicts, batch_size) - - # map to the destination records - subsite_tag_map = { - (subsite_id, pose_id): i - for i, subsite_id, pose_id, metadata in destination.select( - table='subsite_tag', - query='subsite_tag_id, subsite_tag_ref, subsite_tag_pose, subsite_tag_metadata', - multiple=True, - ) - } - - subsite_tag_id_map = { - i: subsite_tag_map[ - ( - migration_data['subsite_id_map'][subsite_id], - migration_data['pose_id_map'][pose_id], - ) - ] - for i, subsite_id, pose_id, metadata in subsite_tag_records - } - - migration_data['subsite_tag_id_map'] = subsite_tag_id_map - - return migration_data - - -def migrate_quotes( - *, - source: 'Database', - destination: 'PostgresDatabase', - migration_data: dict, - batch_size: int, - execute: bool = True, -) -> dict: - """migrate quotes""" - - quote_fields = [ - 'quote_id', - 'quote_smiles', - 'quote_amount', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_lead_time', - 'quote_price', - 'quote_currency', - 'quote_purity', - 'quote_date', - 'quote_compound', - ] - - # source data - quote_records = source.select( - table='quote', - query=', '.join(quote_fields), - multiple=True, - ) - - mrich.var('source: #quotes', len(quote_records)) - - if not quote_records: - return migration_data - - # insertion query - sql = """ - INSERT INTO hippo.quote( - quote_smiles, - quote_amount, - quote_supplier, - quote_catalogue, - quote_entry, - quote_lead_time, - quote_price, - quote_currency, - quote_purity, - quote_date, - quote_compound - ) - VALUES( - %(smiles)s, - %(amount)s, - %(supplier)s, - %(catalogue)s, - %(entry)s, - %(lead_time)s, - %(price)s, - %(currency)s, - %(purity)s, - %(date)s, - %(compound)s - ) - ON CONFLICT ON CONSTRAINT UC_quote - DO UPDATE SET - quote_smiles = EXCLUDED.quote_smiles, - quote_amount = EXCLUDED.quote_amount, - quote_supplier = EXCLUDED.quote_supplier, - quote_catalogue = EXCLUDED.quote_catalogue, - quote_entry = EXCLUDED.quote_entry, - quote_lead_time = EXCLUDED.quote_lead_time, - quote_price = EXCLUDED.quote_price, - quote_currency = EXCLUDED.quote_currency, - quote_purity = EXCLUDED.quote_purity, - quote_date = EXCLUDED.quote_date, - quote_compound = EXCLUDED.quote_compound - WHERE hippo.quote.quote_date < EXCLUDED.quote_date; - """ - - # format the data - quote_dicts = [ - dict( - smiles=smiles, - amount=round(amount, 3), - supplier=supplier, - catalogue=catalogue, - entry=entry, - lead_time=lead_time, - price=price, - currency=currency, - purity=purity, - date=date, - compound=migration_data['compound_id_map'][compound_id], - ) - for ( - i, - smiles, - amount, - supplier, - catalogue, - entry, - lead_time, - price, - currency, - purity, - date, - compound_id, - ) in quote_records - ] - - # do the insertion - if execute: - executemany(destination, 'quote', sql, quote_dicts, batch_size) - - # map to the destination records - quote_map = { - (round(amount, 3), supplier, catalogue, entry): i - for ( - i, - smiles, - amount, - supplier, - catalogue, - entry, - lead_time, - price, - currency, - purity, - date, - compound_id, - ) in destination.select( - table='quote', - query=', '.join(quote_fields), - multiple=True, - ) - } - - quote_id_map = { - i: quote_map[(round(amount, 3), supplier, catalogue, entry)] - for ( - i, - smiles, - amount, - supplier, - catalogue, - entry, - lead_time, - price, - currency, - purity, - date, - compound_id, - ) in quote_records - } - - migration_data['quote_id_map'] = quote_id_map - - return migration_data - - -def get_reaction_id_reaction_dict_map( - db: 'Database | PostgresDatabase', compound_id_map: dict = None -) -> (dict, list): - """Get serialised reaction and reactant data""" - - # reactions - reaction_records = db.select( - table='reaction', - query='reaction_id, reaction_type, reaction_product, reaction_product_yield', - multiple=True, - ) - - reaction_id_reaction_dict_map = { - i: dict( - id=i, - type=t, - product=(compound_id_map[product_id] if compound_id_map else product_id), - product_yield=product_yield, - ) - for i, t, product_id, product_yield in reaction_records - } - - # reactants - reactant_records = db.select( - table='reactant', - query='reactant_amount, reactant_reaction, reactant_compound', - multiple=True, - ) - - # combine - for amount, reaction_id, compound_id in reactant_records: - compound_id = compound_id_map[compound_id] if compound_id_map else compound_id - - reaction_id_reaction_dict_map[reaction_id].setdefault('reactants', set()) - reaction_id_reaction_dict_map[reaction_id]['reactants'].add( - (compound_id, amount) - ) - - reaction_id_reaction_dict_map[reaction_id].setdefault('reactant_ids', set()) - reaction_id_reaction_dict_map[reaction_id]['reactant_ids'].add(compound_id) - - return reaction_id_reaction_dict_map, reactant_records - - -def executemany( - db: 'PostgresDatabase', table: str, sql: str, payload: list, batch_size: int -) -> None | list: - """Bulk execution with console logging""" - - n = db.count(table) - mrich.var(f'destination: #{table}s', n) - - result = db.executemany(sql, payload, batch_size=batch_size) - - if d := db.count(table) - n: - mrich.success('Inserted', d, f'new {table}s') - else: - mrich.warning('Inserted', d, f'new {table}s') - - return result - - -def rename_pose_paths( - pose_dicts: list[dict], - migration_data: dict, -) -> list[dict]: - """Uses regex to rename ID's in pose paths""" - - import re - - mrich.var( - 'pose_path_compound_id_regex', migration_data['pose_path_compound_id_regex'] - ) - mrich.var('pose_path_pose_id_regex', migration_data['pose_path_pose_id_regex']) - - # compound IDs - - pose_path_map = {} - # pose_path_map_log = {} - - for pose_dict in pose_dicts: - orig_path = pose_dict['path'] - - path = orig_path - - for pattern, template in migration_data['pose_path_compound_id_regex']: - if orig_path in pose_path_map: - path = pose_path_map[orig_path] - - match = re.match(pattern, path) - - if not match: - # if "fake.mol" in path: - # print("NO MATCH", pattern, path) - # raise NotImplementedError - continue - - groups = match.groups() - - assert len(groups) == 1, ( - f'pose_path_compound_id_regex replacement not supported with multiple groups, {pattern=}' - ) - - groups = [g for g in groups] - - compound_id = int(groups[0]) - new_compound_id = migration_data['compound_id_map'][compound_id] - - replacement = template.format(new_compound_id=new_compound_id) - - new_path = re.sub(pattern, replacement, path) - - if new_path != path: - pose_path_map[orig_path] = new_path - - raise NotImplementedError('pose_path_pose_id_regex development was not completed') - - # for pattern, template in migration_data["pose_path_pose_id_regex"]: - - # if orig_path in pose_path_map: - # path = pose_path_map[orig_path] - - # match = re.match(pattern, path) - - # if not match: - # # if "fake.mol" in path: - # # print("NO MATCH", pattern, path) - # # raise NotImplementedError - # continue - - # groups = match.groups() - - # assert ( - # len(groups) == 1 - # ), f"pose_path_pose_id_regex replacement not supported with multiple groups, {pattern=}" - - # groups = [g for g in groups] - - # pose_id = int(groups[0]) - # new_pose_id = migration_data["pose_id_map"][pose_id] - - # replacement = template.format(new_pose_id=new_pose_id) - - # new_path = re.sub(pattern, replacement, path) - - # if new_path != path: - # pose_path_map[orig_path] = new_path - - return pose_dicts - - -def dump_json(data: dict, file: str) -> None: - """Dump migration data to JSON""" - from json import dump - - mrich.writing(file) - dump(data, open(file, 'w')) - - -def dump_xlsx(data: dict, file: str) -> None: - """Dump migration data to Excel""" - - import pandas as pd - - mrich.writing(file) - - meta = [] - for key, value in data.items(): - if not isinstance(value, dict): - meta.append(dict(key=key, value=value)) - - meta_df = pd.DataFrame(meta).set_index('key') - - source = meta_df.loc['source', 'value'] - destination = meta_df.loc['destination', 'value'] - - sheets = {} - for key, value in data.items(): - if isinstance(value, dict): - data = [{source: k, destination: v} for k, v in value.items()] - - if len(data) > 1_000_000: - from itertools import batched - - batches = batched(data, 1_000_000) - - for i, batch in enumerate(batches): - df = pd.DataFrame(batch) - sheets[f'{key} ({i + 1})'] = df.set_index(source) - - else: - df = pd.DataFrame(data) - sheets[key] = df.set_index(source) - - with pd.ExcelWriter(file) as writer: - meta_df.to_excel(writer, sheet_name='meta') - - for name, df in sheets.items(): - df.to_excel(writer, sheet_name=name, index=True) diff --git a/hippo/pca.py b/hippo/pca.py deleted file mode 100644 index 8982ed3..0000000 --- a/hippo/pca.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Classes and functions for generating molecular principal component analyses""" - -# https://github.com/rdkit/rdkit-tutorials/blob/master/notebooks/005_Chemical_space_analysis_and_visualization.ipynb - -import numpy as np -from rdkit import DataStructs -from rdkit.Chem import AllChem, rdFingerprintGenerator - - -class FP: - """ - Molecular fingerprint class, useful to pack features in pandas df - - Parameters - ---------- - fp : np.array - Features stored in numpy array - names : list, np.array - Names of the features - """ - - def __init__(self, fp: 'np.array', names: list[str]) -> None: - """FP initialisation""" - self.fp = fp - self.names = names - - def __str__(self) -> str: - """string representation""" - return '%d bit FP' % len(self.fp) - - def __len__(self) -> int: - """length""" - return len(self.fp) - - -def get_cfps( - mol: 'rdkit.Chem.Mol', - radius: int = 2, - nBits: int = 1024, - useFeatures: bool = False, - counts: bool = False, - dtype=np.float32, -): - """Calculates circular (Morgan) fingerprint. - http://rdkit.org/docs/GettingStartedInPython.html#morgan-fingerprints-circular-fingerprints - - Parameters - ---------- - mol : rdkit.Chem.rdchem.Mol - radius : float - Fingerprint radius, default 2 - nBits : int - Length of hashed fingerprint (without descriptors), default 1024 - useFeatures : bool - To get feature fingerprints (FCFP) instead of normal ones (ECFP), defaults to False - counts : bool - If set to true it returns for each bit number of appearances of each substructure (counts). Defaults to false (fingerprint is binary) - dtype : np.dtype - Numpy data type for the array. Defaults to np.float32 because it is the default dtype for scikit-learn - - Returns - ------- - ML.FP - Fingerprint (feature) object - """ - arr = np.zeros((1,), dtype) - - if counts is True: - info = {} - fp = AllChem.GetHashedMorganFingerprint( - mol, radius, nBits, useFeatures=useFeatures - ) - DataStructs.ConvertToNumpyArray(fp, arr) - else: - # https://greglandrum.github.io/rdkit-blog/posts/2023-01-18-fingerprint-generator-tutorial.html#additional-information-explaining-bits - fmgen = rdFingerprintGenerator.GetMorganGenerator( - radius=radius, - fpSize=nBits, - atomInvariantsGenerator=rdFingerprintGenerator.GetMorganFeatureAtomInvGen(), - ) - - assert not useFeatures - - DataStructs.ConvertToNumpyArray( - fmgen.GetFingerprint(mol), - # AllChem.GetMorganFingerprintAsBitVect( - # mol, radius, nBits=nBits, useFeatures=useFeatures - # ), - arr, - ) - return FP(arr, range(nBits)) diff --git a/hippo/plotting.py b/hippo/plotting.py deleted file mode 100644 index 8f48964..0000000 --- a/hippo/plotting.py +++ /dev/null @@ -1,2010 +0,0 @@ -"""Functions to generate standard HIPPO plots""" - -import functools - -import molparse as mp -import mrich -import pandas as pd -import plotly.express as px -import plotly.graph_objects as go - -""" - - ALL GRAPHS DEFINED HERE SHOULD: - - * Have a HIPPO logo - * Include the target name in the title - -""" - - -# hippo_graph decorator -def hippo_graph(func): - """HIPPO graph decorator""" - - @functools.wraps(func) - def wrapper(animal, *args, logo='top right', **kwargs): - """ - - :param animal: - :param *args: - :param logo: (Default value = 'top right') - :param **kwargs: - - """ - - wrapper_kwargs = {} - wrapper_keys = ['show', 'html', 'pdf', 'png'] - for key in wrapper_keys: - wrapper_kwargs[key] = kwargs.pop(key, None) - - fig = func(animal, *args, **kwargs) - - if not isinstance(fig, go.Figure): - return fig - - if wrapper_kwargs['show']: - fig.show() - - if wrapper_kwargs['html']: - file = wrapper_kwargs['html'] - if not file.endswith('.html'): - file = f'{file}.html' - mp.write(file, fig) - - if wrapper_kwargs['pdf']: - file = wrapper_kwargs['pdf'] - if not file.endswith('.pdf'): - file = f'{file}.pdf' - mp.write(file, fig) - - if wrapper_kwargs['png']: - file = wrapper_kwargs['png'] - if not file.endswith('.png'): - file = f'{file}.png' - mp.write(file, fig) - - if not fig.layout.images and logo: - add_hippo_logo(fig, position=logo) - - return fig - - return wrapper - - -@hippo_graph -def plot_tag_statistics( - animal, - color='type', - subtitle=None, - log_y=False, - show_compounds=True, - show_poses=True, - compounds=None, - poses=None, - title: str | None = None, - skip: list[str] | None = None, -): - """ - - :param animal: - :param color: (Default value = 'type') - :param subtitle: (Default value = None) - :param log_y: (Default value = False) - :param compounds: (Default value = True) - :param poses: (Default value = True) - - """ - - compounds = compounds or animal.compounds - poses = poses or animal.poses - skip = skip or [] - - plot_data = [] - - for tag in animal.tags.unique: - if tag in skip: - continue - - if show_compounds: - num_compounds = len(compounds.get_by_tag(tag=tag)) - if num_compounds: - data = dict(tag=tag, number=num_compounds, type='compounds') - plot_data.append(data) - - if show_poses: - num_poses = len(poses.get_by_tag(tag=tag)) - if num_poses: - data = dict(tag=tag, number=num_poses, type='poses') - plot_data.append(data) - - from pandas import DataFrame - - df = DataFrame(plot_data) - - df.sort_values(by='tag', inplace=True) - - fig = px.bar(df, x='tag', y='number', color=color, log_y=log_y) - - if not title: - title = 'Tag Statistics' - - if subtitle: - title = f'{animal.name}: {title}
{subtitle}' - else: - title = f'{animal.name}: {title}' - - fig.update_layout( - title=title, title_automargin=False, title_yref='container', barmode='group' - ) - - fig.update_layout(xaxis_title='Tag', yaxis_title='#') - - return fig - - -@hippo_graph -def plot_interaction_histogram( - animal, - poses, - feature_metadata, - subtitle=None, -): - """ - - :param animal: - :param poses: - :param feature_metadata: - :param subtitle: (Default value = None) - - """ - - raise NotImplementedError - - df = animal._fingerprint_df(poses) - - plot_data = [] - - for key in df.columns: - count = int(df[key].sum()) - - if not count: - continue - - data = dict(str=key, count=count) - - data['family'] = feature_metadata[key]['family'] - data['res_name'] = feature_metadata[key]['res_name'] - data['res_number'] = feature_metadata[key]['res_number'] - data['res_chain'] = feature_metadata[key]['res_chain'] - data['atom_numbers'] = feature_metadata[key]['atom_numbers'] - - data['res_name_number_chain_str'] = ( - f'{feature_metadata[key]["res_name"]} {feature_metadata[key]["res_number"]} {feature_metadata[key]["res_chain"]}' - ) - - plot_data.append(data) - - plot_df = pd.DataFrame(plot_data) - plot_df.sort_values(['res_chain', 'res_number', 'family'], inplace=True) - plot_df - - fig = px.bar( - plot_df, - x='res_name_number_chain_str', - y='count', - color='family', - hover_data=plot_df.columns, - ) - - title = 'Leveraged protein features' - - if subtitle: - title = f'{animal.name}: {title}
{subtitle}' - else: - title = f'{animal.name}: {title}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(xaxis_title='Residue') - fig.update_layout(yaxis_title='#Interactions') - - return fig - - -@hippo_graph -def plot_interaction_punchcard( - animal, - poses=None, - subtitle=None, - opacity=1.0, - group: str = 'pose_name', - ignore_chains=False, -): - """ - - :param animal: - :param poses: (Default value = None) - :param subtitle: (Default value = None) - :param opacity: (Default value = 1.0) - :param group: (Default value = 'pose_name') - :param ignore_chains: (Default value = False) - - """ - - import plotly - - from .pset import PoseTable - - poses = poses or animal.poses - - if isinstance(poses, PoseTable): - iset = animal.interactions - else: - iset = poses.interactions - - mrich.var('#poses', len(poses)) - mrich.var('#interactions', len(iset)) - - plot_data = iset.df - - name_lookup = poses.id_name_dict - - names = [] - for pose_id in plot_data['pose_id'].values: - names.append(name_lookup[pose_id]) - plot_data['pose_name'] = names - - if ignore_chains: - x = 'res_name_number' - plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( - lambda x: ' '.join([str(i) for i in x]), axis=1 - ) - plot_data = plot_data.sort_values([group, x, 'residue_number']) - sort_key = lambda x: x[1] - else: - x = 'chain_res_name_number_str' - plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( - lambda x: ' '.join([str(i) for i in x]), axis=1 - ) - sort_key = lambda x: (x[2], x[1]) - - title = 'Interaction Punch-Card' - - if subtitle: - title = f'{animal.name}: {title}
{subtitle}' - else: - title = f'{animal.name}: {title}' - - fig = px.scatter( - plot_data, - x=x, - y='type', - marginal_x='histogram', - marginal_y='histogram', - hover_data=plot_data.columns, - color=group, - title=title, - ) - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(xaxis_title='Residue', yaxis_title='Feature Family') - - # x-axis sorting - categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg(tuple, axis=1) - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - - # sort axes - fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) - fig.update_yaxes(categoryorder='category descending') - - for trace in fig.data: - if type(trace) == plotly.graph_objs._histogram.Histogram: - trace.opacity = 1 - trace.xbins.size = 1 - else: - trace['marker']['size'] = 10 - trace['marker']['opacity'] = opacity - - fig.update_layout(barmode='stack') - fig.update_layout(scattermode='group', scattergap=0.75) - - return add_punchcard_logo(fig) - - -# @hippo_graph -def plot_interaction_punchcard_by_tags( - animal, - tags: dict[str, str] | list[str], - permitted_residues: dict[str, list[int]] | None = None, - yaxis_title: str = 'Tag', - subtitle=None, - opacity=0.7, - group='type', - marginal_histogram_x: bool = True, - # marginal_histogram_y: bool = False, - sizeref=0.08, - counts: bool = True, - ignore_chains=True, - backbone_only: bool = False, - sidechain_only: bool = False, - return_plot_data: bool = False, -): - """ - - :param animal: - :param poses: (Default value = None) - :param subtitle: (Default value = None) - :param opacity: (Default value = 1.0) - :param group: (Default value = 'pose_name') - :param ignore_chains: (Default value = False) - :param permitted_residues: dictionary mapping with keys matching `tags` mapping to list of IDs for residues to include for each tag - - """ - - import numpy as np - import plotly - - if isinstance(tags, list): - tags = {v: v for v in tags} - - permitted_residues = permitted_residues or {} - - dfs = [] - for group_name, tag in tags.items(): - poses = animal.poses(tag=tag) - mrich.debug(group_name, poses) - - name_lookup = poses.id_name_dict - - with mrich.loading('Getting interactions dataframe'): - df = poses.interactions.df - - df['tag'] = tag - df['group_name'] = group_name - df['pose_name'] = [name_lookup[pose_id] for pose_id in df['pose_id'].values] - - if group_name in permitted_residues: - subset = df[df['residue_number'].isin(permitted_residues[group_name])] - diff = len(df) - len(subset) - if diff: - mrich.warning( - 'Skipping', - diff, - 'markers due unpermitted residue numbers for: ', - group_name, - ) - df = subset - - dfs.append(df) - - mrich.debug('Concatenating dataframes') - plot_data = pd.concat(dfs, ignore_index=True) - - ### add permitted residues - - if permitted_residues and ignore_chains: - permitted_df = [] - for group_name, ids in permitted_residues.items(): - unique_combinations = plot_data[plot_data['group_name'] == group_name][ - ['residue_name', 'residue_number'] - ].drop_duplicates() - - for resid in ids: - try: - resname = unique_combinations[ - unique_combinations['residue_number'] == resid - ]['residue_name'].values[0] - except IndexError: - continue - - # if plot_data[['residue_name', 'residue_number']] - - permitted_df.append( - dict(res_name_number=f'{resname} {resid}', group_name=group_name) - ) - - permitted_df = pd.DataFrame(permitted_df) - - mrich.debug('Building plot_data') - if backbone_only: - plot_data = plot_data[plot_data['backbone'] == True] - if sidechain_only: - plot_data = plot_data[plot_data['sidechain'] == True] - - if ignore_chains: - x = 'res_name_number' - plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( - lambda x: ' '.join([str(i) for i in x]), axis=1 - ) - plot_data = plot_data.sort_values([group, x, 'residue_number']) - sort_key = lambda x: x[1] - else: - x = 'chain_res_name_number_str' - plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( - lambda x: ' '.join([str(i) for i in x]), axis=1 - ) - sort_key = lambda x: (x[2], x[1]) - - if counts: - mrich.debug('Summing by residue') - orig_data = plot_data.copy() - - if ignore_chains: - plot_data = ( - plot_data.groupby(['group_name', 'type', x, 'residue_number']) - .size() - .reset_index(name='count') - ) - else: - plot_data = ( - plot_data.groupby( - ['group_name', 'type', x, 'residue_number', 'chain_name'] - ) - .size() - .reset_index(name='count') - ) - - plot_data['size'] = np.sqrt(plot_data['count']) - - # add a size reference - - type_str = 'Size' - sizes = [1, 50, 100, 250] - - dicts = [] - for group_name, size in zip(tags.keys(), sizes, strict=False): - dicts.append( - dict( - group_name=group_name, - type='type_str', - res_name_number='', - residue_number=999, - count=size, - size=np.sqrt(size), - text=size, - ) - ) - - plot_data = pd.concat([plot_data, pd.DataFrame(dicts)]) - - mrich.debug('Making scatter plot') - fig = px.scatter( - plot_data, - x=x, - y='group_name', - hover_data=plot_data.columns, - color=group, - size='size' if counts else None, - text='text', - # color_discrete_sequence=px.colors.qualitative.Dark2 - ) - - fig.update_traces(textposition='middle right') - - if return_plot_data: - data_snapshot1 = plot_data.copy() - - # fig.update_layout(title=title, title_automargin=False, title_yref="container") - - fig.update_layout(xaxis_title='Residue', yaxis_title=yaxis_title) - - # x-axis sorting - if ignore_chains: - categoryarray = plot_data[[x, 'residue_number']].agg(tuple, axis=1) - else: - categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg( - tuple, axis=1 - ) - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - - # sort axes - fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) - - for trace in fig.data: - if type(trace) == plotly.graph_objs._histogram.Histogram: - trace.opacity = 1 - trace.xbins.size = 1 - else: - trace['marker']['opacity'] = opacity - - if marginal_histogram_x: - from plotly.subplots import make_subplots - - subplot_fig = make_subplots( - rows=2, - cols=1, - specs=[[{'type': 'histogram'}], [{'type': 'scatter'}]], - shared_xaxes=True, - shared_yaxes=False, - vertical_spacing=0.02, - horizontal_spacing=0.02, - ) - - # add in scatter traces - for trace in fig.data: - trace.yaxis = 'y2' - trace.showlegend = False - trace.marker.sizeref = sizeref - trace.marker.line.width = 0 - subplot_fig.add_trace(trace) - - # aggregate data for histogram plot - plot_data = ( - orig_data.groupby(['type', x, 'residue_number']) - .size() - .reset_index(name='count') - ) - - # generate histogram - fig2 = px.histogram(plot_data, x=x, y='count', color='type') - - # add in histogram traces - for trace in fig2.data: - trace.yaxis = 'y1' - subplot_fig.add_trace(trace) - - # if permitted_residues and ignore_chains: - # trace = go.Scatter( - # name="Subsite Residues", - # x=permitted_df[x], - # y=permitted_df["group_name"], - # mode="markers", - # marker_symbol="square-open", - # marker_size=25, - # marker_color="grey") - - # trace.yaxis="y2" - # subplot_fig.add_trace(trace) - - # clean up the axes - subplot_fig.update_layout( - xaxis=dict(anchor='y2', visible=True, showticklabels=True, side='bottom'), - # xaxis2=dict(visible=True, showticklabels=True, side="bottom"), - yaxis2=dict( - anchor='x', - overlaying='x', - side='top', - categoryorder='category descending', - ), # Secondary y-axis for x marginal - ) - - # x-axis sorting - if ignore_chains: - categoryarray = plot_data[[x, 'residue_number']].agg(tuple, axis=1) - else: - raise NotImplementedError - categoryarray = sorted([v for v in categoryarray.values], key=sort_key) - categoryarray = [v[0] for v in categoryarray] - subplot_fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) - - # y-axis sorting - categoryarray = list(reversed(tags.keys())) - subplot_fig.update_yaxes(categoryorder='array', categoryarray=categoryarray) - - # stack histogram bars on top of each other - subplot_fig.update_layout(barmode='stack') - - subplot_fig.update_layout( - margin=dict(l=20, r=20, t=20, b=20), - ) - - if return_plot_data: - return subplot_fig, [orig_data, plot_data, data_snapshot1] - - return subplot_fig - - # fig.update_layout(scattermode="group", scattergap=0.75) - - if return_plot_data: - return fig, plot_data - - # return add_punchcard_logo(fig) - return fig - - -@hippo_graph -def plot_residue_interactions( - animal, - residue_number, - poses: str | None = None, - subtitle: str | None = None, - chain: str | None = None, - target: int = 1, -): - """ - - :param animal: - :param poses: - :param residue_number: - :param subtitle: (Default value = None) - :param chain: (Default value = None) - - """ - - if not poses: - poses = animal.poses - - mrich.var('#poses', len(poses)) - - from .iset import InteractionSet - - iset = InteractionSet.from_residue( - animal.db, residue_number=residue_number, chain=chain - ) - - # return iset - - mrich.var('#interactions', len(iset)) - - plot_data = iset.df - - # name_lookup = {i:n for i,n in zip(poses.ids,poses.names)} - name_lookup = poses.id_name_dict - - # print(name_lookup) - - names = [] - for pose_id in plot_data['pose_id'].values: - names.append(name_lookup[int(pose_id)]) - plot_data['pose_name'] = names - - fig = px.histogram(plot_data, x='pose_name', color='type') - - # return plot_data[plot_data['pose_name'] == ' x1762b'] - - fig.update_xaxes(categoryorder='total descending') - - # set customdata from x-axis labels - for trace in fig.data: - trace['customdata'] = trace['x'] - - if not subtitle: - subtitle = f'#Poses={len(poses)}' - - residue_name = animal.db.get_feature( - id=plot_data['feature_id'].values[0] - ).residue_name - - title = f'Interactions w/ {residue_name} {residue_number}' - - if chain: - title += f' {chain}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_xaxes(title='Pose') - fig.update_yaxes(title='#Interactions') - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactant_amounts( - animal, subtitle=None, color='has_price_picker', named_only=False, most_common=None -): - """ - - :param animal: - :param subtitle: (Default value = None) - :param color: (Default value = 'has_price_picker') - :param named_only: (Default value = False) - :param most_common: (Default value = None) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - mrich.debug('making plot_data') - plot_data = [] - for bb in bbs: - d = bb.dict - # if most_common and d['amount'] is not None: - - if not named_only or not d['name_is_smiles']: - plot_data.append(d) - - if most_common: - mrich.debug('sorting') - plot_data = sorted(plot_data, key=lambda x: x['amount'], reverse=True)[ - :most_used_number - ] - - fig = px.bar( - plot_data, x='name', y='amount', color=color, hover_data=plot_data[0].keys() - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = 'Building Blocks' - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f'#BBs={len(bbs)}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(xaxis_title='Reactant', yaxis_title='#Reactions') - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactant_price(animal, subtitle=None, amount=20): - """ - - :param animal: - :param subtitle: (Default value = None) - :param amount: (Default value = 20) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - d = bb.dict - if not d['has_price_picker']: - continue - - d[f'price_{amount}mg'] = bb.get_price(amount) - d['min_amount'] = bb.price_picker.min_amount - - plot_data.append(d) - - # fig = px.bar(plot_data, x='name', y=f'price_{amount}mg', color='lead_time', log_y=True, hover_data=plot_data[0].keys()) - fig = px.histogram( - plot_data, - x=f'price_{amount}mg', - color='lead_time', - hover_data=plot_data[0].keys(), - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = 'Reactant Pricing' - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f'#BBs={len(bbs)}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout( - yaxis_title='Number of reactants', xaxis_title=f'Price for {amount}mg [$USD]' - ) - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_reactants_2d(animal, subtitle=None, amount=20): - """ - - :param animal: - :param subtitle: (Default value = None) - :param amount: (Default value = 20) - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - d = bb.dict - if not d['has_price_picker']: - continue - - d[f'price_{amount}mg'] = bb.get_price(amount) - d['min_amount'] = bb.price_picker.min_amount - - plot_data.append(d) - - fig = px.scatter( - plot_data, - y='amount', - x=f'price_{amount}mg', - color='name', - hover_data=plot_data[0].keys(), - ) - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = 'Building Blocks' - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f'#BBs={len(bbs)}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(scattermode='group', scattergap=0.75) - - fig.update_layout( - yaxis_title='Quantity [mg]', xaxis_title=f'Price for {amount}mg [$USD]' - ) - - return fig - - -@hippo_graph -# def plot_building_blocks(animal, subtitle=None, cset='elabs', color='name_is_smiles'): -def plot_building_blocks(animal, subtitle=None, color='name_is_smiles'): - """ - - :param animal: - :param subtitle: (Default value = None) - :param color: (Default value = 'name_is_smiles') - - """ - - # cset = animal.compound_sets[cset] - - # bbs = cset.get_building_blocks() - - bbs = animal.building_blocks - - plot_data = [] - for bb in bbs: - plot_data.append(bb.dict) - - fig = px.scatter(plot_data, x='name', y='max', color='amount') - # fig = px.bar(plot_data, x='smiles', y='amount', color=color) - - title = 'Building Blocks' - - if not subtitle: - # subtitle = f'"{cset.name}": #BBs={len(bbs)}' - subtitle = f'#BBs={len(bbs)}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(xaxis_title='Reactant', yaxis_title='Quantity') - - return fig - - -@hippo_graph -def plot_synthetic_routes(animal, subtitle=None, cset='elabs', color='num_reactants'): - """ - - :param animal: - :param subtitle: (Default value = None) - :param cset: (Default value = 'elabs') - :param color: (Default value = 'num_reactants') - - """ - - cset = animal.compound_sets[cset] - - plot_data = [] - for reax in cset.reactions: - plot_data.append(reax.dict) - - # fig = px.bar(plot_data, x='name', y='amount', color=color) - fig = px.histogram(plot_data, x='type', color=color) - - title = 'Synthetic Routes' - - if not subtitle: - subtitle = f'"{cset.name}": #compounds={len(cset)}' - - title = f'{animal.name}: {title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - fig.update_layout(xaxis_title='Compound', yaxis_title='#Routes') - - return fig - - -@hippo_graph -def plot_numbers(animal, subtitle=None): - """ - - y-axis: numbers - - x-categories - * hits - * hit poses - * scaffolds - * scaffold poses - * elabs - * elab poses - * BBs (total) - * BBs (in enamine) - - :param animal: - :param subtitle: (Default value = None) - - """ - - # cset = animal.compound_sets[cset] - - plot_data = [ - dict(category='Experimental Hits', number=len(animal.hits), type='compound'), - dict(category='Experimental Hits', number=len(animal.hits.poses), type='poses'), - dict(category='Base compounds', number=len(animal.scaffolds), type='compound'), - dict( - category='Base compounds', number=len(animal.scaffolds.poses), type='poses' - ), - dict( - category='Syndirella Elaborations', - number=len(animal.elabs), - type='compound', - ), - dict( - category='Syndirella Elaborations', - number=len(animal.elabs.poses), - type='poses', - ), - dict( - category='Unique Reactants', - number=len(animal.building_blocks), - type='compound', - ), - ] - - fig = px.bar(plot_data, x='category', y='number', log_y=True, color='type') - - title = 'Compounds & Poses' - - title = f'{animal.name}: {title}
' - - fig.update_layout( - title=title, title_automargin=False, title_yref='container', barmode='group' - ) - - fig.update_layout(xaxis_title=None, yaxis_title='Log(Quantity)') - - return fig - - -@hippo_graph -def plot_compound_property( - animal, - prop, - compounds=None, - style='bar', - null=None, - hover_data=None, - custom_data=None, -): - """Get an arbitrary property from all the compounds in animal.compounds - - If one property, plot a 1D histogram - If 2D plot a bar/scatter - - :param animal: - :param prop: - :param compounds: (Default value = None) - :param style: (Default value = 'bar') - :param null: (Default value = None) - - """ - - if not isinstance(prop, list): - prop = [prop] - - if hover_data is None: - hover_data = [] - - plot_data = [] - - if not compounds: - compounds = animal.compounds - - if len(compounds) > 1000: - compounds = mrich.track(compounds, prefix='Generating plot data') - - for comp in compounds: - data = comp.dict - - for p in prop: - # has attr - - if p not in data: - # get attr - if hasattr(comp, p): - v = getattr(comp, p) - - elif p in (m := comp.metadata): - v = m[p] - - else: - v = null - - data[p] = v - - plot_data.append(data) - - if len(prop) == 1: - title = f'Compound {prop[0]}' - - fig = px.histogram(plot_data, x=prop[0]) - - fig.update_layout(xaxis_title=prop[0], yaxis_title='Quantity') - - elif len(prop) == 2: - hover_data = prop + ['smiles'] + hover_data - - title = f'Compound {prop[0]} vs {prop[1]}' - - func = eval(f'px.{style}') - fig = func( - plot_data, - x=prop[0], - y=prop[1], - hover_data=hover_data, - custom_data=custom_data, - ) - - fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) - - else: - mrich.error('Unsupported') - - title = f'{animal.name}: {title}
' - - fig.update_layout( - title=title, title_automargin=False, title_yref='container', barmode='group' - ) - - return fig - - -@hippo_graph -def plot_pose_property( - animal, - prop, - poses=None, - style='scatter', - title=None, - null=None, - color=None, - log_y=False, - subtitle=None, - data_only=False, - custom_data=None, - **kwargs, -): - """Get an arbitrary property from all the poses in animal.poses - - If one property, plot a 1D histogram - If 2D plot a scatter plot - - :param animal: - :param prop: - :param poses: (Default value = None) - :param style: (Default value = 'scatter') - :param title: (Default value = None) - :param null: (Default value = None) - :param color: (Default value = None) - :param log_y: (Default value = False) - :param subtitle: (Default value = None) - :param data_only: (Default value = False) - :param **kwargs: - - """ - - # fetch these directly from the database - if prop in ['energy_score', 'distance_score']: - p = prop - - if not poses: - # great all poses! - poses = animal.poses - n_poses = len(poses) - mrich.out(f'Querying database for {n_poses} poses...') - field = f'pose_{p}' - title = title or f'{p} of all poses' - query = animal.db.select_where( - table='pose', query=field, key=f'{field} is not NULL', multiple=True - ) - - else: - # subset of poses - assert poses.table == 'pose', f'{poses=} is not a set of Pose objects' - n_poses = len(poses) - mrich.out(f'Querying database for {n_poses} poses...') - field = f'pose_{p}' - title = title or f'{p} of pose subset' - query = animal.db.select_where( - table='pose', - query=field, - key=f'{field} is not NULL and pose_id in {poses.str_ids}', - multiple=True, - ) - - plot_data = [{p: v} for (v,) in query] - - if p == 'energy_score': - subtitle = ( - subtitle - or f'#poses={n_poses}, energy_score < 0 = {len([None for d in plot_data if d[p] < 0]) / n_poses:.1%}' - ) - elif p == 'distance_score': - subtitle = ( - subtitle - or f'#poses={n_poses}, distance_score < 2 = {len([None for d in plot_data if d[p] < 2]) / n_poses:.1%}' - ) - else: - subtitle = subtitle or f'#poses={n_poses}' - - prop = [prop] - - elif prop == ['energy_score', 'distance_score'] or prop == [ - 'distance_score', - 'energy_score', - ]: - query = 'pose_id, pose_distance_score, pose_energy_score' - - # hardcoded errorbars - distance_score_err = 0.03 - energy_score_err = 6 - - if color: - query += f', {color}' - - if not poses: - # great all poses! - poses = animal.poses - n_poses = len(poses) - mrich.out(f'Querying database for {n_poses} poses...') - title = 'distance & energy scores of all poses' - query = animal.db.select(table='pose', query=query, multiple=True) - - else: - # subset of poses - n_poses = len(poses) - mrich.out(f'Querying database for {n_poses} poses...') - title = 'distance & energy scores of pose subset' - query = animal.db.select_where( - table='pose', - query=query, - key=f'pose_id in {poses.str_ids}', - multiple=True, - ) - - plot_data = [] - for q in query: - d = { - 'id': q[0], - 'distance_score': q[1], - 'energy_score': q[2], - 'distance_score_err': distance_score_err, - 'energy_score_err': energy_score_err, - } - if color: - d[color] = q[-1] - if color == 'pose_compound': - d[color] = f'C{d[color]}' - - plot_data.append(d) - - kwargs['error_x'] = 'energy_score_err' - kwargs['error_y'] = 'distance_score_err' - - subtitle = subtitle or f'#poses={n_poses}' - - # elif prop == ['num_atoms_added', 'energy_score'] or prop == ['num_atoms_added', 'energy_score']: - # mrich.error('Use animal.plot_pose_risk_vs_placement') - # raise NotImplementedError - - # query = f'pose_id, , pose_distance_score, pose_energy_score' - - # if not poses: - # # great all poses! - # poses = animal.poses - # n_poses = len(poses) - # mrich.out(f'Querying database for {n_poses} poses...') - # title = f'distance & energy scores of all poses' - # query = animal.db.select(table='pose', query=query, multiple=True) - - # else: - - # # subset of poses - # n_poses = len(poses) - # mrich.out(f'Querying database for {n_poses} poses...') - # title = f'distance & energy scores of pose subset' - # query = animal.db.select_where(table='pose', query=query, key=f'pose_id in {poses.str_ids}', multiple=True) - - # plot_data = [{'id':id, 'distance_score':v1, 'energy_score':v2 } for id,v1,v2 in query] - - # subtitle = f'#poses={n_poses}' - - else: - if not poses: - poses = animal.poses - - if prop == 'tags': - plot_data = [] - - for tag in poses.tags: - num_poses = len(poses.get_by_tag(tag=tag)) - data = dict(tag=tag, number=num_poses) - plot_data.append(data) - - fig = px.bar(plot_data, x='tag', y='number', color=color, log_y=log_y) - - title = 'Tag Statistics' - - fig.update_layout( - title=title, - title_automargin=False, - title_yref='container', - barmode='group', - ) - - fig.update_layout(xaxis_title='Tag', yaxis_title='#') - - return fig - - if not isinstance(prop, list): - prop = [prop] - - plot_data = [] - if len(poses) > 1000: - poses = mrich.track(poses, prefix='Generating plot data') - - for pose in poses: - if len(prop) > 1: - data = dict(id=pose.id) - else: - data = {} - - for p in prop: - if p not in data: - # get attr - if hasattr(pose, p): - v = getattr(pose, p) - - elif p in (m := pose.metadata): - v = m[p] - - else: - v = null - - data[p] = v - - if color: - if color not in data: - # get attr - if hasattr(pose, color): - v = getattr(pose, color) - - elif color in (m := pose.metadata): - v = m[color] - - else: - v = null - - data[color] = v - - plot_data.append(data) - - if data_only: - return plot_data - - hover_data = ['id'] # , 'alias', 'inchikey'] #, 'tags', 'inspirations'] - - if len(prop) == 1: - title = title or f'Pose {prop[0]}' - - fig = px.histogram(plot_data, x=prop[0], hover_data=None, color=color, **kwargs) - - fig.update_layout(xaxis_title=prop[0], yaxis_title='Quantity') - - elif len(prop) == 2: - if style == 'histogram': - x = [d[prop[0]] for d in plot_data] - y = [d[prop[1]] for d in plot_data] - - fig = go.Figure(go.Histogram2d(x=x, y=y, **kwargs)) - - else: - # if style == "bar": - # style = "scatter" - - func = eval(f'px.{style}') - fig = func( - plot_data, - x=prop[0], - y=prop[1], - color=color, - hover_data=hover_data, - custom_data=custom_data, - **kwargs, - ) - - title = title or f'Pose {prop[0]} vs {prop[1]}' - fig.update_layout(xaxis_title=prop[0], yaxis_title=prop[1]) - - else: - mrich.error('Unsupported') - - title = title or f'{animal.name}: {title}
' - - if subtitle: - title = f'{title}
{subtitle}' - - fig.update_layout( - title=title, title_automargin=False, title_yref='container', barmode='group' - ) - - return fig - - -@hippo_graph -def plot_compound_availability(animal, compounds=None, title=None, subtitle=None): - """ - - :param animal: - :param compounds: (Default value = None) - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - from .cset import CompoundSet, CompoundTable - - compounds = compounds or animal.compounds - - match compounds: - case CompoundTable(): - pairs = animal.db.select( - table='quote', - query='DISTINCT quote_supplier, quote_catalogue', - multiple=True, - ) - - plot_data = [] - for supplier, catalogue in pairs: - if catalogue is None: - catalogue = 'None' - cat_str = 'NULL' - else: - cat_str = f'"{catalogue}"' - - (count,) = animal.db.select_where( - table='quote', - query='COUNT(DISTINCT quote_compound)', - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str}', - ) - - plot_data.append( - dict(supplier=supplier, catalogue=catalogue, count=count) - ) - - case CompoundSet(): - pairs = animal.db.select( - table='quote', - query='DISTINCT quote_supplier, quote_catalogue', - multiple=True, - ) - - plot_data = [] - for supplier, catalogue in pairs: - if catalogue is None: - catalogue = 'None' - cat_str = 'NULL' - else: - cat_str = f'"{catalogue}"' - - (count,) = animal.db.select_where( - table='quote', - query='COUNT(DISTINCT quote_compound)', - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', - ) - - if not count: - continue - - plot_data.append( - dict(supplier=supplier, catalogue=catalogue, count=count) - ) - - case _: - raise NotImplementedError - - fig = px.bar(plot_data, x='catalogue', y='count', color='supplier') - - title = 'Compound availability' - - title = title or f'{animal.name}: Compound availability
' - - if subtitle: - title = f'{title}
{subtitle}' - - fig.update_layout( - title=title - ) # ,title_automargin=False, title_yref='container', barmode='group') - - return fig - - -# @hippo_graph -def plot_compound_availability_venn(animal, compounds): - """ - - :param animal: - :param compounds: (Default value = None) - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - from venn import venn - - pairs = animal.db.select( - table='quote', - query='DISTINCT quote_supplier, quote_catalogue', - multiple=True, - ) - - plot_data = {} - - for supplier, catalogue in pairs: - if catalogue is None: - catalogue = 'None' - cat_str = 'NULL' - else: - cat_str = f'"{catalogue}"' - - if (supplier, catalogue) not in plot_data: - plot_data[(supplier, catalogue)] = set() - - records = animal.db.select_where( - table='quote', - query='quote_compound', - key=f'quote_supplier IS "{supplier}" AND quote_catalogue IS {cat_str} AND quote_compound IN {compounds.str_ids}', - multiple=True, - none='quiet', - ) - - if not records: - continue - - for (i,) in records: - plot_data[(supplier, catalogue)].add(i) - - plot_data = {k: v for k, v in plot_data.items() if v} - - # return plot_data - - return venn(plot_data) - - -@hippo_graph -def plot_compound_price( - animal, - compounds=None, - min_amount=1, - subtitle=None, - title=None, - style='histogram', - **kwargs, -): - """ - - :param animal: - :param compounds: (Default value = None) - :param min_amount: (Default value = 1) - :param subtitle: (Default value = None) - :param title: (Default value = None) - :param style: (Default value = 'histogram') - :param **kwargs: - - """ - - import numpy as np - - from .cset import CompoundSet, CompoundTable - - compounds = compounds or animal.compounds - - match compounds: - case CompoundTable(): - if style == 'scatter': - sql = f""" - SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM {animal.db.SQL_SCHEMA_PREFIX}quote - INNER JOIN compound ON quote.quote_compound = compound.compound_id - INNER JOIN reactant ON quote.quote_compound = reactant.reactant_compound - WHERE quote_amount >= {min_amount} - GROUP BY quote_compound - """.format(min_amount=min_amount) - - results = animal.db.execute(sql).fetchall() - - n_compounds = len(results) - - plot_data = [] - for ( - compound_id, - amount, - price, - lead_time, - smiles, - num_reactions, - ) in results: - plot_data.append( - dict( - compound_id=compound_id, - min_price=price, - quoted_amount=amount, - lead_time=lead_time, - smiles=smiles, - num_reactions=num_reactions, - log_price_per_reaction=np.log(price / num_reactions), - price_per_reaction=price / num_reactions, - ) - ) - - else: - data = animal.db.select_where( - table='quote', - query='quote_amount, MIN(quote_price)', - key=f'quote_amount >= {min_amount} GROUP BY quote_compound', - multiple=True, - ) - - n_compounds = len(data) - - plot_data = [] - for amount, price in data: - plot_data.append(dict(min_price=price, quoted_amount=amount)) - - case CompoundSet(): - if style == 'scatter': - sql = f""" - SELECT quote_compound, quote_amount, MIN(quote_price), quote_lead_time, compound_smiles, COUNT(DISTINCT reactant_reaction) - FROM {animal.db.SQL_SCHEMA_PREFIX}quote - INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}compound ON quote.quote_compound = compound.compound_id - INNER JOIN {animal.db.SQL_SCHEMA_PREFIX}reactant ON quote.quote_compound = reactant.reactant_compound - WHERE quote_amount >= {min_amount} - AND quote_compound IN {str_ids} - GROUP BY quote_compound - """.format(min_amount=min_amount, str_ids=compounds.str_ids) - - results = animal.db.execute(sql).fetchall() - - n_compounds = len(results) - - plot_data = [] - for ( - compound_id, - amount, - price, - lead_time, - smiles, - num_reactions, - ) in results: - plot_data.append( - dict( - compound_id=compound_id, - min_price=price, - quoted_amount=amount, - lead_time=lead_time, - smiles=smiles, - num_reactions=num_reactions, - log_price_per_reaction=np.log(price / num_reactions), - price_per_reaction=price / num_reactions, - ) - ) - - else: - data = animal.db.select_where( - table='quote', - query='quote_amount, MIN(quote_price)', - key=f'quote_amount >= {min_amount} AND quote_compound IN {compounds.str_ids} GROUP BY quote_compound', - multiple=True, - ) - - n_compounds = len(data) - - plot_data = [] - for amount, price in data: - plot_data.append(dict(min_price=price, quoted_amount=amount)) - - case _: - raise NotImplementedError('CompoundSet not yet supported') - - plot_data = sorted(plot_data, key=lambda x: x['quoted_amount']) - - match style: - case 'histogram': - fig = px.histogram( - plot_data, color='quoted_amount', x='min_price', **kwargs - ) - - case 'violin': - fig = px.violin(plot_data, color='quoted_amount', x='min_price', **kwargs) - - case 'scatter': - fig = px.scatter( - plot_data, - color='log_price_per_reaction', - x='min_price', - y='lead_time', - hover_data=plot_data[0].keys(), - **kwargs, - ) - - case _: - raise NotImplementedError(f'{style=}') - - subtitle = subtitle or f'#compounds={n_compounds}, {min_amount=} mg' - - title = title or f'{animal.name}: Compound price
' - - if subtitle: - title = f'{title}
{subtitle}' - - fig.update_layout( - title=title - ) # ,title_automargin=False, title_yref='container', barmode='group') - - return fig - - -@hippo_graph -def plot_reaction_funnel(animal, title=None, subtitle=None): - """ - - :param animal: - :param title: (Default value = None) - :param subtitle: (Default value = None) - - """ - - compounds = animal.compounds - - data = dict( - number=[ - compounds.num_reactants, - compounds.num_intermediates, - compounds.num_products, - ], - category=['Reactants', 'Intermediates', 'Products'], - ) - - fig = px.funnel(data, x='category', y='number') - - title = title or f'{animal.name}: Reaction statistics' - - if subtitle: - title = f'{title}
{subtitle}' - - fig.update_layout(title=title, title_automargin=False, title_yref='container') - - return fig - - -HIPPO_LOGO_URL = 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_logo_tightcrop.png' -HIPPO_HEAD_URL = ( - 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png' -) - - -def plot_pose_interactions( - animal: 'HIPPO', pose: 'Pose' -) -> 'plotly.graph_objects.Figure': - """3d figure showing the interactions between a :class:`.Pose` and the protein. In a Jupyter notebook this figure may be unusable, instead write it as a HTML file and open it in your browser: - - :: - - import molparse as mp - fig = animal.plot_pose_interactions(pose) - mp.write(f'{pose}_interactions.html', fig) - - :param pose: the :class:`.Pose` whose interactions are to be rendered - - """ - - import molparse as mp - - # get interactions - iset = pose.interactions - - # get the protein - protein = pose.protein_system - - # get interacting residues - pairs = iset.residue_number_chain_pairs - - # get residues - residues = [] - for resnum, chain in pairs: - residues.append(protein.get_chain(chain).residues[f'n{resnum}']) - - # get ligand - lig_group = mp.rdkit.mol_to_AtomGroup(pose.mol) - - # create the combined plotting group - plot_group = mp.AtomGroup.from_any(str(pose), residues + [lig_group]) - - # interaction labels and vectors - labels = [] - extras = [] - for interaction in iset: - extras.append([interaction.prot_coord, interaction.lig_coord]) - labels.append(interaction.description) - - # create the figure - fig = plot_group.plot3d(show=False, extra=extras, extra_labels=labels) - - return fig - - -@hippo_graph -def plot_compound_tsnee( - animal: 'HIPPO | None' = None, - compounds: 'CompoundSet | None' = None, - df: 'pd.DataFrame | None' = None, - title: str | None = None, - subtitle: str | None = None, - legend: bool = False, - symbol: str = 'type', - sort_by: str = 'type', - color: str = 'cluster', - cluster_by: str = 'scaffolds', - **kwargs, -) -> 'plotly.graph_objects.Figure': - """Plot a compound tanimoto similarity plot with principal components determined by pattern binary fingerprint similarity. - - :param compounds: compounds to plot - :param df: optional pre-computed DataFrame - :param title: plot title - :param subtitle: plot subtitle - :param legend: show the plot legend - :param symbol: property used to determine symbol - :param sort_by: property used to sort dataframe - :param color: property used to determine marker color - :returns: `plotly.graph_objects.Figure` - - """ - - import numpy as np - from sklearn.decomposition import PCA - - from .pca import get_cfps - - if compounds: - mrich.var('#compounds', len(compounds)) - - if df is None: - with mrich.loading('Getting Compound DataFrame'): - df = compounds.get_df(mol=True, scaffolds=True, inchikey=True, alias=True) - df = df.reset_index() - - df['scaffolds'] = df['scaffolds'].map( - lambda x: x if not isinstance(x, float) else None - ) - - else: - # check dataframe columns - if 'mol' not in df.columns: - mrich.error("'mol' column not in dataframe") - return None - - if cluster_by not in df.columns: - mrich.error(f'{cluster_by=} column not in dataframe') - return None - - with mrich.loading('Getting Compound fingerprints'): - df['FP'] = df['mol'].map(get_cfps) - - def get_cluster(row): - """Get cluster""" - - scaffolds = row[cluster_by] - - if not scaffolds: - return row['id'] - - if scaffolds is None: - mrich.error(row) - return - - if len(scaffolds) == 1: - return list(scaffolds)[0] - - return tuple(scaffolds) - - def get_type(row): - """Get type""" - - if row[cluster_by] is None: - return 'scaffold' - - return 'elaboration' - - with mrich.loading('Adding columns'): - df[cluster_by] = df[cluster_by].apply(tuple) - df['cluster'] = df.apply(get_cluster, axis=1) - df['type'] = df.apply(get_type, axis=1) - - if sort_by: - df = df.sort_values(by=sort_by) - - X = np.array([x.fp for x in df['FP']]) - - with mrich.loading('Computing PCA'): - pca = PCA(n_components=2, random_state=0) - pca_fit = pca.fit_transform(X) - - df['PC1'] = pca_fit.T[0] - df['PC2'] = pca_fit.T[1] - - hover_data = [ - 'id', - 'smiles', - 'alias', - 'inchikey', - 'PC1', - 'PC2', - cluster_by, - 'cluster', - 'type', - ] - - df['scaffolds'] = df[cluster_by].astype(str) - df['cluster'] = df['cluster'].astype(str) - - with mrich.loading('Creating figure'): - fig = px.scatter( - df, - x='PC1', - y='PC2', - hover_data=hover_data, - color=color, - symbol=symbol, - # custom_data="alias", - **kwargs, - ) - - subtitle = subtitle or f'#compounds={len(df)}' - - title = title or f'{compounds} PCA
' - - if subtitle: - title = f'{title}
{subtitle}' - - fig.update_layout(title=title) - - if not legend: - fig.update_layout(showlegend=False) - - return fig - - -def add_hippo_logo(fig, in_plot=True, position='top right'): - """ - - :param fig: - :param in_plot: (Default value = True) - :param position: (Default value = 'top right') - - """ - - assert fig.layout.title.text, 'Figure must have a title to add the HIPPO logo' - - if in_plot: - sizex = 0.3 - sizey = 0.3 - - if 'top' in position: - yanchor = 'top' - y = 0.95 - elif 'bottom' in position: - yanchor = 'bottom' - y = 0.05 - else: - yanchor = 'middle' - y = 0.50 - - if 'left' in position: - xanchor = 'left' - x = 0.05 - elif 'right' in position: - xanchor = 'right' - x = 0.95 - else: - xanchor = 'center' - x = 0.50 - - fig.add_layout_image( - dict( - source=HIPPO_LOGO_URL, - xref='paper', - yref='paper', - # layer='below', - x=x, - y=y, - sizex=sizex, - sizey=sizey, - xanchor=xanchor, - yanchor=yanchor, - ) - ) - - return fig - - has_legend = fig.layout.legend.title.text is not None - fig.layout.margin.t = None - - if has_legend: - fig.add_layout_image( - dict( - source='', - xref='paper', - yref='paper', - x=1, - y=1.05, - sizex=0.4, - sizey=0.4, - xanchor='left', - yanchor='bottom', - ) - ) - - else: - fig.add_layout_image( - dict( - source=HIPPO_LOGO_URL, - xref='paper', - yref='paper', - x=1, - y=1.05, - sizex=0.3, - sizey=0.3, - xanchor='right', - yanchor='bottom', - ) - ) - - return fig - - -def add_punchcard_logo(fig): - """Add the HIPPO logo to a punchcard figure""" - - fig.add_layout_image( - dict( - source=HIPPO_HEAD_URL, - xref='paper', - yref='paper', - x=1, - y=1, - sizex=0.25, - sizey=0.25, - xanchor='right', - yanchor='top', - ) - ) - - return fig diff --git a/hippo/pose.py b/hippo/pose.py deleted file mode 100644 index 50904c5..0000000 --- a/hippo/pose.py +++ /dev/null @@ -1,1744 +0,0 @@ -"""Classes for working with poses""" - -from pathlib import Path - -import mcol -import molparse as mp -import mrich -import numpy as np -from molparse.rdkit.features import ( - COMPLEMENTARY_FEATURES, - FEATURE_FAMILIES, - INTERACTION_TYPES, -) -from mrich import print -from rdkit import Chem - -from .tags import TagSet - -INTERACTION_CUTOFF = { - 'Hydrophobic': 4.5, - 'Hydrogen Bond': 3.5, - 'Electrostatic': 4.5, - 'π-stacking': 6.0, - 'π-cation': 4.5, - 'Sulfur-Sulfur': 4.0, # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 -} - -PI_STACK_MIN_CUTOFF = 3.8 -PI_STACK_F2F_CUTOFF = 4.5 -PI_STACK_E2F_CUTOFF = 6.0 -MUTATION_WARNING_DIST = 15 - - -class Pose: - """A :class:`.Pose` is a particular conformer of a :class:`.Compound` within a protein environment. A pose will have its own (stereochemical) smiles string, and must have a path to a coordinate file. Poses can have *inspirations* that can be used to trace fragment-derived scaffolds in merges and expansions. - - .. attention:: - - :class:`.Pose` objects should not be created directly. Instead use :meth:`.HIPPO.register_pose` or :meth:`.HIPPO.poses` - - """ - - _table = 'pose' - - def __init__( - self, - db: 'Database', - id: int, - inchikey: str | None, - alias: str | None, - smiles: str, - reference: int, # another pose - path: str, - compound: int, - target: int, - mol: Chem.Mol | bytes | None, - fingerprint: int, - energy_score: float | None = None, - distance_score: float | None = None, - inspiration_score: float | None = None, - metadata: dict | None = None, - ): - """Pose initialisation""" - - self._db = db - self._id = id - self._inchikey = inchikey - self._alias = alias - self._smiles = smiles - self._compound_id = compound - self._target = target - self._path = path - self._protein_system = None - self._energy_score = energy_score - self._distance_score = distance_score - self._inspiration_score = inspiration_score - - self._scaffold_ids = None - self._num_heavy_atoms = None - - self._has_fingerprint = False - - if fingerprint is None: - self._has_fingerprint = False - elif not isinstance(fingerprint, int): - mrich.warning('Legacy fingerprint data format') - self.has_fingerprint = False - else: - self.has_fingerprint = bool(fingerprint) - - # print(f'{self}{metadata=}') - self._metadata = metadata - self._tags = None - self._reference = reference - self._reference_id = reference - self._interactions = None - - if isinstance(mol, bytes): - self._mol = Chem.Mol(mol) - else: - self._mol = mol - - self._total_changes = db.total_changes - self._num_atoms_added_wrt_inspirations = None - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the pose's database ID""" - return self._id - - @property - def inchikey(self) -> str: - """Returns the pose's inchikey""" - if not self._inchikey: - self.smiles - return self._inchikey - - @property - def alias(self) -> str: - """Returns the pose's alias""" - return self._alias - - @property - def name(self) -> str: - """Returns the pose's name""" - if n := self.alias: - return n - else: - return self.inchikey - - @alias.setter - def alias(self, n) -> None: - """Set the pose's alias""" - assert isinstance(n, str) - self._alias = n - self.db.update(table='pose', id=self.id, key='pose_alias', value=n) - - @inchikey.setter - def inchikey(self, n) -> None: - """Set the pose's inchikey""" - assert isinstance(n, str) - self._inchikey = n - self.db.update(table='pose', id=self.id, key='pose_inchikey', value=n) - - @property - def smiles(self) -> str: - """Returns the pose's smiles""" - if not self._smiles: - from molparse.rdkit import mol_to_smiles - from rdkit.Chem.inchi import MolToInchiKey - - try: - mol = self.mol - self._smiles = mol_to_smiles(mol) - self.inchikey = MolToInchiKey(mol) - self.db.update( - table='pose', id=self.id, key='pose_smiles', value=self._smiles - ) - except InvalidMolError: - mrich.warning(f'Taking smiles from {self.compound}') - self._smiles = self.compound.smiles - return self._smiles - - @property - def target(self) -> 'Target': - """Returns the pose's associated target""" - if isinstance(self._target, int): - self._target = self.db.get_target(id=self._target) - return self._target - - @property - def compound_id(self) -> int: - """Returns the pose's associated compound ID""" - return self._compound_id - - @property - def compound(self) -> 'Compound': - """Returns the pose's associated compound""" - return self.get_compound() - - @property - def path(self) -> str: - """Returns the pose's path""" - return self._path - - @property - def reference(self) -> 'Pose': - """Returns the pose's protein reference (another pose)""" - if isinstance(self._reference, int): - self._reference = self.db.get_pose(id=self._reference) - return self._reference - - @property - def reference_id(self) -> int: - """Returns the pose's protein reference ID""" - return self._reference_id - - @reference.setter - def reference(self, p): - """Set the pose's reference""" - if not isinstance(p, int): - assert p._table == 'pose' - p = p.id - self._reference = p - self._reference_id = p - self.db.update(table='pose', id=self.id, key='pose_reference', value=p) - - @property - def mol(self) -> 'rdkit.Chem.Mol': - """Returns a pose's rdkit.Chem.Mol""" - if not self._mol and self.path: - if self.path.endswith('.pdb'): - mrich.reading(self.path) - - # mrich.reading(self.path) - sys = mp.parse(self.path, verbosity=False) - - self.protein_system = sys.protein_system - - sdf_path = list(Path(self.path).parent.glob('*_ligand.sdf')) - - if len(sdf_path) == 1: - supplier = Chem.SDMolSupplier(sdf_path[0]) - mols = [mol for mol in supplier if mol is not None] - - if len(mols) > 1: - mrich.warning(f'Multiple molecules in SDF {self}') - - self.mol = mols[0] - return self._mol - - # look for ligand mol from Fragalysis - mol_path = list( - Path(self.path).parent.glob('*_ligand.mol') - ) # str(Path(self.path).name).replace('.pdb','_ligand.mol') - - if len(mol_path) == 1: - mol_path = mol_path[0] - from rdkit.Chem import MolFromMolFile - - self._mol_path = mol_path.resolve() - - mol = MolFromMolFile(str(self._mol_path)) - - elif len(mol_path) == 0: - lig_residues = sys['rLIG'] - - if not lig_residues: - lig_residues = [r for r in sys.residues if r.type == 'LIG'] - - if len(lig_residues) > 1: - mrich.warning(f'Multiple ligands in PDB {self}') - - lig_res = lig_residues[0] - - if not (mol := lig_res.rdkit_mol): - mrich.error( - f'[{self}] Error computing RDKit Mol from PDB={self.path}' - ) - - print(lig_res.pdb_block) - - lig_res.plot3d() - - raise InvalidMolError - - # clean up bond orders - from rdkit.Chem.AllChem import ( - AssignBondOrdersFromTemplate, - MolFromSmiles, - ) - - template = MolFromSmiles(self.compound.smiles) - try: - mol = AssignBondOrdersFromTemplate(template, mol) - except Exception as e: - mrich.error( - f'Exception occured during AssignBondOrdersFromTemplate for {self}.mol' - ) - print(f'template_smiles={self.compound.smiles}') - print(f'pdbblock={print(lig_res.pdb_block)}') - mrich.error(e) - mol = lig_res.rdkit_mol - - else: - path = Path(self.path) - parent_dir = path.parent - mol_path = parent_dir / path.name.replace( - '_hippo.pdb', '.pdb' - ).replace('.pdb', '_ligand.mol') - - if not mol_path.exists(): - mrich.warning( - f'There are multiple *_ligand.mol files in {Path(self.path).parent}' - ) - raise FileNotFoundError(mol_path) - - from rdkit.Chem import MolFromMolFile - - mol = MolFromMolFile(str(mol_path)) - - self.mol = mol - - elif self.path.endswith('.mol'): - mrich.reading(self.path) - - # mrich.reading(self.path) - mol = mp.parse(self.path, verbosity=False) - - if not mol: - mrich.error( - f'[{self}] Error computing RDKit Mol from .mol={self.path}' - ) - - raise InvalidMolError - - self.mol = mol - - else: - raise NotImplementedError - - if not mol: - mrich.error(f'Could not parse {self}.path={self.path}') - - return self._mol - - @mol.setter - def mol(self, m): - """Set the pose's rdkit.Chem.Mol""" - assert m - from .tools import sanitise_mol - - self._mol = sanitise_mol(m) - self.db.update_pose_mol(pose_id=self.id, mol=self._mol) - - @property - def protonated_mol(self) -> 'rdkit.Chem.Mol': - """Guess hydrogen positions""" - from rdkit.Chem import AllChem - - mol = self.mol - protonated_mol = Chem.AddHs(mol) - try: - protonated_mol = AllChem.ConstrainedEmbed(protonated_mol, mol) - except Exception as e: - mrich.error('Error while embedding protonated molecule') - mrich.error(e) - return mol - return protonated_mol - - @property - def protein_system(self) -> 'molparse.System': - """Returns the pose's protein molparse.System""" - if self._protein_system is None and self.path.endswith('.pdb'): - # mrich.debug(f'getting pose protein system {self}') - self.protein_system = mp.parse(self.path, verbosity=False).protein_system - return self._protein_system - - @protein_system.setter - def protein_system(self, a): - """Sets the pose's protein molparse.System""" - self._protein_system = a - - @property - def complex_system(self) -> 'molparse.System': - """Get molparse.System representation of the protein-ligand complex""" - - if self.has_complex_pdb_path: - return mp.parse(self.path, verbosity=False) - - elif self.reference: - # construct from .mol and reference - - system = self.reference.protein_system.copy() - - system.name = ( - f'{self.target.name}_{self.reference.name}_{self.compound.name}' - ) - - from molparse.rdkit import mol_to_AtomGroup - - ligand = mol_to_AtomGroup(self.mol) - - for atom in ligand.atoms: - system.add_atom(atom) - - return system - - else: - raise NotImplementedError - - @property - def has_complex_pdb_path(self) -> bool: - """Does this pose have a PDB file?""" - return self.path.endswith('.pdb') - - @property - def metadata(self) -> 'MetaData': - """Returns the pose's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table='pose', id=self.id) - return self._metadata - - @property - def has_fingerprint(self) -> bool: - """Does the pose have a fingerprint?""" - return self._has_fingerprint - - @has_fingerprint.setter - def has_fingerprint(self, fp): - self.set_has_fingerprint(fp) - - @property - def tags(self) -> 'TagSet': - """Returns the pose's tags""" - if not self._tags: - self._tags = self.get_tags() - return self._tags - - @property - def inspirations(self) -> 'PoseSet': - """Returns the pose's inspirations""" - return self.get_inspirations() - - @property - def derivatives(self) -> 'PoseSet': - """Returns the pose's derivatives""" - return self.get_derivatives() - - @property - def features(self) -> 'list[molparse.rdkit.Feature]': - """Returns the pose's features""" - return mp.rdkit.features_from_mol(self.mol) - - @property - def dict(self) -> dict: - """Serialised dictionary representing the pose""" - return self.get_dict() - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def num_heavy_atoms(self) -> int: - """Number of heavy atoms""" - if not self._num_heavy_atoms: - self._num_heavy_atoms = self.db.get_compound_computed_property( - 'num_heavy_atoms', self.compound_id - ) - return self._num_heavy_atoms - - @property - def num_atoms_added(self) -> int: - """Calculate the number of atoms added relative to the scaffold or inspirations""" - if self.num_scaffolds == 1: - return self.num_atoms_added_wrt_scaffolds - else: - return self.num_atoms_added_wrt_inspirations - - @property - def num_atoms_added_wrt_scaffolds(self) -> int | list[int] | None: - """Calculate the number of atoms added relative to the scaffold""" - return self.compound.num_atoms_added - - @property - def num_atoms_added_wrt_inspirations(self) -> int | None: - """Calculate the number of atoms added relative to its inspirations""" - - if self._num_atoms_added_wrt_inspirations is None or self._db_changed: - sql = f""" - WITH inspirations AS ( - SELECT SUM({self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol)) AS sum, inspiration_derivative - FROM {self.db.SQL_SCHEMA_PREFIX}inspiration - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON inspiration_original = pose_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON pose_compound = compound_id - WHERE inspiration_derivative = {self.id} - ) - SELECT {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(compound_mol) - sum FROM inspirations - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON inspiration_derivative = pose_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound ON compound_id = pose_compound - """ - - result = self.db.execute(sql).fetchone() - - if not result: - return None - - (self._num_atoms_added_wrt_inspirations,) = result - - return self._num_atoms_added_wrt_inspirations - - @property - def num_scaffolds(self) -> int: - """Get the number of scaffold scaffolds""" - return len(self.scaffold_ids) - - @property - def scaffold_ids(self) -> list[int] | None: - """Get the scaffold :class:`.Compound` IDs""" - if self._scaffold_ids is None: - records = self.db.select_where( - table='scaffold', - query='scaffold_base', - key='superstructure', - value=self.compound_id, - multiple=True, - none='quiet', - ) - records = [i for (i,) in records] - self._scaffold_ids = records - return self._scaffold_ids - - @property - def energy_score(self) -> float | None: - """Energy score of the Pose (kcal/mol)""" - return self._energy_score - - @property - def distance_score(self) -> float | None: - """Distance score of the Pose (w.r.t. its inspirations), in Angstroms""" - return self._distance_score - - @property - def inspiration_score(self) -> float | None: - """inspiration score of the Pose in range 0.00-1.00""" - return self._inspiration_score - - @property - def interactions(self) -> 'InteractionSet': - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" - if not self._interactions: - from .iset import InteractionSet - - self._interactions = InteractionSet.from_pose(self) - return self._interactions - - @property - def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - return self.interactions.classic_fingerprint - - @property - def subsites(self) -> 'list[SubsiteTag]': - """Get member :class:`.SubsiteTag`""" - - from .subsite import SubsiteTag - - records = self.db.select_where( - table='subsite_tag', - key='pose', - value=self.id, - multiple=True, - query='subsite_tag_id, subsite_tag_ref', - none='quiet', - ) - - if not records: - return None - - subsite_tags = [] - for record in records: - id, ref = record - subsite_tag = SubsiteTag(db=self.db, id=id, subsite_id=ref, pose_id=self.id) - subsite_tags.append(subsite_tag) - - return subsite_tags - - @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 - - @property - def mol_path(self) -> 'Path': - """Get Path to molecule file""" - path = Path(self.path) - if path.name.endswith('.pdb'): - mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( - '.pdb', '_ligand.mol' - ) - if not mol_path.exists(): - mol_path = path.parent / path.name.replace( - '_hippo.pdb', '.pdb' - ).replace('.pdb', '_ligand.sdf') - if not mol_path.exists(): - mrich.error('Could not find ligand mol/sdf:', mol_path) - return None - return mol_path - elif path.name.endswith('.mol'): - return path - else: - raise NotImplementedError - - @property - def apo_path(self) -> 'Path': - """Get path to apo protein file""" - path = Path(self.path) - if path.name.endswith('.pdb'): - apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( - '.pdb', '_apo-desolv.pdb' - ) - if not apo_path.exists(): - return None - return apo_path - else: - raise NotImplementedError - - ### METHODS - - def score_inspiration( - self, - debug: bool = False, - draw: bool = False, - return_all: bool = False, - ) -> float: - """Score how well this Pose recapitulates the pharmacophoric features of its inspirations. - - :param debug: Increased verbosity for debugging (Default value = False) - :param draw: Render each inspiration pose with it's features, the derivative with the combined features of the inspirations, and the derivative with it's features. (Default value = False) - - """ - - # from molparse.rdkit import SuCOS_score - from mucos import MuCOS_score - - multi_sucos = MuCOS_score( - self.inspirations.mols, - self.mol, - print_scores=debug, - draw=draw, - return_all=return_all, - ) - - if debug: - mrich.var('energy_score', self.energy_score) - mrich.var('distance_score', self.distance_score) - - for inspiration in self.inspirations: - mrich.var( - f'{inspiration} SuCOS', - MuCOS_score(inspiration.mol, self.mol, print_scores=debug), - ) - - mrich.var('multi SuCOS', multi_sucos) - - return multi_sucos - - def get_compound(self) -> 'Compound': - """Get the :class:`.Compound` that this pose is a conformer of""" - return self.db.get_compound(id=self._compound_id) - - def get_tags(self) -> 'TagSet': - """Get this Pose's tags""" - tags = self.db.select_where( - query='tag_name', - table='tag', - key='pose', - value=self.id, - multiple=True, - none='quiet', - ) - return TagSet(self, {t[0] for t in tags}) - - def get_inspiration_ids(self) -> list[int]: - """Get the :class:`.Pose` IDs of this pose's inspirations""" - inspirations = self.db.select_where( - query='inspiration_original', - table='inspiration', - key='derivative', - value=self.id, - multiple=True, - none='quiet', - ) - if not inspirations: - return None - return set([v for (v,) in inspirations]) - - def get_derivative_ids(self) -> list[int]: - """Get the :class:`.Pose` IDs of this pose's derivatives""" - derivatives = self.db.select_where( - query='inspiration_derivative', - table='inspiration', - key='original', - value=self.id, - multiple=True, - none='quiet', - ) - if not derivatives: - return None - return set([v for (v,) in derivatives]) - - def get_inspirations(self) -> 'PoseSet': - """Get a :class:`.PoseSet` of this pose's inspirations""" - if not (inspirations := self.get_inspiration_ids()): - return None - - from .pset import PoseSet - - return PoseSet(self.db, indices=inspirations) - - def get_derivatives(self) -> 'PoseSet': - """Get a :class:`.PoseSet` of this pose's derivatives""" - if not (derivatives := self.get_derivative_ids()): - return None - - from .pset import PoseSet - - return PoseSet(self.db, indices=derivatives) - - def get_dict( - self, - mol: bool = False, - inspirations: bool | str = True, - subsites: bool | str = True, - reference: bool | str = True, - metadata: bool = True, - duplicate_name: str | bool = False, - sanitise_null_metadata_values: bool = False, - skip_metadata: list[str] | None = None, - sanitise_tag_list_separator: str | None = None, - sanitise_metadata_list_separator: str | None = ';', - tags: bool = True, - ) -> dict: - """Returns a dictionary representing this Pose. Arguments: - - :param mol: Include a ``rdkit.Chem.Mol`` in the output? (Default value = False) - :param inspirations: Include inspirations? ``[True, False, 'names']`` Specify ``names`` to format as a comma-separated string (Default value = True) - :param subsites: Include subsites? ``[True, False, 'names']`` Specify ``names`` to format as a comma-separated string (Default value = True) - :param reference: Include reference? ``[True, False, 'name']`` Specify ``name`` to include the :class:`.Pose` name rather than it's ID (Default value = True) - :param metadata: Include metadata? (Default value = True) - :param duplicate_name: Specify the name of a new column duplicating the pose name column (Default value = False) - :param tags: bool: Include tags? (Default value = True) - - """ - - skip_metadata = skip_metadata or [] - - serialisable_fields = [ - 'id', - 'inchikey', - 'alias', - 'name', - 'smiles', - 'path', - 'distance_score', - 'energy_score', - 'inspiration_score', - ] - - data = {} - for key in serialisable_fields: - data[key] = getattr(self, key) - - if duplicate_name: - assert isinstance(duplicate_name, str) - data[duplicate_name] = data['name'] - - if mol: - try: - data['mol'] = self.mol - except InvalidMolError: - data['mol'] = None - - data['compound'] = self.compound.name - data['compound_id'] = self.compound.id - data['target'] = self.target.name - - if tags: - data['tags'] = self.tags - if sanitise_tag_list_separator: - data['tags'] = sanitise_tag_list_separator.join(data['tags']) - - if inspirations == 'names': - if not self.inspirations: - data['inspirations'] = None - else: - data['inspirations'] = ','.join([p.name for p in self.inspirations]) - elif inspirations: - data['inspirations'] = self.inspirations - - if subsites == 'names': - if not (sites := self.subsites): - data['subsites'] = None - else: - data['subsites'] = ','.join([p.name for p in sites]) - elif subsites: - data['subsites'] = self.subsites - - if reference == 'name': - if not self.reference: - data['reference'] = '' - else: - data['reference'] = self.reference.name - elif reference: - data['reference'] = self.reference - - if metadata and (metadict := self.metadata): - for key in metadict: - value = metadict[key] - - if key in skip_metadata: - continue - - if ( - sanitise_null_metadata_values - and isinstance(value, str) - and not value - ): - value = None - - elif sanitise_metadata_list_separator and isinstance(value, list): - new_values = [] - - for v in value: - if ( - sanitise_null_metadata_values - and isinstance(v, str) - and not v - ): - v = None - - else: - v = str(v) - - new_values.append(v) - - value = sanitise_metadata_list_separator.join(new_values) - - data[key] = value - - return data - - def add_subsite(self, name: str, commit: bool = True) -> 'SubsiteTag': - """Tag this pose with a protein subsite - - :param name: the name of the subsite - :param commit: commit the insertion to the database - :returns: :class:`.SubsiteTag` - - """ - - id = self.db.insert_subsite_tag(pose_id=self.id, name=name, commit=commit) - - if not id: - return None - - return self.db.get_subsite_tag(id=id) - - def calculate_interactions( - self, - resolve: bool = True, - distance_padding: float = 0.0, - angle_padding: float = 0.0, - force: bool = False, - debug: bool = False, - commit: bool = True, - mutation_warnings: bool = True, - in_memory_db: bool = True, - delete_temp_table: bool = True, - ) -> None: - """Enumerate all valid interactions between this ligand and the protein - - :param resolve: Cull duplicate / less-significant interactions - :param distance_padding: Apply a padding in Angstrom to all distance cutoffs - :param angle_padding: Apply a padding in degrees to all angle cutoffs - :param force: Force a recalculation even if the pose has already been fingerprinted - :param debug: Increase verbosity for debugging - :param commit: commit the changes to the database (Default value = True) - :param mutation_warnings: warn when there has been a mutation in the protein (Default value = True) - :param in_memory_db: use an in-memory sqlite database when resolving interactions, faster but may break IPyWidgets (Default value = True) - :param delete_temp_table: delete the temporary interaction table created during interaction resolution (Default value = True) - - """ - - if not self.has_fingerprint or force: - - def norm(coords): - """Vector norm""" - import numpy as np - - coords = np.array(coords).T - cov = np.cov(coords) - eig = np.linalg.eig(cov) - vec = eig[1][:, 0] - return vec - - def unit_vector(vector): - """Returns the unit vector of the vector.""" - return vector / np.linalg.norm(vector) - - def angle_between(v1, v2): - """Returns the angle in radians between vectors 'v1' and 'v2':: - - >>> angle_between((1, 0, 0), (0, 1, 0)) - 1.5707963267948966 - >>> angle_between((1, 0, 0), (1, 0, 0)) - 0.0 - >>> angle_between((1, 0, 0), (-1, 0, 0)) - 3.141592653589793 - """ - v1_u = unit_vector(v1) - v2_u = unit_vector(v2) - a = 180 * np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) / np.pi - - if a > 90: - a = 180 - a - return a - - # calculate interactions... - - ### clear old interactions - - self.set_has_fingerprint(False, commit=commit) - self._interactions = None - - ### IN-MEMORY DB - - if in_memory_db: - from .db import Database - - temp_db = Database( - ':memory:', - animal=None, - create_blank=False, - check_legacy=False, - create_indexes=False, - debug=False, - ) - - temp_db.create_table_interaction(debug=False) - temp_db.commit() - - else: - temp_db = db - - ### create temporary table - - if 'temp_interaction' in temp_db.table_names: - self.db.execute('DROP TABLE temp_interaction') - - temp_db.create_table_interaction(table='temp_interaction', debug=False) - - ### load the ligand structure - - if debug: - mrich.debug('path', self.path) - - if self.path.endswith('.pdb'): - from molparse import parse - - protein_system = self.protein_system - if not self.protein_system: - protein_system = parse(self.path, verbosity=False).protein_system - - elif self.path.endswith('.mol') and self.reference: - protein_system = self.reference.protein_system - - else: - mrich.debug('Unsupported: Pose.calculate_interactions()') - raise NotImplementedError(f'{self}, {self.reference=}, {self.path=}') - - assert protein_system - - if not self.mol: - mrich.error(f'Could not read molecule for {self}') - return - - ### get features - - comp_features = self.features - - if debug: - mrich.debug('Getting protein features...') - - protein_features = self.target.calculate_features( - protein_system, reference_id=self.reference_id - ) - - if debug: - print('ligand features', comp_features) - - ### organise ligand features by family - comp_features_by_family = {} - for family in FEATURE_FAMILIES: - comp_features_by_family[family] = [ - f for f in comp_features if f.family == family - ] - - ### protein chain names - chains = protein_system.chain_names - - mutation_warnings = set() - mutation_count = 0 - - # loop over protein features - for prot_feature in protein_features: - # skip chains that aren't present - if prot_feature.chain_name not in chains: - continue - - prot_family = prot_feature.family - - prot_residue = protein_system.get_chain( - prot_feature.chain_name - ).residues[f'n{prot_feature.residue_number}'] - - if not prot_residue: - continue - - if prot_residue.name != prot_feature.residue_name: - com = prot_residue.centre_of_mass() - if any( - np.linalg.norm(com - cf.position) < MUTATION_WARNING_DIST - for cf in comp_features - ): - mutation_warnings.add( - f'{prot_residue.name} {prot_residue.number} -> {prot_feature.residue_name} {prot_feature.residue_number}' - ) - mutation_count += 1 - continue - - ### calculate protein coordinate - prot_atoms = [] - for atom_name in prot_feature.atom_names.split(' '): - atom = prot_residue.get_atom(atom_name, verbosity=0) - if atom: - prot_atoms.append(atom) - - prot_coords = [a.np_pos for a in prot_atoms if a is not None] - - if not prot_coords: - # mrich.warning("Skipping feature with no atoms") - continue - - prot_coord = np.array(np.sum(prot_coords, axis=0) / len(prot_atoms)) - - if prot_family not in COMPLEMENTARY_FEATURES: - continue - - complementary_families = COMPLEMENTARY_FEATURES[prot_family] - - # print(prot_family, complementary_families) - - for complementary_family in complementary_families: - interaction_type = INTERACTION_TYPES[ - (prot_family, complementary_family) - ] - - complementary_comp_features = comp_features_by_family[ - complementary_family - ] - - for lig_feature in complementary_comp_features: - distance = np.linalg.norm(lig_feature - prot_coord) - angle = None - - # check distance cutoff - if ( - distance - > INTERACTION_CUTOFF[interaction_type] + distance_padding - ): - continue - - # special rules for aromatics - if interaction_type.startswith('π'): - lig_coords = [ - self.mol.GetConformer().GetAtomPosition(i - 1) - for i in lig_feature.atom_numbers - ] - - # special rules for pi-stacking - if interaction_type == 'π-stacking': - # calculate minimum distance - min_distance = None - for lig_coord in lig_coords: - for p_coord in prot_coords: - d = np.linalg.norm(lig_coord - p_coord) - if not min_distance or d < min_distance: - min_distance = d - - # skip interaction if no atom is within PI_STACK_MIN_CUTOFF - if min_distance > PI_STACK_MIN_CUTOFF + distance_padding: - if debug: - print( - f'skipping {prot_feature} due to pi-stack min_distance' - ) - print( - prot_feature.residue_name, - prot_feature.residue_number, - prot_feature.chain_name, - prot_feature.family, - lig_feature, - distance, - ) - continue - - ### angles - lig_norm = norm([list(p) for p in lig_coords]) - prot_norm = norm(prot_coords) - angle = angle_between(lig_norm, prot_norm) - - # Face to Face has more stringent restraints - if ( - angle < 40 - angle_padding - and distance > PI_STACK_F2F_CUTOFF + distance_padding - ): - if debug: - print(prot_feature.res_name_number_family_str) - print(angle, distance) - continue - - # special rules for pi-cation - elif interaction_type == 'π-cation': - # construct vectors - if prot_family == 'Aromatic': - aromatic_norm = norm(prot_coords) - cation_vec = lig_feature.position - prot_coord - else: - aromatic_norm = norm([list(p) for p in lig_coords]) - cation_vec = prot_coord - lig_feature.position - - # calculate angle - angle = angle_between(aromatic_norm, cation_vec) - - # skip if angle too large - if angle > 30 + angle_padding: - if debug: - print(prot_feature.res_name_number_family_str) - print(angle, distance) - continue - - if debug: - print('Prot:', prot_feature, 'Lig:', lig_feature) - - # insert into the Database - temp_db.insert_interaction( - feature=prot_feature.id, - pose=self.id, - type=interaction_type, - family=lig_feature.family, - atom_ids=lig_feature.atom_numbers, - prot_coord=prot_coord, - lig_coord=lig_feature.position, - distance=distance, - angle=angle, - energy=None, - commit=False, - table='temp_interaction', - ) - - if mutation_warnings: - mrich.warning( - f'Skipped {mutation_count} protein features because the residue was mutated:' - ) - for mutation in mutation_warnings: - mrich.warning(mutation) - - if resolve: - from .feature import Feature - from .iset import InteractionSet - - interactions = InteractionSet.from_pose( - self, table='temp_interaction', db=temp_db - ) - - feature_ids = str(tuple(interactions.feature_ids)).replace(',)', ')') - - records = self.db.select_all_where( - table='feature', key=f'feature_id IN {feature_ids}', multiple=True - ) - - feature_cache = { - pk: Feature( - id=pk, - family=family, - target=target, - chain_name=chain_name, - residue_name=residue_name, - residue_number=residue_number, - atom_names=atom_names, - ) - for pk, family, target, chain_name, residue_name, residue_number, atom_names in records - } - - interactions.resolve(debug=debug, feature_cache=feature_cache) - - ### transfer interactions from temporary table - self.db.delete_where( - table='interaction', key='pose', value=self.id, commit=commit - ) - - if in_memory_db: - self.db.copy_temp_interactions(source_db=temp_db) - else: - self.db.copy_temp_interactions() - - self.set_has_fingerprint(True, commit=commit) - - ### delete temporary table - - if in_memory_db and delete_temp_table: - temp_db.close(debug=False) - - elif debug: - mrich.warning(f'{self} is already fingerprinted, no new calculation') - - def calculate_prolif_interactions( - self, - return_all: bool = False, - max_retry: int = 5, - use_mda: bool = False, - force: bool = False, - clear_existing: bool = True, - debug: bool = False, - resolve: bool = True, - ) -> 'prolif.Fingerprint': - """Use ProLIF to populate the interactions table""" - - if not self.has_fingerprint or force: - ### clear old interactions - - if clear_existing: - self.db.delete_where( - table='interaction', key='pose', value=self.id, commit=False - ) - self.set_has_fingerprint(False, commit=False) - - ### create temporary table - - table = 'temp_interaction' - - if 'temp_interaction' in self.db.table_names: - mrich.warning('Deleting existing temp_interaction table') - self.db.execute('DROP TABLE temp_interaction') - - self.db.create_table_interaction(table='temp_interaction', debug=False) - - if not clear_existing: - self.db.copy_interactions_to_temp(pose_id=self.id) - - # clear cached InteractionSet - self._interactions = None - - import logging - from tempfile import NamedTemporaryFile - - import prolif as plf - from MDAnalysis import Universe - - from .prolif import parse_prolif_interactions - - mdanalysis_logger = logging.getLogger('MDAnalysis') - mdanalysis_logger.setLevel(logging.WARNING) - - ## prepare inputs - - # decide if MDA is needed - unprotonated_sys = self.protein_system - residue_names = set(r.name for r in unprotonated_sys.residues) - nonstandard = ['HID', 'HIE', 'HSE', 'HSD', 'HSP'] - if any(r in residue_names for r in nonstandard): - mrich.debug('Using MDA') - use_mda = True - - # protonated protein - for i in range(max_retry): - try: - protonated_sys, protein_file = unprotonated_sys.add_hydrogens( - return_file=True - ) - - if use_mda: - with mrich.loading('Creating MDAnalysis.Universe'): - universe = Universe(protein_file.name) - protein_mol = plf.Molecule.from_mda(universe) - else: - with mrich.loading('Creating protein rdkit.Chem.Mol'): - rdkit_prot = Chem.MolFromPDBFile( - protein_file.name, removeHs=False - ) - protein_mol = plf.Molecule(rdkit_prot) - - break - - except Exception as e: - mrich.warning( - f'Could not create satisfactory protein molecule, attempts = {i + 1}/{max_retry}' - ) - mrich.warning(e) - use_mda = True - continue - else: - mrich.error( - f'Tried {max_retry} times to create protein molecule and failed' - ) - return None - - # ligand - ligand_file = NamedTemporaryFile(mode='w+t', suffix='.sdf') - writer = Chem.SDWriter(ligand_file.name) - writer.write(self.protonated_mol) - writer.close() - ligand_iterable = plf.sdf_supplier(ligand_file.name) - - ## run prolif - - fp = plf.Fingerprint(count=True) - fp.run_from_iterable(ligand_iterable, protein_mol, progress=False, n_jobs=1) - - ## parse outputs and insert Feature and Interaction records - parse_prolif_interactions( - self, fp, protonated_sys, debug=debug, table=table - ) - - if resolve: - from .iset import InteractionSet - - interactions = InteractionSet.from_pose(self, table='temp_interaction') - interactions.resolve(debug=debug) - - self.db.copy_temp_interactions() - self.set_has_fingerprint(True, commit=True) - - ### delete temporary table - - self.db.execute('DROP TABLE temp_interaction') - - ## close files - protein_file.close() - ligand_file.close() - - if return_all: - return fp, ligand_iterable, protein_mol - - def calculate_classic_fingerprint( - self, - debug: bool = False, - ) -> dict: - """Calculate the pose's interaction fingerprint""" - - if self.path.endswith('.pdb'): - import molparse as mp - - protein_system = self.protein_system - if not self.protein_system: - # mrich.reading(self.path) - protein_system = mp.parse(self.path, verbosity=False).protein_system - - elif self.path.endswith('.mol') and self.reference: - # mrich.debug('fingerprint from .mol and reference pose') - protein_system = self.reference.protein_system - - else: - mrich.debug('Unsupported: Pose.calculate_fingerprint()') - raise NotImplementedError(f'{self.reference=}, {self.path=}') - - assert protein_system - - if not self.mol: - return - - comp_features = self.features - - comp_features_by_family = {} - for family in FEATURE_FAMILIES: - comp_features_by_family[family] = [ - f for f in comp_features if f.family == family - ] - - # protein_features = self.target.features - # if not protein_features: - protein_features = self.target.calculate_features(protein_system) - - fingerprint = {} - - chains = protein_system.chain_names - - for prot_feature in protein_features: - if prot_feature.chain_name not in chains: - continue - - prot_family = prot_feature.family - - prot_residue = protein_system.get_chain(prot_feature.chain_name).residues[ - f'n{prot_feature.residue_number}' - ] - - if not prot_residue: - continue - - # if prot_feature.residue_number == 77: - # mrich.debug(repr(prot_feature)) - - if prot_residue.name != prot_feature.residue_name: - mrich.warning(f'Feature {repr(prot_feature)}') - continue - - prot_atoms = [ - prot_residue.get_atom(a) for a in prot_feature.atom_names.split(' ') - ] - - prot_coords = [a.np_pos for a in prot_atoms if a is not None] - - prot_coord = np.array(np.sum(prot_coords, axis=0) / len(prot_atoms)) - - complementary_family = COMPLEMENTARY_FEATURES[prot_family] - - complementary_comp_features = comp_features_by_family[complementary_family] - - cutoff = FEATURE_PAIR_CUTOFFS[f'{prot_family} {complementary_family}'] - - valid_features = [ - f - for f in complementary_comp_features - if np.linalg.norm(f - prot_coord) <= cutoff - ] - - if valid_features: - if debug: - mrich.debug( - f'PROT: {prot_feature.residue_name} {prot_feature.residue_number} {prot_feature.atom_names}, LIG: #{len(valid_features)} {[f for f in valid_features]}' - ) - fingerprint[prot_feature.id] = len(valid_features) - - return fingerprint - - # self.fingerprint = fingerprint - - def draw( - self, - inspirations: bool = True, - protein: bool = False, - **kwargs, - ) -> None: - """Render this pose (and its inspirations) - - :param inspirations: Render the inspirations? (Default value = True) - :param protein: Render the protein? This wraps :meth:`.Pose.render` (Default value = False) - - """ - - if protein: - self.render(**kwargs) - - from molparse.rdkit import draw_mols - - mols = [self.mol] - if inspirations and self.inspirations: - mols += [i.mol for i in self.inspirations] - - draw_mols(mols) - - def draw2d( - self, - ) -> None: - """Draw a 2D drawing of this pose""" - from rdkit.Chem import MolFromSmiles - - mol = MolFromSmiles(self.smiles) - display(mol) - - def render( - self, - protein='cartoon', - ligand='stick', - protein_color='spectrum', - interactions: bool = True, - file: str | None = None, - ) -> None: - """Render this pose with the protein using py3Dmol - - :param protein: protein representation, default = 'cartoon' - :param ligand: ligand representation, default = 'stick' - :param protein_color: color of protein representation, default = 'spectrum' - - """ - - from molparse.py3d import render - - sys = self.complex_system - - def make_view(width='640px', height='480px'): - """Create py3Dmol view""" - - view = render( - sys, - protein=protein, - ligand=ligand, - protein_color=protein_color, - width=width, - height=height, - ) - - if interactions: - COLORS = { - 'Hydrophobic': 'green', - 'Hydrogen Bond': 'blue', - 'π-stacking': 'purple', - 'π-cation': 'pink', - 'Electrostatic': 'red', - 'Sulfur-Sulfur': 'yellow', - } - - iset = self.interactions - - if not iset: - return view - - df = iset.df - - residues = set() - - for i, row in df.iterrows(): - prot_coord = row['prot_coord'] - lig_coord = row['lig_coord'] - type = row['type'] - color = COLORS.get(type, 'black') - - view.addCylinder( - { - 'start': { - 'x': prot_coord[0], - 'y': prot_coord[1], - 'z': prot_coord[2], - }, - 'end': { - 'x': lig_coord[0], - 'y': lig_coord[1], - 'z': lig_coord[2], - }, - # 'radius': radius, - 'color': color, - } - ) - - residues.add((row['residue_name'], row['residue_number'])) - - for res_name, res_num in residues: - res = sys.residues[f'{res_name} n{res_num}'] - view.addModel(res.pdb_block, 'pdb') - view.setStyle({'model': -1}, {ligand: {}}) - - return view - - if file: - view = make_view(width='100%', height='100%') - html = view._make_html() - - mrich.writing(file) - with open(file, 'w') as f: - f.write(html) - - view = make_view() - return view._repr_html_() - - def grid(self) -> None: - """Draw a grid of this pose with its inspirations""" - from IPython.display import display - from molparse.rdkit import draw_grid - - mols = [self.compound.mol] - labels = [self.plain_repr()] - if self.inspirations: - mols += [i.compound.mol for i in self.inspirations] - labels += [i.plain_repr() for i in self.inspirations] - - display(draw_grid(mols, labels=labels)) - - def summary( - self, metadata: bool = True, tags: bool = True, subsites: bool = True - ) -> None: - """Print a summary of this pose - - :param metadata: include metadata (Default value = True) - - """ - if self.alias: - mrich.header(f'{str(self)}: {self.alias}') - else: - mrich.header(f'{str(self)}: {self.inchikey}') - mrich.var('inchikey', self.inchikey) - mrich.var('alias', self.alias) - mrich.var('smiles', self.smiles) - mrich.var('compound', self.compound) - mrich.var('path', self.path) - mrich.var('target', self.target) - mrich.var('reference', self.reference) - if tags: - mrich.var('tags', self.tags) - if subsites: - mrich.var('subsites', self.subsites) - mrich.var('num_heavy_atoms', self.num_heavy_atoms) - mrich.var('distance_score', self.distance_score) - mrich.var('energy_score', self.energy_score) - mrich.var('inspiration_score', self.inspiration_score) - if inspirations := self.inspirations: - mrich.var('inspirations', self.inspirations.names) - mrich.var('num_atoms_added', self.num_atoms_added) - if metadata: - mrich.var('metadata', str(self.metadata)) - - def showcase(self) -> None: - """Print and render this pose as if you were using :meth:`.PoseSet.interactive`""" - - self.summary(metadata=False) - self.grid() - self.draw() - from pprint import pprint - - mrich.title('Metadata:') - pprint(self.metadata) - - def plain_repr(self) -> str: - """Unformatted detailed string representation""" - if self.name: - return f'{self.compound}->{self}: "{self.name}"' - else: - return f'{self.compound}->{self}' - - def plot3d( - self, - features: bool = False, - **kwargs, - ) -> 'plotly.graph_objects.Figure': - """Use Molparse/Plotly to create a 3d figure of this pose - - :param features: include the features in the figure - :returns: a plotly Figure object - - """ - - mol = self.mol - - import molparse as mp - - group = mp.rdkit.mol_to_AtomGroup(mol) - - if features: - features = self.features - - return mp.go.plot3d(atoms=group.atoms, features=features, **kwargs) - - def set_has_fingerprint(self, fp: bool, commit: bool = True) -> None: - """Update the database to reflect this pose's has_fingerprint property""" - assert isinstance(fp, bool) - self._has_fingerprint = fp - self.db.update( - table='pose', - id=self.id, - key='pose_fingerprint', - value=int(fp), - commit=commit, - ) - - def posebusters(self, debug: bool = False) -> bool: - """Run a posebusters ligand check on this pose's molecule""" - - # use syndirella implementation - from syndirella.slipper import flatness, intra_geometry - - geometries: Dict = intra_geometry.check_geometry(self.mol, threshold_clash=0.4) - flat_results: Dict = flatness.check_flatness(self.mol) - - if not geometries['results']['bond_lengths_within_bounds']: - if debug: - mrich.debug(self, 'did not pass bond length checks.') - return False - if not geometries['results']['bond_angles_within_bounds']: - if debug: - mrich.debug(self, 'did not pass bond angle checks.') - return False - if not geometries['results']['no_internal_clash']: - if debug: - mrich.debug(self, 'did not pass internal clash checks.') - return False - if not flat_results['results']['flatness_passes']: - if debug: - mrich.debug(self, 'did not pass flatness checks.') - return False - return True - - def to_syndirella(self, out_key: 'str | Path') -> 'DataFrame': - """Create syndirella inputs. See :meth:`.PoseSet.to_syndirella`""" - from .pset import PoseSet - - return PoseSet(self.db, [self.id]).to_syndirella( - out_key=out_key, separate=False - ) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'P{self.id}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.plain_repr()}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Formatted string representation""" - return f'[bold underline]{self.plain_repr()}' - - def __eq__(self, other: 'Pose') -> bool: - """Compare this pose with another instance""" - - if isinstance(other, int): - return self.id == other - - return self.id == other.id - - def __add__( - self, - other: 'Pose | PoseSet', - ) -> 'PoseSet': - """Add a :class:`.PoseSet` to this pose""" - from .pset import PoseSet - - if isinstance(other, PoseSet): - return PoseSet(self.db, [self.id] + other.ids, sort=False) - elif isinstance(other, Pose): - return PoseSet(self.db, [self.id, other.id], sort=False) - else: - raise NotImplementedError - - -class InvalidMolError(Exception): - """Exception to be thrown when the molecule could not be parsed""" - - ... diff --git a/hippo/postgres.py b/hippo/postgres.py deleted file mode 100644 index 1340f3f..0000000 --- a/hippo/postgres.py +++ /dev/null @@ -1,811 +0,0 @@ -"""PostgreSQL database wrapper class using psycopg3""" - -from pathlib import Path - -import mrich -import psycopg - -from .db import Database -from .tools import strip_sql - - -class PostgresDatabase(Database): - """Wrapper to connect to a HIPPO Postgres database. - - .. attention:: - - :class:`.PostGresDatabase` objects should not be created directly. Instead use the methods in :class:`.HIPPO` to interact with data in the database. See :doc:`getting_started` and :doc:`insert_elaborations`. - - """ - - TABLES = [ - 'subsite', - 'subsite_tag', - 'scaffold', - 'compound', - 'pose', - 'inspiration', - 'reaction', - 'reactant', - 'tag', - 'quote', - 'route', - 'component', - 'feature', - 'interaction', - 'target', - ] - - SQL_STRING_PLACEHOLDER = '%s' - SQL_PK_DATATYPE = 'SERIAL' - SQL_SCHEMA = 'hippo' - SQL_SCHEMA_PREFIX = f'{SQL_SCHEMA}.' - - ERROR_UNIQUE_VIOLATION = psycopg.errors.UniqueViolation - - SQL_CREATE_TABLE_COMPOUND = """CREATE TABLE hippo.compound( - compound_id SERIAL PRIMARY KEY, - compound_inchikey TEXT, - compound_alias TEXT, - compound_smiles TEXT, - compound_base INTEGER, - compound_mol MOL, - compound_pattern_bfp bit(2048), - compound_morgan_bfp bit(2048), - compound_metadata TEXT, - FOREIGN KEY (compound_base) REFERENCES hippo.compound(compound_id), - CONSTRAINT UC_compound_inchikey UNIQUE (compound_inchikey), - CONSTRAINT UC_compound_alias UNIQUE (compound_alias), - CONSTRAINT UC_compound_smiles UNIQUE (compound_smiles) - ); - """ - - SQL_CREATE_TABLE_POSE = """CREATE TABLE hippo.pose( - pose_id SERIAL PRIMARY KEY, - pose_inchikey TEXT, - pose_alias TEXT, - pose_smiles TEXT, - pose_reference INTEGER, - pose_path TEXT, - pose_compound INTEGER, - pose_target INTEGER, - pose_mol MOL, - pose_fingerprint INTEGER, - pose_energy_score REAL, - pose_distance_score REAL, - pose_inspiration_score REAL, - pose_metadata TEXT, - FOREIGN KEY (pose_compound) REFERENCES hippo.compound(compound_id), - CONSTRAINT UC_pose_alias UNIQUE (pose_alias), - CONSTRAINT UC_pose_path UNIQUE (pose_path) - ); - """ - - SQL_INSERT_COMPOUND = """ - INSERT INTO hippo.compound( - compound_inchikey, - compound_smiles, - compound_mol, - compound_alias - ) - VALUES( - %(inchikey)s, - %(smiles)s, - hippo.mol_from_smiles(%(smiles)s), - %(alias)s - ) - RETURNING compound_id; - """ - - SQL_BULK_INSERT_INTERACTIONS = """ - INSERT INTO hippo.interaction( - interaction_feature, - interaction_pose, - interaction_type, - interaction_family, - interaction_atom_ids, - interaction_prot_coord, - interaction_lig_coord, - interaction_distance, - interaction_angle, - interaction_energy - ) - VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) - ON CONFLICT ON CONSTRAINT UC_interaction DO NOTHING - """ - - POSE_FIELDS = [ - 'pose_id', - 'pose_inchikey', - 'pose_alias', - 'pose_smiles', - 'pose_reference', - 'pose_path', - 'pose_compound', - 'pose_target', - 'hippo.mol_to_pkl(pose_mol)', - 'pose_fingerprint', - 'pose_energy_score', - 'pose_distance_score', - 'pose_inspiration_score', - ] - - COMPOUND_PROPERTY_FUNCTIONS = { - 'num_heavy_atoms': 'hippo.mol_numheavyatoms', - 'formula': ('hippo.mol_formula', ', false, false'), - 'num_rings': 'hippo.mol_numrings', - 'molecular_weight': 'hippo.mol_amw', - } - - def __init__( - self, - animal: 'HIPPO', - username: str, - password: str, - host: str = 'localhost', - port: int = 5432, - dbname: str = 'hippo', - update_legacy: bool = False, - auto_compute_bfps: bool = False, - create_blank: bool = True, - check_legacy: bool = False, - create_indexes: bool = True, - update_indexes: bool = False, - debug: bool = True, - ) -> None: - """PostgresDatabase initialisation""" - - assert isinstance(username, str) - assert isinstance(password, str) - assert isinstance(port, int) - - if debug: - mrich.debug('hippo.PostgresDatabase.__init__()') - - self._username = username - self._password = password - self._port = port - self._host = host - - self._connection = None - self._cursor = None - self._animal = animal - self._auto_compute_bfps = auto_compute_bfps - self._engine = 'psycopg' - self._dbname = dbname - - if debug: - mrich.debug(f'PostgresDatabase.username = {self.username}') - mrich.debug(f'PostgresDatabase.password = {self.password}') - mrich.debug(f'PostgresDatabase.host = {self.host}') - mrich.debug(f'PostgresDatabase.port = {self.port}') - - self.connect() - - if not self.table_names: - if create_blank: - self.create_schema() - self.create_blank_db() - else: - mrich.error('Database is empty!', self.path) - raise ValueError( - 'Database is empty! Check connection or run with create_blank=True' - ) - - if check_legacy: - self.check_schema(update=update_legacy) - - if create_indexes: - self.create_indexes(update=update_indexes, debug=debug) - - ### PROPERTIES - - @property - def path(self) -> None: - """PostgresDatabase path""" - # raise NotImplementedError("PostgresDatabase has no path") - return f'postgresql://{self.username}@{self.host}:{self.port}' - - @property - def username(self) -> str: - """PostgresDatabase username""" - return self._username - - @property - def dbname(self) -> str: - """PostgresDatabase dbname""" - return self._dbname - - @property - def password(self) -> str: - """PostgresDatabase password""" - return self._password - - @property - def host(self) -> str: - """PostgresDatabase host""" - return self._host - - @property - def port(self) -> int: - """PostgresDatabase port""" - return self._port - - @property - def table_names(self) -> list[str]: - """List of all the table names in the database""" - results = self.execute( - f""" - SELECT table_name - FROM information_schema.tables - WHERE table_schema = '{self.SQL_SCHEMA}' - AND table_type = 'BASE TABLE'; - """ - ).fetchall() - return [n for (n,) in results] - - def index_names(self) -> list[str]: - """Get the index names""" - - cursor = self.execute( - f""" - SELECT indexname - FROM pg_indexes - WHERE schemaname = '{self.SQL_SCHEMA}'; - """ - ) - - return [n for (n,) in cursor] - - @property - def total_changes(self) -> int: - """Return the current transaction ID as a proxy of sqlite's total_changes.""" - cursor = self.execute('SELECT txid_current()') - return cursor.fetchone()[0] - - ### GENERAL SQL - - def connect(self, debug: bool = True) -> None: - """Connect to the database""" - - if debug: - mrich.debug('hippo.PostgresDatabase.connect()') - - conn = None - - try: - conn = psycopg.connect( - user=self.username, - host=self.host, - password=self.password, - port=self.port, - dbname=self.dbname, - ) - - with conn.cursor() as c: - c.execute( - """ - SELECT EXISTS ( - SELECT 1 - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - WHERE t.typname = 'mol' - ); - """ - ) - (exists,) = c.fetchone() - - if not exists: - raise ValueError( - "'mol' datatype not defined, is the rdkit postgres cartridge installed correctly?" - ) - - conn.execute("SET client_encoding TO 'UTF8'") - - except Exception as e: - mrich.error('Could not connect to', self.path) - mrich.error(e) - raise - - self._connection = conn - self._cursor = conn.cursor() - - def execute( - self, - sql, - payload=None, - *, - debug: bool = False, - time: bool = False, - ): - """Execute arbitrary SQL""" - if debug: - mrich.debug(sql) - - if time: - from time import perf_counter - - start = perf_counter() - - try: - if payload: - records = self.cursor.execute(sql, payload) - else: - records = self.cursor.execute(sql) - except Exception: - # mrich.error(e) - # mrich.print(strip_sql(sql)) - self.rollback() - raise - - if time: - mrich.debug(f'{perf_counter() - start:.2}s: ', strip_sql(sql)) - - return records - - def executemany( - self, - sql, - payload=None, - *, - debug: bool = False, - time: bool = False, - batch_size: int = None, - ): - """Execute arbitrary SQL - - :param batch_size: optional batch size for the execution""" - - returning = 'RETURNING' in sql - - if debug: - from .tools import strip_sql - - mrich.debug(strip_sql(sql)) - mrich.debug('len(payload):', len(payload)) - mrich.debug(f'{returning=}') - - if time: - import re - from time import perf_counter - - start = perf_counter() - - if batch_size and batch_size < len(payload): - from itertools import batched, chain - - batches = list(batched(payload, batch_size)) - - n = len(batches) - - results = [] - for i, batch in enumerate(mrich.track(batches, prefix='batch execution')): - mrich.set_progress_field('i', i) - mrich.set_progress_field('n', n) - - self.cursor.executemany(sql, batch, returning=returning) - - if returning: - result = [self.cursor.fetchone() for _ in self.cursor.results()] - - if result: - results.append(result) - - else: - mrich.set_progress_field('i', n) - - if results: - records = list(chain.from_iterable(results)) - else: - records = None - - else: - self.cursor.executemany(sql, payload, returning=returning) - - if returning: - records = [self.cursor.fetchone() for _ in self.cursor.results()] - else: - records = None - - if time: - sql = re.sub(r'\s+', ' ', sql).strip() - mrich.debug(f'{perf_counter() - start:.2}s: ', sql) - - return records - - def rollback(self) -> None: - """rollback the staged changes. not relevant for sqlite""" - self.connection.rollback() - self.connection.execute("SET client_encoding TO 'UTF8'") - - def sql_return_id_str(self, key: str) -> str: - """Add this to SQL queries to return the entry primary key""" - return f'RETURNING {key}_id' - - def get_lastrowid(self) -> int: - """Get ID of last inserted row""" - return self.cursor.fetchone()[0] - - def column_names(self, table: str) -> list[str]: - """Get the column names of the given table""" - - sql = f""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = 'hippo' - AND table_name = '{table}' - ORDER BY ordinal_position; - """ - - return [n for (n,) in self.execute(sql).fetchall()] - - ### CREATE TABLES - - def create_schema(self) -> None: - """Create postgres schema if it does not exist""" - - sql = """ - SELECT EXISTS ( - SELECT 1 FROM information_schema.schemata - WHERE schema_name = %s - ); - """ - - c = self.execute(sql, (self.SQL_SCHEMA,)) - - exists = c.fetchone()[0] - - if exists: - return None - - self.execute('CREATE SCHEMA IF NOT EXISTS hippo;') - self.commit() - - def create_table_pattern_bfp(self) -> None: - """Create the pattern_bfp table""" - mrich.warning( - 'HIPPO.PostgresDatabase.create_table_pattern_bfp(): NotImplemented' - ) - - return - - mrich.debug('HIPPO.PostgresDatabase.create_table_pattern_bfp()') - - sql = """ - CREATE VIRTUAL TABLE compound_pattern_bfp - USING rdtree(compound_id, fp bits(2048)) - """ - - self.execute(sql) - - ### GETTERS - - def get_compound_mol( - self, - compound_id: int, - ) -> 'Chem.Mol': - """Get the rdkit.Chem.Mol for a given :class:`.Compound`""" - - from rdkit.Chem import Mol - - (bytestr,) = self.select_where( - query='hippo.mol_to_pkl(compound_mol)', - table='compound', - key='id', - value=compound_id, - ) - - return Mol(bytestr) - - ### SINGLE UPDATES - - def update_pose_mol(self, pose_id: int, mol: 'Chem.Mol') -> None: - """Update the molecule stored for a specific pose""" - - sql = """ - UPDATE hippo.pose - SET pose_mol = hippo.mol_from_pkl(%s) - WHERE pose_id = %s; - """ - - self.execute(sql, (mol.ToBinary(), pose_id)) - self.commit() - - ### BULK CALCULATIONS - - def calculate_all_scaffolds(self) -> None: - """Placeholder for calculate_all_scaffolds""" - raise NotImplementedError - - ### MIGRATIONS - - def migrate_sqlite( - self, - source: str | Path, - *, - reactions: bool = True, - scaffolds: bool = True, - features: bool = True, - interactions: bool = True, - subsites: bool = True, - quotes: bool = True, - batch_size: int = 10_000, - tag_compound_id_regex: list[tuple[str, str]] | None = None, - # pose_path_compound_id_regex: list[tuple[str, str]] | None = None, - # pose_path_pose_id_regex: list[tuple[str, str]] | None = None, - # overwrite_quotes: bool = True, - ) -> None: - """Migrate records from a SQLite :class:`.Database` to this :class:`.PostgresDatabase` - - :param source: path to source sqlite database - :param batch_size: SQL insertion batch size - :param tag_compound_id_regex: Provide regex to identify compound ID's to replace in tag names, defaults to `[(r"^C([0-9]+)", "C{new_compound_id}")]` - - The default tag_compound_id_regex means that tags such as "C123 85 percent analogues" are replaced with "C234 85 percent analogues", - where 123 is the compound ID in the source database, and 234 in the destination. - - """ - - from datetime import datetime - - from .animal import HIPPO - from .migration import ( - dump_json, - dump_xlsx, - migrate_compounds, - migrate_features, - migrate_inspirations, - migrate_interactions, - migrate_pose_references, - migrate_poses, - migrate_quotes, - migrate_reactions_and_reactants, - migrate_scaffolds, - migrate_subsites, - migrate_tags, - migrate_targets, - ) - - mrich.var('source', source) - mrich.var('batch_size', batch_size) - - source_path = Path(source).resolve() - assert source_path.exists() - - json_file_name = f'{source_path.name.removesuffix(".sqlite")}_migration.json' - xlsx_file_name = f'{source_path.name.removesuffix(".sqlite")}_migration.xlsx' - mrich.var('json_file_name', json_file_name) - mrich.var('xlsx_file_name', xlsx_file_name) - - if not tag_compound_id_regex: - tag_compound_id_regex = [ - (r'^C([0-9]+)', 'C{new_compound_id}'), - ] - mrich.var('tag_compound_id_regex', tag_compound_id_regex) - - ### THIS DEV WAS NOT COMPLETED - - # if not pose_path_compound_id_regex: - # pose_path_compound_id_regex = [ - # (r"\/.*\/C([0-9]+)-P[0-9]+\.fake\.mol$", "C{new_compound_id}"), - # ] - # mrich.var("pose_path_compound_id_regex", pose_path_compound_id_regex) - - # if not pose_path_pose_id_regex: - # pose_path_pose_id_regex = [ - # (r"\/.*\/C[0-9]+-P([0-9]+)\.fake\.mol$", "P{new_pose_id}"), - # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P([0-9]+)-P[0-9]+-P[0-9]+-[0-9]{6}.fake.mol$", "P{new_pose_id}"), - # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P[0-9]+-P([0-9]+)-P[0-9]+-[0-9]{6}.fake.mol$", "P{new_pose_id}"), - # (r"\/.*\/[A-Z]{14}-[A-Z]{10}-[A-Z]-P[0-9]+-P[0-9]+-P([0-9]+)-[0-9]{6}.fake.mol$", "P{new_pose_id}"), - # ] - # mrich.var("pose_path_pose_id_regex", pose_path_pose_id_regex) - - source = HIPPO('source', source_path) - - ### helper functions - - try: - migration_data = { - 'source': str(source_path.resolve()), - 'destination': self.path, - 'time': str(datetime.now()), - 'tag_compound_id_regex': tag_compound_id_regex, - # "pose_path_compound_id_regex": pose_path_compound_id_regex, - # "pose_path_pose_id_regex": pose_path_pose_id_regex, - } - - ### compounds - - migration_data = migrate_compounds( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### scaffolds - - if scaffolds: - migration_data = migrate_scaffolds( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### targets - - migration_data = migrate_targets( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### poses - - migration_data = migrate_poses( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### pose references - - migration_data = migrate_pose_references( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### inspirations - - migration_data = migrate_inspirations( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### tags - - migration_data = migrate_tags( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### reactions & reactants - - if reactions: - migration_data = migrate_reactions_and_reactants( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - ) - - ### features - - if features or interactions: - migration_data = migrate_features( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### interactions - - if interactions: - migration_data = migrate_interactions( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### subsites - - if subsites: - migration_data = migrate_subsites( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - ### quotes - - if quotes: - migration_data = migrate_quotes( - source=source.db, - destination=self, - migration_data=migration_data, - batch_size=batch_size, - # execute=False, - ) - - except Exception as e: - self.rollback() - - mrich.error(e) - - json_file_name = ( - f'{source.db.path.name.removesuffix(".sqlite")}_migration_partial.json' - ) - xlsx_file_name = ( - f'{source.db.path.name.removesuffix(".sqlite")}_migration_partial.xlsx' - ) - - dump_json(migration_data, json_file_name) - dump_xlsx(migration_data, xlsx_file_name) - - source.db.close() - - raise - - dump_json(migration_data, json_file_name) - dump_xlsx(migration_data, xlsx_file_name) - - source.db.close() - - mrich.success( - 'Migration staged. Please review and db.commit() or db.rollback() the changes' - ) - - ### MAINTENANCE - - def _drop_schema(self) -> None: - """Empty the Database schema entirely and recreate it""" - - self.execute(f'DROP SCHEMA IF EXISTS {self.SQL_SCHEMA} CASCADE;') - self.commit() - - def _drop_tables(self) -> None: - """Delete all HIPPO tables and restart sequences""" - - for table in self.TABLES: - self.execute(f'DROP TABLE IF EXISTS {self.SQL_SCHEMA}.{table} CASCADE;') - - # sql = f""" - # DO $$ - # DECLARE - # seq RECORD; - # BEGIN - # FOR seq IN - # SELECT sequence_schema, sequence_name - # FROM information_schema.sequences - # WHERE sequence_schema = '{self.SQL_SCHEMA}' - # LOOP - # EXECUTE format( - # 'ALTER SEQUENCE %I.%I RESTART WITH 1;', - # seq.sequence_schema, - # seq.sequence_name - # ); - # END LOOP; - # END $$; - # """ - - # self.execute(sql) - - self.commit() - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return f'Database @ {self.path}' diff --git a/hippo/prolif.py b/hippo/prolif.py deleted file mode 100644 index b49403b..0000000 --- a/hippo/prolif.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Functions for ProLIF interaction profiling""" - -import molparse as mp -import mrich -from molparse.rdkit.features import FEATURE_FAMILIES, INTERACTION_TYPES - -INTERACTION_TYPES = list(INTERACTION_TYPES.values()) + ['VdWContact'] -FEATURE_FAMILIES = list(FEATURE_FAMILIES) + ['VdWSphere'] - - -def guess_feature_families( - interaction_type: str, prot_atom_names: list[str], lig_atom_ids: list[int] -) -> tuple[str, str, str]: - """Guess feature families from atom names - - :param interaction_type: interaction type - - """ - - match interaction_type: - case 'VdWContact': - lig_feature_family = 'VdWSphere' - prot_feature_family = 'VdWSphere' - - case 'Hydrophobic': - lig_feature_family = ( - 'LumpedHydrophobe' if len(lig_atom_ids) > 1 else 'Hydrophobe' - ) - prot_feature_family = ( - 'LumpedHydrophobe' if len(prot_atom_names) > 1 else 'Hydrophobe' - ) - - case 'Anionic': - lig_feature_family = 'NegIonizable' - prot_feature_family = 'PosIonizable' - interaction_type = 'Electrostatic' - - case 'Cationic': - lig_feature_family = 'PosIonizable' - prot_feature_family = 'NegIonizable' - interaction_type = 'Electrostatic' - - case 'CationPi': - lig_feature_family = 'PosIonizable' - prot_feature_family = 'Aromatic' - interaction_type = 'π-cation' - - case 'PiCation': - lig_feature_family = 'Aromatic' - prot_feature_family = 'PosIonizable' - interaction_type = 'π-cation' - - case 'PiStacking': - lig_feature_family = 'Aromatic' - prot_feature_family = 'Aromatic' - interaction_type = 'π-stacking' - - case 'PiStacking': - lig_feature_family = 'Aromatic' - prot_feature_family = 'Aromatic' - interaction_type = 'π-stacking' - - case 'EdgeToFace': - lig_feature_family = 'Aromatic' - prot_feature_family = 'Aromatic' - interaction_type = 'π-stacking (EdgeToFace)' - - case 'FaceToFace': - lig_feature_family = 'Aromatic' - prot_feature_family = 'Aromatic' - interaction_type = 'π-stacking (FaceToFace)' - - case 'HBAcceptor': - lig_feature_family = 'Acceptor' - prot_feature_family = 'Donor' - interaction_type = 'Hydrogen Bond' - - case 'HBDonor': - lig_feature_family = 'Donor' - prot_feature_family = 'Acceptor' - interaction_type = 'Hydrogen Bond' - - case 'XBAcceptor': - lig_feature_family = 'Acceptor' - prot_feature_family = 'Donor' - interaction_type = 'Halogen Bond' - - case 'XBDonor': - lig_feature_family = 'Donor' - prot_feature_family = 'Acceptor' - interaction_type = 'Halogen Bond' - - case 'MetalAcceptor': - lig_feature_family = None - prot_feature_family = 'Metal' - interaction_type = 'Metal complexation' - - case 'MetalDonor': - lig_feature_family = 'Metal' - prot_feature_family = None - interaction_type = 'Metal complexation' - - case _: - prot_feature_family = None - lig_feature_family = None - - mrich.error('Unsupported interaction_type', interaction_type) - - assert interaction_type in INTERACTION_TYPES - assert prot_feature_family in FEATURE_FAMILIES or prot_feature_family is None - assert lig_feature_family in FEATURE_FAMILIES or lig_feature_family is None - - return prot_feature_family, lig_feature_family, interaction_type - - -def parse_prolif_interactions( - pose: 'Pose', - fp: 'plf.Fingerprint', - protonated_sys: 'molparse.System', - table: str = 'temp_interaction', - debug: bool = False, -) -> None: - """Parse ProLIF output into HIPPO database table""" - - target_id = pose.target.id - - for key, value in fp.ifp[0].items(): - res_number = key[1].number - res_name = key[1].name - chain_name = key[1].chain - - chain = protonated_sys.get_chain(chain_name) - residue = chain.residues.get_matches(number=res_number) - - prot_mol = residue.rdkit_mol - prot_group = mp.AtomGroup.from_pdb_block(mp.rdkit.mol_to_pdb_block(prot_mol)) - - if not residue.name == res_name: - mrich.debug(protonated_sys.name + '.pdb') - raise AssertionError( - f'Residue name mismatch: [sys]={residue.name} [prolif]={res_name}' - ) - - for interaction_type, interaction_dicts in value.items(): - for interaction_dict in interaction_dicts: - angle = interaction_dict.get('angle') - distance = interaction_dict.get('distance') - - lig_atom_ids = list(interaction_dict['indices']['ligand']) - - # insert a dummy protein feature - prot_interaction_atoms = [ - prot_group.atoms[i] for i in interaction_dict['indices']['protein'] - ] - prot_atom_names = [a.name for a in prot_interaction_atoms] - - # rename stuff - prot_family, lig_family, interaction_type = guess_feature_families( - interaction_type, prot_atom_names, lig_atom_ids - ) - - feature_id = pose.db.insert_feature( - family=prot_family, - target=target_id, - chain_name=chain_name, - residue_name=res_name, - residue_number=residue.number, - atom_names=prot_atom_names, - commit=False, - ) - - if not feature_id: - sql = f""" - feature_target = {target_id} - AND feature_family = '{prot_family}' - AND feature_chain_name = '{chain_name}' - AND feature_residue_name = '{res_name}' - AND feature_residue_number = {residue.number} - AND feature_atom_names = '{' '.join(sorted(prot_atom_names))}' - """ - - try: - (feature_id,) = pose.db.select_id_where( - table='feature', key=sql - ) - except: - feature_id = pose.db.insert_feature( - family=prot_family, - target=target_id, - chain_name=chain_name, - residue_name=res_name, - residue_number=residue.number, - atom_names=prot_atom_names, - warn_duplicate=True, - commit=False, - ) - raise - - # insert into the Database - interaction_id = pose.db.insert_interaction( - feature=feature_id, - pose=pose.id, - type=interaction_type, - family=lig_family, - atom_ids=[i + 1 for i in lig_atom_ids], - prot_coord=None, - lig_coord=None, - distance=distance, - angle=angle, - energy=None, - commit=False, - table=table, - ) - - if debug: - mrich.debug( - f'Residue: {res_name} {res_number} {chain_name}. Interaction: {interaction_type}. Ligand: {lig_family} ({lig_atom_ids}). Protein: {prot_family} ({prot_atom_names})' - ) diff --git a/hippo/pyvis.py b/hippo/pyvis.py deleted file mode 100644 index a6bfc62..0000000 --- a/hippo/pyvis.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Functions to visualise networks with pyvis""" - -import mrich - - -def get_scaffold_network( - animal, - compounds='CompoundSet | None', - scaffolds='CompoundSet | None', - # filename: "str | Path" = "network.html", - notebook: bool = True, - depth: int = 5, - scaffold_tag: str | None = None, - exclude_tag: str | None = None, - physics: bool = True, - arrows: bool = True, -) -> 'pyvis.network.Network': - """Use PyVis to display a network of molecules connected by scaffold relationships in the database""" - - from molparse.rdkit import smiles_to_pngstr - from pyvis.network import Network - - net = Network(notebook=notebook, cdn_resources='in_line') - - nodes = set() - edges = set() - - arrows = 'to' if arrows else None - - def add_node(compound: 'Compound') -> None: - """Add node to network""" - - if compound.id in nodes: - return - - pngstr = smiles_to_pngstr(compound.smiles) - - net.add_node( - compound.id, - label=compound.alias or str(compound), - title=str(compound), - shape='circularImage', - image=f'data:image/png;base64,{pngstr}', - physics=physics, - ) - - nodes.add(compound.id) - - def add_edge(scaffold: 'Compound', compound: 'Compound') -> None: - """Add edge to network""" - - key = (scaffold.id, compound.id) - - if key in edges: - return - - net.add_edge( - scaffold.id, - compound.id, - arrows=arrows, - # label="PDE5", - # title="Protein target", - # color="purple" - ) - - edges.add(key) - - def get_scaffold_records( - scaffolds: 'None | CompoundSet' = None, compounds: 'None | CompoundSet' = None - ): - """Get scaffold records""" - - if scaffolds: - return animal.db.select_all_where( - table='scaffold', - key=f'scaffold_base IN {scaffolds.str_ids}', - multiple=True, - none='quiet', - ) - elif compounds: - return animal.db.select_all_where( - table='scaffold', - key=f'scaffold_superstructure IN {compounds.str_ids}', - multiple=True, - none='quiet', - ) - raise ValueError - - if compounds and not scaffolds: - mrich.var('recursion depth', depth) - mrich.var('#compounds', len(compounds)) - - records = [] - n = 0 - while n < depth: - if not compounds: - break - - results = get_scaffold_records(compounds=compounds) - - if results: - records.extend(results) - compounds = animal.compounds[[a for a, b in results]] - n += 1 - else: - break - - if n == depth: - mrich.warning( - 'Reached recursion depth. More scaffolds may be in the database' - ) - - elif scaffolds and not compounds: - mrich.var('recursion depth', depth) - mrich.var('#scaffolds', len(scaffolds)) - - records = [] - n = 0 - while n < depth: - if not scaffolds: - break - - results = get_scaffold_records(scaffolds=scaffolds) - - if results: - records.extend(results) - scaffolds = animal.compounds[[b for a, b in results]] - n += 1 - else: - break - - if n == depth: - mrich.warning( - 'Reached recursion depth. More superstructures may be in the database' - ) - - else: - raise ValueError - - mrich.var('#edges', len(records)) - - if records: - for scaffold_id, compound_id in mrich.track( - records, prefix='Adding nodes and edges' - ): - scaffold = animal.db.get_compound(id=scaffold_id) - - scaffold_tags = scaffold.tags - - if scaffold_tag and scaffold_tag not in scaffold_tags: - continue - - compound = animal.db.get_compound(id=compound_id) - - if exclude_tag and ( - exclude_tag in scaffold_tags or exclude_tag in compound.tags - ): - continue - - add_node(scaffold) - add_node(compound) - add_edge(scaffold, compound) - - return net diff --git a/hippo/quote.py b/hippo/quote.py deleted file mode 100644 index c415035..0000000 --- a/hippo/quote.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Classes to work with quote data""" - -import mcol -import mrich - -from .price import Price - - -class Quote: - """Supplier quote for a specific quantity of a :class:`.Compound`. - - .. attention:: - - :class:`.Quote` objects should not be created directly. Instead use :meth:`.Compound.get_quotes`. - - """ - - _db = None - - def __init__( - self, - db: 'Database', - id: int, - compound: int, - smiles: str, - supplier: str, - catalogue: str, - entry: str, - amount: float, - price: float, - currency: str, - purity: float, - lead_time: int, - date: str | None = None, - type: str | None = None, - ) -> None: - """Quote initialisation""" - - price = Price(price, currency) - - self._db = db - self._id = id - self._compound = compound - self._smiles = smiles - self._supplier = supplier - self._catalogue = catalogue - self._entry = entry - self._amount = amount - self._price = price - self._purity = purity - self._lead_time = lead_time - self._date = date - self._type = type - - from datetime import datetime - - quote_age = (datetime.today() - datetime.strptime(self.date, '%Y-%m-%d')).days - # if quote_age > 30: - # mrich.warning(f'Quote is {quote_age} days old') - # mrich.warning(self) - - ### FACTORIES - - @classmethod - def combination( - cls, - required_amount: float, - quotes: list['Quote'], - debug: bool = False, - ) -> 'Quote': - """Combine a list of quotes into one :class:`.Quote` object. - - * Start with biggest pack - * Estimate by scaling linearly with unit price - - :param required_amount: amount in mg - :param quotes: list of quotes to be combined - - """ - - biggest_pack = sorted(quotes, key=lambda x: x.amount)[-1] - unit_price = biggest_pack.price / biggest_pack.amount - estimated_price = unit_price * required_amount - - quote_data = dict( - db=biggest_pack.db, - id=None, - compound=biggest_pack.compound, - smiles=biggest_pack.smiles, - supplier=biggest_pack.supplier, - catalogue=biggest_pack.catalogue, - entry=biggest_pack.entry, - amount=required_amount, - price=estimated_price.amount, - currency=biggest_pack.currency, - purity=biggest_pack.purity, - lead_time=biggest_pack.lead_time, - date=biggest_pack.date, - type=f'estimate from quote={biggest_pack.id}', - ) - - if debug: - mrich.debug('Quote.combination()') - mrich.debug(f'{required_amount=}') - for quote in quotes: - mrich.debug(quote) - mrich.debug(f'{biggest_pack=}') - mrich.debug(f'{unit_price=}') - mrich.debug(f'{estimated_price=}') - mrich.print(quote_data) - - self = cls.__new__(cls) - self.__init__(**quote_data) - - return self - - ### PROPERTIES - - @property - def entry_str(self) -> str: - """Unformatted string including the supplier, catalogue (if available), and entry name of the quote""" - if self.catalogue: - return f'{self.supplier}:{self.catalogue}:{self.entry}' - else: - return f'{self.supplier}:{self.entry}' - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the quote's database ID""" - return self._id - - @property - def compound(self) -> int: - """Returns the associated :class:`.Compound`""" - return self._compound - - @property - def smiles(self) -> str: - """Returns the catalogue SMILES string""" - return self._smiles - - @property - def supplier(self) -> str: - """Name of the supplier""" - return self._supplier - - @property - def catalogue(self) -> str | None: - """Name of the catalogue""" - return self._catalogue - - @property - def entry(self) -> str: - """Name/ID of the catalogue entry""" - return self._entry - - @property - def amount(self) -> float: - """Amount in mg""" - return self._amount - - @property - def price(self) -> 'Price': - """Price""" - return self._price - - @property - def currency(self) -> str: - """Currency of the associated :class:`.Price` object""" - return self.price.currency - - @property - def purity(self) -> float: - """Purity fraction""" - return self._purity - - @property - def lead_time(self) -> float: - """Lead time in days""" - return self._lead_time - - @property - def date(self) -> str: - """Date the quote was registered to the database""" - return self._date - - @property - def type(self) -> str: - """Description of this quote""" - return self._type - - @property - def dict(self) -> dict: - """Dictionary representation of this quote""" - return dict( - id=self.id, - compound=self.compound, - smiles=self.smiles, - supplier=self.supplier, - catalogue=self.catalogue, - entry=self.entry, - amount=self.amount, - price=self.price, - purity=self.purity, - lead_time=self.lead_time, - date=self.date, - type=self.type, - ) - - @property - def currency_symbol(self) -> str: - """Currency symbol of the associated :class:`.Price`""" - return self.price.symbol - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - if self.purity: - purity = f' @ {self.purity:.0%}' - else: - purity = '' - - if self.supplier == 'Stock': - return f'C{self.compound} In Stock: {self.amount:}mg{purity}' - elif self.type: - return f'C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles} [{self.type}]' - else: - return f'C{self.compound} {self.entry_str} {self.amount:}mg{purity} = {self.price:} ({self.lead_time} days) {self.smiles}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/hippo/reaction.py b/hippo/reaction.py deleted file mode 100644 index a6b479e..0000000 --- a/hippo/reaction.py +++ /dev/null @@ -1,425 +0,0 @@ -"""Classes to work with Reaction objects""" - -import mcol -import mrich - -from .compound import Compound -from .recipe import Recipe - - -class Reaction: - """ - A :class:`.Reaction` is a simplified representation of a synthetic pathway to create a product :class:`.Compound`. Reactants (also :class:`.Compound` objects) as well as a reaction type are required. - - .. attention:: - - :class:`.Reaction` objects should not be created directly. Instead use :meth:`.HIPPO.register_reaction` or :meth:`.HIPPO.reactions` - - """ - - _table = 'reaction' - - def __init__( - self, - db: 'Database', - id: int, - type: str, - product: int, - product_yield: float, - ) -> None: - """Reaction initialisation""" - - self._db = db - self._id = id - self._type = type - self._product_id = product - self._product = None - self._product_yield = product_yield - self._metadata = None - - ### PROPERTIES - - @property - def id(self) -> int: - """Returns the :class:`.Reaction` ID""" - return self._id - - @property - def type(self) -> str: - """Returns the :class:`.Reaction` tyoe""" - return self._type - - @property - def product(self) -> 'Compound': - """Returns the reaction's product :class:`.Compound`""" - if self._product is None: - self._product = self.db.get_compound(id=self.product_id) - return self._product - - @property - def product_yield(self) -> float: - """Returns the reaction's product yield (fraction)""" - return self._product_yield - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def reactants(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of the reactants""" - from .cset import CompoundSet - - return CompoundSet(self.db, indices=self.reactant_ids) - - @property - def reaction_str(self) -> str: - """Returns a string representing the reaction""" - s = ' + '.join([str(r) for r in self.reactants]) - s = f'{s} -> {str(self.product)}' - return s - - @property - def reactant_ids(self) -> set[int]: - """Returns a set of reactant ID's""" - return set(v for v in self.get_reactant_ids()) - - @property - def reactant_str_ids(self) -> str: - """Return an SQL formatted tuple string of the reactant :class:`.Compound` IDs""" - return str(tuple(self.reactant_ids)).replace(',)', ')') - - @property - def product_id(self) -> int: - """Returns the product :class:`.Compound` ID""" - return self._product_id - - @property - def product_smiles(self) -> str: - """Product :class:`.Compound` SMILES string""" - return self.product.smiles - - @property - def reactant_smiles(self) -> list[str]: - """List of reactant :class:`.Compound` SMILES strings""" - return [r.smiles for r in self.reactants] - - @property - def product_mol(self): - """Product :class:`.Compound` ``rdkit.Chem.Mol`` object""" - return self.product.mol - - @property - def reactant_mols(self): - """List of reactant :class:`.Compound` ``rdkit.Chem.Mol`` object""" - return [r.mol for r in self.reactants] - - @property - def price_estimate(self) -> float: - """Estimate the price of this :class:`.Reaction`""" - return self.db.get_reaction_price_estimate(reaction=self) - - @property - def plain_repr(self) -> str: - """Unformatted long string representation""" - return f'{self}: {self.reaction_str} via {self.type}' - - @property - def metadata(self) -> 'MetaData': - """Returns the compound's metadata dict""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table='reaction', id=self.id) - return self._metadata - - ### METHODS - - def get_reactant_amount_pairs(self, compound_object: bool = True) -> list[tuple]: - """Returns pairs of reactants and their amounts - - :param compound_object: return :class:`.Compound` object instead of ID, (Default value = True) - :returns: list of tuples containing :class:`.Compound` ID/object and amount in mg - """ - - compound_ids = self.db.select_where( - query='reactant_compound, reactant_amount', - table='reactant', - key='reaction', - value=self.id, - multiple=True, - ) - - if compound_ids: - if compound_object: - return [ - # (self.db.get_compound(id=id), amount/self.product_yield) for id, amount in compound_ids - (self.db.get_compound(id=id), amount) - for id, amount in compound_ids - ] - else: - return compound_ids - else: - return [] - - def get_reactant_ids(self) -> list[int]: - """Returns list of reactants :class:`.Compound` IDs - - :returns: list of :class:`.Compound` IDs - """ - - compound_ids = self.db.select_where( - query='reactant_compound', - table='reactant', - key='reaction', - value=self.id, - multiple=True, - ) - - if compound_ids: - return [id for (id,) in compound_ids] - else: - return [] - - def get_recipes( - self, - amount: float = 1, # in mg - debug: bool = False, - pick_cheapest: bool = False, - permitted_reactions: 'None | ReactionSet' = None, - supplier: str | None = None, - ) -> 'Recipe | list[Recipe]': - """Get a :class:`.Recipe` describing how to make the product - - :param amount: Amount in ``mg``, defaults to ``1`` - :param debug: Increase verbosity, (Default value = False) - :param pick_cheapest: pick the cheapest :class:`.Recipe`, (Default value = False) - :param permitted_reactions: Limit the reactions to consider to members of this set, (Default value = None) - :param supplier: Limit to reactants from this supplier (Default value = None) - :returns: :class:`.Recipe` object or list thereof - - """ - - from .recipe import Recipe - - return Recipe.from_reaction( - self, - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - permitted_reactions=permitted_reactions, - supplier=supplier, - ) - - def summary( - self, - draw: bool = True, - ) -> None: - """Print a summary of this reaction's information - - :param draw: draw the reaction compounds (Default value = True) - - """ - - print(f'id={self.id}') - print(f'type={self.type}') - print(f'product={self.product}') - print(f'product_yield={self.product_yield}') - - reactants = self.get_reactant_amount_pairs() - print(f'reactants={reactants}') - - print(f'price_estimate={self.price_estimate}') - - if draw: - self.draw() - - def draw(self) -> None: - """Draw the molecules involved in this reaction""" - - from molparse.rdkit import draw_grid - - reactants = self.reactants - - product = self.product - - mols = [r.mol for r in reactants] - mols.append(product.mol) - - labels = [f'+ {r}' if i > 0 else f'{r}' for i, r in enumerate(reactants)] - labels.append(f'-> {product}') - - drawing = draw_grid(mols, labels=labels, highlightAtomLists=None) - display(drawing) - - def check_chemistry( - self, - debug: bool = False, - ) -> bool: - """Sanity check the chemistry of this reaction - - :param debug: increase verbosity (Default value = False) - """ - from .chem import check_chemistry - - return check_chemistry(self.type, self.reactants, self.product, debug=debug) - - def check_reactant_availability( - self, - supplier: None | str = None, - debug: bool = False, - ) -> bool: - """Check the availability of reactant compounds - - :param supplier: Limit to quotes from this supplier (Default value = None) - :param debug: increase verbosity (Default value = False) - - """ - - if debug: - mrich.var('reaction', self.id) - mrich.var('reactants', self.reactant_ids) - mrich.var('supplier', supplier) - - if supplier is None: - triples = self.db.execute( - f""" - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}quote ON quote_compound = reactant_compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound - WHERE reactant_reaction = {self.id} - GROUP BY reactant_compound - """ - ).fetchall() - - else: - triples = self.db.execute( - f""" - WITH filtered_quotes AS - ( - SELECT * FROM {self.db.SQL_SCHEMA_PREFIX}quote - WHERE quote_supplier = "{supplier}" - ) - SELECT reactant_compound, SUM(quote_id), SUM(reaction_id) FROM {self.db.SQL_SCHEMA_PREFIX}reactant - LEFT JOIN filtered_quotes ON quote_compound = reactant_compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON reaction_product = reactant_compound - WHERE reactant_reaction = {self.id} - GROUP BY reactant_compound - """ - ).fetchall() - - for reactant_compound, has_quote, has_reaction in triples: - if debug: - mrich.debug( - f'{reactant_compound=}, {bool(has_quote)=}, {bool(has_reaction)=}' - ) - - if has_quote: - if debug: - mrich.debug(f'reactant={reactant_compound} has quote') - continue - - if has_reaction: - if debug: - mrich.debug(f'reactant={reactant_compound} has reaction') - continue - - if debug: - mrich.warning(f'No quote or reaction for reactant={reactant_compound}') - - return False - - return True - - def get_dict( - self, - smiles: bool = True, - mols: bool = True, - ) -> dict[str]: - """Returns a dictionary representing this :class:`.Reaction` - - :param smiles: include smiles string (Default value = True) - :param mols: include ``rdkit.Chem.Mol`` (Default value = True) - - """ - - serialisable_fields = ['id', 'type', 'product_id', 'reactant_ids'] - - data = {} - for key in serialisable_fields: - data[key] = getattr(self, key) - - if smiles: - data['product_smiles'] = self.product_smiles - data['reactant_smiles'] = self.reactant_smiles - - if mols: - data['product_mol'] = self.product_mol - data['reactant_mols'] = self.reactant_mols - - return data - - def _delete(self) -> None: - """Delete this reaction and any related reactants, routes, and components""" - - route_ids = self.db.select_where( - query='component_route', - table='component', - key=f'component_ref = {self.id} AND component_type = 1', - multiple=True, - ) - - route_ids = [r for (r,) in route_ids] - route_str_ids = str(tuple(route_ids)).replace(',)', ')') - - self.db.delete_where( - table='component', key=f'component_route IN {route_str_ids}' - ) - - self.db.delete_where(table='route', key=f'route_id IN {route_str_ids}') - - self.db.delete_where(table='reactant', key='reaction', value=self.id) - - self.db.delete_where(table='reaction', key='id', value=self.id) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'R{self.id}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self.plain_repr}' - - def __eq__( - self, - other: 'int | Reaction', - ) -> bool: - """compare this reaction to a :class:`.Reaction` object or ID""" - - match other: - case int(): - return self.id == other - - case Reaction(): - if self.type != other.type: - return False - - if self.product != other.product: - return False - - if self.reactant_ids != other.reactant_ids: - return False - - return True - - case _: - raise NotImplementedError - - def __hash__(self) -> int: - """Integer hash from ID""" - return self.id diff --git a/hippo/rgen.py b/hippo/rgen.py deleted file mode 100644 index a4f95c9..0000000 --- a/hippo/rgen.py +++ /dev/null @@ -1,802 +0,0 @@ -"""Classes for generating random recipes/selections""" - -import json -from pathlib import Path - -import mrich - -from .cset import CompoundSet, IngredientSet -from .recipe import Recipe -from .tools import dt_hash - - -class RRGMixin: - """Mixin class for shared properties""" - - @property - def db(self) -> 'Database': - """Get the linked HIPPO Database object""" - return self._db - - @property - def db_path(self) -> str: - """Get the path of the linked Database""" - return self._db_path - - @property - def starting_recipe(self): - """Get the starting recipe used in all generations""" - return self._starting_recipe - - @property - def suppliers_str(self) -> str: - """SQL formatted tuple of suppliers""" - return str(tuple(self.suppliers)).replace(',)', ')') - - @property - def suppliers(self) -> list[str]: - """List of suppliers""" - return self._suppliers - - @property - def max_lead_time(self) -> float: - """Maximum lead-time constraint""" - return self._max_lead_time - - @property - def data_path(self): - """File path for the JSON data export""" - return self._data_path - - @property - def recipe_dir(self): - """File path for the JSON recipe export""" - return self._recipe_dir - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __call__(self, *args, **kwargs) -> 'Recipe': - """Generate Recipe""" - return self.generate(*args, **kwargs) - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class RandomRecipeGenerator(RRGMixin): - """Class to create randomly sampled Recipe from a HIPPO Database""" - - def __init__( - self, - db, - *, - max_lead_time=None, - suppliers: list | None = None, - start_with: Recipe | CompoundSet | IngredientSet | None = None, - route_pool: 'RouteSet | None' = None, - out_key: str | None = None, - ): - """RandomRecipeGenerator initialisation""" - - mrich.debug('RandomRecipeGenerator.__init__()') - - if not start_with: - start_with = Recipe(db) - - # Static parameters - self._db_path = db.path - self._max_lead_time = max_lead_time - self._suppliers = suppliers - self._starting_recipe = start_with - - mrich.var('database', self.db_path) - mrich.var('max_lead_time', self.max_lead_time) - mrich.var('suppliers', self.suppliers) - - # Database set up - self._db = db - - if not out_key: - out_key = str(self.db_path.name).removesuffix('.sqlite') - mrich.var('out_key', out_key) - - parent_dir = Path(out_key).parent - if not parent_dir.exists(): - parent_dir.mkdir(parents=True) - - # JSON I/O set up - self._data_path = Path(f'{out_key}_rgen.json') - if self.data_path.exists(): - mrich.warning(f'Will overwrite existing rgen data file: {self.data_path}') - - # Recipe I/O set up - path = Path(f'{out_key}_recipes') - if not path.exists(): - mrich.writing(f'{path}/') - path.mkdir() - self._recipe_dir = path - - # Route pool - if route_pool: - route_pool = route_pool.prune_unavailable(suppliers=suppliers) - self._route_pool = route_pool - else: - mrich.debug('Solving route pool...') - self._route_pool = self.get_route_pool() - - assert len(self._route_pool), 'Route pool is empty!' - - # dump data - self.dump_data() - - ### FACTORIES - - @classmethod - def from_json(cls, db: 'Database', path: 'Path | str'): - """Construct the RandomRecipeGenerator from a JSON file""" - - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._db_path = Path(data['db_path']) - self._recipe_dir = Path(data['recipe_dir']) - self._max_lead_time = data['max_lead_time'] - self._suppliers = data['suppliers'] - - self._starting_recipe = Recipe.from_json( - db=db, - path=None, - data=data['starting_recipe'], - allow_db_mismatch=True, - ) - - mrich.var('database', self.db_path) - mrich.var('max_lead_time', self.max_lead_time) - mrich.var('suppliers', self.suppliers) - - self._db = db - - # JSON I/O set up - self._data_path = Path(path) - - # Route pool - from .recipe import RouteSet - - self._route_pool = RouteSet.from_json(path=None, data=data['route_pool'], db=db) - - return self - - ### PROPERTIES - - @property - def route_pool(self): - """Get the RouteSet of all product reaction routes considered by this generator""" - return self._route_pool - - ### POOL METHODS - - def get_route_pool(self, mini_test=False): - """Construct the pool of routes that will be randomly sampled from - - :param mini_test: (Default value = False) - - """ - - """ - Explainer for SQL query: - - - get table of quoted compounds with a count of the valid suppliers - - join routes, components, and the new table together and grouped by route count the unavailable reactants - - return route ids where no reactants are unavailable - - """ - - if 'route' not in self.db.table_names: - mrich.error('route table not in Database') - raise NotImplementedError - - assert self.suppliers_str - if self.max_lead_time: - raise NotImplementedError - - ### EXCLUDE PRODUCTS OF ROUTES IN STARTING RECIPE!!! - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT(CASE WHEN quote_supplier IN {self.suppliers_str} THEN 1 END) AS [count_valid] - FROM {self.db.SQL_SCHEMA_PREFIX}quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route_reactants - WHERE count_unavailable = 0 - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for (i,) in route_ids] - - if mini_test: - route_ids = route_ids[:100] - - from .recipe import RouteSet - - return RouteSet.from_ids(self.db, route_ids) - - ### FILE I/O METHODS - - def dump_data(self): - """Dump data to JSON""" - - data = {} - - data['db_path'] = str(self.db_path.resolve()) - data['recipe_dir'] = str(self.recipe_dir.resolve()) - data['max_lead_time'] = self.max_lead_time - data['suppliers'] = self.suppliers - data['starting_recipe'] = self.starting_recipe.get_dict(serialise_price=True) - data['route_pool'] = self.route_pool.get_dict() - - mrich.writing(self.data_path) - json.dump(data, open(self.data_path, 'w'), indent=4) - - def generate( - self, - budget: float = 10000, - currency: str = 'EUR', - max_products: int = 1000, - max_reactions: int = 1000, - debug: bool = False, - max_iter: int | None = None, - shuffle: bool = True, - balance_clusters: bool = False, - permitted_clusters: None | set = None, - ): - """Generate random recipe - - :param budget: maximum budget (Default value = 10000) - :param currency: currency (Default value = 'EUR') - :param max_products: maximum number of products (Default value = 1000) - :param max_reactions: maximum number of reactions (Default value = 1000) - :param debug: increase verbosity for debugging (Default value = True) - :param max_iter: maximum number of iterations (Default value = None) - :param shuffle: randomly shuffle recipe pool (Default value = True) - :param balance_clusters: balance selection across scaffold clusters (Default value = False) - :param permitted_clusters: restrict selection to provided set of clusters (Default value = False) - """ - - # construct filename - - out_file = self.recipe_dir / f'Recipe_{dt_hash()}.json' - - from .price import Price - - if not max_iter: - max_iter = max_products + max_reactions - - max_iter = min(max_iter, len(self.route_pool)) - - budget = Price(budget, currency) - - recipe = self.starting_recipe.copy() - - recipe.reactants._supplier = self.suppliers - - # get the RouteSet - pool = self.route_pool.copy() - - assert len(pool), 'Route pool is empty!' - - if shuffle: - mrich.debug('Shuffling Route pool') - pool.shuffle() - - old_recipe = recipe.copy() - - mrich.var('route pool', len(pool)) - mrich.var('max_iter', max_iter) - - for i in mrich.track(range(max_iter), prefix='Generating Recipe...'): - if debug: - mrich.title(f'Iteration {i}') - - price = recipe.price - mrich.set_progress_field('price', str(price)) - mrich.set_progress_field('#products', len(recipe.products)) - - if debug: - mrich.var('price', price) - - # pop a route - if balance_clusters: - candidate_route = pool.balanced_pop( - permitted_clusters=permitted_clusters - ) - else: - candidate_route = pool.pop() - - if debug: - mrich.var('candidate_route', candidate_route) - if debug: - mrich.var('candidate_route.reactants', candidate_route.reactants.ids) - - if candidate_route.product in recipe.products: - continue - - # add the route to the recipe - if debug: - mrich.var('#recipe.reactants', len(recipe.reactants)) - recipe += candidate_route - if debug: - mrich.var('#recipe.reactants', len(recipe.reactants)) - - # calculate the new price - try: - new_price = recipe.price - except AssertionError: - mrich.error( - f'Something went wrong while calculating the price after adding {candidate_route=} to recipe' - ) - raise - - if debug: - mrich.var('new price', new_price) - - # Break if product pool depleted - if not len(pool): - stop_reason = 'Product pool depleted' - mrich.success(stop_reason) - break - - # check breaking conditions - if new_price > budget: - recipe = old_recipe.copy() - continue - - if len(recipe.reactions) > max_reactions: - stop_reason = 'Max #reactions exceeded' - mrich.success(stop_reason) - break - - if len(recipe.products) > max_products: - stop_reason = 'Max #products exceeded' - mrich.success(stop_reason) - break - - # accept change - old_recipe = recipe.copy() - - else: - stop_reason = 'Max #iterations reached' - mrich.warning(stop_reason) - - ### recalculate the products to see if any extra can be had for free? - - mrich.success(f'Completed after {i} iterations') - - metadict = { - 'rgen_data_path': str(self.data_path.resolve()), - 'rgen_db_path': str(self.db_path.resolve()), - 'rgen_recipe_dir': str(self.recipe_dir.resolve()), - 'rgen_max_lead_time': self.max_lead_time, - 'rgen_suppliers': self.suppliers, - 'gen_budget': budget.amount, - 'gen_currency': budget.currency, - 'gen_max_products': max_products, - 'gen_max_reactions': max_reactions, - 'gen_max_iter': max_iter, - 'gen_shuffle': shuffle, - 'gen_iterations': i, - 'gen_stop_reason': stop_reason, - 'gen_recipe_path': str(out_file.resolve()), - } - - # write the Recipe JSON - recipe.write_json(out_file, extra=metadict) - - return recipe - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'RandomRecipeGenerator(recipe_dir={self.recipe_dir})' - - -class RandomSelectionGenerator(RRGMixin): - """Class to create randomly sampled (no-chemistry) Recipe from a HIPPO Database""" - - def __init__( - self, - db, - *, - # max_lead_time=None, - suppliers: list | None = None, - amount: float = 1.0, # in mg - start_with: Recipe | CompoundSet | IngredientSet = None, - compounds: CompoundSet | None = None, - quoted_only: bool = True, - ): - """RandomSelectionGenerator initialisation""" - - mrich.debug('RandomRecipeGenerator.__init__()') - - # Static parameters - self._db_path = db.path - self._suppliers = suppliers - self._amount = amount - self._quoted_only = quoted_only - self._db = db - - mrich.var('database', self.db_path) - mrich.var('suppliers', self.suppliers) - mrich.var('amount per compound', self.amount, unit='mg') - mrich.var('quoted_only', self.quoted_only) - - self.get_starting_recipe(start_with) - mrich.var('starting recipe', self.starting_recipe) - - # JSON I/O set up - self._data_path = Path(str(self.db_path.name).replace('.sqlite', '_sgen.json')) - if self.data_path.exists(): - mrich.warning(f'Will overwrite existing rgen data file: {self.data_path}') - - # Recipe I/O set up - path = Path(str(self.db_path.name).replace('.sqlite', '_selections')) - mrich.writing(f'{path}/') - path.mkdir(exist_ok=True) - self._recipe_dir = path - - with mrich.spinner('Getting compound pool'): - self.get_compound_pool(compounds) - mrich.var('compound pool', self.compound_pool) - - # dump data - self.dump_data() - - ### FACTORIES - - @classmethod - def from_json( - cls, db: 'Database', path: 'Path | str' - ) -> 'RandomSelectionGenerator': - """Construct the RandomRecipeGenerator from a JSON file""" - - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._db_path = Path(data['db_path']) - self._recipe_dir = Path(data['recipe_dir']) - # self._max_lead_time = data["max_lead_time"] - self._suppliers = data['suppliers'] - self._amount = data['amount'] - - self._starting_recipe = Recipe.from_json( - db=db, - path=None, - data=data['starting_recipe'], - allow_db_mismatch=True, - ) - - mrich.var('database', self.db_path) - mrich.var('suppliers', self.suppliers) - mrich.var('amount', self.amount) - mrich.var('starting_recipe', self.starting_recipe) - - self._db = db - - # JSON I/O set up - self._data_path = Path(path) - - # Route pool - self._compound_pool = IngredientSet.from_json( - path=None, data=data['compound_pool']['data'], db=db - ) - mrich.var('compound_pool', self.compound_pool) - - return self - - ### PROPERTIES - - @property - def amount(self) -> float: - """Amount to quote each compound for""" - return self._amount - - @property - def quoted_only(self) -> bool: - """Only consider compounds with quotes""" - return self._quoted_only - - @property - def compound_pool(self) -> 'CompoundTable | CompoundSet': - """The pool of compounds that will be chosen from""" - return self._compound_pool - - ### METHODS - - def get_starting_recipe( - self, start_with: 'Recipe | CompoundSet | IngredientSet' - ) -> Recipe: - """Process start_with into Recipe object""" - - if isinstance(start_with, Recipe): - if start_with.type != 'NOCHEM': - raise NotImplementedError('Only NOCHEM recipes are supported') - self._starting_recipe = start_with - return self._starting_recipe - - from .compound import Compound - - self._starting_recipe = Recipe(self.db) - - if start_with is not None: - for item in start_with: - if isinstance(item, Compound): - item = item.as_ingredient(amount=self._amount) - - self._starting_recipe.compounds.add(item) - - return self._starting_recipe - - def get_compound_pool( - self, compounds: CompoundSet | None - ) -> 'CompoundTable | CompoundSet': - """Get pool of compounds to select from""" - - if self.suppliers: - raise NotImplementedError - - if compounds is None: - # all compounds - if not self.quoted_only: - ids = self.db.select( - table='compound', query='compound_id', multiple=True - ) - self._compound_pool = IngredientSet.from_compounds( - db=self.db, ids=[i for (i,) in ids], amount=self.amount - ) - return self._compound_pool - - # get all compounds that have a quote - - sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM {self.db.SQL_SCHEMA_PREFIX}quote - WHERE quote_amount >= {self.amount} - GROUP BY quote_compound - """ - - records = self.db.execute(sql).fetchall() - - ingredients = [ - dict( - quote_id=i, - compound_id=c, - amount=self.amount, - quoted_amount=a, - supplier=None, - max_lead_time=None, - ) - for i, c, a, s, p in records - ] - - self._compound_pool = IngredientSet.from_ingredient_dicts( - self.db, ingredients - ) - - else: - # ignore quoting - if not self.quoted_only: - self._compound_pool = IngredientSet.from_compounds( - db=self.db, ids=compounds.ids, amount=self.amount - ) - return self._compound_pool - - # get all compounds that have a quote - - sql = f""" - SELECT quote_id, quote_compound, quote_amount, quote_supplier, MIN(quote_price) - FROM {self.db.SQL_SCHEMA_PREFIX}quote - WHERE quote_amount >= {self.amount} - AND quote_compound IN {compounds.str_ids} - GROUP BY quote_compound - """ - - records = self.db.execute(sql).fetchall() - - ingredients = [ - dict( - quote_id=i, - compound_id=c, - amount=self.amount, - quoted_amount=a, - supplier=None, - max_lead_time=None, - ) - for i, c, a, s, p in records - ] - - self._compound_pool = IngredientSet.from_ingredient_dicts( - self.db, ingredients - ) - - def dump_data(self): - """Dump data to JSON""" - - data = {} - - data['db_path'] = str(self.db_path.resolve()) - data['recipe_dir'] = str(self.recipe_dir.resolve()) - # data["max_lead_time"] = self.max_lead_time - data['amount'] = self.amount - data['suppliers'] = self.suppliers - data['starting_recipe'] = self.starting_recipe.get_dict(serialise_price=True) - data['compound_pool'] = self.compound_pool.get_dict() - - mrich.writing(self.data_path) - json.dump(data, open(self.data_path, 'w'), indent=4) - - def generate( - self, - budget: float = 10000, - currency: str = 'EUR', - max_iter: int | None = None, - max_compounds: int = 1000, - debug: bool = False, - shuffle: bool = True, - ): - """Generate random selection - - :param budget: maximum budget - :param currency: currency - :param max_iter: maximum number of iterations - :param max_compounds: maximum number of compounds - :param debug: Increase verbosity for debugging - :param shuffle: Randomise order of compound pool - """ - - # construct filename - - out_file = self.recipe_dir / f'Recipe_{dt_hash()}.json' - - from .price import Price - - budget = Price(budget, currency) - - recipe = self.starting_recipe.copy() - - recipe.compounds._supplier = self.suppliers - - # get the RouteSet - pool = self.compound_pool.copy() - - assert len(pool), 'Route pool is empty!' - - if shuffle: - mrich.debug('Shuffling Route pool') - pool.shuffle() - - old_recipe = recipe.copy() - - if not max_iter: - max_iter = max_compounds * 3 - - mrich.var('compound pool', pool) - mrich.var('max_compounds', max_compounds) - mrich.var('max_iter', max_iter) - - for i in mrich.track(range(max_iter), prefix='Generating Recipe...'): - if debug: - mrich.title(f'Iteration {i}') - - price = recipe.price - mrich.set_progress_field('price', str(price)) - mrich.set_progress_field('#compounds', len(recipe.compounds)) - - if debug: - mrich.var('price', price) - - # # pop a route - # if balance_clusters: - # candidate_route = pool.balanced_pop( - # permitted_clusters=permitted_clusters - # ) - # else: - candidate = pool.pop() - - if debug: - mrich.var('candidate', candidate) - - if candidate in recipe.compounds: - continue - - # add the route to the recipe - recipe.compounds.add(candidate) - - # calculate the new price - try: - new_price = recipe.price - except AssertionError: - mrich.error( - f'Something went wrong while calculating the price after adding {candidate_route=} to recipe' - ) - raise - - if debug: - mrich.var('#compounds', recipe.num_compounds) - mrich.var('new price', new_price) - - # Break if product pool depleted - if not len(pool): - stop_reason = 'Compound pool depleted' - mrich.success(stop_reason) - break - - # check breaking conditions - if new_price > budget: - recipe = old_recipe.copy() - continue - - if len(recipe.compounds) > max_compounds: - stop_reason = 'Max #compounds exceeded' - mrich.success(stop_reason) - break - - # accept change - old_recipe = recipe.copy() - - else: - stop_reason = 'Max #iterations reached' - mrich.warning(stop_reason) - - ### recalculate the products to see if any extra can be had for free? - - mrich.success(f'Completed after {i} iterations') - - metadict = { - 'rgen_data_path': str(self.data_path.resolve()), - 'rgen_db_path': str(self.db_path.resolve()), - 'rgen_recipe_dir': str(self.recipe_dir.resolve()), - 'rgen_suppliers': self.suppliers, - 'rgen_amount': self.amount, - 'gen_budget': budget.amount, - 'gen_currency': budget.currency, - 'gen_max_compounds': max_compounds, - 'gen_shuffle': shuffle, - 'gen_iterations': i, - 'gen_stop_reason': stop_reason, - 'gen_recipe_path': str(out_file.resolve()), - } - - # write the Recipe JSON - recipe.write_json(out_file, extra=metadict) - - return recipe - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'RandomSelectionGenerator(recipe_dir={self.recipe_dir})' diff --git a/hippo/rset.py b/hippo/rset.py deleted file mode 100644 index 3385c33..0000000 --- a/hippo/rset.py +++ /dev/null @@ -1,737 +0,0 @@ -"""Classes for working with sets of :class:`.Reaction` objects""" - -import mcol -import mrich -from numpy import int64 - -from .db import Database -from .reaction import Reaction - - -class ReactionTable: - """Class representing all :class:`.Reaction` objects in the 'reaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.ReactionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started`. - - Use as an iterable - ================== - - Iterate through :class:`.Reaction` objects in the table: - - :: - - for reaction in animal.reactions: - ... - - - Selecting reactions in the table - ================================ - - The :class:`.ReactionTable` can be indexed with :class:`.Reaction` ID, or list/sets/tuples/slices thereof: - - :: - - rtable = animal.reactions - - # indexing individual compounds - reaction = rtable[13] # using the ID - - # getting a subset of compounds - rset = rtable[13,15,18] # using IDs (tuple) - rset = rtable[[13,15,18]] # using IDs (list) - rset = rtable[set(13,15,18)] # using IDs (set) - rset = rtable[13:18] # using a slice - - """ - - _name = 'all reactions' - - def __init__( - self, - db: Database, - table: str = 'reaction', - ) -> None: - """ReactionTable initialisation""" - - self._db = db - self._table = table - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def types(self) -> list[str]: - """Returns a list of the unique reaction types present in the table""" - result = self.db.select( - table=self.table, query='DISTINCT reaction_type', multiple=True - ) - return [q for (q,) in result] - - @property - def ids(self) -> list[int]: - """Returns the IDs of child reactions""" - result = self.db.select(table=self.table, query='reaction_id', multiple=True) - return [q for (q,) in result] - - ### METHODS - - def interactive(self) -> None: - """Interactive widget to navigate reactions in the table - - .. attention:: - - This method instantiates a :class:`.ReactionSet` containing all poses, it is recommended to instead select a subset for display. This method is only intended for use within a Jupyter Notebook. - - """ - return self[self.ids].interactive() - - def get_by_type(self, reaction_type: str) -> 'ReactionSet': - """Get all child reactions of the given type - - :param reaction_type: reaction type to filter by - - """ - result = self.db.select_where( - table=self.table, - query='reaction_id', - key='type', - value=reaction_type, - multiple=True, - ) - rset = self[[q for (q,) in result]] - rset._name = f'all {reaction_type} reactions' - return rset - - def get_df(self, *, smiles: bool = True, mols: bool = True) -> 'pandas.DataFrame': - """Construct a pandas.DataFrame of all reactions in the database - - :param smiles: Include smiles column (Default value = True) - :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - - """ - - from pandas import DataFrame - from rdkit.Chem import Mol - - ### SQL QUERY - - data = {} - - if not smiles and not mols: - sql = f""" - SELECT reaction_id, reaction_type, reaction_product, reactant_compound - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON reaction.reaction_id = reactant.reactant_reaction - """ - - triples = self.db.execute(sql).fetchall() - - for reaction_id, product_id, reactant_id in triples: - if reaction_id not in data: - data[reaction_id] = dict(product_id=product_id, reactant_ids=[]) - else: - assert data[reaction_id]['product_id'] == product_id - - data[reaction_id]['reactant_ids'].append(reactant_id) - - else: - sql = f""" - SELECT {query} - FROM {self.db.SQL_SCHEMA_PREFIX}reaction - - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON reaction.reaction_id = reactant.reactant_reaction - - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound c_r - ON c_r.compound_id = reactant.reactant_compound - - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound c_p - ON c_p.compound_id = reaction.reaction_product - """ - - if not mols: - sql = sql.format( - query='reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles' - ) - - else: - sql = sql.format( - query='reaction_id, reaction_type, reaction_product, reactant_compound, c_p.compound_smiles, c_r.compound_smiles, mol_to_binary_mol(c_p.compound_mol), mol_to_binary_mol(c_r.compound_mol)' - ) - - results = self.db.execute(sql).fetchall() - - for result in results: - ( - reaction_id, - reaction_type, - product_id, - reactant_id, - product_smiles, - reactant_smiles, - ) = result[:6] - - if mols: - product_mol, reactant_mol = result[6:] - - if reaction_id not in data: - data[reaction_id] = dict( - reaction_id=reaction_id, - reaction_type=reaction_type, - product_id=product_id, - reactant_ids=set(), - product_smiles=product_smiles, - reactant_smiles=set(), - ) - if mols: - data[reaction_id]['product_mol'] = Mol(product_mol) - data[reaction_id]['reactant_mols'] = set() - else: - assert data[reaction_id]['product_id'] == product_id - - data[reaction_id]['reactant_ids'].add(reactant_id) - data[reaction_id]['reactant_smiles'].add(reactant_smiles) - if mols: - data[reaction_id]['reactant_mols'].add(Mol(reactant_mol)) - - data = data.values() - return DataFrame(data) - - def set_product_yields( - self, *, type: str, product_yield: float, commit: bool = True - ) -> None: - """Set the product_yield for all member :class:`.Reaction` entries with given type - - :param type: the :class:`.Reaction` type to filter by - :param product_yield: the :class:`.Reaction` product_yield to assign - - """ - - assert isinstance(product_yield, float) - assert product_yield > 0 - assert product_yield <= 1.0 - - sql = f""" - UPDATE {self.db.SQL_SCHEMA_PREFIX}reaction - SET reaction_product_yield = {self.db.SQL_STRING_PLACEHOLDER} - WHERE reaction_type = {self.db.SQL_STRING_PLACEHOLDER} - """ - - self.db.execute( - sql, - ( - product_yield, - type, - ), - ) - - if commit: - self.db.commit() - - ### DUNDERS - - def __getitem__(self, key) -> 'Reaction | ReactionSet | None': - """Get a member :class:`.Reaction` object or subset :class:`.ReactionSet` thereof. - - :param key: Can be an integer ID, negative integer index, list/set/tuple of IDs, or slice of IDs - - """ - - match key: - case int(): - if key == 0: - return self.__getitem__(key=1) - - if key < 0: - key = len(self) + 1 + key - return self.__getitem__(key=key) - - else: - return self.db.get_reaction(id=key) - - case key if ( - isinstance(key, list) or isinstance(key, tuple) or isinstance(key, set) - ): - return ReactionSet(self.db, key) - - case slice(): - ids = self.db.slice_ids( - table=self.table, start=key.start, stop=key.stop, step=key.step - ) - return self[ids] - - case _: - mrich.error( - f'Unsupported type for ReactionTable.__getitem__(): {key=} {type(key)}' - ) - - return None - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f'{self.name}: ' - else: - s = '' - - s += f'{{R × {len(self)}}}' - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __len__(self) -> int: - """Number of reactions in this set""" - return self.db.count(self.table) - - def __iter__(self): - """Iterate through poses in this set""" - return iter(self[i + 1] for i in range(len(self))) - - def __call__( - self, - *, - type: str = None, - ) -> 'ReactionSet': - """Filter reactions by a given type - - :param type: reaction type to filter by - :returns: :class:`.ReactionSet` - - """ - - if type: - return self.get_by_type(type) - else: - mrich.error('Must provide type argument') - return None - - -class ReactionSet: - """Object representing a subset of the 'reaction' table in the :class:`.Database`. - - .. attention:: - - :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Reaction` objects in the set: - - :: - - rset = animal.reactions[:100] - - for reaction in rset: - ... - - Check membership - ================ - - To determine if a :class:`.Reaction` is present in the set: - - :: - - is_member = reaction in cset - - Selecting compounds in the set - ============================== - - The :class:`.ReactionSet` can be indexed like standard Python lists by their indices - - :: - - rset = animal.reactions[1:100] - - # indexing individual compounds - reaction = rset[0] # get the first reaction - reaction = rset[1] # get the second reaction - reaction = rset[-1] # get the last reaction - - # getting a subset of compounds using a slice - rset2 = rset[13:18] # using a slice - - """ - - _table = 'reaction' - - def __init__( - self, - db: Database, - indices: list = None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """ReactionSet initialisation""" - - self._db = db - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - assert all(isinstance(i, int) or isinstance(i, int64) for i in indices) - - if sort: - self._indices = sorted(list(set(indices))) - else: - self._indices = list(set(indices)) - - self._name = name - - ### PROPERTIES - - @property - def db(self) -> Database: - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def indices(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._indices - - @property - def types(self) -> list[str]: - """Returns the types of reactions in this set""" - records = self.db.select_where( - table='reaction', - key=f'reaction_id IN {self.str_ids}', - query='DISTINCT reaction_type', - multiple=True, - ) - return [t for (t,) in records] - - @property - def num_types(self) -> int: - """Returns the number of reaction types in this set""" - (count,) = self.db.select_where( - table='reaction', - key=f'reaction_id IN {self.str_ids}', - query='COUNT(DISTINCT reaction_type)', - ) - return count - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def products(self) -> 'CompoundSet': - """Get all product compounds that can be synthesised with these reactions (no intermediates)""" - from .cset import CompoundSet - - intermediates = self.intermediates - product_ids = self.db.execute( - f""" - SELECT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON compound_id = reaction_product - WHERE reaction_id IN {self.str_ids} - AND compound_id NOT IN {intermediates.str_ids} - """ - ).fetchall() - cset = CompoundSet(self.db, [i for (i,) in product_ids]) - if self.name: - cset._name = f'products of {self}' - return cset - - @property - def intermediates(self) -> 'CompoundSet': - """Get all intermediate compounds that can be synthesised with these reactions""" - from .cset import CompoundSet - - sql = f""" - SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON compound_id = reaction_product - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound - WHERE reactant_reaction IN {self.str_ids} - """ - intermediate_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for (i,) in intermediate_ids]) - if self.name: - cset._name = f'intermediates of {self}' - return cset - - @property - def reactants(self) -> 'CompoundSet': - """Get all reactant compounds that are used by these reactions""" - from .cset import CompoundSet - - sql = f""" - SELECT DISTINCT reactant_compound FROM {self.db.SQL_SCHEMA_PREFIX}reactant - WHERE reactant_reaction IN {self.str_ids} - """ - reactant_ids = self.db.execute(sql).fetchall() - cset = CompoundSet(self.db, [i for (i,) in reactant_ids]) - if self.name: - cset._name = f'reactants of {self}' - return cset - - ### METHODS - - def add(self, r: Reaction) -> None: - """Add a :class:`.Reaction` to this set - - :param r: :class:`.Reaction` to be added - - """ - assert isinstance(r, Reaction) - if (id := r.id) not in self._indices: - self._indices.append(id) - - def interactive(self): - """Creates a ipywidget to interactively navigate this PoseSet.""" - - from IPython.display import display - from ipywidgets import ( - BoundedIntText, - Checkbox, - GridBox, - Layout, - VBox, - interactive_output, - ) - - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Rs (/{len(self)}):', - disabled=False, - ) - - b = Checkbox(description='Name', value=True) - c = Checkbox(description='Summary', value=False) - d = Checkbox(description='Draw', value=True) - e = Checkbox(description='Check chemistry', value=False) - f = Checkbox(description='Reactant Quotes', value=False) - - ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') - ) - ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) - ui = VBox([a, ui1, ui2]) - - def widget( - i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False - ): - """ - - :param i: - :param name: (Default value = True) - :param summary: (Default value = True) - :param draw: (Default value = True) - :param check_chemistry: (Default value = True) - :param reactants: (Default value = False) - - """ - reaction = self[i] - if name: - print(repr(reaction)) - if summary: - reaction.summary(draw=False) - if draw: - reaction.draw() - if check_chemistry: - reaction.check_chemistry(debug=True) - if reactants: - for comp in reaction.reactants: - # if summary: - # comp.summary(draw=False) - # elif name: - print(repr(comp)) - - quotes = comp.get_quotes(df=True) - display(quotes) - - # break - - # if draw: - # comp.draw() - - out = interactive_output( - widget, - { - 'i': a, - 'name': b, - 'summary': c, - 'draw': d, - 'check_chemistry': e, - 'reactants': f, - }, - ) - - display(ui, out) - - def get_df(self, smiles=True, mols=True, **kwargs) -> 'pandas.DataFrame': - """Construct a pandas.DataFrame of this ReactionSet - - :param smiles: Include smiles column (Default value = True) - :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: - - """ - - from pandas import DataFrame - - mrich.debug('Using slower Reaction.dict rather than direct SQL query...') - - data = [] - for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): - data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) - - return DataFrame(data) - - def copy(self) -> 'ReactionSet': - """Return a copy of this set""" - return ReactionSet(self.db, self.ids, sort=False, name=self.name) - - def get_recipes( - self, amounts: float | list[float] = 1.0, **kwargs - ) -> 'Recipe | list[Recipe]': - """Get the :class:`.Recipe` object(s) from this set of recipes - - :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) - :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: - - """ - from .recipe import Recipe - - return Recipe.from_reactions(db=self.db, reactions=self, amounts=1, **kwargs) - - def reverse(self) -> None: - """In-place reversal of indices""" - self._indices = list(reversed(self._indices)) - - def get_dict(self) -> dict[str]: - """Serializable dictionary""" - return dict(db=str(self.db), indices=self.indices) - - def summary(self) -> None: - """Print a summary of the Reactions""" - - mrich.header(self) - for reaction in self: - print(repr(reaction)) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f'{self.name}: ' - else: - s = '' - - s += f'{{R × {len(self)}}}' - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __len__(self) -> int: - """Number of member :class:`.Reaction` objects""" - return len(self.indices) - - def __iter__(self): - """Iterate through member :class:`.Reaction` objects""" - return iter(self.db.get_reaction(id=i) for i in self.indices) - - def __getitem__(self, key) -> 'Reaction | ReactionSet': - """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" - - match key: - case int(): - try: - index = self.indices[key] - except IndexError: - mrich.error(f'list index out of range: {key=} for {self}') - raise - return self.db.get_reaction(id=index) - case slice(): - ids = self.ids[key] - return ReactionSet(self.db, ids) - case key if ( - isinstance(key, list) or isinstance(key, tuple) or isinstance(key, set) - ): - ids = self.ids[key] - return ReactionSet(self.db, ids) - case _: - mrich.error( - f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' - ) - - return None - - def __add__(self, other: 'ReactionSet') -> 'ReactionSet': - """Add a :class:`.ReactionSet` to this one""" - if other: - for reaction in other: - self.add(reaction) - self._name = None - return self - - def __sub__( - self, - other: 'ReactionSet', - ) -> 'ReactionSet': - """Substract a :class:`.ReactionSet` from this set""" - match other: - case ReactionSet(): - ids = set(self.ids) - set(other.ids) - return ReactionSet(self.db, ids, sort=False) - case int(): - # assert other in set(self.ids) - return ReactionSet( - self.db, [i for i in self.ids if i != other], sort=False - ) diff --git a/hippo/scoring.py b/hippo/scoring.py deleted file mode 100644 index e735a5c..0000000 --- a/hippo/scoring.py +++ /dev/null @@ -1,1069 +0,0 @@ -"""Classes for scoring Recipes""" - -import mrich -import numpy as np -import pandas as pd -from scipy.interpolate import interp1d - -DATA_COLUMNS = [ - 'score', - 'price', - 'compound_ids', - 'pose_ids', - 'interaction_ids', - 'pose_metadata', -] - - -class Scorer: - """Create a scorer object to score sets of recipes - - :param db: :class:`.Database` - :param directory: path to directory containing recipe JSONs - :param pattern: glob pattern for :class:`.Recipe` JSON, default: "*.json" - :param attributes: attributes of :class:`.Recipe` objects to use for scoring - :param populate: Pre-populate query caches and child objects in memory (don't disable unless you have a good reason) - :param load_cache: Load cache from existing JSON - :param allowed_pose_ids: Restrict interaction and subsite calculations to these :class:`.Pose` IDs - """ - - def __init__( - self, - db: 'Database', - directory: 'Path | str', - pattern: str = '*.json', - attributes: list[str] = None, - populate: bool = True, - load_cache: bool = True, - allowed_poses: 'PoseSet | list[int] | None' = None, - out_key: str = 'scorer', - ) -> None: - """Scorer initialisation""" - - from .pset import PoseSet - from .recipe import RecipeSet - - self._db = db - self._out_key = out_key - - if allowed_poses is None: - self._allowed_pose_ids = None - elif isinstance(allowed_poses, PoseSet): - self._allowed_pose_ids = set(allowed_poses.ids) - else: - self._allowed_pose_ids = set(allowed_poses) - - attributes = attributes or [] - - recipes = RecipeSet(db, directory, pattern=pattern) - - self._recipes = recipes - - self._attributes = {} - - for key in attributes: - attribute = Attribute(self, key) - self._attributes[key] = attribute - - self._data = pd.DataFrame( - index=recipes.keys(), - columns=DATA_COLUMNS + self.attribute_keys, - ) - - (self._data.replace({np.nan: None}, inplace=True),) - - if populate: - if load_cache and self.json_path.exists(): - self._load_json() - else: - self._populate_query_cache() - - self._populate_recipe_child_sets() - - self.weights = 1.0 - - ### FACTORIES - - @classmethod - def default( - cls, - db: 'Database', - directory: 'Path | str', - pattern: str = '*.json', - skip: list[str] | None = None, - load_cache: bool = True, - subsites: bool = True, - allowed_poses: 'PoseSet | list[int] | None' = None, - out_key: str = 'scorer', - ) -> 'Scorer': - """Create a Scorer instance with Default attributes""" - - self = cls.__new__(cls) - - attributes = [ - k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard' - ] - - self.__init__( - db=db, - directory=directory, - pattern=pattern, - attributes=attributes, - populate=False, - allowed_poses=allowed_poses, - out_key=out_key, - ) - - skip = skip or [] - - if not db.count('interaction'): - mrich.warning('No interactions in DB, skipping related metrics') - skip.append('interaction_count') - skip.append('interaction_balance') - - if not db.count('pose'): - mrich.warning('No poses in DB, skipping related metrics') - skip.append('num_inspirations') - skip.append('num_inspiration_sets') - skip.append('avg_energy_score') - skip.append('avg_distance_score') - - if not db.count('scaffold'): - mrich.warning('No scaffold entries in DB, skipping related metrics') - skip.append('num_scaffolds') - skip.append('num_scaffolds_elaborated') - skip.append('elaboration_balance') - - # custom attributes - for key, attribute in [ - (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'custom' - ]: - if skip and key in skip: - continue - - if not subsites and 'subsite' in key: - continue - - self.add_custom_attribute( - key, attribute['function'], weight_reset_warning=False - ) - - if load_cache and self.json_path.exists(): - self._load_json() - else: - self._populate_query_cache() - - self._populate_recipe_child_sets() - - # weights - wsum = sum(abs(d['weight']) for d in DEFAULT_ATTRIBUTES.values()) - for attribute in self.attributes: - d = DEFAULT_ATTRIBUTES[attribute.key] - attribute.weight = d['weight'] / wsum - - return self - - ### PROPERTIES - - @property - def num_recipes(self) -> int: - """Number of recipes being evaluated""" - return len(self._recipes) - - @property - def attributes(self) -> 'list[Attribute | CustomAttribute]': - """Return list of :class:`.Attribute` / :class:`.CustomAttribute` objects""" - return list(self._attributes.values()) - - @property - def attribute_keys(self) -> list[str]: - """Return list of :class:`.Attribute` / :class:`.CustomAttribute` names/keys""" - return list(self._attributes.keys()) - - @property - def recipes(self) -> 'RecipeSet': - """Return :class:`.RecipeSet` of recipes being scored""" - return self._recipes - - @property - def num_attributes(self) -> int: - """Count of attributes""" - return len(self.attributes) - - @property - def weights(self) -> list[float]: - """List of attribute weights""" - return [a.weight for a in self.attributes] - - @weights.setter - def weights(self, ws) -> None: - """Setter for weights list""" - - self._flag_weight_modification() - - if isinstance(ws, float) or isinstance(ws, int): - ws = [ws] * self.num_attributes - - ws = [w for w in ws] - wsum = sum([abs(w) for w in ws]) - - for a, w in zip(self.attributes, ws, strict=False): - a.weight = w / wsum - - @property - def score_dict(self) -> dict[str, float]: - """Dictionary of scores keyed by :meth:`.Recipe.hash`""" - - col = self._data['score'] - - null = col.isnull() - - if null.sum(): - mrich.debug('Calculating scores...') - for key in col[null].index.values: - recipe = self.recipes[key] - score = self.score(recipe) - self._data.at[key, 'score'] = score - - self._dump_json() - - return col.to_dict() - - @property - def scores(self) -> list[float]: - """List of :class:`.Recipe` scores""" - return list(self.score_dict.values()) - - @property - def best(self) -> 'Recipe': - """Return highest scoring :class:`.Recipe`""" - return self.top(1) - - @property - def db(self) -> 'Database': - """:class:`.Database`""" - return self._db - - @property - def json_path(self) -> 'Path': - """Path where cache will be written""" - from pathlib import Path - - return Path(self.db.path.name.replace('.sqlite', f'_{self._out_key}.json')) - - @property - def poses(self) -> 'PoseSet': - """Return all associated poses as :class:`.PoseSet`""" - from .pset import PoseSet - - ids = set().union(*self._data['pose_ids']) - return PoseSet(self.db, ids) - - ### METHODS - - def add_custom_attribute( - self, - key: str, - function: 'Callable', - weight_reset_warning: bool = True, - ) -> 'CustomAttribute': - """Add a custom scoring attribute - - :param key: name/key for the attribute - :param function: function call to get the attribute alue, will be passed :class:`.Recipe` object - :param weight_reset_warning: write a warning to indicate weights have been reset - """ - - ca = CustomAttribute(self, key, function) - - if key not in self._attributes: - # self._flag_weight_modification() - - self._attributes[key] = ca - - if weight_reset_warning: - mrich.warning('Attribute weights have been reset') - self.weights = 1.0 - - self._data[key] = None - - else: - mrich.warning('Existing attribute with {key=}') - - return self._attributes[key] - - def add_recipes(self, json_paths: 'list', debug: bool = False) -> None: - """Add more serialised :class:`.Recipe` objects to be scored - - :param json_paths: list of JSON paths - :param debug: increase verbosity for debugging - """ - - from pathlib import Path - - from .recipe import Recipe - - for json_path in json_paths: - path = Path(json_path) - - key = path.name.removeprefix('Recipe_').removesuffix('.json') - - if key in self.recipes: - mrich.warning(f'Skipping duplicate {path}') - continue - - recipe = Recipe.from_json(self._db, path, allow_db_mismatch=True) - - recipe._hash = key - - if debug: - mrich.debug(recipe) - - if debug: - mrich.debug('Updating Scorer.recipes._json_paths') - self.recipes._json_paths[key] = path.resolve() - - if debug: - mrich.debug('Updating Scorer.recipes._recipes') - self.recipes._recipes[key] = recipe - - self._data.loc[key] = None - - (self._data.replace({np.nan: None}, inplace=True),) - self._populate_query_cache() - self._populate_recipe_child_sets() - self._flag_weight_modification() - - def score( - self, - recipe: 'Recipe', - *, - debug: bool = False, - ) -> float: - """Score a :class:`.Recipe` object - - :param recipe: :class:`.Recipe` to be scored - :param debug: increase verbosity for debugging - :returns: float score from 0 to 1 - """ - - score = 0.0 - - for attribute in self.attributes: - recipe_score = attribute(recipe) - score += recipe_score - - if debug: - print_data = [] - for attribute in self.attributes: - print_data.append( - dict( - key=attribute.key, - weight=f'{attribute.weight:.2f}', - value=f'{attribute.get_value(recipe):.2f}', - unweighted=f'{attribute.unweighted(recipe):.2%}', - weighted=f'{attribute(recipe):.2%}', - ) - ) - - df = pd.DataFrame(print_data).set_index('key') - mrich.print(df) - mrich.var('score', score) - - recipe._score = score - - return score - - def compare(self, recipes: 'list[Recipe] | list[str]') -> None: - """Compare attribute values and scores for recipes - - :param recipes: list of :class:`.Recipe` objects or hashes - """ - - recipes = [ - self.recipes[recipe] if isinstance(recipe, str) else recipe - for recipe in recipes - ] - - print_data = [] - - for attribute in self.attributes: - d = {'attribute (weight)': f'{attribute.key} ({attribute.weight:.2%})'} - for recipe in recipes: - # d = dict(hash=recipe.hash) - d[recipe.hash] = ( - f'{attribute.get_value(recipe):.2f} ({attribute.unweighted(recipe):.2%})' - ) - print_data.append(d) - - df = pd.DataFrame(print_data).set_index('attribute (weight)') - mrich.print(df) - - def get_sorted_df(self) -> 'pd.DataFrame': - """Get DataFrame sorted by descending score""" - - # compute scores - self.scores - - return self._data.sort_values(by='score', ascending=False) - - def plot( - self, - keys: list[str], - budget: float | None = None, - ) -> 'plotly.graph_objects.Figure': - """Plot any two attributes as a scatter plot - - :param keys: list two attribute keys to plot - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: plotly Figure object containing a scatter trace - """ - - import plotly.express as px - - if len(keys) != 2: - mrich.error('Only two keys supported') - return None - - # calculate scores - self.scores - - df = self._data.drop( - columns=[ - 'compound_ids', - 'pose_ids', - 'interaction_ids', - ] - ) - - df['score'] = pd.to_numeric(df['score']) - - if isinstance(keys, str): - assert keys in df.columns - return px.histogram(df, x=keys) - - if not all(key in df.columns for key in keys): - for key in keys: - if key not in df.columns: - raise KeyError(f'no attribute/column named "{key}"') - - if budget: - df = df[df['price'] < budget] - - df['hash'] = df.index.values - - hover_data = [ - 'hash', - ] - - hover_data += [c for c in df.columns] - - return px.scatter( - df, x=keys[0], y=keys[1], color='score', hover_data=hover_data - ) - - def top_keys(self, n: int, budget: float | None = None) -> list[str]: - """Return keys of top `n` scoring :class:`.Recipe` - - :param n: number of keys to return - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: list of :class:`.Recipe` hashes - """ - keys = self.get_sorted_df(budget=budget).index[:n] - return list(keys) - - def top(self, n: int, budget: float | None = None) -> 'list[Recipe]': - """Return top `n` scoring :class:`.Recipe` - - :param n: number of :class:`.Recipe` objects to return - :param budget: limit :class:`.Recipe` objects to below this budget value - :returns: list of :class:`.Recipe` objects - """ - keys = self.top_keys(n=n, budget=budget) - if n == 1: - return [self.recipes[key] for key in keys][0] - else: - return [self.recipes[key] for key in keys] - - ### INTERNALS - - def _flag_weight_modification(self): - """Reset scores due to weight modification""" - self._data['score'] = None - - def summary(self) -> None: - """Print some summary statistics of the scorer's attributes""" - - mrich.header(self) - for attribute in self.attributes: - mrich.print( - attribute, - f'min={attribute.min:.3g}, mean={attribute.mean:.3g}, std={attribute.std:.3g}, max={attribute.max:.3g}', - ) - - def __check_integrity(self) -> bool: - """Check integrity of data""" - - n_recipes = len(self.recipes) - - for attribute in self.attributes: - assert len(attribute._value_dict) == n_recipes - - assert len(self._scores) == n_recipes - - assert len(self._data) == n_recipes - assert len(self._data.columns) == len(attributes) + len(DATA_COLUMNS) - - return True - - def _populate_query_cache(self) -> None: - """Update internal data with pre-fetched related database IDs""" - - from .cset import CompoundSet - from .pset import PoseSet - - df = self._data - - ### Recipe prices - - for recipe in self.recipes: - self._data.at[recipe.hash, 'price'] = recipe.price.amount - - ### Compound IDs - - col = 'compound_ids' - null = df[col].isnull() - - # populate missing product compound ids - if null.sum(): - mrich.debug(f'Populating _data["{col}"]...') - assert len(df[null]) == null.sum() - for key in df[null].index.values: - recipe = self.recipes[key] - df.at[key, col] = recipe.combined_compound_ids - - ### Pose IDs - - col = 'pose_ids' - null = df[col].isnull() - - # populate missing product pose ids - if null.sum(): - compound_ids = set() - for ids in df[null]['compound_ids']: - for id in ids: - compound_ids.add(id) - - cset = CompoundSet(self.db, compound_ids, sort=False) - - mrich.debug(f'Getting poses for {len(cset)} compounds') - pose_map = self.db.get_compound_id_pose_ids_dict(cset) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - comp_ids = df['compound_ids'][key] - - all_pose_ids = set() - - for comp_id in comp_ids: - pose_ids = pose_map.get(comp_id, set()) - - if self._allowed_pose_ids: - pose_ids = set( - i for i in pose_ids if i in self._allowed_pose_ids - ) - - all_pose_ids |= pose_ids - - df.at[key, col] = all_pose_ids - - ### Interaction IDs - - col = 'interaction_ids' - null = df[col].isnull() - - # populate missing product interaction ids - if null.sum(): - pose_ids = set() - for ids in df[null]['pose_ids']: - for id in ids: - pose_ids.add(id) - - pset = PoseSet(self.db, pose_ids, sort=False) - - mrich.debug(f'Getting interactions for {len(pset)} poses') - interaction_map = self.db.get_pose_id_interaction_ids_dict(pset) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - pose_ids = df['pose_ids'][key] - - all_interaction_ids = set() - - for pose_id in pose_ids: - interaction_ids = interaction_map.get(pose_id, set()) - all_interaction_ids |= interaction_ids - - df.at[key, col] = all_interaction_ids - - ### Metadata Dictionaries - - col = 'pose_metadata' - null = df[col].isnull() - - # populate missing product interaction ids - if null.sum(): - pose_ids = set() - for ids in df[null]['pose_ids']: - for id in ids: - pose_ids.add(id) - - # pset = PoseSet(self.db, pose_ids, sort=False) - - mrich.debug(f'Getting metadata for {len(pose_ids)} poses') - metadata_lookup = self.db.get_id_metadata_dict(table='pose', ids=pose_ids) - - mrich.debug(f'Populating _data["{col}"]...') - for key in df[null].index.values: - assert len(df[null]) == null.sum() - recipe = self.recipes[key] - pose_ids = df['pose_ids'][key] - - row = df.loc[key] - - metadata = {} - for pose_id in pose_ids: - metadata[pose_id] = metadata_lookup[pose_id] - - df.at[key, col] = metadata - - def _populate_recipe_child_sets(self) -> None: - """Populate internal cache of recipe child compound/pose/interaction sets""" - - from .cset import CompoundSet - from .iset import InteractionSet - from .pset import PoseSet - - mrich.debug('Populating recipe caches') - for key, recipe in self.recipes.items(): - row = self._data.loc[key] - - if recipe._combined_compounds is None: - ids = row['compound_ids'] - cache = CompoundSet(self.db, ids) - cache._name = f'Recipe_{key} products' - recipe._combined_compounds = cache - - if recipe._poses is None: - ids = row['pose_ids'] - cache = PoseSet(self.db, ids) - cache._name = f'Recipe_{key} poses' - recipe._poses = cache - - if recipe._interactions is None: - ids = row['interaction_ids'] - cache = InteractionSet(self.db, ids) - cache._name = f'Recipe_{key} product interactions' - recipe._interactions = cache - - if recipe._poses._metadata_dict is None: - cache = row['pose_metadata'] - recipe._poses._metadata_dict = cache - - def _dump_json(self) -> None: - """Write JSON cache to file""" - path = self.json_path - mrich.writing(path) - self._data.to_json(path) - - def _load_json(self): - """Load JSON cache from file""" - path = self.json_path - - mrich.reading(path) - cached = pd.read_json(path, orient='columns') - - if (cached_columns := set(cached.columns)) != ( - self_columns := set(self._data.columns) - ): - for col in cached_columns - self_columns: - mrich.error(f'JSON has unexpected {col}') - - for col in self_columns - cached_columns: - mrich.error(f'JSON is missing {col}') - - display(cached.head()) - display(self._data.head()) - - raise ValueError("JSON columns don't match expectation") - - cached_keys = set(cached.index.values) - self_keys = set(self._data.index.values) - - if difference := cached_keys - self_keys: - mrich.warning('JSON has extra Recipes:') - mrich.warning(difference) - - if difference := self_keys - cached_keys: - mrich.error('JSON is missing Recipes:') - mrich.error(difference) - raise ValueError('JSON is missing Recipes') - - (cached.replace({np.nan: None}, inplace=True),) - - self._data = cached - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'Scorer(#recipes={self.num_recipes})' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class Attribute: - """Scoring Attribute to be used with a :class:`.Scorer` object - - :param scorer: associated :class:`.Scorer` - :param key: key/name for the attribute - :param inverse: if true, lower values score higher - :param weight: adjust scores by this weight - :param bins: number of scoring bins - """ - - _type = 'Attribute' - - ### DUNDERS - - def __init__( - self, - scorer: 'Scorer', - key: str, - *, - inverse: bool = False, - weight: float = 1.0, - bins: int = 100, - ) -> None: - """Attribute initialisation""" - - self._scorer = scorer - - self._key = key - self._inverse = inverse - self._weight = weight - - self._value_dict = {} - - self._bins = bins - - self._percentile_interpolator = None - - ### PROPERTIES - - @property - def scorer(self) -> 'Scorer': - """Get associated :class:`.Scorer`""" - return self._scorer - - @property - def key(self) -> str: - """Get name/key""" - return self._key - - @property - def inverse(self) -> bool: - """Is this attribute inverted, lower values will score higher if true""" - return self._inverse - - @property - def bins(self) -> int: - """Number of bins""" - return self._bins - - @property - def value_dict(self) -> dict[str, float]: - """Dictionary of attribute values keyed by :class:`.Recipe` hash""" - df = self.scorer._data[self.key] - - null = df.isnull() - - if null.sum(): - with mrich.loading(f'Constructing value dictionary for {self}'): - for key in df[null].index.values: - recipe = self.scorer.recipes[key] - self.get_value(recipe, force=True) - self.scorer._dump_json() - - return df.to_dict() - - @property - def values(self) -> list[float]: - """Return list of values""" - return list(self.value_dict.values()) - - @property - def mean(self) -> float: - """Return mean of value""" - return np.mean(self.values) - - @property - def std(self) -> float: - """Return standard deviation of values""" - return np.std(self.values) - - @property - def max(self) -> float: - """Return maximum of values""" - return max(self.values) - - @property - def min(self) -> float: - """Return minimum of values""" - return min(self.values) - - @property - def weight(self) -> float: - """Return weight""" - return self._weight - - @weight.setter - def weight(self, w): - """Set attribute weight""" - self.scorer._flag_weight_modification() - self._weight = abs(w) - self._reverse = w < 0 - - @property - def percentile_interpolator(self): - """Interpolator function""" - if self._percentile_interpolator is None: - count, bins_count = np.histogram(self.values, bins=self.bins) - - pdf = count / sum(count) - cdf = np.cumsum(pdf) - self._percentile_interpolator = interp1d( - bins_count[1:], cdf, kind='linear', fill_value='extrapolate' - ) - - return self._percentile_interpolator - - ### METHODS - - def get_value( - self, - recipe: 'Recipe', - serialise_price: bool = True, - force: bool = False, - ) -> float: - """Get value for a :class:`.Recipe` - - :param serialise_price: serialise :class:`.Price` objects to their amount - :param force: force calculation? (don't use cache) - """ - - if not force: - cached = self.scorer._data[self.key][recipe.hash] - - if force or cached is None: - value = getattr(recipe, self.key) - if serialise_price and self.key == 'price': - value = value.amount - self.scorer._data.at[recipe.hash, self.key] = value - else: - return cached - - return value - - def histogram(self) -> 'plotly.graph_objects.Figure': - """Plot histogram of attribute values""" - - import plotly.graph_objects as go - - fig = go.Figure(go.Histogram(x=self.values)) - fig.update_layout(xaxis_title=self.key, yaxis_title='count') - - return fig - - def unweighted( - self, - recipe: 'Recipe', - ) -> float: - """Return unweighted percentile score for a given :class:`.Recipe`""" - - value = self.get_value(recipe) - - score = float(self.percentile_interpolator(value)) - - if self.inverse: - score = 1 - score - - return score - - ### DUNDERS - - def __call__( - self, - recipe: 'Recipe', - ) -> float: - """return the weighted score of a given :class:`.Recipe`""" - - if not self.weight: - return 0.0 - - value = self.unweighted(recipe) - - return self.weight * value - - def __str__(self) -> str: - """Unformatted string representation""" - if self.weight is None: - return f'{self._type}("{self.key}", inverse={self.inverse})' - else: - return f'{self._type}("{self.key}", weight={self.weight:.2f}, inverse={self.inverse})' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - import mcol - - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class CustomAttribute(Attribute): - """Scoring attribute with a custom function""" - - _type = 'CustomAttribute' - - def __init__(self, scorer: 'Scorer', key: str, function: 'Callable') -> None: - """CustomAttribute initialisation""" - self._function = function - super().__init__(scorer=scorer, key=key) - - ### METHODS - - def get_value( - self, - recipe: 'Recipe', - serialise_price: bool = True, - force: bool = False, - ) -> float: - """Compute custom attribute value for provided :class:`.Recipe` - - :param serialise_price: serialise :class:`.Price` objects to their amount - :param force: force calculation? (don't use cache) - """ - - if not force: - cached = self.scorer._data[self.key][recipe.hash] - - if force or cached is None: - value = self._function(recipe) - - if serialise_price and self.key == 'price': - value = value.amount - self.scorer._data.at[recipe.hash, self.key] = value - else: - return cached - - return value - - -DEFAULT_ATTRIBUTES = { - 'num_scaffolds': dict( - type='custom', - weight=1.0, - function=lambda r: r.combined_compounds.count_by_tag(tag='Syndirella scaffold'), - description='The number of Syndirella scaffold compounds in this selection. Higher is better.', - ), - 'num_compounds': dict( - type='standard', - weight=1.0, - description='The number of product compounds in this selection. Higher is better.', - ), - 'num_scaffolds_elaborated': dict( - type='custom', - weight=1.0, - function=lambda r: r.combined_compounds.num_scaffolds_elaborated, - description='The number of Syndirella scaffold compounds that have at least one elaboration in this selection. Higher is better.', - ), - 'elaboration_balance': dict( - type='custom', - weight=1.0, - function=lambda r: r.combined_compounds.elaboration_balance, - description='A measure for how evenly scaffold compounds have been elaborated using an h-index. Higher is better.', - ), ### REALLY UNPERFORMANT? - 'num_inspirations': dict( - type='custom', - weight=1.0, - function=lambda r: r.poses.num_inspirations, - description='The number of unique fragment compounds that inspired poses for product compounds in this selection. Higher is better.', - ), - 'num_inspiration_sets': dict( - type='custom', - weight=1.0, - function=lambda r: r.poses.num_inspiration_sets, - description='The number of unique fragment combinations that inspired poses for product compounds in this selection. Higher is better.', - ), - # "risk_diversity": dict( - # type="custom", - # weight=0.0, - # function=lambda r: r.combined_compounds.risk_diversity, - # description="A measure of how evenly spread the risk of elaborations are for each scaffold compound. Risk in this case refers to the number of atoms added. Higher is better", - # ), # REMOVED BECAUSE IT DOES NOT NECESSARILY IMPROVE AS PRODUCTS ARE ADDED - 'interaction_count': dict( - type='custom', - weight=1.0, - function=lambda r: r.interactions.num_features, - description='The number of protein features that are being interecated with in this selection. Higher is better.', - ), - 'interaction_balance': dict( - type='custom', - weight=0.0, - function=lambda r: r.interactions.per_feature_count_hirsch, - description='A measure for how evenly protein features are being interacted with in this selection using an h-index. Higher is better', - ), - 'num_subsites': dict( - type='custom', - weight=1.0, - function=lambda r: r.poses.num_subsites, - description='Count the number of subsites that poses in this set come into contact with. Higher is better.', - ), - 'subsite_balance': dict( - type='custom', - weight=0.0, - function=lambda r: r.poses.subsite_balance, - description='Count the number of subsites that poses in this set come into contact with', - ), - 'avg_distance_score': dict( - type='custom', - weight=-0.0, - function=lambda r: r.poses.avg_distance_score, - description='Average distance score (e.g. RMSD to fragment inspirations) for poses in this set. Lower is better.', - ), - 'avg_energy_score': dict( - type='custom', - weight=-0.0, - function=lambda r: r.poses.avg_energy_score, - description='Average energy score (e.g. binding ddG) for poses in this set. Lower is better.', - ), - # "reaction_risk": dict(type='custom', weight=1.0, function=None), - # "pockets?": dict(type='custom', weight=1.0, function=None), - # "chemical_diversity": dict(type='custom', weight=1.0, function=None), - # "DMS/sequence_variability": dict(type='custom', weight=1.0, function=None), -} diff --git a/hippo/subsite.py b/hippo/subsite.py deleted file mode 100644 index 1f73329..0000000 --- a/hippo/subsite.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Classes for working with protein subsites""" - -import mcol - - -class Subsite: - """ - Class representing a subsite/subsite on a protein :class:`.Target` - - .. attention:: - - :class:`.Subsite` objects should not be created directly. Instead use :meth:`.Target.subsites`. - - """ - - _table = 'subsite' - - def __init__(self, db: 'Database', id: int, target_id: int, name: str) -> None: - """Subsite initialisation""" - - self._db = db - self._id = id - self._target_id = target_id - self._name = name - self._metadata = None - self._target = None - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the SubsiteTag's database ID""" - return self._id - - @property - def table(self): - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def target(self) -> 'Target': - """Returns the associated protein :class:`.Target`""" - if self._target is None: - self._target = self.db.get_target(id=self.target_id) - return self._target - - @property - def target_id(self) -> int: - """Returns the associated protein :class:`.Target` ID""" - return self._target_id - - @property - def name(self) -> str: - """The subsite name""" - return self._name - - @property - def metadata(self) -> 'MetaData': - """Returns the SubsiteTag's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table='subsite', id=self.id) - return self._metadata - - @property - def poses(self) -> 'PoseSet | None': - """Return all poses in this subsite""" - from .pset import PoseSet - - indices = self.db.select_where( - table='subsite_tag', - query='subsite_tag_pose', - multiple=True, - key='ref', - value=self.id, - ) - indices = [i for (i,) in indices] - if not indices: - return None - return PoseSet(self.db, indices, name=f'poses in {self}') - - ### METHODS - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return f'S{self.id}: {self.target.name}->{self.name}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class SubsiteTag: - """ - Class representing a tag assigning a :class:`.Subsite` to a :class:`.Pose` - - .. attention:: - - :class:`.SubsiteTag` objects should not be created directly. Instead use :meth:`.Pose.subsites`. - - """ - - _table = 'subsite_tag' - - def __init__(self, db: 'Database', id: int, subsite_id: int, pose_id: int): - """SubsiteTag initialisation""" - - self._db = db - self._id = id - self._subsite_id = subsite_id - self._pose_id = pose_id - self._metadata = None - - self._name = db.get_subsite_name(id=subsite_id) - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the SubsiteTag's database ID""" - return self._id - - @property - def table(self): - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def pose_id(self) -> int: - """Returns the associated :class:`.Pose`'s database ID""" - return self._pose_id - - @property - def subsite_id(self): - """Returns the associated :class:`.Subsite`'s database ID""" - return self._subsite_id - - @property - def name(self): - """Returns the associated :class:`.Subsite`'s name""" - return self._name - - @property - def metadata(self) -> 'MetaData': - """Returns the SubsiteTag's metadata""" - if self._metadata is None: - self._metadata = self.db.get_metadata(table='subsite_tag', id=self.id) - return self._metadata - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - return self.name - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}"{self}"{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]"{self}"' diff --git a/hippo/syndirella.py b/hippo/syndirella.py deleted file mode 100644 index 0114cec..0000000 --- a/hippo/syndirella.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Functions for interfacing with syndirella data""" - - -def reactions_from_row( - *, animal: 'HIPPO', row: 'pandas.Series', num_steps: int -) -> 'ReactionSet': - """Get :class:`.ReactionSet` from a syndirella *to-hippo* DataFrame row""" - - reaction_ids = set() - - for step in range(num_steps): - step += 1 - - # get relevant fields - reaction_name = row[f'{step}_reaction'] - - product = None - reactants = [] - - if reactant1_smiles := row[f'{step}_r1_smiles']: - reactants.append(animal.register_compound(smiles=reactant1_smiles)) - - if reactant2_smiles := row[f'{step}_r2_smiles']: - reactants.append(animal.register_compound(smiles=reactant2_smiles)) - - if product_smiles := row[f'{step}_product_smiles']: - product = animal.register_compound(smiles=product_smiles) - - assert product - assert reactants - - reaction = animal.register_reaction( - type=reaction_name, - product=product, - reactants=reactants, - ) - - assert reaction - - reaction_ids.add(reaction.id) - - return animal.reactions[reaction_ids] diff --git a/hippo/tags.py b/hippo/tags.py deleted file mode 100644 index fa72662..0000000 --- a/hippo/tags.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Classes for managing compound/pose tags""" - -from collections.abc import MutableSet - -import mcol -import mrich - - -class TagTable: - """Object representing the 'tag' table in the :class:`.Database`. - - .. attention:: - - :class:`.TagTable` objects should not be created directly. Instead use the :meth:`.HIPPO.tags` property. - - """ - - _table = 'tag' - - def __init__( - self, - db: 'Database', - ) -> None: - """TagTable initialisation""" - - self._db = db - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def unique(self) -> set[str]: - """Returns a set of unique tag names contained in the table""" - values = self.db.select( - table=self.table, query='DISTINCT tag_name', multiple=True - ) - return list(sorted(set(v for (v,) in values))) - - ### METHODS - - def summary(self, return_df: bool = False) -> 'pd.DataFrame': - """Print a summary table of tags with compound and pose counts""" - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound), - COUNT(DISTINCT tag_pose) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - data = [ - dict(tag=a, num_compounds=b, num_poses=c) for a, b, c in cursor.fetchall() - ] - - df = DataFrame(data) - df = df.set_index('tag') - - # compounds with poses - - sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON tag_pose = pose_id - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_posed_compounds'] = count - - df = df.fillna(0) - df = df.astype(int) - - if return_df: - return df - else: - mrich.print(df) - - def rename(self, old: str, new: str) -> None: - """Rename all instances of a tag across the database""" - - match self.db.engine: - case 'sqlite3': - sql = """ - UPDATE OR IGNORE tag - SET tag_name = ? - WHERE tag_name = ?; - """ - case 'psycopg': - sql = """ - UPDATE hippo.tag - SET tag_name = %s - WHERE tag_name = %s - ON CONFLICT DO NOTHING; - """ - - self.db.execute(sql, (str(new), str(old))) - - self.delete(old) - - self.db.commit() - - def delete(self, tag: str) -> None: - """Delete all assignments for the given tag""" - self.db.delete_where(table='tag', key='name', value=tag) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted representation of this object""" - return f'Tags {self.unique}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class TagSet(MutableSet): - """Object representing a subset of the 'tag' table in the :class:`.Database` belonging to a certain :class:`.Compound` or :class:`.Pose`. - - .. attention:: - - :class:`.TagSet` objects should not be created directly. Instead use the :meth:`.Compound.tags` or :meth:`.Pose.tags` property. - - """ - - def __init__( - self, - parent: 'Compound | Pose', - tags: list | tuple | None = None, - immutable: bool = False, - commit: bool = True, - ): - """TagSet initialisation""" - - self._elements = [] - self._immutable = immutable - self._parent = parent - - tags = tags or [] - - for tag in tags: - if tag not in self._elements: - self._elements.append(tag) - - ### PROPERTIES - - @property - def tags(self) -> list: - """Returns the elements in this set""" - return self._elements - - @property - def immutable(self) -> bool: - """Is this set is immutable?""" - return self._immutable - - @immutable.setter - def immutable( - self, - b: bool, - ) -> None: - self._immutable = b - - @property - def parent(self): - """Returns this set of tags parent :class:`.Compound` or :class:`.Pose`.""" - return self._parent - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self.parent.db - - ### DATABASE - - def _remove_tag_from_db( - self, - tag: str, - ) -> None: - """Delete a specific tag assignment for the parent :class:`.Compound`/:class:`.Pose` - - :param tag: tag to delete - - """ - sql = f""" - DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_name="{tag}" - AND tag_{self.parent.table} = {self.parent.id} - """ - - self.db.execute(sql) - - def _clear_tags_from_db( - self, - tag: str, - ) -> None: - """Delete all tag assignments for the parent :class:`.Compound`/:class:`.Pose` - - :param tag: tag to delete - - """ - sql = f""" - DELETE FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_{self.parent.table} = {self.parent.id} - """ - - self.db.execute(sql) - - def _add_tag_to_db( - self, - tag: str, - commit: bool = True, - ) -> None: - """Assign a given tag to the parent - - :param tag: tag to add - :param commit: commit the changes? (Default value = True) - - """ - payload = {'name': tag, self.parent.table: self.parent.id} - self.db.insert_tag(**payload, commit=commit) - - ### METHODS - - def pop(self) -> str: - """Pop the last element""" - assert not self.immutable - return self._elements.pop() - - def discard( - self, - tag: str, - ) -> None: - """Discard an element - - :param tag: tag to discard - - """ - - self.discard(tag) - - def clear(self): - """Clear all tags""" - self._elements = [] - self._clear_tags_from_db(self) - - def remove(self, tag: str) -> None: - """Remove an element - - :param tag: tag to remove - :raises ValueError: if tag is not in set - - """ - - assert not self.immutable - if tag in self: - i = self._elements.index(tag) - del self._elements[i] - self._remove_tag_from_db(tag) - else: - raise ValueError(f'{tag} not in {self}') - - def add( - self, - tag: str, - commit: bool = True, - ) -> None: - """Add a tag to the set - - :param tag: tag to add - :param commit: commit the change? (Default value = True) - - """ - - assert not self.immutable - if tag not in self._elements: - self._elements.append(tag) - self._add_tag_to_db(tag, commit=commit) - - def glob(self, pattern: str) -> list[str]: - """Construct a list from tags in the set names that match a given UNIX-style pattern. - - :param pattern: unix style pattern with shell-style wildcards - :returns: list of tags - - """ - - import fnmatch - - return fnmatch.filter(self.tags, pattern) - - ### DUNDERS - - def __contains__(self, tag: str) -> bool: - """Is this tag in the set?""" - return tag in self.tags - - def __str__(self) -> str: - """Unformatted representation of this object""" - return str(self._elements) - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __len__(self) -> int: - """Number of tags in this set""" - return len(self._elements) - - def __iter__(self): - """Iterate through this set""" - return iter(self._elements) - - def __add__(self, other): - """ - .. attention:: - - Adding sets together is not supported - - """ - raise NotImplementedError - - def __getitem__(self, key: int): - """Get a specific element in the set by index""" - return self._elements[key] diff --git a/hippo/target.py b/hippo/target.py deleted file mode 100644 index 5ef5dd1..0000000 --- a/hippo/target.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Classes for protein targets""" - -import mcol -import mrich - - -class Target: - """Object representing a protein target - - .. attention:: - - :class:`.Target` objects should not be created directly. Instead use :meth:`.HIPPO.register_target` or :meth:`.Pose.target`. - - """ - - _feature_cache = {} - - def __init__( - self, - db: 'Database', - id: int, - name: str, - ) -> None: - """Target initialisation""" - - self._db = db - self._id = id - self._name = name - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns a pointer to the parent database""" - return self._db - - @property - def id(self) -> int: - """Returns the target's ID""" - return self._id - - @property - def name(self) -> str: - """Returns the target's name""" - return self._name - - @property - def feature_ids(self) -> list[int]: - """Returns the target's feature ID's""" - - records = self.db.select_where( - query='feature_id', - table='feature', - key='target', - value=self.id, - none=False, - multiple=True, - sort='feature_chain_name, feature_residue_number', - ) - - if not records: - return None - - return [v for (v,) in records] - - @property - def features(self) -> list['Feature']: - """Returns the target's features""" - if feature_ids := self.feature_ids: - from .feature import Feature - - feature_ids = str(tuple(feature_ids)).replace(',)', ')') - - records = self.db.select_all_where( - table='feature', key=f'feature_id IN {feature_ids}', multiple=True - ) - - return [Feature(*record) for record in records] - - return None - - @property - def subsites(self) -> 'list[Subsite]': - """List of :class:`.Subsite` objects on this target""" - - from .subsite import Subsite - - records = self.db.select_where( - table='subsite', - key='target', - value=self.id, - multiple=True, - query='subsite_id, subsite_name', - ) - - if not records: - return [] - - subsites = [] - for record in records: - id, name = record - subsite = Subsite(db=self.db, id=id, name=name, target_id=self.id) - subsites.append(subsite) - - return subsites - - ### METHODS - - def calculate_features( - self, - protein: 'mp.System', - reference_id: int | None = None, - force: bool = False, - debug: bool = False, - ) -> list['Feature']: - """Calculate features from a protein system - - :param protein: `molparse.System` object, likely from :meth:`.Pose.protein_system` - :returns: a list of :class:`.Feature` objects - - """ - - if not force and reference_id and reference_id in self._feature_cache: - return self._feature_cache[reference_id] - - else: - if debug: - mrich.debug('protein.get_protein_features()') - - features = protein.get_protein_features() - - if debug: - mrich.debug('inserting features...') - - records = [ - dict( - family=f.family, - target=self.id, - atom_names=[a.name for a in f.atoms], - residue_name=f.res_name, - residue_number=f.res_number, - chain_name=f.res_chain, - ) - for f in features - ] - - self.db.insert_features(records) - - features = self.features - - if reference_id: - self._feature_cache[reference_id] = features - - return features - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'T{self.id} "{self.name}"' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/hippo/test.db b/hippo/test.db deleted file mode 100644 index 378426eb600de0d6948324e48ca9b6449c18bf5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#&1%9x5C`yG5emT)!9!1hIVwmmzJSr@AkBwO;~^JA#8DQEfn*Q8im&VgxH$xb zR!VPD{=*KlGqb1PZ9hMJrI~hRRpgo`qA#TEO^JjM(qzYE8%^JB8&jM9w~*pBbDqsB zX%B=s2nav`0uX=z1Rwwb2tWV=5P-m=2%OgP>BVv6+1B~y!1E|rMyv3xt(|%7xrs-K zJM%qir!=HLgr&Bmj+*ye(K22HZk*D)myWGtUNC9?(iBDsg)85uu~ir9!1edB=8DVg t&bF-7{>Pm)x`l53=EA<*j&O$n1Rwwb2tWV=5P$##AOHafK;WMVTmf None: - """ProjectPage initialisation""" - - # setup directories - self._output_dir = Path(output_dir) - self.make_directories() - - # project objects - self._animal = animal - self._scaffolds = scaffolds - self._suppliers = suppliers - self._starting_recipe = starting_recipe - self._rgen = rgen - self._scorer = scorer - self._proposals = proposals - self._extra_recipe_dir = extra_recipe_dir - - self._all_scaffolds = self.animal.compounds(tag=scaffold_tag) - - mrich.debug(f'{len(self.all_scaffolds)=}') - - self._all_scaffold_poses = None - self._all_elabs = None - self._all_elab_poses = None - self._scaffold_poses = None - - self._title = title or animal.name - - self._skip_existing = skip_existing - - self.setup_page() - self.write_html() - - ### FACTORIES - - ### PROPERTIES - - @property - def animal(self) -> 'HIPPO': - """associated :class:`.HIPPO` object""" - return self._animal - - @property - def db(self) -> 'Database': - """associated :class:`.Database` object""" - return self.animal.db - - @property - def doc(self) -> 'yattag.Doc': - """yattag.Doc""" - return self._doc - - @property - def tag(self) -> 'yattag.tag': - """yattag.tag""" - return self._tag - - @property - def text(self) -> 'yattag.text': - """yattag.text""" - return self._text - - @property - def line(self) -> 'yattag.line': - """yattag.line""" - return self._line - - @property - def title(self) -> str: - """Page title""" - return self._title - - @property - def output_dir(self) -> 'Path': - """Output directory""" - return self._output_dir - - @property - def resource_dir(self) -> 'Path': - """Output directory""" - return self.output_dir / 'web_resources' - - @property - def mol_image_dir(self) -> 'Path': - """Output directory""" - return self.resource_dir / 'mol_images' - - @property - def pose_sdf_dir(self) -> 'Path': - """Output directory""" - return self.resource_dir / 'pose_sdfs' - - @property - def index_path(self) -> 'Path': - """index.html Path""" - return self.output_dir / 'index.html' - - @property - def proposals(self) -> 'list[Recipe]': - """List of proposal :class:`.Recipe` objects""" - return self._proposals - - @property - def scaffolds(self) -> 'CompoundSet': - """Scaffold :class:`.CompoundSet`""" - return self._scaffolds - - @property - def suppliers(self) -> list[str]: - """Compound suppliers""" - return self._suppliers - - @property - def starting_recipe(self) -> 'Recipe': - """Starting :class:`.Recipe`""" - return self._starting_recipe - - @property - def rgen(self) -> 'RandomRecipeGenerator': - """:class:`.RandomRecipeGenerator`""" - return self._rgen - - @property - def scorer(self) -> 'Scorer': - """:class:`.Scorer`""" - return self._scorer - - @property - def all_scaffolds(self) -> 'CompoundSet': - """All scaffold compounds""" - return self._all_scaffolds - - @property - def all_elabs(self) -> 'CompoundSet': - """All elaborations""" - if self._all_elabs is None: - self._all_elabs = self.all_scaffolds.elabs - return self._all_elabs - - @property - def all_elab_poses(self) -> 'PoseSet': - """All elaboration poses""" - if self._all_elab_poses is None: - self._all_elab_poses = self.all_elabs.poses - return self._all_elab_poses - - @property - def scaffold_poses(self) -> 'PoseSet': - """Scaffold poses""" - if self._scaffold_poses is None: - self._scaffold_poses = self.scaffolds.poses - return self._scaffold_poses - - @property - def all_scaffold_poses(self) -> 'PoseSet': - """All scaffold poses""" - if self._all_scaffold_poses is None: - self._all_scaffold_poses = self.all_scaffolds.poses - return self._all_scaffold_poses - - @property - def proposal(self) -> 'Recipe': - """Return single :class:`.Recipe` proposal""" - if len(self.proposals) != 1: - mrich.warning(f'{len(self.proposals)=}') - return self._proposals[0] - - @property - def extra_recipe_dir(self) -> 'Path': - """Optional extra recipe directory""" - return self._extra_recipe_dir - - @property - def skip_existing(self) -> bool: - """Skip creation of existing files""" - return self._skip_existing - - ### METHODS - - def write_html(self) -> None: - """Write the index.html file""" - - from yattag import indent - - path = self.index_path - - with open(path, 'w') as f: - mrich.writing(path) - f.writelines(indent(self.doc.getvalue())) - - ### INTERNAL HTML STUFF - - def make_directories(self) -> None: - """Create output directories""" - - mrich.writing(self.output_dir) - self.output_dir.mkdir(parents=True, exist_ok=True) - - mrich.writing(self.resource_dir) - (self.resource_dir).mkdir(parents=True, exist_ok=True) - - mrich.writing(self.mol_image_dir) - (self.mol_image_dir).mkdir(parents=True, exist_ok=True) - - mrich.writing(self.pose_sdf_dir) - (self.pose_sdf_dir).mkdir(parents=True, exist_ok=True) - - def setup_page(self) -> None: - """Create the yattag page content""" - - # yattag setup - from yattag import Doc - - doc, tag, text, line = Doc().ttl() - - self._doc = doc - self._tag = tag - self._text = text - self._line = line - - self.doc.asis('') - - with self.tag('html'): - self.header() - - with self.tag('body', klass='w3-content', style='max-width:none'): - with self.tag('div', klass='w3-bar w3-teal'): - with self.tag('div', klass='w3-bar-item'): - src = 'https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_assets-02.png?raw=true' - self.doc.stag( - 'img', src=src, style='max-height:75px' - ) # , klass="w3-image") - - with self.tag('div', klass='w3-bar-item'): - with self.tag('h1'): - self.text(self.title) - - with self.tag('div', klass='w3-container w3-dark-gray w3-padding'): - self.section(self.sec_targets) - self.section(self.sec_hits) - - # placeholders - if self.scaffolds: - self.section(self.sec_scaffolds) - if self.scaffolds: - self.section(self.sec_elaborations) - # if self.quoting: self.section(self.sec_quoting) - # if self.product_pool: self.section(self.sec_product_pool) - # if self.route_pool: self.section(self.sec_route_pool) - if self.rgen: - self.section(self.sec_rgen) - if self.scorer: - self.section(self.sec_scorer) - if self.proposals: - self.section(self.sec_proposals) - - with self.tag('div', klass='w3-container w3-teal w3-padding'): - with self.tag('div', klass='w3-center'): - src = 'https://github.com/mwinokan/HIPPO/raw/main/logos/hippo_logo_tightcrop.png?raw=true' - self.doc.stag('img', src=src, style='max-height:150px') - - def header(self) -> None: - """Create the page header""" - - with self.tag('head'): - with self.tag('title'): - self.text(self.title) - - self.doc.stag('meta', charset='UTF-8') - self.doc.stag( - 'meta', name='viewport', content='width=device-width, initial-scale=1' - ) - self.doc.stag( - 'link', - rel='stylesheet', - href='https://www.w3schools.com/w3css/4/w3.css', - ) - self.doc.stag( - 'link', - rel='stylesheet', - href='https://fonts.googleapis.com/css?family=Oswald', - ) - self.doc.stag( - 'link', - rel='stylesheet', - href='https://fonts.googleapis.com/css?family=Open Sans', - ) - self.doc.stag( - 'link', - rel='stylesheet', - href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css', - ) - - with self.tag('script', src='https://cdn.plot.ly/plotly-latest.min.js'): - ... - - with self.tag('script', src='https://3Dmol.org/build/3Dmol-min.js'): - ... - - with self.tag('script', src='https://3Dmol.org/build/3Dmol.ui-min.js'): - ... - - self.style() - - def style(self) -> None: - """Create the page style""" - - # change to a .css file and use doc.stag("link", rel="stylesheet", href="style.css") - - with self.tag('style'): - self.doc.asis( - """h1,h2,h3,h4,h5,h6 {font-family: "Oswald"}body {font-family: "Open Sans"}""" - ) - - def section_header(self, title: str, tag: str = 'h2') -> None: - """section header""" - with self.tag(tag): - self.text(str(title)) - - def accordion(self) -> None: - """sub-content accordion""" - # https://www.w3schools.com/w3css/w3css_accordions.asp - raise NotImplementedError - - def sidebar(self) -> None: - """https://www.w3schools.com/w3css/w3css_sidebar.asp""" - raise NotImplementedError - - def var(self, key, value, tag=None, separator=': ') -> None: - """sub-content accordion""" - text = f'{key}{separator}{value}' - - if not tag: - with self.tag('b'): - self.text(key) - self.text(separator) - self.text(str(value)) - else: - with self.tag(tag): - self.var(key, value, separator=separator) - - def section(self, function) -> None: - """create section div""" - with self.tag('div', klass='w3-panel w3-border w3-white'): - function() - - def plotly_graph(self, figure, filename, write: bool = True): - """Generic plotly graph component""" - - # from plotly.offline import plot - from hippo_plot import write_html - - """
- -
""" - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - if write: - write_html(path, figure) - - # embed the graph - with self.tag('div'): - with self.tag( - 'iframe', - src=str(rel_path), - width='100%', - height='500', - style='border:none', - ): - ... - - def table(self, data, style: str = 'w3-table-all w3-responsive', **kwargs): - """Embed some data as a table""" - from pandas import DataFrame - - df = DataFrame(data) - html = df.to_html(**kwargs, classes=style, index=False, escape=False) - self.doc.asis(html) - self.doc.asis('
') - - # def mol_grid_svg(self, cset, **kwargs): - - # mols = [c.mol for c in cset] - - # from rdkit.Chem.Draw import MolsToGridImage - - # return MolsToGridImage(mols, - # molsPerRow=3, - # subImgSize=(200, - # 200), - # legends=None, - # # highlightAtomLists=None, - # # highlightBondLists=None, - # useSVG=True, - # returnPNG=False, - # **kwargs) - - def save_compound_image(self, compound): - """Create 2D compound drawing""" - from rdkit.Chem.Draw import MolToImage - - image = MolToImage(compound.mol) - path = self.mol_image_dir / f'C{compound.id}.png' - mrich.writing(path) - image.save(path) - - def save_pose_sdf(self, pose): - """Export pose SDF""" - path = self.pose_sdf_dir / f'P{pose.id}.sdf' - self.animal.poses([pose.id]).write_sdf(path, inspirations=False) - - def save_pset_sdf(self, name, pset): - """Save poseset as SDF""" - path = self.pose_sdf_dir / f'{name}.sdf' - pset.write_sdf(path, inspirations=False) - - def compound_image(self, compound, max_height='250px'): - """Compound image stag""" - self.save_compound_image(compound) - - src = str( - Path(self.resource_dir.name) - / Path(self.mol_image_dir.name) - / f'C{compound.id}.png' - ) - - self.doc.stag('img', src=src, style=f'max-height:{max_height}') - - # def pose_3d_view(self, pose): - # """Compound image stag""" - - # self.save_pose_sdf(compound) - - # src = str( - # Path(self.resource_dir.name) - # / Path(self.mol_image_dir.name) - # / f"C{compound.id}.png" - # ) - - # self.doc.stag("img", src=src, style=f"max-height:{max_height}") - - def compound_grid(self, compounds, style='w3-center', pose_modal: bool = False): - """Compound grid component""" - - id_num_poses_dict = compounds.id_num_poses_dict - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - with self.tag('div', klass='w3-row'): - for compound in compounds: - with self.tag( - 'div', - klass=f'w3-col s12 m6 l4 {style} w3-hover-border-black', - style='border:8px solid white', - ): - with self.tag('p'): - with self.tag('b'): - self.text(f'{compound}') - - self.compound_image(compound) - - with self.tag('p', klass='w3-small w3-monospace'): - self.text(f'{compound.inchikey}') - self.doc.asis('
') - self.text(f'{compound.smiles}') - self.doc.asis('
') - - inspirations = inspiration_map.get(compound.id, None) - - if ( - not inspirations - and 'inspiration_pose_ids' in compound.metadata - ): - inspirations = compound.metadata['inspiration_pose_ids'] - - if inspirations: - inspirations = self.animal.poses[inspirations] - self.text(f'inspirations: {inspirations.names}') - else: - self.text('inspirations: ?') - - num_poses = id_num_poses_dict[compound.id] - self.button(f'{num_poses} poses', disable=num_poses == 0) - - if pose_modal: - poses = compound.poses - - modal_name = f'modal_c{compound.id}_poses' - - # MOLECULE MODAL - self.modal_button( - f'view {len(poses)} poses', - modal_name, - disable=len(poses) == 0, - ) - - if poses: - self.save_pset_sdf(modal_name, poses) - - def modal_content(): - """modal content""" - with self.tag('p'): - self.text('TEXT TEXT TEXT') - - self.modal(modal_name, modal_content) - - def modal_button( - self, text, modal_name, disable: bool = False, style: str = 'w3-teal' - ): - """Modal opening button""" - onclick = f"document.getElementById('{modal_name}').style.display='block'" - self.button(text, style=style, onclick=onclick, disable=disable) - - def button( - self, text: str, onclick: str = '', style='w3-teal', disable: bool = False - ): - """Generic button component""" - - assert text - - klass = f'w3-btn {style}' - - if disable: - klass += ' w3-disabled' - onclick = '' - - with self.tag('button', klass=klass, onclick=onclick): - self.text(text) - - def modal(self, modal_name, content_function): - """Generic modal""" - with self.tag('div', id=modal_name, klass='w3-modal'): - with self.tag('div', klass='w3-modal-content'): - with self.tag('div', klass='w3-container'): - with self.tag( - 'span', - onclick=f"document.getElementById('{modal_name}').style.display='none'", - klass='w3-button w3-display-topright', - ): - self.doc.asis('×') - content_function() - - def recipe_subsection( - self, recipe, title, sankey: bool = False, title_style='h3', show_title=True - ): - """recipe subsection""" - - if show_title: - self.section_header(title, title_style) - - recipe_name = title.lower().replace(' ', '_') - - if sankey: - fig = recipe.sankey() - self.plotly_graph(fig, f'{recipe_name}.html') - - # self.section_header("Products", "h4") - - df = recipe.products.compounds.get_df() - - def modal_content(): - """modal content""" - self.table(df, style='w3-table-all w3-small') - - modal_name = f'{recipe_name}_products' - self.modal_button('products', modal_name) - self.modal(modal_name, modal_content) - - if intermediates := recipe.intermediates: - # self.section_header("Intermediates", "h4") - df = intermediates.compounds.get_df() - - def modal_content(): - """modal content""" - self.table(df, style='w3-table-all w3-small') - - modal_name = f'{recipe_name}_intermediates' - self.modal_button('intermediates', modal_name) - self.modal(modal_name, modal_content) - - # self.section_header("Reactants", "h4") - - df = recipe.reactants.df - - def modal_content(): - """modal content""" - self.table(df, style='w3-table-all w3-small') - - modal_name = f'{recipe_name}_reactants' - self.modal_button('reactants', modal_name) - self.modal(modal_name, modal_content) - - # self.section_header("Reactions", "h4") - - df = recipe.reactions.get_df(mols=False) - - def modal_content(): - """modal content""" - self.table(df, style='w3-table-all w3-small') - - modal_name = f'{recipe_name}_reactions' - self.modal_button('reactions', modal_name) - self.modal(modal_name, modal_content) - - def scorer_attribute(self, attribute, histogram: bool = True): - """scorer attribute component""" - - from .scoring import DEFAULT_ATTRIBUTES - - key = attribute.key - - self.section_header(f'{attribute._type}: "{key}"') - - with self.tag('ul'): - self.var('weight', f'{attribute.weight:.2f}', tag='li') - self.var('inverse', f'{attribute.inverse}', tag='li') - self.var('min', f'{attribute.min:.2f}', tag='li') - self.var('max', f'{attribute.max:.2f}', tag='li') - self.var('mean', f'{attribute.mean:.2f}', tag='li') - self.var('std', f'{attribute.std:.2f}', tag='li') - - if key in DEFAULT_ATTRIBUTES: - description = DEFAULT_ATTRIBUTES[key]['description'] - if attribute.inverse: - description += '(Lower is better)' - else: - description += '(Lower is better)' - - self.var('Description', description, tag='li') - - if histogram: - fig = attribute.histogram(progress=True) - self.plotly_graph(fig, f'attribute_{key}_hist.html') - - ### SECTION CONTENT - - def sec_targets(self) -> None: - """Section on targets""" - - title = 'Protein Target' - targets = self.animal.targets - - if len(targets) > 1: - title += 's' - - self.section_header(title) - - for target in targets: - self.section_header(target.name, 'h3') - - self.var('name', target.name) - - subsites = target.subsites - - if subsites: - self.section_header('Subsites', 'h4') - with self.tag('ul'): - for subsite in subsites: - self.var(f'Site {subsite.id}', subsite.name, tag='li') - - # try: - fig = self.funnel() - self.plotly_graph(fig, 'project_funnel.html') - # except Exception as e: - # mrich.error(e) - - def sec_hits(self) -> None: - """Section on experimental hits""" - - title = 'Experimental hits' - hit_compounds = self.animal.compounds(tag='hits') - hit_poses = self.animal.poses(tag='hits') - - self.section_header(title) - - with self.tag('ul'): - self.var('#compounds', len(hit_compounds), tag='li') - self.var('#observations', len(hit_poses), tag='li') - - from .animal import GENERATED_TAG_COLS - - # tag statistics - fig = self.animal.plot_tag_statistics( - show_compounds=False, - poses=hit_poses, - logo=None, - title='Tags', - skip=['Pose', 'hits'] + GENERATED_TAG_COLS, - ) - - self.plotly_graph(fig, 'hit_tags.html') - - # files - self.section_header('Downloads', 'h3') - path = self.resource_dir / 'hit_poses.sdf' - rel_path = Path(self.resource_dir.name) / 'hit_poses.sdf' - hit_poses.write_sdf(path, inspirations=False) - table_data = [ - dict( - Name='hit_poses.sdf', - Description='SDF of the experimental hits', - Download=f'SDF', - ) - ] - self.table(table_data) - - def sec_scaffolds(self) -> None: - """Section on scaffolds""" - - title = 'Scaffolds' - self.section_header(title) - - self.section_header('All scaffolds', 'h3') - - with self.tag('ul'): - self.var('#compounds', len(self.all_scaffolds), tag='li') - - # route dict? - df = self.all_scaffolds.get_df(mol=False, num_poses=True) # , routes=True) - - def modal_content(): - """modal content""" - self.table(df, style='w3-table-all w3-small') - - modal_name = 'all_scaffolds_modal' - self.modal_button('all scaffolds table', modal_name) - self.modal(modal_name, modal_content) - - # quoting? - - self.section_header('Selected scaffolds', 'h3') - - self.compound_grid(self.scaffolds, pose_modal=False) - - # files - self.section_header('Downloads', 'h3') - - table_data = [] - - path = self.resource_dir / 'all_scaffold_smiles.csv' - rel_path = Path(self.resource_dir.name) / path.name - self.scaffolds.write_smiles_csv(path) - table_data.append( - dict( - Name='All scaffolds', - Description='CSV of scaffold SMILES', - Download=f'CSV', - ) - ) - - path = self.resource_dir / 'selected_scaffold_smiles.csv' - rel_path = Path(self.resource_dir.name) / path.name - self.scaffolds.write_smiles_csv(path) - table_data.append( - dict( - Name='Selected scaffolds', - Description='CSV of scaffold SMILES', - Download=f'CSV', - ) - ) - - self.table(table_data) - - def sec_elaborations(self) -> None: - """Section on elaborations""" - - elabs = self.scaffolds.elabs - - if not elabs: - mrich.warning('No elaborations') - return None - - self._elaborations = elabs - - title = 'Elaborations' - self.section_header(title) - - fig = self.animal.plot_reaction_funnel( - title='Syndirella elaboration space', logo=False - ) - self.plotly_graph(fig, 'reaction_funnel.html') - - def sec_quoting(self) -> None: - """Section on quoting""" - - title = 'Quoting' - self.section_header(title) - - def sec_product_pool(self) -> None: - """Section on product_pool""" - - title = 'product_pool' - self.section_header(title) - - def sec_route_pool(self) -> None: - """Section on route_pool""" - - title = 'route_pool' - self.section_header(title) - - def sec_rgen(self) -> None: - """Section on rgen""" - - title = 'Random Recipe Generation' - self.section_header(title) - - rgen = self.rgen - - with self.tag('ul'): - self.var('suppliers', rgen.suppliers, tag='li') - self.var('max_lead_time', rgen.max_lead_time, tag='li') - self.var('route_pool', len(rgen.route_pool), tag='li') - - self.recipe_subsection(rgen.starting_recipe, 'Starting Recipe', sankey=False) - - def sec_scorer(self) -> None: - """Section on scorer""" - - title = 'Recipe Selection' - self.section_header(title) - - scorer = self.scorer - - with self.tag('ul'): - self.var('#recipes', len(scorer.recipes), tag='li') - self.var('#attributes', len(scorer.attributes), tag='li') - - fig = scorer.plot(['price', 'score']) - self.plotly_graph(fig, 'scorer_scatter.html') - - for attribute in scorer.attributes: - self.scorer_attribute(attribute) - - def sec_proposals(self) -> None: - """Section on proposals""" - - title = 'Proposal Recipes' - self.section_header(title) - - table_data = [] - - for proposal in self.proposals: - d = {} - - d['hash'] = str(proposal.hash) - d['price'] = str(proposal.price) - d['price/compound'] = str(proposal.price / proposal.num_products) - - for attribute in self.scorer.attributes: - d[f'{attribute.key} w={attribute.weight}'] = ( - f'{attribute.get_value(proposal):.1f} ({attribute.unweighted(proposal):.0%})' - ) - - table_data.append(d) - - self.table(table_data) - - for proposal in self.proposals: - filename = f'proposal_{proposal.hash}.html' - path = self.resource_dir / filename - - self.section_header(str(proposal), 'h3') - - from .plotting import plot_compound_tsnee - - if path.exists() and self.skip_existing: - self.plotly_graph(None, filename, write=False) - - else: - fig = plot_compound_tsnee( - proposal.products.compounds, - logo=False, - legend=False, - title='Product Clustering', - ) - - self.plotly_graph(fig, filename) - - self.recipe_subsection( - proposal, f'Recipe {proposal.hash}', sankey=False, show_title=False - ) - - # files - self.section_header('Downloads', 'h3') - - table_data = [] - - for proposal in self.proposals: - # JSON - filename = f'Recipe_{proposal.hash}.json' - original = self.rgen.recipe_dir / filename - if not original.exists() and self.extra_recipe_dir: - original = Path(self.extra_recipe_dir) / filename - shutil.copyfile(original, self.resource_dir / filename) - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - table_data.append( - dict( - Name=str(proposal), - Description='Recipe JSON', - Download=f'JSON', - ) - ) - - # SDF - filename = f'Recipe_{proposal.hash}_poses.sdf' - - try: - original = self.rgen.recipe_dir / filename - if not original.exists() and self.extra_recipe_dir: - original = Path(self.extra_recipe_dir) / filename - shutil.copyfile(original, self.resource_dir / filename) - - path = self.resource_dir / filename - rel_path = Path(self.resource_dir.name) / filename - - table_data.append( - dict( - Name=str(proposal), - Description='Recipe product poses (Fragalysis compatible)', - Download=f'SDF', - ) - ) - except FileNotFoundError: - # hit_poses.write_sdf(path, inspirations=False) - mrich.error(f'Could not find pose SDF: {original}') - - # CAR CSVs - - filename = f'Recipe_{proposal.hash}_CAR' - path = self.resource_dir / f'{filename}.csv' - proposal.write_CAR_csv(path) - - for file in Path(self.resource_dir).glob(f'{filename}*.csv'): - rel_path = Path(self.resource_dir.name) / file.name - - table_data.append( - dict( - Name=str(proposal), - Description=f'CAR input file [{file.name}]', - Download=f'CSV', - ) - ) - - # Reactant CSV - - filename = f'Recipe_{proposal.hash}_reactants' - path = self.resource_dir / f'{filename}.csv' - - if not path.exists() or not self.skip_existing: - proposal.write_reactant_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description='Reactant data file', - Download=f'CSV', - ) - ) - - # Product CSV - - filename = f'Recipe_{proposal.hash}_products' - path = self.resource_dir / f'{filename}.csv' - - if not path.exists() or not self.skip_existing: - proposal.write_product_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description='Product data file', - Download=f'CSV', - ) - ) - - # Scaffold/chemistry CSV - - filename = f'Recipe_{proposal.hash}_chemistry' - path = self.resource_dir / f'{filename}.csv' - - if not path.exists() or not self.skip_existing: - proposal.write_chemistry_csv(path) - - rel_path = Path(self.resource_dir.name) / path.name - - table_data.append( - dict( - Name=str(proposal), - Description='Chemistry review file', - Download=f'CSV', - ) - ) - - self.table(table_data) - - ### GRAPHS - - def funnel( - self, - log_y: bool = True, - scaffolds: 'CompoundSet | None' = None, - num_inspirations: int | None = None, - num_inspiration_sets: int | None = None, - ) -> 'plotly.graph_objects.Figure': - """Funnel plot""" - - if scaffolds is None: - scaffolds = self.all_scaffolds - scaffold_poses = self.all_scaffold_poses - elabs = self.all_elabs - else: - scaffold_poses = scaffolds.poses - elabs = scaffolds.elabs - - import plotly.express as px - from numpy import log as np_log - from pandas import DataFrame - - data = dict( - number=[ - num_inspirations or scaffold_poses.num_inspirations, - num_inspiration_sets or scaffold_poses.num_inspiration_sets, - len(scaffolds), - len(elabs), - len(self.rgen.route_pool), - len(self.proposal.products), - ], - category=[ - 'Fragments', - 'Fragment Sets', - 'Scaffolds', - 'Elaborations', - 'Accessible Products', - 'Selected Products', - ], - ) - - df = DataFrame(data) - - if log_y: - y = 'log_y' - df['log_y'] = df.apply(lambda x: np_log(x['number']), axis=1) - else: - y = 'number' - - fig = px.funnel(df, x='category', y=y, text='number', log_y=False) - - fig.data[0].texttemplate = '%{text}' - fig.update_layout( - xaxis={'side': 'top'}, - ) - - fig.layout.xaxis.title.text = '' - - # title = title or f"{animal.name}: Reaction statistics" - - # if subtitle: - # title = f"{title}
{subtitle}" - - # fig.update_layout(title=title, title_automargin=False, title_yref="container") - - return fig diff --git a/hippo/xchem_hippo/__init__.py b/hippo/xchem_hippo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hippo/xchem_hippo/asgi.py b/hippo/xchem_hippo/asgi.py new file mode 100644 index 0000000..a393821 --- /dev/null +++ b/hippo/xchem_hippo/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for xchem_hippo project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_asgi_application() diff --git a/hippo/xchem_hippo/urls.py b/hippo/xchem_hippo/urls.py new file mode 100644 index 0000000..dcf76c7 --- /dev/null +++ b/hippo/xchem_hippo/urls.py @@ -0,0 +1,23 @@ +""" +URL configuration for xchem_hippo project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] diff --git a/hippo/xchem_hippo/wsgi.py b/hippo/xchem_hippo/wsgi.py new file mode 100644 index 0000000..bc49854 --- /dev/null +++ b/hippo/xchem_hippo/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for xchem_hippo project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') + +application = get_wsgi_application() From a3aa8af58ddf247363453896868d8c6b4d226d38 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 10 Apr 2026 11:40:37 +0100 Subject: [PATCH 125/163] fix: directory rename vol 2 --- images/xchem-designdb/01_schema_OLD.sql | 468 ---- src/__init__.py | 3 - src/bootstrap.py | 116 - src/designdb/__init__.py | 3 - src/designdb/admin.py | 1 - src/designdb/animal.py | 404 --- src/designdb/apps.py | 5 - src/designdb/chem.py | 397 --- src/designdb/ingredient.py | 267 -- src/designdb/models.py | 994 -------- src/designdb/price.py | 249 -- src/designdb/recipe.py | 3074 ----------------------- src/designdb/route.py | 219 -- src/designdb/services/__init__.py | 0 src/designdb/services/compound.py | 136 - src/designdb/services/ingestion.py | 1065 -------- src/designdb/services/pose.py | 208 -- src/designdb/services/reaction.py | 109 - src/designdb/services/route.py | 80 - src/designdb/services/score.py | 74 - src/designdb/sets/__init__.py | 0 src/designdb/sets/compound.py | 2511 ------------------ src/designdb/sets/interaction.py | 802 ------ src/designdb/sets/pose.py | 2243 ----------------- src/designdb/sets/reaction.py | 362 --- src/designdb/sets/route.py | 427 ---- src/designdb/tests.py | 1 - src/designdb/utils.py | 341 --- src/designdb/utils_frag.py | 197 -- src/designdb/utils_xca.py | 39 - src/designdb/views.py | 1 - src/manage.py | 23 - src/xchem_hippo/__init__.py | 0 src/xchem_hippo/asgi.py | 16 - src/xchem_hippo/urls.py | 23 - src/xchem_hippo/wsgi.py | 16 - tests/test_05_scaffolds.py | 4 +- 37 files changed, 2 insertions(+), 14876 deletions(-) delete mode 100644 images/xchem-designdb/01_schema_OLD.sql delete mode 100644 src/__init__.py delete mode 100644 src/bootstrap.py delete mode 100644 src/designdb/__init__.py delete mode 100644 src/designdb/admin.py delete mode 100644 src/designdb/animal.py delete mode 100644 src/designdb/apps.py delete mode 100644 src/designdb/chem.py delete mode 100644 src/designdb/ingredient.py delete mode 100644 src/designdb/models.py delete mode 100644 src/designdb/price.py delete mode 100644 src/designdb/recipe.py delete mode 100644 src/designdb/route.py delete mode 100644 src/designdb/services/__init__.py delete mode 100644 src/designdb/services/compound.py delete mode 100644 src/designdb/services/ingestion.py delete mode 100644 src/designdb/services/pose.py delete mode 100644 src/designdb/services/reaction.py delete mode 100644 src/designdb/services/route.py delete mode 100644 src/designdb/services/score.py delete mode 100644 src/designdb/sets/__init__.py delete mode 100644 src/designdb/sets/compound.py delete mode 100644 src/designdb/sets/interaction.py delete mode 100644 src/designdb/sets/pose.py delete mode 100644 src/designdb/sets/reaction.py delete mode 100644 src/designdb/sets/route.py delete mode 100644 src/designdb/tests.py delete mode 100644 src/designdb/utils.py delete mode 100644 src/designdb/utils_frag.py delete mode 100644 src/designdb/utils_xca.py delete mode 100644 src/designdb/views.py delete mode 100755 src/manage.py delete mode 100644 src/xchem_hippo/__init__.py delete mode 100644 src/xchem_hippo/asgi.py delete mode 100644 src/xchem_hippo/urls.py delete mode 100644 src/xchem_hippo/wsgi.py diff --git a/images/xchem-designdb/01_schema_OLD.sql b/images/xchem-designdb/01_schema_OLD.sql deleted file mode 100644 index 93c25cb..0000000 --- a/images/xchem-designdb/01_schema_OLD.sql +++ /dev/null @@ -1,468 +0,0 @@ --- ========================================================= --- designdb Database Schema --- ========================================================= - --- ========================================================= --- PREREQUISITES & EXTENSIONS --- ========================================================= - -DROP SCHEMA IF EXISTS designdb CASCADE; -CREATE SCHEMA IF NOT EXISTS designdb; -CREATE SCHEMA IF NOT EXISTS rdkit; - -CREATE EXTENSION IF NOT EXISTS rdkit WITH SCHEMA rdkit; -CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA designdb; - -SET search_path TO designdb, rdkit, public; - -REVOKE CREATE ON SCHEMA public FROM PUBLIC; - --- ========================================================= --- TABLES (ordered by FK dependencies) --- ========================================================= - -CREATE TABLE IF NOT EXISTS designdb.target ( - target_pk BIGSERIAL PRIMARY KEY, --Must be a link to Scarab protein production target - target_name TEXT, --Insert from HIPPO codebase. Must be a link to Scarab protein production target - target_metadata TEXT, -- Not populated by code - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_target UNIQUE (target_name) -); - --- New table -CREATE TABLE IF NOT EXISTS designdb.scoring_method ( - method_pk BIGSERIAL PRIMARY KEY, - method_name TEXT, - method_description TEXT, - method_version TEXT, - method_organization TEXT, - method_link TEXT, - note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.enumeration_method ( - enum_pk BIGSERIAL PRIMARY KEY, - enum_name TEXT, - enum_description TEXT, - enum_version TEXT, - enum_organization TEXT, - enum_link TEXT, - enum_note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.pose_method ( - pose_method_pk BIGSERIAL PRIMARY KEY, - pose_method_name TEXT, - pose_method_description TEXT, - pose_method_version TEXT, - pose_method_organization TEXT, - pose_method_link TEXT, - pose_method_note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.compound ( - compound_pk BIGSERIAL PRIMARY KEY, - compound_inchikey TEXT, -- Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? - compound_alias TEXT, -- Maybe insert by the codebase. - compound_smiles TEXT, -- Inseret by the codebase. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? - compound_base BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Not populated by code - compound_mol rdkit.mol, -- Maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge - compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently its broken - compound_morgan_bfp bit(2048), -- Postgresartridge can't calc this. Msut be insert by codebase, but currently its broken - compound_metadata TEXT, -- currently Null - note TEXT, -- New column - rdkit_version TEXT, --Can be done by RDkit cartridge - inchi_version TEXT, -- Must be done by codebase - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_compound_alias UNIQUE (compound_alias), - CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), - CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) -); - -CREATE TABLE IF NOT EXISTS designdb.feature ( - feature_pk BIGSERIAL PRIMARY KEY, - feature_family TEXT, -- Insert by codebase - feature_target BIGINT REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, -- Insert by codebase - feature_chain_name TEXT, -- Insert by codebase - feature_residue_name TEXT, -- Insert by codebase - feature_residue_number INTEGER, -- Insert by codebase - feature_atom_names TEXT, -- Insert by codebase - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_feature UNIQUE (feature_family, feature_target, feature_chain_name, feature_residue_number, feature_residue_name, feature_atom_names) -); - -CREATE TABLE IF NOT EXISTS designdb.route ( - route_pk BIGSERIAL PRIMARY KEY, - route_product BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.reaction ( - reaction_pk BIGSERIAL PRIMARY KEY, - reaction_type TEXT, -- Insert by codebase/notebook, Synderilla - reaction_product BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - reaction_product_yield REAL, -- Insert by codebase/notebook, Synderilla - reaction_metadata TEXT, -- Not populated by code - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.pose ( - pose_pk BIGSERIAL PRIMARY KEY, - pose_inchikey TEXT, -- Insert by codebase when registering poses? Might be done from pose.mol? - pose_alias TEXT, - pose_smiles TEXT, -- LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. - pose_reference INTEGER, - pose_path TEXT, - pose_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, - pose_target BIGINT REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, - pose_mol rdkit.mol, -- Insert by the codebase and /or Chemicalite/Postgres RDkit cartridge - pose_fingerprint INTEGER, - --pose_energy_score REAL, -- LR - this may become redundant once the scores table is implemented - --pose_distance_score REAL, -- LR - this may become redundant once the scores table is implemented - --pose_inspiration_score REAL, -- LR - this may become redundant once the scores table is implemented - pose_metadata TEXT, - note TEXT, -- New column - rdkit_version TEXT, --Can be done by RDkit cartridge - inchi_version TEXT, -- Must be done by codebase - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_pose_alias UNIQUE (pose_alias), - CONSTRAINT uc_pose_path UNIQUE (pose_path) -); - -CREATE TABLE IF NOT EXISTS designdb.scores ( - score_pk BIGSERIAL PRIMARY KEY, - pose_pk BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, - compound_pk BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, - score JSONB, -- method_name -> {"score": number, "version": text} - note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.subsite ( - subsite_pk BIGSERIAL PRIMARY KEY, - subsite_target BIGINT NOT NULL REFERENCES designdb.target (target_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook - subsite_name TEXT NOT NULL, -- Insert by codebase/notebook - subsite_metadata TEXT, -- Not populated by code - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_subsite UNIQUE (subsite_target, subsite_name) -); - -CREATE TABLE IF NOT EXISTS designdb.component ( - component_pk BIGSERIAL PRIMARY KEY, - component_route BIGINT REFERENCES designdb.route (route_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - component_type INTEGER, -- Insert by codebase/notebook, Synderilla-- - component_ref INTEGER, -- Insert by codebase/notebook, Synderilla - component_amount REAL, -- Insert by codebase/notebook, Synderilla - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_component UNIQUE (component_route, component_ref, component_type) -); - -CREATE TABLE IF NOT EXISTS designdb.inspiration ( - inspiration_pk BIGSERIAL PRIMARY KEY, - inspiration_original BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase - inspiration_derivative BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_inspiration UNIQUE (inspiration_original, inspiration_derivative) -); - -CREATE TABLE IF NOT EXISTS designdb.interaction ( - interaction_pk BIGSERIAL PRIMARY KEY, - interaction_feature BIGINT NOT NULL REFERENCES designdb.feature (feature_pk) ON DELETE RESTRICT, -- Insert by codebase - interaction_pose BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, -- Insert by codebase - interaction_type TEXT NOT NULL, -- Insert by codebase - interaction_family TEXT NOT NULL, -- Insert by codebase - interaction_atom_ids TEXT NOT NULL, -- Insert by codebase - interaction_prot_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing - interaction_lig_coord TEXT NOT NULL, -- Insert by codebase. Not populated by ProLIF, needs reviewing - interaction_distance REAL NOT NULL, -- Insert by codebase - interaction_angle REAL, -- Insert by codebase - interaction_energy REAL, -- Insert by codebase. Not populated by ProLIF, needs reviewing - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_interaction UNIQUE (interaction_feature, interaction_pose, interaction_type, interaction_family, interaction_atom_ids) -); - -CREATE TABLE IF NOT EXISTS designdb.quote ( - quote_pk BIGSERIAL PRIMARY KEY, - quote_smiles TEXT, -- From compound - quote_mol rdkit.mol, -- New column, should be generated by cartridge - quote_amount REAL, - quote_supplier TEXT, - quote_catalogue TEXT, - quote_entry TEXT, - quote_lead_time INTEGER, - quote_price REAL, - quote_currency TEXT, - quote_purity REAL, - quote_date TEXT, - quote_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) -); - -CREATE TABLE IF NOT EXISTS designdb.reactant ( - reactant_pk BIGSERIAL PRIMARY KEY, - reactant_amount REAL, -- Insert by codebase/notebook, Synderilla - reactant_reaction BIGINT REFERENCES designdb.reaction (reaction_pk) ON DELETE CASCADE, -- Insert by codebase/notebook, Synderilla - reactant_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE RESTRICT, -- Insert by codebase/notebook, Synderilla - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_reactant UNIQUE (reactant_reaction, reactant_compound) -); - -CREATE TABLE IF NOT EXISTS designdb.scaffold ( - scaffold_pk BIGSERIAL PRIMARY KEY, - scaffold_base BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase - scaffold_superstructure BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_scaffold UNIQUE (scaffold_base, scaffold_superstructure) -); - -CREATE TABLE IF NOT EXISTS designdb.subsite_tag ( - subsite_tag_pk BIGSERIAL PRIMARY KEY, - subsite_tag_ref BIGINT NOT NULL REFERENCES designdb.subsite (subsite_pk) ON DELETE RESTRICT, -- Insert by codebase - subsite_tag_pose BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE RESTRICT, -- Insert by codebase - subsite_tag_metadata TEXT, -- Not populated by code - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_subsite_tag UNIQUE (subsite_tag_ref, subsite_tag_pose) -); - --- CREATE TABLE IF NOT EXISTS designdb.tag ( --- tag_pk BIGSERIAL PRIMARY KEY, --- tag_name TEXT, -- Insert by codebase --- tag_description TEXT, -- New column --- note TEXT, -- New column --- -- tag_compound BIGINT REFERENCES designdb.compound (compound_pk) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed --- -- tag_pose BIGINT REFERENCES designdb.pose (pose_pk) ON DELETE SET NULL, -- Insert by codebase, need to be removed, change in code needed --- created_on TIMESTAMPTZ DEFAULT now(), --- updated_on TIMESTAMPTZ DEFAULT now() --- -- CONSTRAINT uc_tag_compound UNIQUE (tag_name, tag_compound), --- -- CONSTRAINT uc_tag_pose UNIQUE (tag_name, tag_pose) --- ); - -CREATE TABLE IF NOT EXISTS designdb.pose_tag ( - pose_tag_pk BIGSERIAL PRIMARY KEY, - pose_tag_name TEXT, - pose_tag_description TEXT, - pose_tag_note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS designdb.compound_tag ( - compound_tag_pk BIGSERIAL PRIMARY KEY, - compound_tag_name TEXT, - compound_tag_description TEXT, - compound_tag_note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - --- ========================================================= --- New tables supporting tagging - -CREATE TABLE IF NOT EXISTS designdb.has_pose_tag ( - has_pose_tag_pk BIGSERIAL PRIMARY KEY, - pose_pk BIGINT NOT NULL REFERENCES designdb.pose (pose_pk) ON DELETE CASCADE, - pose_tag_pk BIGINT NOT NULL REFERENCES designdb.pose_tag (pose_tag_pk) ON DELETE CASCADE, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_has_pose_tag UNIQUE (pose_pk, pose_tag_pk) -); - -CREATE TABLE IF NOT EXISTS designdb.has_compound_tag ( - has_compound_tag_pk BIGSERIAL PRIMARY KEY, - compound_pk BIGINT NOT NULL REFERENCES designdb.compound (compound_pk) ON DELETE CASCADE, - compound_tag_pk BIGINT NOT NULL REFERENCES designdb.compound_tag (compound_tag_pk) ON DELETE CASCADE, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_has_compound_tag UNIQUE (compound_pk, compound_tag_pk) -); - --- ========================================================= --- INDEXES --- ========================================================= - -CREATE INDEX IF NOT EXISTS idx_target_name ON designdb.target(target_name); -CREATE INDEX IF NOT EXISTS idx_target_created ON designdb.target(created_on); - -CREATE INDEX IF NOT EXISTS idx_scoring_method_name ON designdb.scoring_method(method_name); -CREATE INDEX IF NOT EXISTS idx_scoring_method_created ON designdb.scoring_method(created_on); - -CREATE INDEX IF NOT EXISTS idx_enumeration_method_name ON designdb.enumeration_method(enum_name); -CREATE INDEX IF NOT EXISTS idx_enumeration_method_created ON designdb.enumeration_method(created_on); - -CREATE INDEX IF NOT EXISTS idx_pose_method_name ON designdb.pose_method(pose_method_name); -CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_method(created_on); - -CREATE INDEX IF NOT EXISTS idx_compound_base ON designdb.compound(compound_base); -CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compound(compound_inchikey); -CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compound(compound_smiles); -CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compound(created_on); - -CREATE INDEX IF NOT EXISTS idx_feature_target ON designdb.feature(feature_target); -CREATE INDEX IF NOT EXISTS idx_feature_created ON designdb.feature(created_on); - -CREATE INDEX IF NOT EXISTS idx_route_product ON designdb.route(route_product); -CREATE INDEX IF NOT EXISTS idx_route_created ON designdb.route(created_on); - -CREATE INDEX IF NOT EXISTS idx_reaction_product ON designdb.reaction(reaction_product); -CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reaction(created_on); - -CREATE INDEX IF NOT EXISTS idx_pose_compound ON designdb.pose(pose_compound); -CREATE INDEX IF NOT EXISTS idx_pose_target ON designdb.pose(pose_target); -CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.pose(pose_path); -CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.pose(created_on); - -CREATE INDEX IF NOT EXISTS idx_scores_pose_pk ON designdb.scores(pose_pk); -CREATE INDEX IF NOT EXISTS idx_scores_compound_pk ON designdb.scores(compound_pk); -CREATE INDEX IF NOT EXISTS idx_scores_created ON designdb.scores(created_on); -CREATE INDEX IF NOT EXISTS idx_scores_score_gin ON designdb.scores USING GIN (score); - -CREATE INDEX IF NOT EXISTS idx_subsite_target ON designdb.subsite(subsite_target); -CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsite(created_on); - -CREATE INDEX IF NOT EXISTS idx_component_route ON designdb.component(component_route); -CREATE INDEX IF NOT EXISTS idx_component_created ON designdb.component(created_on); - -CREATE INDEX IF NOT EXISTS idx_inspiration_original ON designdb.inspiration(inspiration_original); -CREATE INDEX IF NOT EXISTS idx_inspiration_derivative ON designdb.inspiration(inspiration_derivative); -CREATE INDEX IF NOT EXISTS idx_inspiration_created ON designdb.inspiration(created_on); - -CREATE INDEX IF NOT EXISTS idx_interaction_feature ON designdb.interaction(interaction_feature); -CREATE INDEX IF NOT EXISTS idx_interaction_pose ON designdb.interaction(interaction_pose); -CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interaction(created_on); - -CREATE INDEX IF NOT EXISTS idx_quote_compound ON designdb.quote(quote_compound); -CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quote(created_on); - -CREATE INDEX IF NOT EXISTS idx_reactant_reaction ON designdb.reactant(reactant_reaction); -CREATE INDEX IF NOT EXISTS idx_reactant_compound ON designdb.reactant(reactant_compound); -CREATE INDEX IF NOT EXISTS idx_reactant_created ON designdb.reactant(created_on); - -CREATE INDEX IF NOT EXISTS idx_scaffold_base ON designdb.scaffold(scaffold_base); -CREATE INDEX IF NOT EXISTS idx_scaffold_superstructure ON designdb.scaffold(scaffold_superstructure); -CREATE INDEX IF NOT EXISTS idx_scaffold_created ON designdb.scaffold(created_on); - -CREATE INDEX IF NOT EXISTS idx_subsite_tag_ref ON designdb.subsite_tag(subsite_tag_ref); -CREATE INDEX IF NOT EXISTS idx_subsite_tag_pose ON designdb.subsite_tag(subsite_tag_pose); -CREATE INDEX IF NOT EXISTS idx_subsite_tag_created ON designdb.subsite_tag(created_on); - --- CREATE INDEX IF NOT EXISTS idx_tag_compound ON designdb.tag(tag_compound); --- CREATE INDEX IF NOT EXISTS idx_tag_pose ON designdb.tag(tag_pose); --- CREATE INDEX IF NOT EXISTS idx_tag_created ON designdb.tag(created_on); - -CREATE INDEX IF NOT EXISTS idx_pose_tag_created ON designdb.pose_tag(created_on); -CREATE INDEX IF NOT EXISTS idx_compound_tag_created ON designdb.compound_tag(created_on); - -CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_pk ON designdb.has_pose_tag(pose_pk); -CREATE INDEX IF NOT EXISTS idx_has_pose_tag_pose_tag_pk ON designdb.has_pose_tag(pose_tag_pk); -CREATE INDEX IF NOT EXISTS idx_has_pose_tag_created ON designdb.has_pose_tag(created_on); - -CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_pk ON designdb.has_compound_tag(compound_pk); -CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_pk ON designdb.has_compound_tag(compound_tag_pk); -CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tag(created_on); - --- ========================================================= --- FUNCTIONS --- ========================================================= - -CREATE OR REPLACE FUNCTION designdb.update_updated_on() -RETURNS trigger AS $$ -BEGIN - NEW.updated_on = now(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- ========================================================= --- TRIGGERS (updated_on) --- ========================================================= - -DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.target; -CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.target FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_method; -CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_method; -CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_method; -CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_method FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compound; -CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compound FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.feature; -CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.feature FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.route; -CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.route FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reaction; -CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reaction FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.pose; -CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.pose FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scores_updated_on ON designdb.scores; -CREATE TRIGGER trg_scores_updated_on BEFORE UPDATE ON designdb.scores FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsite; -CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsite FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.component; -CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.component FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspiration; -CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspiration FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interaction; -CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interaction FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quote; -CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quote FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactant; -CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactant FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffold; -CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffold FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tag; -CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tag; --- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tag; -CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tag; -CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tag; -CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - -DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tag; -CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tag FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e49f7b7..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .bootstrap import load_hippo as HIPPO - -__all__ = ['HIPPO'] diff --git a/src/bootstrap.py b/src/bootstrap.py deleted file mode 100644 index 24bec1d..0000000 --- a/src/bootstrap.py +++ /dev/null @@ -1,116 +0,0 @@ -import sys -from pathlib import Path - -import django -import mrich -from django.conf import settings - -# fix path -ROOT = Path(__file__).resolve().parent -sys.path.insert(0, str(ROOT)) - - -def configure_django(db_config, manage_models: bool): - - if settings.configured: - return - - if manage_models: - # sqlite3 db, create and manage models - database = { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': db_config, - } - else: - # postgres, existing installation, don't touch - # TODO: pass vars from dbconfig - database = { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'designdb', - 'USER': 'postgres', - 'PASSWORD': 's_URzt7CWfWZ.AXD7RcF', - 'HOST': 'database', - 'PORT': '5432', - 'OPTIONS': { - # sets the schema - 'options': '-c search_path=rdkit,designdb' - }, - } - - settings.configure( - INSTALLED_APPS=[ - 'designdb.apps.DesigndbConfig', - ], - DATABASES={'default': database}, - SECRET_KEY='runtime', - DEFAULT_AUTO_FIELD='django.db.models.BigAutoField', - TIME_ZONE='UTC', - USE_TZ=True, - MIGRATION_MODULES={'designdb': None}, - MANAGE_MODELS=manage_models, - ) - - django.setup() - - -def load_hippo( - target_name: str, - *, - db: str | Path | dict | None = None, - # copy_from: str | Path | None = None, - # overwrite_existing: bool = False, - # update_legacy: bool = False, -): - """Initialisation function for HIPPO object. - - User should not call HIPPO directly because the db needs to be initialised. - """ - - mrich.bold('Creating HIPPO animal') - mrich.var('target_name', target_name, color='arg') - - if db is None: - db = {} - - if isinstance(db, str): - # sqlite db - - db_path = Path(db) - - mrich.var('db_path', db_path, color='file') - - # if copy_from: - # self._db = Database.copy_from( - # source=copy_from, - # destination=db_path, - # animal=self, - # update_legacy=update_legacy, - # overwrite_existing=overwrite_existing, - # ) - # else: - # self._db = Database(db_path, animal=self, update_legacy=update_legacy) - - configure_django(db_path, manage_models=True) - - from django.apps import apps - from django.db import connection - - with connection.schema_editor() as schema_editor: - for model in apps.get_models(): - if model._meta.managed: - schema_editor.create_model(model) - - else: - # postgres db - # pass - - # self._db = PostgresDatabase(animal=self, **db) - configure_django(db, manage_models=False) - - # import .testmodule - from designdb.animal import HIPPO - - animal = HIPPO(target_name) - - mrich.success('Initialised animal', f'{target_name}') - return animal diff --git a/src/designdb/__init__.py b/src/designdb/__init__.py deleted file mode 100644 index 967b94b..0000000 --- a/src/designdb/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import logging - -logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/designdb/admin.py b/src/designdb/admin.py deleted file mode 100644 index 846f6b4..0000000 --- a/src/designdb/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here. diff --git a/src/designdb/animal.py b/src/designdb/animal.py deleted file mode 100644 index 64ca39d..0000000 --- a/src/designdb/animal.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Main animal class for HIPPO""" - -import logging -import re -from enum import Enum -from pathlib import Path - -import mrich -import pandas as pd -from django.db import transaction - -from .models import Pose, Target -from .services.ingestion import IngestionBatchResult, IngestionService -from .sets.pose import PoseSet -from .utils import make_warn_once_per_key - -logger = logging.getLogger(__name__) - - -class HIPPO: - """Entry-point class of the xchem-hippo package. - - Update: this is atm not being called directly by the user. - """ - - def __init__( - self, - target_name: str, - ) -> None: - - # TODO: user- or project based targets - self._target, _ = Target.objects.get_or_create(target_name=target_name) - - # TODO: the way this worked previously was it gave the HIPPO - # instance full access to the pose table. When working with - # multi-project central postgres db, this is almost certainly - # not what I want. How is it that I'm going to keep this - # updated? What does it mean upadte? Access to all objects - # along this target? - - # self._compounds = CompoundTable(self.db) - # self._poses = PoseSet(Pose.objects.all()) # <- NB! for testing - # self._tags = TagTable(self.db) - # self._reactions = ReactionTable(self.db) - - # ### in memory subsets - # self._reactants = None - # self._products = None - # self._intermediates = None - # self._scaffolds = None - # self._elabs = None - - # @property - # def name(self) -> str: - # """Returns the project name - - # :returns: project name - # """ - # return self._name - - @property - def target(self) -> Target: - """Returns the target instance""" - return self._target - - # actually expected to return all poses. filtering in PoseTable - # class i.e. get_by_target. - - # Looks like I need to implement this. PoseService with some - # manager- and instance mthods as helpers? - - # Actually it's more compplex than this: in the original code - # there's PoseTable, and then there's PoseSet for a selection - @property - def poses(self): - """Return pose instances for this target""" - return Pose.objects.filter(target=self._target) - - @property - def num_poses(self) -> int: - """Total number of Poses in the Database""" - return self.poses.count() - - def add_hits( - self, - *, - metadata_csv: str | Path, - aligned_directory: str | Path, - tags: list | None = None, - skip: list | None = None, - # debug: bool = False, - # load_pose_mols: bool = False, - ) -> pd.DataFrame: - """Crystallographic hits from a Fragalysis download or XChemAlign alignment. - - For a Fragalysis download `aligned_directory` and `metadata_csv` - should point to the `aligned_files` and `metadata.csv` at the - root of the extracted download. - For an XChemAlign dataset the `aligned_directory` - should point to the `aligned_files`. - - :param target_name: Name of this protein :class:`.Target` - :param metadata_csv: Path to the metadata.csv from the Fragalysis download - :param aligned_directory: Path to the aligned_files directory - from the Fragalysis download - :param skip: optional list of observation names to skip - :param debug: bool: (Default value = False) - :returns: a DataFrame of metadata - - """ - - ### Process arguments - # NB! meta not required when loading XCA data - assert metadata_csv, 'metadata.csv required' - - assert aligned_directory, 'aligned_directory must be provided' - skip = skip or [] - tags = tags or ['hits'] - - if not isinstance(aligned_directory, Path): - aligned_directory = Path(aligned_directory) - - mrich.var('aligned_directory', aligned_directory) - - ### Determine data format - - # TODO: as it appears that users are currently only loading - # fragalysis data, XCA format is not supported. Leaving the - # format checks here to print a message for user - - class DataFormat(Enum): - """DataFormat enum""" - - Fragalysis_v2 = 1 - XChemAlign_v2 = 2 - XChemAlign_v3 = 3 - - def __str__(self) -> str: - """name""" - return self.name - - subdirs = list(aligned_directory.glob('*')) - - SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') - SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') - - fragalysis_subdirs_present = any( - SUBDIR_PATTERN_FRAGALYSIS.match(subdir.name) for subdir in subdirs - ) - xca_subdirs_present = any( - SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs - ) - assert fragalysis_subdirs_present ^ xca_subdirs_present, ( - 'Unexpected mixed data format' - ) - - if fragalysis_subdirs_present: - data_format = DataFormat.Fragalysis_v2 - else: - if any(list(subdir.glob('*_artefacts.pdb')) for subdir in subdirs): - data_format = DataFormat.XChemAlign_v3 - else: - data_format = DataFormat.XChemAlign_v2 - - mrich.error( - 'Loading XChemAlign data currently not supported.' - + ' Contact developers to enable this feature' - ) - - mrich.var('data_format', data_format) - - try: - with transaction.atomic(): - result: IngestionBatchResult = IngestionService.ingest_filesystem( - root_path=aligned_directory, - target=self.target, - skip_records=skip, - compound_tag_list=tags, - metadata_file=metadata_csv, - ) - except Exception as exc: - logger.error(exc, exc_info=True) - # TODO: handle gracefully - raise Exception from exc - - # looking at the code, it seems to be the same, there are no - # skips between observations and dirs_parsed declaratiosn - mrich.var('#valid observations', result.attempts) - - # n_poses = self.num_poses - # n_poses = Pose.objects.count() - - mrich.var('#directories parsed', result.attempts) - mrich.var('#compounds registered', result.compounds_created) - mrich.var('#poses registered', result.poses_created) - - def load_sdf( - self, - *, - path: str | Path, - reference: int | Pose | None = None, - inspirations: list[int] | PoseSet | None = None, - compound_tags: None | list[str] = None, - pose_tags: None | list[str] = None, - mol_col: str = 'ROMol', - name_col: str = 'ID', - inspiration_col: str = 'ref_mols', - reference_col: str = 'ref_pdb', - inspiration_map: None | dict = None, - convert_floats: bool = True, - skip_equal_dict: dict | None = None, - skip_not_equal_dict: dict | None = None, - ) -> None: - """Add posed virtual hits from an SDF into the database. - - :param target: Name of the protein :class:`.Target` - :param path: Path to the SDF - :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's - :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` - :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` - :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` - :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` - :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` - :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` - :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids - :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` - :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` - :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` - :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` - :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` - - All non-name columns are added to the Pose metadata. - N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. - """ - # TODO: original code reads sdf into data frame. I don't see - # much point for this in this function. get rid of it at some - # point - - if not isinstance(path, Path): - path = Path(path) - - skip_equal_dict = skip_equal_dict or {} - skip_not_equal_dict = skip_not_equal_dict or {} - - mrich.debug(f'{path=}') - - compound_tags = compound_tags or [] - pose_tags = pose_tags or [] - - if isinstance(inspirations, PoseSet): - inspiration_list = list(inspirations.ids) - elif isinstance(inspirations, list): - # TODO: potentially check types - inspiration_list = inspirations - else: - inspiration_list = [] - - if reference and isinstance(reference, Pose): - reference_id = reference.id - else: - reference_id = None - - if inspiration_map is None: - inspiration_map = {} - - warn = make_warn_once_per_key() - - try: - with transaction.atomic(): - result: IngestionBatchResult = IngestionService.ingest_sdf( - file_path=path, - target=self.target, - compound_tag_list=compound_tags, - pose_tag_list=pose_tags, - mol_col=mol_col, - name_col=name_col, - inspiration_col=inspiration_col, - inspirations=inspiration_list, - reference_col=reference_col, - reference=reference_id, - skip_equal=skip_equal_dict, - skip_not_equal=skip_not_equal_dict, - convert_floats=convert_floats, - field_warning=warn, - inspiration_map=inspiration_map, - ) - except Exception as exc: - logger.error(exc, exc_info=True) - # TODO: handle gracefully - raise Exception from exc - - # It's not clear what the original code was trying to do. I'm - # going to issue warning when number of compounds and poses - # was less than the number of compounds in sdf (not all were - # successfully parsed) but that may not have been the original - # intention - if result.attempts == result.compounds_created: - f = mrich.success - else: - f = mrich.warning - - f(f'{result.compounds_created} new compounds from {path}') - - if result.attempts == result.poses_created: - f = mrich.success - else: - f = mrich.warning - - f(f'{result.poses_created} new poses from {path}') - - def add_syndirella_routes( - self, - pickle_path: str | Path, - CAR_only: bool = True, - pick_first: bool = True, - check_chemistry: bool = True, - register_routes: bool = True, - ) -> pd.DataFrame: - """Add routes found from syndirella --just_retro query""" - - try: - with transaction.atomic(): - result: IngestionBatchResult = ( - IngestionService.ingest_syndirella_routes( - pickle_path=pickle_path, - CAR_only=CAR_only, - pick_first=pick_first, - do_check_chemistry=check_chemistry, - register_routes=register_routes, - ) - ) - except Exception as exc: - logger.error(exc, exc_info=True) - # TODO: handle gracefully - raise Exception from exc - - def add_syndirella_elabs( - self, - df_path: str | Path, - max_energy_score: float | None = 0.0, - max_distance_score: float | None = 2.0, - require_intra_geometry_pass: bool = True, - reject_flags: list[str] | None = None, - register_reactions: bool = True, - dry_run: bool = False, - scaffold_route: 'Route | None' = None, - scaffold_compound: 'Compound | None' = None, - pose_tags: list[str] | None = None, - product_tags: list[str] | None = None, - ) -> pd.DataFrame: - """ - Load Syndirella elaboration compounds and poses from a pickled DataFrame - - :param df_path: Path to the pickled DataFrame - :param max_energy_score: Filter out poses with `∆∆G` above this value - :param max_distance_score: Filter out poses with `comRMSD` above this value - :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values - :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) - :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing - :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing - :param dry_run: Don't insert new records into the database (for debugging/testing) - :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] - :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] - :returns: annotated DataFrame - """ - - reject_flags = reject_flags or [ - 'one_of_multiple_products', - 'selectivity_issue_contains_reaction_atoms_of_both_reactants', - ] - - pose_tags = pose_tags or ['syndirella_product', 'syndirella_placed'] - product_tags = product_tags or ['syndirella_product'] - - df_path = Path(df_path) - mrich.h3(df_path.name) - mrich.reading(df_path) - df = pd.read_pickle(df_path) - - # testing - # df = pd.read_csv(df_path.replace('.pkl.gz', '.csv')) - - try: - with transaction.atomic(): - result: pd.DataFrame = IngestionService.ingest_syndirella_elabs( - df=df, - # TODO: check if target eists - target=self.target, - reject_flags=reject_flags, - pose_tag_list=pose_tags, - product_tag_list=pose_tags, - max_energy_score=max_energy_score, - max_distance_score=max_distance_score, - require_intra_geometry_pass=require_intra_geometry_pass, - register_reactions=register_reactions, - scaffold_route=scaffold_route, - scaffold_compound=scaffold_compound, - ) - return result - except Exception as exc: - logger.error(exc, exc_info=True) - # TODO: handle gracefully - raise Exception from exc diff --git a/src/designdb/apps.py b/src/designdb/apps.py deleted file mode 100644 index f5477dd..0000000 --- a/src/designdb/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class DesigndbConfig(AppConfig): - name = 'designdb' diff --git a/src/designdb/chem.py b/src/designdb/chem.py deleted file mode 100644 index 654072d..0000000 --- a/src/designdb/chem.py +++ /dev/null @@ -1,397 +0,0 @@ -"""functions for validating chemistry""" - -import mrich - -from designdb.models import Compound - -""" - -Checks -====== - -- Num heavy atoms difference -- Formula checks -- Num rings difference - -""" - -SUPPORTED_CHEMISTRY = { - 'Amidation': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': 2}, - }, - }, - 'Ester_amidation': { - 'heavy_atoms_diff': '>=3', - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': '>=1', '*': '*'}, - }, - }, - 'Williamson_ether_synthesis': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, # any halogen - }, - }, - 'N-Boc_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 2, 'C': 5, 'H': 8}, - }, - }, - 'TBS_alcohol_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'C': 6, 'Si': 1, 'H': 14}, - }, - }, - 'Sp3-sp2_Suzuki_coupling': { - # "heavy_atoms_diff": 10, - 'heavy_atoms_diff': '>=4', - 'rings_diff': '>=0', - 'atomtype': { - # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen - 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen - }, - }, - 'Sp2-sp2_Suzuki_coupling': { - 'heavy_atoms_diff': '>=4', - 'rings_diff': '>=0', - 'atomtype': { - 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen - }, - }, - 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'Buchwald-Hartwig_amination': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'Nucleophilic_substitution_with_amine': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'N-nucleophilic_aromatic_substitution': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, # any halogen - }, - }, - 'Reductive_amination': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1}, # any halogen - }, - }, - 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': '>=1'}, - }, - }, - 'Steglich_esterification': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': 2}, - }, - }, - 'Benzyl_alcohol_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 1, - 'atomtype': { - 'removed': {'C': 7, 'H': 6}, - }, - }, - 'N-Bn_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 1, - }, - 'Formation_of_urea_from_two_amines': { - 'heavy_atoms_diff': -2, - 'rings_diff': 0, - }, - 'Amide_Schotten-Baumann_with_amine': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - }, - 'Nucleophilic_substitution': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - }, -} - - -def check_reaction_types(types: list[str]) -> None: - """ - Prints a warning if any of the reaction type strings in ``types`` are not in ``SUPPORTED_CHEMISTRY`` - - :param types: A list of reaction type strings to check - """ - - for reaction_type in types: - if reaction_type not in SUPPORTED_CHEMISTRY: - mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") - - -def check_chemistry( - reaction_type: str, - reactants: 'CompoundSet', - product: Compound, - debug: bool = False, -) -> bool: - """Check chemistry of given reaction""" - - if reaction_type not in SUPPORTED_CHEMISTRY: - mrich.var('reactants', reactants.ids) - mrich.var('product', product) - - raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') - - assert reactants - assert product - - CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] - - if 'heavy_atoms_diff' in CHEMISTRY: - check = check_count_diff( - 'heavy_atoms', reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if 'rings_diff' in CHEMISTRY: - check = check_count_diff( - 'rings', reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if 'atomtype' in CHEMISTRY: - check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) - if not check: - return False - - if debug: - mrich.success(f'{reaction_type}: All OK') - - return True - - -def check_count_diff( - check_type: str, - reaction_type: str, - reactants: 'CompoundSet', - product: 'Compound', - debug: bool = False, -): - """Check integer difference""" - - # get target value - diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] - - # get attribute name - attr = f'num_{check_type}' - - # get values - reac_count = getattr(reactants, attr) - prod_count = getattr(product, attr) - if debug: - mrich.var(f'#{check_type} reactants', reac_count) - if debug: - mrich.var(f'#{check_type} product', prod_count) - - # check against target value - if isinstance(diff, str): - assert diff.startswith('>='), diff - - diff = int(diff[2:]) - - if reac_count - prod_count < diff: - if debug: - mrich.error( - f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' - ) - return False - - elif debug: - mrich.success(f'{reaction_type}: #{check_type} OK') - - else: - if reac_count - diff != prod_count: - if debug: - mrich.error(f'{reaction_type}: #{check_type} FAIL') - return False - - elif debug: - mrich.success(f'{reaction_type}: #{check_type} OK') - - return True - - -def check_atomtype_diff( - reaction_type: str, - reactants: 'CompoundSet', - product: 'Compound', - debug: bool = False, -) -> bool: - """check atomtypes""" - - check_type = 'atomtype' - - # get values - reac = reactants.atomtype_dict - prod = product.atomtype_dict - - if debug: - mrich.var('reactants.atomtype_dict', str(reac)) - mrich.var('product.atomtype_dict', str(prod)) - - if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: - removal = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=True, debug=debug - ) - - if not removal: - return False - - if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: - addition = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=False, debug=debug - ) - - if not addition: - return False - - if debug: - mrich.success(f'{reaction_type}: atomtypes OK') - - return True - - -def check_specific_atomtype_diff( - reaction_type: str, - prod: 'Compound', - reac: 'Compound', - removal: bool = False, - debug: bool = False, -) -> bool: - """check specific atomtype difference""" - - if removal: - add_str = 'removed' - else: - add_str = 'added' - - add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] - - if not add_dict: - return True - - if debug: - mrich.var(add_str, str(add_dict)) - - for symbol, count in add_dict.items(): - if symbol == 'Ha': - p_count = halogen_count(prod) - r_count = halogen_count(reac) - - elif symbol == '*': - assert count == '*', (symbol, count) - if debug: - mrich.debug('Allowing wildcard atomtype differences') - continue - - else: - p_count = prod[symbol] if symbol in prod else 0 - r_count = reac[symbol] if symbol in reac else 0 - - if isinstance(count, str): - assert count.startswith('>='), (symbol, count) - - count = int(count[2:]) - - if removal and r_count - p_count < count: - if debug: - mrich.error( - f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' - ) - mrich.error( - f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' - ) - return False - - elif not removal and p_count - r_count < count: - if debug: - mrich.error( - f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' - ) - mrich.error( - f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' - ) - return False - - else: - if removal and r_count - p_count != count: - if debug: - mrich.error( - f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' - ) - mrich.error( - f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' - ) - return False - - elif not removal and p_count - r_count != count: - if debug: - mrich.error( - f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' - ) - mrich.error( - f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' - ) - return False - - return True - - -def halogen_count(atomtype_dict: dict[str, int]) -> int: - """Count halogens""" - count = 0 - symbols = ['F', 'Cl', 'Br', 'I'] - for symbol in symbols: - if symbol in atomtype_dict: - count += atomtype_dict[symbol] - return count - - -class InvalidChemistryError(Exception): - """Chemistry is not valid""" - - ... - - -class UnsupportedChemistryError(Exception): - """Chemistry is not supported""" - - ... diff --git a/src/designdb/ingredient.py b/src/designdb/ingredient.py deleted file mode 100644 index 8bc0860..0000000 --- a/src/designdb/ingredient.py +++ /dev/null @@ -1,267 +0,0 @@ -import mcol -import mrich -import pandas as pd -from django.db.models import Exists, OuterRef, Q - -from designdb.models import CataloguePrice, CataloguePriceCompoundJunction, Compound - - -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, - compound: Compound, # or CatalogueCompound? - amount: float, - quote: CataloguePrice, - max_lead_time: float | None = None, - supplier: str | None = None, - ): - """Ingredient initialisation""" - - self._compound = compound - self._quote = quote - self._amount = amount - self._max_lead_time = max_lead_time - self._supplier = supplier - - def __str__(self) -> str: - """Plain string representation""" - return f'{self.amount:.2f}mg of C{self._compound.id}' - - 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)}' - - def __eq__(self, other) -> bool: - """Equality operator""" - - if self.compound != other.compound: - return False - - return self.amount == other.amount - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.Compound`""" - return getattr(self.compound, key) - - @classmethod - def from_compound( - cls, - compound: Compound, - 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 - - quote = cls.get_quotes( - compound=compound, - pick_cheapest=True, - min_amount=amount, - max_lead_time=max_lead_time, - supplier=supplier, - none=quote_none, - ) - - else: - quote = None - - return Ingredient( - compound=compound, - amount=amount, - quote=quote, - supplier=supplier, - max_lead_time=max_lead_time, - ) - - @classmethod - def get_quotes( - cls, - compound: Compound, - 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, - ): - """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 - - """ - - qs = CataloguePrice.objects.annotate( - has_compound=Exists( - CataloguePriceCompoundJunction.objects.filter( - compound=compound, - catalogue_price=OuterRef('pk'), - ), - ), - ).filter( - has_compound=True, - ) - - if supplier: - if isinstance(supplier, str): - qs = qs.filter(supplier=supplier) - else: - qs = qs.filter(supplier__in=supplier) - - if not qs.exists(): - return None - - if max_lead_time: - qs = qs.filter(lead_time__lte=max_lead_time) - - if min_amount: - qs = qs.filter(amount__gte=min_amount) - - if not qs.exists(): - mrich.debug( - f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' - ) - - if pick_cheapest: - return qs.order_by('price').first() - - if df: - return pd.DataFrame(qs.values()).drop(columns='compound') - - return qs - - ### METHODS - - def get_cheapest_quote_id( - self, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - ) -> 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. - """ - - query = Q(compound=self.compound) - - if supplier: - query &= Q(quote_supplier=supplier) - - if min_amount: - query &= Q(quote_amount__gte=min_amount) - - if max_lead_time: - query &= Q(quote_lead_time__lte=max_lead_time) - - return CataloguePrice.objects.filter(query).order_by('quote_price').first() - - ### PROPERTIES - - @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(self) -> int: - """Returns the ID of the associated :class:`.Quote`""" - return self._quote - - @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 = self.get_cheapest_quote_id( - min_amount=a, - max_lead_time=self._max_lead_time, - supplier=self._supplier, - none='quiet', - ) - - self._quote = quote - - 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 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 diff --git a/src/designdb/models.py b/src/designdb/models.py deleted file mode 100644 index 9ffdacd..0000000 --- a/src/designdb/models.py +++ /dev/null @@ -1,994 +0,0 @@ -from pathlib import Path - -import mrich -# from django.db.models import indexes -from django.conf import settings -from django.db import models -from django.db.models import Q -from django.utils import timezone -from rdkit import Chem - -_MANAGE_MODELS = settings.MANAGE_MODELS - - -# Custom field type for text fields that store json. Once switchint to -# postgres, replace -class JSONTextField(models.TextField): - def from_db_value(self, value, expression, connection): - import json - - return json.loads(value) if value else {} - - def get_prep_value(self, value): - import json - - if isinstance(value, dict): - return json.dumps(value) - return value - - -class RDKitMolField(models.TextField): - """ - Stores RDKit molecules as MolBlock text (SDF format) in DB, - but returns RDKit Mol objects in Python. - """ - - description = 'RDKit molecule stored as MolBlock text' - - # ------------------------- - # DB → Python (read path) - # ------------------------- - def from_db_value(self, value, expression, connection): - if not value: - return None - return Chem.MolFromMolBlock(value) - - # ------------------------- - # Python → DB (write path) - # ------------------------- - def get_prep_value(self, value): - if value is None: - return None - - # Already serialized - if isinstance(value, str): - return value - - # RDKit Mol → MolBlock - if isinstance(value, Chem.Mol): - return Chem.MolToMolBlock(value) - - raise TypeError( - f'RDKitMolField only accepts RDKit Mol or MolBlock string, got {type(value)}' - ) - - def deconstruct(self): - name, path, args, kwargs = super().deconstruct() - return name, path, args, kwargs - - -if settings.MANAGE_MODELS: - # sqlite3, rdkit field types not available - # shouldn't this be binary as well? - from django.db.models import BinaryField as BfpField - - from .models import RDKitMolField as MolField -else: - from django_rdkit.models import BfpField, MolField - - -class BaseModel(models.Model): - created_on = models.DateTimeField(null=True, blank=True, default=timezone.now) - updated_on = models.DateTimeField(null=True, blank=True, default=timezone.now) - - class Meta: - abstract = True - managed = _MANAGE_MODELS - app_label = 'designdb' - default_related_name = '%(class)ss' - - -class Target(BaseModel): - id = models.BigAutoField(primary_key=True) - external_target_id = models.BigIntegerField(null=True, blank=True) - target_name = models.TextField() - target_metadata = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'targets' - constraints = [ - models.UniqueConstraint( - fields=[ - 'target_name', - ], - name='uc_target', - ), - ] - indexes = [ - models.Index(fields=['target_name'], name='idx_target_name'), - models.Index(fields=['created_on'], name='idx_target_created'), - ] - - -# TODO: tautomer hashes -class Compound(BaseModel): - id = models.BigAutoField(primary_key=True) - compound_inchikey = models.TextField(null=True, blank=True) - compound_alias = models.TextField(null=True, blank=True) - compound_smiles = models.TextField(null=True, blank=True) - compound_hash = models.TextField(null=False, blank=True, default='a') - - base_compound = models.ForeignKey( - 'self', - null=True, - blank=True, - on_delete=models.SET_NULL, - db_column='base_compound_id', - related_name='+', # add if needed - ) - - # compound_mol = models.TextField(null=True, blank=True) - # compound_pattern_bfp = models.TextField(null=True, blank=True) - # compound_morgan_bfp = models.TextField(null=True, blank=True) - compound_mol = MolField(null=True) - compound_pattern_bfp = BfpField(null=True) - compound_morgan_bfp = BfpField(null=True) - - compound_metadata = models.TextField(null=True, blank=True) - note = models.TextField(null=True, blank=True) - rdkit_version = models.TextField(null=True, blank=True) - inchi_version = models.TextField(null=True, blank=True) - - tags = models.ManyToManyField( - 'CompoundTag', - through='CompoundTagJunction', - related_name='compounds', - ) - - enumeration_methods = models.ManyToManyField( - 'EnumerationMethod', - through='CompoundEnumerationMethodJunction', - related_name='compounds', - ) - - # unlike others, this wasn't clearly defined as m2m. may not want - # to keep it - scaffolds = models.ManyToManyField( - 'self', - through='Scaffold', - ) - - class Meta(BaseModel.Meta): - db_table = 'compounds' - constraints = [ - # I believe there were supposed to be changes to these - # models.UniqueConstraint( - # fields=[ - # 'compound_alias', - # ], - # name='uc_compound_alias', - # ), - models.UniqueConstraint( - fields=[ - 'compound_inchikey', - ], - name='uc_compound_inchikey', - ), - # tautomers mess this up - # models.UniqueConstraint( - # fields=[ - # 'compound_smiles', - # ], - # name='uc_compound_smiles', - # ), - ] - indexes = [ - # models.Index(fields=['base_compound'], name='idx_base_compound_id'), - models.Index(fields=['compound_inchikey'], name='idx_compound_inchikey'), - # models.Index(fields=['compound_smiles'], name='idx_compound_smiles'), - models.Index(fields=['created_on'], name='idx_compound_created'), - ] - - -class Subsite(BaseModel): - id = models.BigAutoField(primary_key=True) - target = models.ForeignKey( - Target, - on_delete=models.RESTRICT, - db_column='target_id', - ) - - subsite_name = models.TextField() - subsite_metadata = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'subsites' - constraints = [ - models.UniqueConstraint( - fields=[ - 'target', - 'subsite_name', - ], - name='uc_subsite', - ), - ] - indexes = [ - models.Index(fields=['target'], name='idx_subsite_target_id'), - models.Index(fields=['created_on'], name='idx_subsite_created'), - ] - - -class Pose(BaseModel): - id = models.BigAutoField(primary_key=True) - - pose_inchikey = models.TextField(null=True, blank=True) - pose_alias = models.TextField(null=True, blank=True) - pose_smiles = models.TextField(null=True, blank=True) - - pose_reference = models.IntegerField(null=True, blank=True) - pose_path = models.TextField(null=True, blank=True) - - compound = models.ForeignKey( - Compound, - on_delete=models.RESTRICT, - db_column='compound_id', - ) - - target = models.ForeignKey( - Target, - on_delete=models.RESTRICT, - db_column='target_id', - ) - - # pose_mol = models.TextField(null=True, blank=True) - pose_mol = MolField(null=True) - # this is integer in the db.. pretty sure this cannot be the case? - pose_fingerprint = models.IntegerField(null=True, blank=True) - - # dicts dumped into that field, change to JSON? - # pose_metadata = models.TextField(null=True, blank=True) - pose_metadata = JSONTextField(null=True, blank=True) - # pose_metadata = models.JSONField(null=True, blank=True) - note = models.TextField(null=True, blank=True) - - rdkit_version = models.TextField(null=True, blank=True) - inchi_version = models.TextField(null=True, blank=True) - - methods = models.ManyToManyField( - 'PoseMethod', - through='PoseMethodJunction', - related_name='poses', - ) - tags = models.ManyToManyField( - 'PoseTag', - through='PoseTagJunction', - related_name='poses', - ) - # unlike others, this wasn't clearly defined as m2m. may not want - # to keep it - inspirations = models.ManyToManyField( - 'self', - through='Inspiration', - symmetrical=False, - ) - - subsites = models.ManyToManyField( - Subsite, - through='SubsiteTag', - ) - - class Meta(BaseModel.Meta): - db_table = 'poses' - # There are no constraints here, but they need to be unique, - # verified in code (rdkit.align_pose coords from - # file). Investigate adding coords to db and doing the search - # there - - # although.. would alias-target combo work? - indexes = [ - models.Index(fields=['compound'], name='idx_pose_compound_id'), - models.Index(fields=['target'], name='idx_pose_target_id'), - models.Index(fields=['pose_path'], name='idx_pose_path'), - models.Index(fields=['created_on'], name='idx_pose_created'), - ] - - @property - def mol_path(self) -> Path | None: - """Get Path to molecule file""" - path = Path(self.pose_path) - if path.name.endswith('.pdb'): - mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( - '.pdb', '_ligand.mol' - ) - if not mol_path.exists(): - mol_path = path.parent / path.name.replace( - '_hippo.pdb', '.pdb' - ).replace('.pdb', '_ligand.sdf') - if not mol_path.exists(): - mrich.error('Could not find ligand mol/sdf:', mol_path) - return None - return mol_path - elif path.name.endswith('.mol'): - return path - else: - raise NotImplementedError - - @property - def apo_path(self) -> Path | None: - """Get path to apo protein file""" - path = Path(self.pose_path) - if path.name.endswith('.pdb'): - apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( - '.pdb', '_apo-desolv.pdb' - ) - if not apo_path.exists(): - return None - return apo_path - else: - raise NotImplementedError - - -class SubsiteTag(BaseModel): - id = models.BigAutoField(primary_key=True) - pose = models.ForeignKey( - Pose, - on_delete=models.RESTRICT, - db_column='pose_id', - ) - subsite = models.ForeignKey( - Subsite, - on_delete=models.RESTRICT, - db_column='subsite_id', - ) - - subsite_tag_metadata = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'subsite_tags' - constraints = [ - models.UniqueConstraint( - fields=[ - 'subsite', - 'pose', - ], - name='uc_subsite_tag', - ), - ] - indexes = [ - models.Index(fields=['subsite'], name='idx_subsite_tag_subsite_id'), - models.Index(fields=['pose'], name='idx_subsite_tag_pose_id'), - models.Index(fields=['created_on'], name='idx_subsite_tag_created'), - ] - - -class PoseMethod(BaseModel): - id = models.BigAutoField(primary_key=True) - pose_method_name = models.TextField(null=True, blank=True) - pose_method_description = models.TextField(null=True, blank=True) - pose_method_version = models.TextField(null=True, blank=True) - pose_method_organization = models.TextField(null=True, blank=True) - pose_method_link = models.TextField(null=True, blank=True) - pose_method_note = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'pose_methods' - constraints = [ - models.UniqueConstraint( - fields=[ - 'pose_method_name', - 'pose_method_version', - ], - name='uc_pose_method', - nulls_distinct=False, - ) - ] - indexes = [ - models.Index(fields=['pose_method_name'], name='idx_pose_method_name'), - models.Index(fields=['created_on'], name='idx_pose_method_created'), - ] - - -class PoseMethodJunction(BaseModel): - pk = models.CompositePrimaryKey('pose_id', 'pose_method_id') - pose = models.ForeignKey( - 'Pose', - on_delete=models.CASCADE, - db_column='pose_id', - ) - - pose_method = models.ForeignKey( - 'PoseMethod', - on_delete=models.CASCADE, - db_column='pose_method_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'has_pose_methods' - indexes = [ - models.Index( - fields=['pose_method'], name='idx_has_pose_methods_pose_method_id' - ), - models.Index( - fields=['created_on'], name='idx_idx_has_pose_methods_created' - ), - ] - - -class PoseTag(BaseModel): - id = models.BigAutoField(primary_key=True) - pose_tag_name = models.TextField() - pose_tag_description = models.TextField(null=True, blank=True) - pose_tag_note = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'pose_tags' - constraints = [ - models.UniqueConstraint( - fields=[ - 'pose_tag_name', - ], - name='uc_pose_tag', - ) - ] - indexes = [ - models.Index(fields=['created_on'], name='idx_pose_tag_created'), - ] - - -class PoseTagJunction(BaseModel): - pk = models.CompositePrimaryKey('pose_id', 'pose_tag_id') - pose = models.ForeignKey( - Pose, - on_delete=models.CASCADE, - db_column='pose_id', - ) - pose_tag = models.ForeignKey( - PoseTag, - on_delete=models.CASCADE, - db_column='pose_tag_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'has_pose_tags' - indexes = [ - models.Index(fields=['pose_tag'], name='idx_has_pose_tag_pose_tag_id'), - models.Index(fields=['created_on'], name='idx_has_pose_tag_created'), - ] - - -# this was missing.. is this a m2m table as well? really looks like it -class Inspiration(BaseModel): - id = models.BigAutoField(primary_key=True) - # original behaviour described in schema was SET_NULL but I don't - # see how that makes sense. if either original or derivative is - # deleted, you'll have orphaned entries - original_pose = models.ForeignKey( - Pose, - # on_delete=models.SET_NULL, - on_delete=models.CASCADE, - db_column='original_pose_id', - related_name='+', - ) - derivative_pose = models.ForeignKey( - Pose, - # on_delete=models.SET_NULL, - on_delete=models.CASCADE, - db_column='derivative_pose_id', - related_name='+', - ) - - class Meta(BaseModel.Meta): - db_table = 'inspirations' - constraints = [ - models.UniqueConstraint( - fields=[ - 'original_pose', - 'derivative_pose', - ], - name='uc_inspiration', - ) - ] - indexes = [ - models.Index( - fields=['original_pose'], name='idx_inspiration_original_pose_id' - ), - models.Index( - fields=['derivative_pose'], name='idx_inspiration_derivative_pose_id' - ), - models.Index(fields=['created_on'], name='idx_inspiration_created'), - ] - - -class Feature(BaseModel): - id = models.BigAutoField(primary_key=True) - feature_family = models.TextField(null=True, blank=True) - target = models.ForeignKey( - Target, - on_delete=models.RESTRICT, - db_column='target_id', - ) - - feature_chain_name = models.TextField(null=True, blank=True) - feature_residue_name = models.TextField(null=True, blank=True) - feature_residue_number = models.IntegerField(null=True, blank=True) - feature_atom_name = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'features' - constraints = [ - models.UniqueConstraint( - fields=[ - 'feature_family', - 'target', - 'feature_chain_name', - 'feature_residue_name', - 'feature_residue_number', - 'feature_atom_name', - ], - name='uc_feature', - ) - ] - indexes = [ - models.Index(fields=['target'], name='idx_feature_target_id'), - models.Index(fields=['created_on'], name='idx_feature_created'), - ] - - -class Interaction(BaseModel): - id = models.BigAutoField(primary_key=True) - feature = models.ForeignKey( - Feature, - on_delete=models.RESTRICT, - db_column='feature_id', - ) - pose = models.ForeignKey( - Pose, - on_delete=models.RESTRICT, - db_column='pose_id', - ) - - interaction_type = models.TextField() - interaction_family = models.TextField() - interaction_atom_id = models.TextField() - - # could these be vectors? - interaction_prot_coord = models.TextField() - interaction_lig_coord = models.TextField() - - interaction_distance = models.FloatField() - interaction_angle = models.FloatField(null=True, blank=True) - interaction_energy = models.FloatField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'interactions' - constraints = [ - models.UniqueConstraint( - fields=[ - 'feature', - 'pose', - 'interaction_type', - 'interaction_family', - 'interaction_atom_id', - ], - name='uc_interaction', - ) - ] - indexes = [ - models.Index(fields=['feature_id'], name='idx_interaction_feature_id'), - models.Index(fields=['pose'], name='idx_interaction_pose_id'), - models.Index(fields=['created_on'], name='idx_interaction_created'), - ] - - -class CompoundTag(BaseModel): - id = models.BigAutoField(primary_key=True) - compound_tag_name = models.TextField() - compound_tag_description = models.TextField(null=True, blank=True) - compound_tag_note = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'compound_tags' - constraints = [ - models.UniqueConstraint( - fields=[ - 'compound_tag_name', - ], - name='uc_compound_tag_name', - ) - ] - indexes = [ - models.Index(fields=['created_on'], name='idx_compound_tag_created'), - ] - - -class CompoundTagJunction(BaseModel): - pk = models.CompositePrimaryKey('compound_id', 'compound_tag_id') - compound = models.ForeignKey( - Compound, - on_delete=models.CASCADE, - db_column='compound_id', - ) - compound_tag = models.ForeignKey( - CompoundTag, - on_delete=models.CASCADE, - db_column='compound_tag_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'has_compound_tags' - indexes = [ - models.Index( - fields=['compound_tag'], name='idx_has_compound_tag_compound_tag_id' - ), - models.Index(fields=['created_on'], name='idx_has_compound_tag_created'), - ] - - -class EnumerationMethod(BaseModel): - id = models.BigAutoField(primary_key=True) - enum_name = models.TextField(null=True, blank=True) - enum_description = models.TextField(null=True, blank=True) - enum_version = models.TextField(null=True, blank=True) - enum_organization = models.TextField(null=True, blank=True) - enum_link = models.TextField(null=True, blank=True) - enum_note = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'enumeration_methods' - constraints = [ - models.UniqueConstraint( - fields=[ - 'enum_name', - 'enum_version', - ], - name='uc_enumeration_method', - nulls_distinct=False, - ) - ] - indexes = [ - models.Index(fields=['enum_name'], name='idx_enumeration_method_name'), - models.Index(fields=['created_on'], name='idx_enumeration_method_created'), - ] - - -class CompoundEnumerationMethodJunction(BaseModel): - pk = models.CompositePrimaryKey('compound_id', 'enumeration_method_id') - compound = models.ForeignKey( - Compound, - on_delete=models.CASCADE, - db_column='compound_id', - ) - enumeration_method = models.ForeignKey( - EnumerationMethod, - on_delete=models.CASCADE, - db_column='enumeration_method_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'has_enumeration_methods' - indexes = [ - models.Index( - fields=['enumeration_method'], - name='idx_has_enumeration_methods_enumeration_method_id', - ), - models.Index( - fields=['created_on'], name='idx_has_enumeration_methods_created' - ), - ] - - -class ScoringMethod(BaseModel): - id = models.BigAutoField(primary_key=True) - method_name = models.TextField(null=True, blank=True) - method_description = models.TextField(null=True, blank=True) - method_version = models.TextField(null=True, blank=True) - method_organization = models.TextField(null=True, blank=True) - method_link = models.TextField(null=True, blank=True) - note = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'scoring_methods' - constraints = [ - models.UniqueConstraint( - fields=[ - 'method_name', - 'method_version', - ], - name='uc_scoring_method', - nulls_distinct=False, - ) - ] - indexes = [ - models.Index(fields=['method_name'], name='idx_scoring_method_name'), - models.Index(fields=['created_on'], name='idx_scoring_method_created'), - ] - - -class ScoreValue(BaseModel): - pk = models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id') - pose = models.ForeignKey( - Pose, - on_delete=models.RESTRICT, - db_column='pose_id', - related_name='scores', - ) - - compound = models.ForeignKey( - Compound, - on_delete=models.RESTRICT, - db_column='compound_id', - related_name='scores', - ) - - scoring_method = models.ForeignKey( - ScoringMethod, - on_delete=models.RESTRICT, - db_column='scoring_method_id', - related_name='scores', - ) - - score = models.JSONField() - - class Meta(BaseModel.Meta): - db_table = 'score_values' - indexes = [ - models.Index(fields=['pose'], name='idx_score_values_pose_id'), - models.Index(fields=['compound'], name='idx_score_values_compound_id'), - models.Index( - fields=['scoring_method'], name='idx_score_values_scoring_method_id' - ), - models.Index(fields=['created_on'], name='idx_score_values_created'), - ] - - -class Reaction(BaseModel): - id = models.BigAutoField(primary_key=True) - reaction_type = models.TextField(null=True, blank=True) - product_compound = models.ForeignKey( - Compound, - on_delete=models.RESTRICT, - db_column='product_compound_id', - ) - - reaction_product_yield = models.FloatField(null=True, blank=True) - reaction_metadata = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'reactions' - indexes = [ - models.Index( - fields=['product_compound'], name='idx_reaction_product_compound_id' - ), - models.Index(fields=['created_on'], name='idx_reaction_created'), - ] - - -class Reactant(BaseModel): - id = models.BigAutoField(primary_key=True) - reactant_amount = models.FloatField(null=True, blank=True) - reaction = models.ForeignKey( - Reaction, - on_delete=models.CASCADE, - db_column='reaction_id', - ) - compound = models.ForeignKey( - Compound, - on_delete=models.RESTRICT, - db_column='compound_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'reactants' - constraints = [ - models.UniqueConstraint( - fields=[ - 'reaction', - 'compound', - ], - name='uc_reactant', - ) - ] - indexes = [ - models.Index(fields=['reaction'], name='idx_reactant_reaction_id'), - models.Index(fields=['compound'], name='idx_reactant_compound_id'), - models.Index(fields=['created_on'], name='idx_reactant_created'), - ] - - -class CatalogueCompound(BaseModel): - id = models.BigAutoField(primary_key=True) - catalogue_smiles = models.TextField(null=False, blank=True) - catalogue_inchikey = models.TextField(null=False, blank=True) - catalogue_hash = models.TextField(null=False, blank=True) - rdkit_version = models.TextField(null=True, blank=True) - inchi_version = models.TextField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'catalogue_compounds' - constraints = [ - models.UniqueConstraint( - fields=[ - 'catalogue_smiles', - ], - name='uq_catalogue_compounds_smiles', - ), - models.CheckConstraint( - condition=Q(catalogue_hash__isnull=False) & Q(catalogue_hash__gt=''), - name='ck_catalogue_compounds_hash_nonempty', - ), - ] - - -class CataloguePrice(BaseModel): - id = models.BigAutoField(primary_key=True) - catalogue_compound = models.ForeignKey( - CatalogueCompound, - null=True, - on_delete=models.CASCADE, - db_column='catalogue_id', - ) - vendor = models.TextField(null=False, blank=True) - supplier = models.TextField(null=True, blank=True) - supplier_id = models.TextField(null=False, blank=True) - amount = models.FloatField(null=True, blank=True) - price = models.FloatField(null=True, blank=True) - currency = models.TextField(null=True, blank=True) - purity = models.FloatField(null=True, blank=True) - lead_time = models.IntegerField(null=True, blank=True) - - compounds = models.ManyToManyField( - Compound, - through='CataloguePriceCompoundJunction', - related_name='prices', - ) - - class Meta(BaseModel.Meta): - db_table = 'catalogue_prices' - constraints = [ - models.UniqueConstraint( - fields=[ - 'catalogue_compound', - 'vendor', - 'supplier', - 'supplier_id', - 'amount', - ], - name='uc_catalogue_price', - ) - ] - - -class CataloguePriceCompoundJunction(BaseModel): - ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') - catalogue_price = models.ForeignKey( - CataloguePrice, - on_delete=models.CASCADE, - db_column='catalogue_price_id', - ) - compound = models.ForeignKey( - Compound, - on_delete=models.CASCADE, - db_column='compound_id', - ) - - match_hash = models.TextField(null=False, blank=True) - - # Not needed, remove - # catalogue_inchikey = models.TextField(null=False, blank=True) - # supplier = models.TextField(null=True, blank=True) - # amount = models.FloatField(null=True, blank=True) - # price = models.FloatField(null=True, blank=True) - # lead_time = models.IntegerField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'compound_catalogue_map' - constraints = [ - models.CheckConstraint( - condition=Q(match_hash__isnull=False) & Q(match_hash__gt=''), - name='ck_compound_catalogue_map_match_hash_nonempty', - ) - ] - - -class Scaffold(BaseModel): - id = models.BigAutoField(primary_key=True) - # same comment as with inspiratons. original schema says SET_NULL - # but doesn't seem right - base_compound = models.ForeignKey( - Compound, - # on_delete=models.SET_NULL, - on_delete=models.CASCADE, - db_column='base_compound_id', - related_name='scaffold_bases', - ) - superstructure_compound = models.ForeignKey( - Compound, - # on_delete=models.SET_NULL, - on_delete=models.CASCADE, - db_column='superstructure_compound_id', - related_name='scaffold_superstructures', - ) - - class Meta(BaseModel.Meta): - db_table = 'scaffolds' - constraints = [ - models.UniqueConstraint( - fields=[ - 'base_compound', - 'superstructure_compound', - ], - name='uc_scaffold', - ) - ] - indexes = [ - models.Index( - fields=['base_compound'], name='idx_scaffold_base_compound_id' - ), - models.Index( - fields=['superstructure_compound'], - name='idx_scaffold_superstructure_compound_id', - ), - models.Index(fields=['created_on'], name='idx_scaffold_created'), - ] - - -class Route(BaseModel): - id = models.BigAutoField(primary_key=True) - product_compound = models.ForeignKey( - Compound, - on_delete=models.RESTRICT, - db_column='product_compound_id', - ) - - class Meta(BaseModel.Meta): - db_table = 'routes' - indexes = [ - models.Index( - fields=['product_compound'], name='idx_route_product_compound_id' - ), - models.Index(fields=['created_on'], name='idx_route_created'), - ] - - -class Component(BaseModel): - id = models.BigAutoField(primary_key=True) - route = models.ForeignKey( - Route, - on_delete=models.RESTRICT, - db_column='route_id', - ) - component_type = models.IntegerField(null=True, blank=True) - component_ref = models.IntegerField(null=True, blank=True) - component_amount = models.FloatField(null=True, blank=True) - - class Meta(BaseModel.Meta): - db_table = 'components' - constraints = [ - models.UniqueConstraint( - fields=[ - 'route', - 'component_ref', - 'component_type', - ], - name='uc_component', - ) - ] - indexes = [ - models.Index(fields=['route'], name='idx_component_route_id'), - models.Index(fields=['created_on'], name='idx_component_created'), - ] - - -# what follows is audit tables, indexes, materialised views (none), -# views, functions and triggers. I'm not sure I need them here, will create if do. - - -# these functions available in db -# CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol -# LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; - -# CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text -# LANGUAGE SQL AS $$ SELECT rdkit.mol_to_smiles(m); $$; - -# CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text -# LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; diff --git a/src/designdb/price.py b/src/designdb/price.py deleted file mode 100644 index 397a605..0000000 --- a/src/designdb/price.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Class for working with prices""" - -import mcol - -CURRENCIES = { - 'USD': '$', - 'EUR': '€', - 'GBP': '£', -} - - -class Price: - """Class to represent a certain amount of currency. Supported currencies: - - :: - - CURRENCIES = { - 'USD':'$', - 'EUR':'€', - 'GBP':'£', - } - - """ - - def __init__(self, amount: float | None, currency: str | None): - """Price initialisation""" - - if currency not in CURRENCIES: - assert currency is None, f'Unrecognised {currency=}' - assert not amount, f"Null Price can't have {amount=}" - amount = None - - if amount is not None: - amount = float(amount) - - self._amount = amount - self._currency = currency - - ### FACTORIES - - @classmethod - def null(cls) -> 'Price': - """Zero in any currency""" - self = cls.__new__(cls) - self.__init__(None, None) - return self - - @classmethod - def from_dict( - cls, - d: dict, - ) -> 'Price': - """Create a :class:`.Price` object from a dictionary: - - :: - - dict(amount: float, currency: str) - - :param d: dictionary in the above format: - - """ - self = cls.__new__(cls) - self.__init__(d['amount'], d['currency']) - return self - - ### PROPERTIES - - @property - def symbol(self) -> str: - """Currency symbol""" - return CURRENCIES[self.currency] - - @property - def currency(self) -> str: - """Currency string""" - return self._currency - - @property - def amount(self) -> float: - """Amount""" - return self._amountb - - @property - def is_null(self) -> bool: - """Is this :meth:`.Price.null` or zero?""" - return self.amount is None - - ### METHODS - - def get_dict(self) -> dict: - """Dictionary in the format: - - :: - - dict(amount: float, currency: str) - - """ - return dict(amount=self.amount, currency=self.currency) - - def copy(self) -> 'Price': - """Return a copy of this :class:`.Price`""" - return Price(amount=self.amount, currency=self.currency) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - if self.currency is None: - return 'Null Price' - - return f'{self.symbol}{self.amount:.2f} {self.currency}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __add__(self, other: 'Price') -> 'Price': - """Add two :class:`.Price` objects - - :param other: :class:`.Price` object - :returns: :class:`.Price` object - - """ - - if other is None: - return self - - if other.is_null: - return self - - if self.is_null: - return other - - if self.currency != other.currency: - raise NotImplementedError( - f'Adding two different currencies: {self.currency} != {other.currency}' - ) - return Price(self.amount + other.amount, self.currency) - - def __truediv__(self, other: 'Price | float | int') -> 'Price | float': - """Divide this :class:`.Price` by another object - - :param other: :class:`.Price` or float or int - :returns: :class:`.Price` object or float - - """ - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return self - return Price(amount=self.amount / other, currency=self.currency) - - elif isinstance(other, Price): - assert self.currency == other.currency - assert not other.is_null - return self.amount / other.amount - - raise TypeError(f'Division not supported between Price and {type(other)}') - - def __mul__(self, other: 'Price | float | int') -> 'Price | float': - """Multiply this :class:`.Price` by another object - - :param other: :class:`.Price` or float or int - :returns: :class:`.Price` object or float - - """ - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return self - return Price(amount=self.amount * other, currency=self.currency) - - raise TypeError(f'Multiplication not supported between Price and {type(other)}') - - def __eq__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return other == 0 - return self.amount == other - - if self.is_null and other.is_null: - return True - - if self.is_null and not other.is_null: - return False - - if not self.is_null and other.is_null: - return False - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount == other.amount - - def __lt__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return False - return self.amount > other - - if self.is_null and other.is_null: - return False - - if self.is_null and not other.is_null: - return True - - if not self.is_null and other.is_null: - return False - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount < other.amount - - def __gt__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return False - return self.amount < other - - if self.is_null and other.is_null: - return False - - if self.is_null and not other.is_null: - return False - - if not self.is_null and other.is_null: - return True - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount > other.amount - - def __hash__(self) -> int: - """Allow for Prices to be hashed for comparison""" - if self.is_null: - return hash('NULL') - return hash(f'{self.currency} {self.amount}') diff --git a/src/designdb/recipe.py b/src/designdb/recipe.py deleted file mode 100644 index 5150c10..0000000 --- a/src/designdb/recipe.py +++ /dev/null @@ -1,3074 +0,0 @@ -"""Classes for working with Recipes (reaction networks)""" - -import mcol -import mrich - -from designdb.models import Compound, Reaction -from designdb.sets.compound import IngredientSet -from designdb.sets.reaction import ReactionSet - - -class Recipe: - """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" - - def __init__( - self, - *, - products: 'IngredientSet | None' = None, - reactants: 'IngredientSet | None' = None, - intermediates: 'IngredientSet | None' = None, - reactions: 'ReactionSet | None' = None, - compounds: 'IngredientSet | None' = None, - ) -> None: - """Recipe initialisation""" - - if products is None: - products = IngredientSet() - - if reactants is None: - reactants = IngredientSet() - - if intermediates is None: - intermediates = IngredientSet() - - if compounds is None: - compounds = IngredientSet() - - if reactions is None: - reactions = ReactionSet() - - # check typing - assert isinstance(products, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(compounds, IngredientSet) - assert isinstance(reactions, ReactionSet) - - self._products = products - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._compounds = compounds - self._hash = None - - self._score = None - - # caches - self._product_compounds = None - self._poses = None - self._interactions = None - self._combined_compounds = None - - ### FACTORIES - - @classmethod - def from_reaction( - cls, - reaction, - amount=1, - *, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - quoted_only: bool = False, - supplier: None | str = None, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] = None, - reaction_reactant_cache: dict[int, bool] = None, - inner: bool = False, - get_ingredient_quotes: bool = True, - ) -> 'Recipe | list[Recipe]': - """Create a :class:`.Recipe` from a :class:`.Reaction` and its upstream dependencies - - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - :param inner: used to indicate that this is a recursive call (Default value = False) - :param get_ingredient_quotes: get quotes for ingredients in this recipe - - """ - - assert isinstance(reaction, Reaction) - - if debug: - mrich.debug( - f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' - ) - mrich.debug(f'{reaction.product.id=}') - mrich.debug(f'{reaction.reactants.ids=}') - - if permitted_reactions: - assert reaction in permitted_reactions - # raise NotImplementedError - - recipe = cls.__new__(cls) - recipe.__init__( - products=IngredientSet( - [ - reaction.product.as_ingredient( - amount=amount, get_quote=get_ingredient_quotes - ) - ], - ), - reactants=IngredientSet([], supplier=supplier), - intermediates=IngredientSet([]), - reactions=ReactionSet([reaction.id], sort=False), - ) - - recipes = [recipe] - - if quoted_only or supplier: - if debug: - mrich.debug(f'Checking reactant_availability: {reaction=}') - if reaction_checking_cache and reaction.id in reaction_checking_cache: - ok = reaction_checking_cache[reaction.id] - print('reaction_checking_cache used') - else: - ok = reaction.check_reactant_availability(supplier=supplier) - # print('cache not used') - if reaction_checking_cache is not None: - reaction_checking_cache[reaction.id] = ok - if not ok: - if unavailable_reaction == 'error': - mrich.error(f'Reactants not available for {reaction=}') - if pick_cheapest: - return None - else: - return [] - - def get_reactant_amount_pairs(reaction: 'Reaction') -> list[tuple[int, float]]: - """Get pairs of reactant ID and float amounts""" - if reaction_reactant_cache and reaction.id in reaction_reactant_cache: - print('reaction_reactant_cache used') - return reaction_reactant_cache[reaction.id] - else: - pairs = reaction.get_reactant_amount_pairs(compound_object=False) - if reaction_reactant_cache is not None: - reaction_reactant_cache[reaction.id] = pairs - return pairs - - if debug: - mrich.debug(f'get_reactant_amount_pairs({reaction.id})') - pairs = get_reactant_amount_pairs(reaction) - - for reactant, reactant_amount in pairs: - # reactant = db.get_compound(id=reactant) - reactant = Compound.objects.get(pk=reactant) - - if debug: - mrich.debug(f'{reactant.id=}, {reactant_amount=}') - - # scale amount - reactant_amount *= amount - reactant_amount /= reaction.product_yield - - inner_reactions = reactant.get_reactions( - none='quiet', permitted_reactions=permitted_reactions - ) - - if inner_reactions: - if debug: - if len(inner_reactions) == 1: - mrich.debug('Reactant has ONE inner reaction') - else: - mrich.warning(f'{reactant=} has MULTIPLE inner reactions') - - new_recipes = [] - - inner_recipes = [] - for reaction in inner_reactions: - reaction_recipes = Recipe.from_reaction( - reaction=reaction, - amount=reactant_amount, - debug=debug, - pick_cheapest=False, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - inner=True, - ) - inner_recipes += reaction_recipes - - for recipe in recipes: - for inner_recipe in inner_recipes: - combined_recipe = recipe.copy() - - combined_recipe.reactants += inner_recipe.reactants - combined_recipe.intermediates += inner_recipe.intermediates - combined_recipe.reactions += inner_recipe.reactions - combined_recipe.intermediates.add( - reactant.as_ingredient(reactant_amount, supplier=supplier) - ) - - new_recipes.append(combined_recipe) - - recipes = new_recipes - - else: - ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) - for recipe in recipes: - recipe.reactants.add(ingredient) - - # reverse ReactionSet's - if not inner: - for recipe in recipes: - recipe.reactions.reverse() - - if pick_cheapest: - if debug: - mrich.debug('Picking cheapest') - priced = [r for r in recipes if r.get_price(supplier=supplier)] - # priced = [r for r in recipes if r.price] - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return recipes - sorted_recipes = sorted( - priced, key=lambda r: r.get_price(supplier=supplier) - ) - - if debug: - for recipe in recipes: - mrich.debug(f'{recipe}, {recipe.price}') - - return sorted_recipes[0] - # return sorted(priced, key=lambda r: r.price)[0] - - return recipes - - @classmethod - def from_reactions( - cls, - reactions: 'ReactionSet', - amount: float = 1, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - final_products_only: bool = True, - return_products: bool = False, - supplier: str | None = None, - use_routes: bool = False, - debug: bool = False, - **kwargs, - ) -> 'Recipe | list[Recipe] | CompoundSet': - """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies - - :param reactions: reactions to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param final_products_only: don't get routes to intermediates (Default value = True) - :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) - - """ - - from .cset import CompoundSet - from .rset import ReactionSet - - assert isinstance(reactions, ReactionSet) - - if debug: - mrich.debug('Recipe.from_reactions()') - mrich.var('reactions', reactions) - mrich.var('amount', amount) - mrich.var('final_products_only', final_products_only) - mrich.var('permitted_reactions', permitted_reactions) - - # get all the products - products = reactions.products - - if debug: - mrich.var('products', products) - - # return products - - if final_products_only: - if debug: - mrich.var('products.str_ids', products.str_ids) - - # raise NotImplementedError - ids = reactions.db.execute( - f""" - SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound - WHERE reactant_compound IS NULL - AND compound_id IN {products.str_ids} - """ - ).fetchall() - - ids = [i for (i,) in ids] - - products = CompoundSet(db, ids) - if debug: - mrich.var('final products', products) - - # return ids - - if return_products: - return products - - recipe = Recipe.from_compounds( - compounds=products, - amount=amount, - permitted_reactions=reactions, - pick_cheapest=pick_cheapest, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_compounds( - cls, - compounds: 'CompoundSet', - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions=None, - quoted_only: bool = False, - supplier: None | str = None, - solve_combinations: bool = True, - pick_first: bool = False, - warn_multiple_solutions: bool = True, - pick_cheapest_inner_routes: bool = False, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] | None = None, - reaction_reactant_cache: dict[int, bool] | None = None, - use_routes: bool = False, - **kwargs, - ): - """Create recipe(s) to synthesis products in the :class:`.CompoundSet` - - :param compounds: set of compounds to find routes for - :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) - :param pick_first: return the first solution without comparison (Default value = False) - :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) - :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - - """ - - from .cset import CompoundSet - - assert isinstance(compounds, CompoundSet) - - db = compounds.db - - n_comps = len(compounds) - - assert n_comps - - if not hasattr(amount, '__iter__'): - amount = [amount] * n_comps - - if use_routes: - route_lookup = db.get_product_id_routes_dict() - - if supplier: - raise NotImplementedError - # supplier_lookup = db.get_compound_id_suppliers_dict() - - options = [] - - ok = 0 - mrich.var('#compounds', n_comps) - - for comp, a in mrich.track( - zip(compounds, amount, strict=False), - prefix='Solving individual compound recipes...', - total=n_comps, - ): - comp_options = [] - - if use_routes: - if comp.id not in route_lookup: - mrich.error('No routes to', comp) - continue - - comp_options = [] - for route_id in route_lookup[comp.id]: - route = db.get_route(id=route_id) - comp_options.append(route) - - else: - for reaction in comp.reactions: - if permitted_reactions and reaction not in permitted_reactions: - continue - - sol = Recipe.from_reaction( - reaction=reaction, - amount=a, - pick_cheapest=pick_cheapest_inner_routes, - debug=debug, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - **kwargs, - ) - - if pick_cheapest_inner_routes: - if sol: - comp_options.append(sol) - else: - assert isinstance(sol, list) - comp_options += sol - - if not comp_options: - mrich.error( - f'No solutions for compound={comp} ({comp.reactions.ids=})' - ) - continue - - if pick_cheapest and len(comp_options) > 1: - if warn_multiple_solutions: - mrich.warning( - 'Multiple solutions for', comp, '(', len(comp_options), ')' - ) - if debug: - mrich.debug('Picking cheapest...') - priced = [r for r in comp_options if r.price] - comp_options = sorted(priced, key=lambda r: r.price)[:1] - - if warn_multiple_solutions and len(comp_options) > 1: - mrich.warning(f'Multiple solutions for compound={comp}') - if debug: - mrich.debug(f'{comp_options=}') - else: - if n_comps <= 200: - mrich.success(f'Found solution for compound={comp}') - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', n_comps) - - options.append(comp_options) - - assert all(options) - - from itertools import product - - mrich.print('Solving recipe combinations...') - combinations = list(product(*options)) - - if not solve_combinations: - return combinations - - solutions = [] - - if n_comps > 1: - generator = mrich.track( - combinations, prefix='Combining recipes...', total=len(combinations) - ) - else: - generator = combinations - - ok = 0 - for combo in generator: - if debug: - mrich.debug(f'Combination of {len(combo)} recipes') - - if not combo: - continue - - solution = combo[0] - - for i, recipe in enumerate(combo[1:]): - if debug: - mrich.debug(i + 1) - solution += recipe - - solutions.append(solution) - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', len(combinations)) - - if not solutions: - mrich.error('No solutions') - return None - - if pick_first: - return solutions[0] - - if pick_cheapest: - mrich.debug('Calculating prices...') - priced = [r for r in solutions if r.price] - mrich.print('Picking cheapest from', len(priced), 'options') - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return solutions - return sorted(priced, key=lambda r: r.price)[0] - - return solutions - - @classmethod - def from_reactants( - cls, - reactants: 'CompoundSet | IngredientSet', - amount: float = 1, - debug: bool = False, - return_products: bool = False, - supplier: str | None = None, - pick_cheapest: bool = False, - use_routes: bool = False, - **kwargs, - ) -> 'list[Recipe] | Recipe | CompoundSet': - """Find the maximal recipe from a given set of reactants - - :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored - :param amount: amount of each product needed (Default value = 1) - :param debug: increase verbosity (Default value = False) - :param return_products: return products instead of recipe (Default value = False) - :param kwargs: passed to :meth:`.Recipe.from_reactions` - - """ - - from .cset import IngredientSet - - if isinstance(reactants, IngredientSet): - reactant_ids = reactants.compound_ids - else: - reactant_ids = reactants.ids - - db = reactants.db - - all_reactants = set(reactant_ids) - - possible_reactions = [] - - # recursively search for possible reactions - for i in range(300): - if debug: - mrich.debug(i) - - # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) - reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) - - if not reaction_ids: - break - - if debug: - mrich.debug(f'Adding {len(reaction_ids)} reactions') - - possible_reactions += reaction_ids - - if debug: - mrich.var('reaction_ids', reaction_ids) - - product_ids = db.get_possible_reaction_product_ids( - reaction_ids=reaction_ids - ) - - if debug: - mrich.var('product_ids', product_ids) - - n_prev = len(all_reactants) - - all_reactants |= set(product_ids) - - if n_prev == len(all_reactants): - break - - else: - raise NotImplementedError('Maximum recursion depth exceeded') - - possible_reactions = list(set(possible_reactions)) - - if debug: - mrich.var('all possible reactions', possible_reactions) - - from .rset import ReactionSet - - rset = ReactionSet(db, possible_reactions, sort=False) - - recipe = cls.from_reactions( - rset, - amount=amount, - permitted_reactions=rset, - debug=debug, - return_products=return_products, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_json( - cls, - db: 'Database', - path: 'str | Path', - debug: bool = True, - allow_db_mismatch: bool = False, - clear_quotes: bool = False, - data: dict = None, - db_mismatch_warning: bool = True, - ): - """Load a serialised recipe from a JSON file - - :param db: database to link - :param path: path to JSON - :param debug: increase verbosity (Default value = True) - :param allow_db_mismatch: allow a database mismatch (Default value = False) - :param clear_quotes: ignore reactant quotes (Default value = False) - :param data: serialised data (Default value = None) - - """ - - # imports - import json - - from .cset import IngredientSet - from .rset import ReactionSet - - # load JSON - if not data: - if debug: - mrich.reading(path) - data = json.load(open(path)) - - # check metadata - if str(db.path.resolve()) != data['database']: - if db_mismatch_warning: - mrich.var('session', str(db.path.resolve())) - mrich.var('in file', data['database']) - if allow_db_mismatch: - if db_mismatch_warning: - mrich.warning('Database path mismatch') - else: - mrich.error( - 'Database path mismatch, set allow_db_mismatch=True to ignore' - ) - return None - - if debug: - mrich.print(f'Recipe was generated at: {data["timestamp"]}') - price = data['price'] - - # IngredientSets - products = IngredientSet.from_ingredient_dicts(db, data['products']) - intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) - reactants = IngredientSet.from_ingredient_dicts( - db, data['reactants'], supplier=data['reactant_supplier'] - ) - - if 'compounds' in data: - compounds = IngredientSet.from_ingredient_dicts( - db, data['compounds'], supplier=data['compound_supplier'] - ) - else: - compounds = IngredientSet(db) - - if clear_quotes: - reactants.df['quote_id'] = None - reactants.df['quoted_amount'] = None - compounds.df['quote_id'] = None - compounds.df['quoted_amount'] = None - - # ReactionSet - reactions = ReactionSet(db, data['reaction_ids'], sort=False) - - if debug: - mrich.var('reactants', reactants) - mrich.var('intermediates', intermediates) - mrich.var('products', products) - mrich.var('reactions', reactions) - mrich.var('compounds', compounds) - - # Create the object - self = cls.__new__(cls) - self.__init__( - products=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - compounds=compounds, - ) - - return self - - ### PROPERTIES - - @property - def products(self) -> 'IngredientSet': - """Product :class:`.IngredientSet`""" - return self._products - - @property - def compounds(self) -> 'IngredientSet': - """Product :class:`.IngredientSet`""" - return self._compounds - - @compounds.setter - def compounds(self, a: 'IngredientSet'): - """Set the compounds""" - self._compounds = a - self.__flag_modification() - - @property - def poses(self) -> 'PoseSet': - """Product poses""" - if self._poses is None: - self._poses = self.combined_compounds.poses - self._poses._name = f'poses of {self}' - return self._poses - - @property - def product_compounds(self) -> 'CompoundSet': - """Product compounds""" - if self._product_compounds is None: - self._product_compounds = self.products.compounds - self._product_compounds._name = f'products of {self}' - return self._product_compounds - - @property - def combined_compound_ids(self) -> set[int]: - """Combined :class:`.Compound` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" - return set(self.product_compounds.ids) | set(self.compounds.ids) - - @property - def combined_compounds(self) -> 'CompoundSet': - """Combined product and no-chem compounds""" - if self._combined_compounds is None: - from .cset import CompoundSet - - self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) - self._combined_compounds._name = f'combined compounds of {self}' - return self._combined_compounds - - @property - def interactions(self) -> 'InteractionSet': - """Product pose interactions""" - if self._interactions is None: - self._interactions = self.poses.interactions - return self._interactions - - @property - def product(self) -> 'Ingredient': - """Return single product (if there's only one)""" - assert len(self.products) == 1 - return self.products[0] - - @products.setter - def products(self, a: 'IngredientSet'): - """Set the products""" - self._products = a - self.__flag_modification() - - @property - def reactants(self): - """Reactant :class:`.IngredientSet`""" - return self._reactants - - @reactants.setter - def reactants(self, a: 'IngredientSet'): - """Set the reactants""" - self._reactants = a - self.__flag_modification() - - @property - def intermediates(self) -> 'IngredientSet': - """Intermediates :class:`.IngredientSet`""" - return self._intermediates - - @intermediates.setter - def intermediates(self, a: 'IngredientSet'): - """Set the intermediates""" - self._intermediates = a - # self.__flag_modification() - - @property - def reactions(self) -> 'ReactionSet': - """Intermediates :class:`.IngredientSet`""" - return self._reactions - - @reactions.setter - def reactions(self, a: 'ReactionSet'): - """Set the reactions""" - self._reactions = a - self.__flag_modification() - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.get_price() + self.compounds.get_price() - - @property - def num_products(self) -> int: - """Return the number of products""" - return len(self.products) - - @property - def num_compounds(self) -> int: - """Return the number of compounds""" - return len(self.combined_compound_ids) - - @property - def num_reactions(self): - """Return the number of reactions""" - return len(self.reactions) - - @property - def num_reaction_types(self): - """Return the number of reactions""" - return self.reactions.num_types - - @property - def num_reactants(self): - """Return the number of reactants""" - return len(self.reactants) - - @property - def num_intermediates(self): - """Return the number of intermediates""" - return len(self.intermediates) - - @property - def hash(self) -> str: - """Return the unique hash string""" - return self._hash - - @property - def score(self): - """Return the Recipe score""" - return self._score - - @property - def type(self) -> str: - """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" - - if self.empty: - return 'EMPTY' - - chem = bool(self.reactions) - nochem = bool(self.compounds) - - if chem and nochem: - return 'MIXED' - - if chem and not nochem: - return 'CHEM' - - if nochem and not chem: - return 'NOCHEM' - - @property - def empty(self) -> bool: - """Is this Recipe empty?""" - - if self.reactants: - return False - - if self.products: - return False - - if self.intermediates: - return False - - if self.reactions: - return False - - if self.compounds: - return False - - return True - - ### METHODS - - def get_price(self, supplier: str | None = None) -> 'Price': - """get the reactants price. See :meth:`.IngredientSet.get_price` - - :param supplier: restrict quotes to this supplier - - """ - return self.reactants.get_price(supplier=supplier) - - def draw(self, color_mapper=None, node_size=300, graph_only=False): - """draw graph of the reaction network - - :param color_mapper: (Default value = None) - :param node_size: (Default value = 300) - :param graph_only: (Default value = False) - - """ - - import networkx as nx - - color_mapper = color_mapper or {} - colors = {} - sizes = {} - - graph = nx.DiGraph() - - for reaction in self.reactions: - for reactant in reaction.reactants: - key = str(reactant) - ingredient = self.get_ingredient(id=reactant.id) - - graph.add_node( - key, - id=reactant.id, - smiles=reactant.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = self.get_ingredient(id=reactant.id).amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for product in self.products: - key = str(product.compound) - ingredient = self.get_ingredient(id=product.id) - - graph.add_node( - key, - id=product.id, - smiles=product.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = product.amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for reaction in self.reactions: - for reactant in reaction.reactants: - graph.add_edge( - str(reactant), - str(reaction.product), - id=reaction.id, - type=reaction.type, - product_yield=reaction.product_yield, - ) - - # rescale sizes - if not graph_only: - s_min = min(sizes.values()) - sizes = [s / s_min * node_size for s in sizes.values()] - - if graph_only: - return graph - else: - # return nx.draw(graph, pos, with_labels=True, font_weight='bold') - # pos = nx.spring_layout(graph, iterations=200, k=30) - pos = nx.spring_layout(graph) - return nx.draw( - graph, - pos=pos, - with_labels=True, - font_weight='bold', - node_color=list(colors.values()), - node_size=sizes, - ) - - def sankey(self, title: str | None = None) -> 'graph_objects.Figure': - """draw a plotly Sankey diagram - - :param title: (Default value = None) - - """ - - graph = self.draw(graph_only=True) - - import plotly.graph_objects as go - - nodes = {} - - for edge in graph.edges: - c = edge[0] - if c not in nodes: - nodes[c] = len(nodes) - - c = edge[1] - if c not in nodes: - nodes[c] = len(nodes) - - source = [nodes[a] for a, b in graph.edges] - target = [nodes[b] for a, b in graph.edges] - value = [1 for l in graph.edges] - - labels = list(nodes.keys()) - - hoverkeys = None - - customdata = [] - for key in nodes.keys(): - n = graph.nodes[key] - - if not hoverkeys: - hoverkeys = list(n.keys()) - - if not n: - mrich.error(f'problem w/ node {key=}') - compound_id = int(key[1:]) - customdata.append((compound_id, None)) - - else: - d = tuple(v if v is not None else 'N/A' for v in n.values()) - customdata.append(d) - - hoverkeys_edges = None - - customdata_edges = [] - - for s, t in graph.edges.keys(): - edge = graph.edges[s, t] - - if not hoverkeys_edges: - hoverkeys_edges = list(edge.keys()) - - if not n: - mrich.error(f'problem w/ edge {s=} {t=}') - customdata_edges.append((None, None, None)) - - else: - d = tuple(v if v is not None else 'N/A' for v in edge.values()) - customdata_edges.append(d) - - hoverlines = [] - for i, key in enumerate(hoverkeys): - hoverlines.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' - - hoverlines_edges = [] - for i, key in enumerate(hoverkeys_edges): - hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate_edges = ( - 'Reaction ' + '
'.join(hoverlines_edges) + '' - ) - - fig = go.Figure( - data=[ - go.Sankey( - node=dict( - # pad = 15, - # thickness = 20, - # line = dict(color = "black", width = 0.5), - label=labels, - # color = "blue" - customdata=customdata, - # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", - # "Long name C1", "Long name C2"], - # hovertemplate='Compound %{label}

smiles=%{customdata}', - hovertemplate=hovertemplate, - ), - link=dict( - customdata=customdata_edges, - hovertemplate=hovertemplate_edges, - source=source, - target=target, - value=value, - ), - ) - ] - ) - - if not title: - try: - title = f'Recipe
price={self.price}' - except AssertionError: - title = 'Recipe' - - fig.update_layout(title=title) - - return fig - - def summary(self, price: bool = True) -> None: - """Print a summary of this recipe - - :param price: print the price (Default value = True) - - """ - - mrich.h1(str(self)) - - if price: - price = self.price - if price: - mrich.var('\nprice', price.amount, price.currency) - # mrich.var('lead-time', self.lead_time, 'working days)) - - if self.products: - mrich.h3(f'{len(self.products)} products') - - if len(self.products) < 100: - for product in self.products: - mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') - - if self.intermediates: - mrich.h3(f'{len(self.intermediates)} intermediates') - - if len(self.intermediates) < 100: - for intermediate in self.intermediates: - mrich.var( - str(intermediate.compound), - f'{intermediate.amount:.2f}', - 'mg', - ) - - if self.reactants: - mrich.h3(f'{len(self.reactants)} reactants') - - if len(self.reactants) < 100: - for reactant in self.reactants: - mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') - - if self.reactions: - mrich.h3(f'{len(self.reactions)} reactions') - - if len(self.reactions) < 100: - for reaction in self.reactions: - mrich.var(str(reaction), reaction.reaction_str, reaction.type) - - if hasattr(self, '_compounds') and self.compounds: - mrich.h3(f'{len(self.compounds)} compounds') - - if len(self.compounds) < 100: - for compound in self.compounds: - mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') - - def get_ingredient(self, id) -> 'Ingredient': - """Get an ingredient by its compound ID - - :param id: compound ID - - """ - matches = [r for r in self.reactants if r.id == id] - if not matches: - matches = [r for r in self.intermediates if r.id == id] - if not matches: - matches = [r for r in self.products if r.id == id] - - assert len(matches) == 1 - return matches[0] - - def add_to_all_reactants(self, amount: float = 20) -> None: - """Increment all reactants by this amount - - :param amount: amount in ``mg`` (Default value = 20) - - """ - self.reactants.df['amount'] += amount - - def write_json( - self, - file: 'str | Path', - *, - extra: dict | None = None, - indent: str = '\t', - **kwargs, - ) -> None: - """Serialise this recipe object and write it to disk - - :param file: write to this path - :param extra: extra data to serialise - :param indent: indentation whitespace (Default value = '\t') - - """ - import json - from pathlib import Path - - file = Path(file).resolve() - - assert file.parent.exists(), f'Directory does not exist: {file.parent}' - - data = self.get_dict(serialise_price=True, **kwargs) - - if extra: - data.update(extra) - - mrich.writing(file) - json.dump(data, open(file, 'w'), indent=indent) - - def get_dict( - self, - *, - price: bool = True, - reactant_supplier: bool = True, - compound_supplier: bool = True, - database: bool = True, - timestamp: bool = True, - compound_ids_only: bool = False, - products: bool = True, - serialise_price: bool = False, - ): - """Serialise this recipe object - - Store - ===== - - - Path to database - - Timestamp - - Reactants (& their quotes, amounts) - - Intermediates (& their quotes) - - Products (& their poses/scores/fingerprints) - - Reactions - - Total Price - - Lead time - - :param price: include the price (Default value = True) - :param reactant_supplier: include the supplier (Default value = True) - :param database: include the database (Default value = True) - :param timestamp: add a timestamp (Default value = True) - :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) - :param products: include products (Default value = True) - :param serialise_price: serialise :class:`.Price` object (Default value = False) - - """ - - from datetime import datetime - - data = {} - - # Database - if database: - data['database'] = str(self.db.path.resolve()) - if timestamp: - data['timestamp'] = str(datetime.now()) - - # Recipe properties - try: - if price and serialise_price: - data['price'] = self.price.get_dict() - elif price: - data['price'] = self.price - except AssertionError as e: - mrich.warning(f'Could not get price: {e}') - data['price'] = None - - if reactant_supplier: - data['reactant_supplier'] = self.reactants.supplier - - if compound_supplier: - data['compound_supplier'] = self.compounds.supplier - - # IngredientSets - if compound_ids_only: - data['reactant_ids'] = self.reactants.compound_ids - data['intermediate_ids'] = self.intermediates.compound_ids - if products: - data['products_ids'] = self.products.compound_ids - data['compound_ids'] = self.compounds.compound_ids - - else: - data['reactants'] = self.reactants.df.to_dict(orient='list') - data['intermediates'] = self.intermediates.df.to_dict(orient='list') - if products: - data['products'] = self.products.df.to_dict(orient='list') - data['compounds'] = self.compounds.df.to_dict(orient='list') - - # ReactionSet - data['reaction_ids'] = self.reactions.ids - - return data - - def get_routes(self, return_ids: bool = False) -> 'RouteSet': - """Get routes""" - return self.products.get_routes( - permitted_reactions=self.reactions, return_ids=return_ids - ) - - def register_missing_routes( - self, missing_only: bool = True, supplier: str = 'Enamine' - ) -> None: - """Calculate missing routes to products of this Recipe""" - - return products.compounds.register_missing_routes( - missing_only=missing_only, supplier=supplier - ) - - if missing_only: - from .cset import CompoundSet - - records = self.db.select_where( - table='route', - key=f'route_product IN {products.str_ids}', - query='route_product', - multiple=True, - ) - existing = set(i for (i,) in records) - missing = set(products.ids) - existing - products = CompoundSet(self.db, missing) - - mrich.var('#products', len(products)) - - for i, c in mrich.track(enumerate(products), total=len(products)): - try: - reactions = c.reactions - except Exception as e: - mrich.error(f"Error getting {c}'s reactions", e) - continue - - for reaction in reactions: - try: - recipes = reaction.get_recipes(supplier=supplier) - except Exception as e: - mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) - continue - - for recipe in recipes: - route = self.db.register_route(recipe=recipe) - - mrich.print(f'registered {route=}') - - self.db.prune_duplicate_routes() - - def write_CAR_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'DataFrame | None': - """Prepares CSVs for use with CAR. - - .. attention:: - - This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: file to write to - :param return_df: return the dataframe (Default value = False) - - """ - - from pathlib import Path - - from pandas import DataFrame - - # solve each product's reaction - - file = str(Path(file).resolve()) - - rows = [] - - routes = self.get_routes() - - for sub_recipe in routes: - product = sub_recipe.product - - row = { - 'target-names': str(product.compound), - 'no-steps': 0, - 'concentration-required-mM': None, - 'amount-required-uL': None, - 'batch-tag': None, - } - - for i, reaction in enumerate(sub_recipe.reactions): - i = i + 1 - - row['no-steps'] += 1 - - match len(reaction.reactants): - case 1: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = None - case 2: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = reaction.reactants[1].smiles - case _: - # mrich.warning(f"More than two reactants for {reaction=}") - for j, r in enumerate(reaction.reactants): - row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles - - row[f'reaction-product-smiles-{i}'] = reaction.product.smiles - row[f'reaction-name-{i}'] = reaction.type - row[f'reaction-recipe-{i}'] = None - row[f'reaction-groupby-column-{i}'] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - if len(df[df.duplicated()]): - mrich.warning('Removing duplicates from CAR DataFrame') - df = df.drop_duplicates() - - df = df.convert_dtypes() - - for n_steps in set(df['no-steps']): - subset = df[df['no-steps'] == n_steps] - this_file = file.replace('.csv', f'_{n_steps}steps.csv') - mrich.writing(this_file) - subset.to_csv(this_file, index=False) - - mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_reactant_csv( - self, - file: 'str | Path', - reaction_type_counts: bool = True, - return_df: bool = False, - ) -> 'DataFrame | None': - """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use - - Reactant - ======== - - - ID - - SMILES - - Inchikey - - Quote - ===== - - - Supplier - - Catalogue - - Entry - - Lead-time - - Quoted amount - - Quote currency - - Quote price - - Quote purity - - Downstream - ========== - - - num_reaction_dependencies - - num_product_dependencies - - reaction_dependencies - - product_dependencies - - """ - # - remove_with - - # from rich import print - - data = [] - - ### Get lookup data - - route_ids = self.get_routes(return_ids=True) - - sql = f""" - SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} - """ - product_lookup = {} - for reactant_id, product_id in self.db.execute(sql): - product_lookup.setdefault(reactant_id, set()) - product_lookup[reactant_id].add(product_id) - - sql = f""" - WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - ), - - reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id - WHERE component_type = 1 - AND component_ref IN {self.reactions.str_ids} - ) - - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id - """ - reaction_lookup = {} - for reactant_id, reaction_id, reaction_type in self.db.execute(sql): - reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) - reaction_lookup[reactant_id]['ids'].add(reaction_id) - reaction_lookup[reactant_id]['types'].add(reaction_type) - reaction_lookup[reactant_id].setdefault('counts', {}) - reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) - reaction_lookup[reactant_id]['counts'][reaction_type] += 1 - - smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) - - inchikey_lookup = self.db.get_compound_id_inchikey_dict( - self.reactants.compounds - ) - - ### Reactant Dataframe - - df = self.reactants.df - - df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) - df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) - df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) - - ### Quote DataFrame - - qdf = self.db.get_quote_df(self.reactants.quote_ids) - - qdf = qdf.rename( - columns={ - 'id': 'quote_id', - 'smiles': 'quoted_smiles', - 'purity': 'quoted_purity', - 'date': 'quote_date', - 'lead_time': 'quote_lead_time_days', - 'price': 'quote_price', - 'currency': 'quote_currency', - 'catalogue': 'quote_catalogue', - 'supplier': 'quote_supplier', - 'entry': 'quote_entry', - 'amount': 'quoted_amount_mg', - } - ) - qdf = qdf.drop(columns=['compound']) - - ### Downstream info - - try: - df['downstream_product_ids'] = df['compound_id'].apply( - lambda x: product_lookup.get(x, set()) - ) - - df['downstream_reaction_ids'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['ids'] - ) - df['downstream_reaction_types'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['types'] - ) - except KeyError as e: - mrich.error(f'Reactant C{e} is missing downstream reaction') - mrich.error( - 'Are all routes enumerated? Try running calculate_missing_routes()' - ) - return None - - df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) - df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) - df['num_downstream_products'] = df['downstream_product_ids'].apply(len) - - ### Join and reformat - - df = df.merge(qdf, on='quote_id', how='left') - - df = df.rename( - columns={ - 'amount': 'required_amount_mg', - } - ) - - cols = [ - 'compound_id', - 'smiles', - 'inchikey', - 'required_amount_mg', - 'quoted_amount_mg', - 'quote_id', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_price', - 'quote_currency', - 'quote_lead_time_days', - 'quoted_purity', - 'quoted_smiles', - 'quote_date', - 'num_downstream_products', - 'num_downstream_reaction_types', - 'num_downstream_reactions', - ] - - if reaction_type_counts: - for i, row in df.iterrows(): - counts = reaction_lookup[row['compound_id']]['counts'] - - for reaction_type, count in counts.items(): - key = f'num_downstream ({reaction_type})' - df.loc[i, key] = count - if key not in cols: - cols.append(key) - - cols += [ - 'downstream_product_ids', - 'downstream_reaction_types', - 'downstream_reaction_ids', - ] - - df = df[[c for c in cols if c in df.columns]] - - ### Add estimated quotes - - unquoted = df[df['quote_id'].isna()] - - if len(unquoted): - for i, row in unquoted.iterrows(): - compound = self.db.get_compound(id=row['compound_id']) - ingredient = compound.as_ingredient( - amount=row['required_amount_mg'], get_quote=False - ) - - quote = ingredient.quote - - df.loc[i, 'quoted_amount_mg'] = quote.amount - df.loc[i, 'quote_supplier'] = quote.supplier - df.loc[i, 'quote_catalogue'] = quote.catalogue - df.loc[i, 'quote_entry'] = quote.entry - df.loc[i, 'quote_price'] = quote.price.amount - df.loc[i, 'quote_currency'] = quote.price.currency - df.loc[i, 'quote_lead_time_days'] = quote.lead_time - df.loc[i, 'quoted_purity'] = quote.purity - df.loc[i, 'quoted_smiles'] = quote.smiles - df.loc[i, 'quote_date'] = quote.date - - ### N.B. scaffold series no longer output - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_product_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'pd.DataFrame | None': - """Detailed CSV output including product information for selection and synthesis""" - - from pandas import DataFrame - - # from rich import print - from .pset import PoseSet - from .rset import ReactionSet - - data = [] - - routes = self.get_routes() - - pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - for product in mrich.track( - self.products, prefix='Constructing product DataFrame' - ): - d = dict( - hippo_id=product.compound_id, - smiles=product.smiles, - inchikey=product.inchikey, - required_amount_mg=product.amount, - ) - - upstream_routes = [] - upstream_reactions = [] - - for route in routes: - if product in route.products: - upstream_routes.append(route) - - for reaction in route.reactions: - upstream_reactions.append(reaction) - - upstream_reactions = ReactionSet( - self.db, set(reaction.id for reaction in upstream_reactions) - ) - - if not upstream_routes: - mrich.error('No upstream routes for', product) - continue - - if not upstream_reactions: - mrich.error('No upstream reactions for', product) - continue - - def get_scaffold_series() -> tuple[list[int], bool]: - """Get scaffold series value""" - - if scaffolds := product.scaffolds: - return scaffolds.ids, False - - else: - return [product.id], True - - poses = pose_map.get(product.id, set()) - - d['num_poses'] = len(poses) - d['poses'] = poses - d['tags'] = product.tags - d['num_routes'] = len(upstream_routes) - d['num_reaction_steps'] = set( - len(route.reactions) for route in upstream_routes - ) - d['reaction_dependencies'] = upstream_reactions.ids - d['reactant_dependencies'] = set( - sum([route.reactants.ids for route in upstream_routes], []) - ) - d['route_ids'] = [route.id for route in upstream_routes] - d['chemistry_types'] = ', '.join(upstream_reactions.types) - series, is_scaffold = get_scaffold_series() - d['is_scaffold'] = is_scaffold - d['scaffold_series'] = series - - inspirations = inspiration_map.get(product.id, None) - - if not inspirations and not is_scaffold: - scaffold = product.scaffolds[0] - inspirations = inspiration_map.get(scaffold.id, None) - - if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: - inspirations = scaffold.metadata['inspiration_pose_ids'] - - if ( - not inspirations - and is_scaffold - and 'inspiration_pose_ids' in product.metadata - ): - inspirations = product.metadata['inspiration_pose_ids'] - - if inspirations: - inspirations = PoseSet(self.db, inspirations) - d['inspirations'] = ', '.join(n for n in inspirations.names) - else: - d['inspirations'] = '' - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_chemistry_csv( - self, file: 'str | Path', return_df: bool = True - ) -> 'pd.DataFrame | None': - """Detailed CSV output synthetis information for chemistry types in this set""" - - from pandas import DataFrame - - from .cset import CompoundSet - - data = [] - - # get compounds - - scaffolds = CompoundSet(self.db) - - for product in self.products: - if scaffolds := product.scaffolds: - scaffolds += scaffolds - else: - scaffolds.add(product.compound) - - routes = self.get_routes() - - route_types = {} - - for compound in scaffolds: - elabs = ( - self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') - or [] - ) - - d = dict( - scaffold_id=compound.id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elabs), - is_scaffold=True, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.warning(f'No routes to scaffold={compound}') - continue - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - product = reaction.product - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = product.smiles - d[f'{key}_product_id'] = product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - missing_scaffolds = {} - - for compound in self.products.compounds: - if compound in scaffolds: - continue - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - scaffolds = compound.scaffolds - - for scaffold in scaffolds: - if scaffold.id not in route_types: - group = missing_scaffolds.setdefault(scaffold.id, []) - group.append(compound.id) - continue - - else: - for route in upstream_routes: - chem_types = tuple([r.type for r in route.reactions]) - - if chem_types not in route_types[base.id]: - mrich.success(scaffold) - mrich.success(chem_types) - raise ValueError( - 'Scaffold has route not present in dataframe' - ) - - for scaffold_id, elab_ids in missing_scaffolds.items(): - compound = self.db.get_compound(id=sorted(elab_ids)[0]) - - d = dict( - scaffold_id=scaffold_id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elab_ids), - is_scaffold=False, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.error(f'No routes to elab {compound}') - raise ValueError(f'No routes to elab {compound}') - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - product = reaction.product - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = product.smiles - d[f'{key}_product_id'] = product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def to_syndirella( - self, - out_key: 'str | Path', - poses: 'PoseSet', - *, - separate: bool = False, - ) -> 'DataFrame': - """Generate inputs for running syndirella elaboration""" - - import shutil - from pathlib import Path - - out_key = Path('.') / out_key - out_dir = out_key.parent - out_key = out_key.name - - mrich.var('out_key', out_key) - mrich.var('out_dir', out_dir) - - if not out_dir.exists(): - mrich.writing(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - template_dir = out_dir / 'templates' - if not template_dir.exists(): - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) - - """ - - Need to create dataframe with columns: - - compound_id - - pose_id - - smiles - - reaction_name_step1 - - reactant_step1 - - reactant2_step1 - - product_step1 - ... - - hit1 - - hit2 - ... - - template - - compound_set - - """ - - pose_compounds = poses.compounds - assert set(self.products.compound_ids) == set(pose_compounds.ids), ( - 'supplied poses have different compounds to Recipe products' - ) - assert len(poses) == len(self.products), ( - 'some duplicate compounds in supplied poses' - ) - - df = poses.get_df( - inchikey=False, - alias=False, - name=False, - compound_id=True, - reference_id=True, - inspiration_aliases=True, - ) - - df = df.reset_index() - df = df.rename(columns={'id': 'pose_id'}) - df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') - df = df.set_index(['compound_id', 'pose_id']) - - ## CHECKS - - no_refs = df[df['reference_id'].isna()] - - if len(no_refs): - mrich.error(len(no_refs), 'poses without reference!') - ids = set(no_refs.index.get_level_values('pose_id')) - mrich.print(ids) - - no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) - - if no_insps: - mrich.error(len(no_insps), 'poses without inspirations!') - return None - - ## TEMPLATES - - references = poses.references - ref_lookup = self.db.get_pose_id_alias_dict(references) - df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) - - for ref_pose in references: - assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' - - template = template_dir / ref_pose.apo_path.name - - if not template.exists(): - mrich.writing(template) - shutil.copy(ref_pose.apo_path, template) - - ## INSPIRATIONS - - for i, row in df.iterrows(): - for j, alias in enumerate(row['inspiration_aliases']): - df.loc[i, f'hit{j + 1}'] = alias - - inspirations = poses.inspirations - - sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' - - inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col='name', - ) - - ## ADD ROUTE INFO - - routes = self.get_routes() - - for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): - product = sub_recipe.product - - product_id = product.compound_id - - matches = df.xs(product_id, level='compound_id') - - if len(matches) > 1: - mrich.warning('Multiple rows for compound', product_id) - - for i, row in matches.iterrows(): - key = (product_id, i) - - for j, reaction in enumerate(sub_recipe.reactions): - j = j + 1 - - match len(reaction.reactants): - case 1: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = None - case 2: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - case 3: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ - 2 - ].smiles - case _: - raise NotImplementedError('Too many reactants') - - df.loc[key, f'product_step{j}'] = reaction.product.smiles - df.loc[key, f'reaction_name_step{j}'] = reaction.type - - break - - ## REMOVE UNECESSARY COLS - - df = df.drop(columns=['reference_id', 'inspiration_aliases']) - - ## REORDER COLUMNS - - cols = [ - 'smiles', - 'reaction_name_step1', - 'reactant_step1', - 'reactant2_step1', - 'reactant3_step1', - 'product_step11', - 'hit1', - 'hit2', - 'hit3', - 'hit4', - 'hit5', - 'hit6', - 'hit7', - 'hit8', - 'hit9', - 'template', - 'compound_set', - ] - - if not any([c not in cols for c in df.columns]): - df = df[[c for c in cols if c in df.columns]] - - if not separate: - out_path = out_dir / f'{out_key}_syndirella_input.csv' - mrich.writing(out_path) - df.to_csv(out_path) - return df - - for idx, row in df.iterrows(): - out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' - mrich.writing(out_path) - single_df = row.to_frame().T - single_df = single_df.dropna(axis=1, how='all') - single_df.to_csv(out_path, index=False) - - return df - - def copy(self) -> 'Recipe': - """Copy this recipe""" - - if hasattr(self, 'compounds'): - compounds = self.compounds.copy() - else: - compounds = None - - return Recipe( - self.db, - products=self.products.copy(), - reactants=self.reactants.copy(), - intermediates=self.intermediates.copy(), - reactions=self.reactions.copy(), - compounds=compounds, - # supplier=self.supplier - ) - - def __flag_modification(self) -> None: - """Flag this recipe as modified""" - self._product_interactions = None - self._score = None - self._product_compounds = None - self._product_poses = None - - def check_integrity(self, debug: bool = False) -> bool: - """Verify integrity of this recipe""" - - # no duplicate ingredients - - if debug: - mrich.debug('Checking integrity:', self) - mrich.debug('Checking for duplicate compounds') - - if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): - mrich.error("Reactant compound ID's are not unique") - return False - if len(self.intermediates.compound_ids) != len( - set(self.intermediates.compound_ids) - ): - mrich.error("Intermediate compound ID's are not unique") - return False - if len(self.products.compound_ids) != len(set(self.products.compound_ids)): - mrich.error("Product compound ID's are not unique") - return False - - # all references should exist - - if debug: - mrich.debug('Checking for missing references') - - if self.db.count_where( - table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' - ) < len(self.reactions): - mrich.error('Not all Reactions in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' - ) < len(self.products): - mrich.error('Not all product Compounds in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' - ) < len(self.reactants): - mrich.error('Not all reactant Compounds in Database') - return False - - if self.db.count_where( - table='compound', - key=f'compound_id IN {self.intermediates.compounds.str_ids}', - ) < len(self.intermediates): - mrich.error('Not all intermediate Compounds in Database') - return False - - reaction_intermediates = self.reactions.intermediates - reaction_products = self.reactions.products - reaction_reactants = self.reactions.reactants - - if debug: - mrich.debug('Checking for missing reactions') - - # all products should have a reaction - for product in self.products: - if product not in reaction_products: - mrich.error(f'Product: {product} does not have associated reaction') - return False - - # intermediates - for intermediate in self.intermediates: - if intermediate not in reaction_intermediates: - mrich.error( - f'Intermediate: {intermediate} is not in self.reactions.intermediates' - ) - return False - - # reactants - for reactant in self.reactants: - if reactant not in reaction_reactants: - mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') - return False - - # all reactions should have enough reactant - - if debug: - mrich.debug('Checking reactant quantities') - - for reaction in self.reactions: - product_ingredient = self.products(compound_id=reaction.product_id) - - if product_ingredient is None: - product_ingredient = self.intermediates(compound_id=reaction.product_id) - - if debug and reaction.product_yield < 1.0: - mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') - - for reactant in reaction.reactants: - reactant_ingredient = self.intermediates(compound_id=reactant.id) - - if reactant_ingredient is None: - reactant_ingredient = self.reactants(compound_id=reactant.id) - - required_amount = product_ingredient.amount / reaction.product_yield - - if reactant_ingredient.amount < required_amount: - mrich.error( - f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' - ) - return False - - if debug: - mrich.success(self, 'OK') - - return True - - def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): - """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" - self.compounds.add(ingredient) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.score: - s = f'(score={self.score:.3f})' - else: - s = '' - - if self.hash: - return f'Recipe_{self.hash}{s}' - - return f'Recipe{s}' - - def __longstr(self) -> str: - """Unformatted string representation""" - - if self.empty: - return 'Empty Recipe()' - - if self.reactions: - if self.intermediates: - s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' - else: - s = f'{self.reactants} --> {self.products} via {self.reactions}' - - if self.score: - s += f', score={self.score:.3f}' - - if self.hash: - return f'Recipe_{self.hash}({s})' - - return f'Recipe({s})' - - else: - s = f'{self.compounds}' - - if self.hash: - return f'Recipe_{self.hash}({s})' - - return f'Recipe(#compounds={self.num_compounds} [no-chem])' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self.__longstr()}' - - def __add__(self, other: 'Recipe'): - """Add another :class:`.Recipe` to this one""" - result = self.copy() - result.reactants += other.reactants - result.intermediates += other.intermediates - result.reactions += other.reactions - result.products += other.products - if hasattr(other, 'compounds'): - result.compounds += other.compounds - return result - - -class Route(Recipe): - """A recipe with a single product, that is stored in the database""" - - def __init__( - self, - db, - *, - route_id: int, - product: 'IngredientSet', - reactants: 'IngredientSet', - intermediates: 'IngredientSet', - reactions: 'ReactionSet', - ) -> None: - """Route initialisation""" - - from .cset import IngredientSet - from .rset import ReactionSet - - # check typing - assert isinstance(product, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(reactions, ReactionSet) - - assert len(product) == 1 - assert isinstance(route_id, int) - assert route_id - - self._id = route_id - self._products = product - self._product_id = product.ids[0] - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._db = db - - ### FACTORIES - - @classmethod - def from_json( - cls, db: 'Database', path: 'str | Path', data: dict = None - ) -> 'Route': - """Load a serialised route from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - import json - - from .cset import IngredientSet - from .rset import ReactionSet - - if data is None: - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._db = db - self._id = data['id'] - - self._product_id = data['product_id'] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id], db=db - ) # IngredientSet - - self._reactants = IngredientSet.from_json( - db=db, - path=None, - data=data['reactants']['data'], - supplier=data['reactants']['supplier'], - ) - self._intermediates = IngredientSet.from_json( - db=db, - path=None, - data=data['intermediates']['data'], - supplier=data['intermediates']['supplier'], - ) - self._reactions = ReactionSet( - db=db, indices=data['reactions']['indices'] - ) # ReactionSet - - return self - - ### PROPERTIES - - @property - def product(self) -> 'Ingredient': - """Product ingredient""" - return self._products[0] - - @property - def product_compound(self) -> 'Compound': - """Product compound""" - return self.product.compound - - @property - def id(self) -> int: - """Route ID""" - return self._id - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.price - - ### METHODS - - def get_dict(self) -> dict: - """Serialisable dictionary""" - data = {} - - data['id'] = self.id - data['product_id'] = self.product.id - data['reactants'] = self.reactants.get_dict() - data['intermediates'] = self.intermediates.get_dict() - data['reactions'] = self.reactions.get_dict() - - return data - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'Route #{self.id}: {self.product_compound}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class RouteSet: - """A set of Route objects""" - - def __init__(self, db: 'Database', routes: 'list[Route]') -> None: - """RouteSet initialisation""" - - data = {} - for route in routes: - # assert isinstance(route, Route) - data[route.id] = route - - self._data = data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - ### FACTORIES - - @classmethod - def from_ids(cls, db: 'Database', ids: list | set, progress: bool = True): - """Generate a routeset from a set of :class:`.Route` IDs - - :param db: database to link - :param ids: :class:`.Route` database IDs - :param progress: show progress bar - """ - - if progress: - ids = mrich.track(ids, prefix='Getting routes') - - routes = [db.get_route(id=route_id) for route_id in ids] - - self = cls.__new__(cls) - return RouteSet(db, routes) - - @classmethod - def from_product_ids(cls, db: 'Database', ids: list | set, progress: bool = True): - """Generate a routeset from a set of product :class:`.Compound` IDs - - :param db: database to link - :param ids: :class:`.Compound` database IDs - """ - - str_ids = str(tuple(ids)).replace(',)', ')') - - records = db.select_where( - table='route', - query='route_id', - key=f'route_product IN {str_ids}', - multiple=True, - ) - - route_ids = [i for (i,) in records] - - return cls.from_ids(db, route_ids, progress=progress) - - @classmethod - def from_json( - cls, db: 'Database', path: 'str | Path', data: dict = None - ) -> 'RouteSet': - """Load a serialised routeset from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - self = cls.__new__(cls) - - if data is None: - import json - - data = json.load(open(path)) - - new_data = {} - for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): - route_id = d['id'] - new_data[route_id] = Route.from_json(db=db, path=None, data=d) - - self._data = new_data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - return self - - ### PROPERTIES - - @property - def data(self) -> 'dict[int, Route]': - """Get internal data dictionary""" - return self._data - - @property - def db(self): - """Get associated database""" - return self._db - - @property - def routes(self) -> 'list[Route]': - """Get route objects""" - return self.data.values() - - @property - def product_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the products""" - ids = self.db.select_where( - table='route', - query='DISTINCT route_product', - key=f'route_id IN {self.str_ids}', - multiple=True, - ) - return [i for (i,) in ids] - - @property - def reactant_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the reactants""" - sql = f""" - SELECT DISTINCT component_ref FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON component_route = route_id - WHERE component_type = 2 - AND route_id IN {self.str_ids} - """ - - c = self.db.execute(sql) - return [i for (i,) in c] - - @property - def products(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route products""" - from .cset import CompoundSet - - return CompoundSet(self.db, self.product_ids) - - @property - def reactants(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route reactants""" - from .cset import CompoundSet - - return CompoundSet(self.db, self.reactant_ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Route` ID's""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def ids(self) -> list[int]: - """Return the :class:`.Route` IDs""" - return self.data.keys() - - @property - def cluster_map(self) -> dict[tuple, set]: - """Create a dictionary grouping routes by their scaffold/base cluster. - - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. - """ - - if self._cluster_map is None: - # get route mapping - pairs = self.db.select_where( - query='route_product, route_id', - key=f'route_id IN {self.str_ids}', - table='route', - multiple=True, - ) - - route_map = {route_product: route_id for route_product, route_id in pairs} - - # group compounds by cluster - compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) - - # create the map - self._cluster_map = {} - for cluster, compounds in compound_clusters.items(): - self._cluster_map[cluster] = [] - for compound in compounds: - route_id = route_map.get(compound, None) - if not route_id: - continue - self._cluster_map[cluster].append(route_id) - - if not self._cluster_map[cluster]: - del self._cluster_map[cluster] - - return self._cluster_map - - ### METHODS - - def copy(self) -> 'RouteSet': - """Copy this RouteSet""" - return RouteSet(self.db, self.data.values()) - - def set_db_pointers(self, db: 'Database') -> None: - """ - - :param db: - - """ - self._db = db - for route in self.data.values(): - route._db = db - - # def clear_db_pointers(self): - # """ """ - # self._db = None - # for route in self.data.values(): - # route._db = None - - def get_dict(self): - """Get serialisable dictionary""" - - data = dict(db=str(self.db), routes={}) - - # populate with routes - for route_id, route in self.data.items(): - data['routes'][route_id] = route.get_dict() - - return data - - def prune_unavailable(self, suppliers: list[str]): - """Remove routes that don't have all reactants available from given suppliers""" - - suppliers_str = str(tuple(suppliers)).replace(',)', ')') - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] - FROM {self.db.SQL_SCHEMA_PREFIX}quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - AND route_id IN {self.str_ids} - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for (i,) in route_ids] - - mrich.var('#routes before pruning', len(self)) - mrich.var('#routes after pruning', len(route_ids)) - - return RouteSet.from_ids(self.db, route_ids) - - def pop_id(self) -> int: - """Pop the last route from the set and return it's id""" - route_id, route = self.data.popitem() - return route_id - - def pop(self) -> 'Route': - """Pop the last route from the set and return it's object""" - route_id, route = self.data.popitem() - return route - - def balanced_pop( - self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> 'Route': - """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" - - if not self._data: - mrich.print('RouteSet depleted') - return None - - if not self.cluster_map: - # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") - return self.pop() - - # store the permitted clusters (or all clusters) list as property - - if self._permitted_clusters is None: - if permitted_clusters: - permitted_clusters = set( - (cluster,) if isinstance(cluster, int) else cluster - for cluster in permitted_clusters - ) - - self._permitted_clusters = [] - for cluster in permitted_clusters: - if cluster not in self.cluster_map: - mrich.warning( - cluster, 'in permitted_clusters but not cluster_map' - ) - else: - self._permitted_clusters.append(cluster) - - else: - self._permitted_clusters = list(self.cluster_map.keys()) - - if self._current_cluster is None: - self._current_cluster = self._permitted_clusters[0] - - ### pop a Route - - if debug: - mrich.debug(f'Would pop Route from {self._current_cluster=}') - - cluster = self._current_cluster - - # pop the last route id from the given cluster - - try: - route_id = self.cluster_map[cluster].pop() - except IndexError: - mrich.print(self._permitted_clusters) - mrich.print(self.cluster_map) - raise - except AttributeError: - mrich.print(cluster) - mrich.print(self.cluster_map) - raise - except KeyError: - mrich.print('cluster', cluster) - mrich.print('self._permitted_clusters', self._permitted_clusters) - mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) - raise - - # clean up empty clusters - - if debug: - mrich.debug('Popped route', route_id) - - # get the Route object - - if route_id in self._data: - route = self._data[route_id] - del self._data[route_id] - else: - # if debug: - mrich.debug('Route not present') - return self.balanced_pop() - - ### increment cluster - - # def increment_cluster(cluster): - n = len(self._permitted_clusters) - if n > 1: - for i, cluster in enumerate(self._permitted_clusters): - if cluster == self._current_cluster: - if i == n - 1: - self._current_cluster = self._permitted_clusters[0] - else: - self._current_cluster = self._permitted_clusters[i + 1] - break - else: - raise IndexError('This should never be reached...') - - # increment_cluster() - - if not self.cluster_map[cluster]: - del self.cluster_map[cluster] - if not self.cluster_map: - mrich.debug('RouteSet.cluster_map depleted') - self._permitted_clusters = [ - c for c in self._permitted_clusters if c != cluster - ] - # if debug: - mrich.debug('Depleted cluster', cluster) - - if not self._permitted_clusters: - mrich.debug('Depleted all permitted clusters', cluster) - mrich.debug('Removing cluster restriction', cluster) - self._permitted_clusters = list(self.cluster_map.keys()) - self._current_cluster = None - - if debug: - mrich.debug('#Routes in set', len(self._data)) - - return route - - def shuffle(self): - """Randomly shuffle the routes in this set""" - import random - - items = list(self.data.items()) - random.shuffle(items) - self._data = dict(items) - - ### shuffle the cluster map as well - - for cluster, routes in self.cluster_map.items(): - random.shuffle(routes) - self.cluster_map[cluster] = routes - - ### DUNDERS - - def __len__(self) -> int: - """Number of routes in this set""" - return len(self.data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Route × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __iter__(self): - """Iterate over routes in this set""" - return iter(self.data.values()) - - def __getitem__(self, key): - """Get a specific route in this set""" - return list(self.data.values())[key] - - -class RecipeSet: - """A set of recipes stored on disk""" - - def __init__( - self, db: 'Database', directory: 'str | Path', pattern: str = '*.json' - ): - """RecipeSet initialisation""" - - from json import JSONDecodeError - from pathlib import Path - - self._db = db - self._json_directory = Path(directory) - self._json_pattern = pattern - - self._json_paths = {} - for path in self._json_directory.glob(self._json_pattern): - self._json_paths[ - path.name.removeprefix('Recipe_').removesuffix('.json') - ] = path.resolve() - - mrich.reading(f'{directory}/{pattern}') - - self._recipes = {} - for key, path in mrich.track( - self._json_paths.items(), prefix='Loading recipes' - ): - try: - recipe = Recipe.from_json( - db=self.db, - path=path, - allow_db_mismatch=True, - debug=False, - db_mismatch_warning=False, - ) - except JSONDecodeError: - mrich.error(f'Bad JSON in {path}') - continue - recipe._hash = key - self._recipes[key] = recipe - - mrich.success('Loaded', len(self), 'Recipes') - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Associated database""" - return self._db - - ### METHODS - - def get_values( - self, - key: str, - progress: bool = False, - serialise_price: bool = False, - ): - """Get values of member recipes associated with attribute ``key`` - - :param key: attribute to query/calculate - :param progress: show a progress bar - :param serialise_price: serialise price objects - - """ - - values = [] - recipes = self._recipes.values() - - if progress: - recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') - - for recipe in recipes: - value = getattr(recipe, key) - if serialise_price and key == 'price': - value = value.amount - values.append(value) - - return values - - def get_df(self, **kwargs) -> 'pandas.DataFrame': - """Get dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`""" - - data = [] - - for recipe in self: - d = recipe.get_dict( - # reactant_supplier=False, - database=False, - timestamp=False, - **kwargs, - # timestamp=False, - ) - - data.append(d) - - from pandas import DataFrame - - return DataFrame(data) - - def items(self) -> 'list[tuple[str, Recipe]]': - """Get data dictionary items""" - return self._recipes.items() - - def keys(self) -> list[str]: - """Get data dictionary keys (recipe hashes)""" - return self._recipes.keys() - - ### DUNDERS - - def __len__(self) -> int: - """Number of recipes in this set""" - return len(self._recipes) - - def __getitem__( - self, - key: int | str, - ) -> Recipe: - """Get a :class:`.Recipe` in this set by it's index or key/hash""" - - match key: - case int(): - return list(self._recipes.values())[key] - - case str(): - return self._recipes[key] - - case _: - mrich.error( - f'Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}' - ) - - return None - - def __iter__(self): - """Iterate over recipes""" - return iter(self._recipes.values()) - - def __contains__(self, key: str): - """Is this hash contained in the set""" - assert isinstance(key, str) - return key in self._recipes - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Recipe × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/src/designdb/route.py b/src/designdb/route.py deleted file mode 100644 index 47c3753..0000000 --- a/src/designdb/route.py +++ /dev/null @@ -1,219 +0,0 @@ -import json - -import mcol -import mrich - -from designdb.models import Component, Reaction, Route - -from .recipe import Recipe - - -# name conflict with route model. Trying to get rid of this entirely -class RouteObj(Recipe): - """A recipe with a single product, that is stored in the database""" - - def __init__( - self, - *, - route_id: int, - product: 'IngredientSet', - reactants: 'IngredientSet', - intermediates: 'IngredientSet', - reactions: 'ReactionSet', - ) -> None: - """Route initialisation""" - - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet - - # check typing - assert isinstance(product, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(reactions, ReactionSet) - - assert len(product) == 1 - assert isinstance(route_id, int) - assert route_id - - self._id = route_id - self._products = product - self._product_id = product.ids[0] - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - - ### FACTORIES - - @classmethod - def from_json(cls, path: 'str | Path', data: dict = None) -> 'Route': - """Load a serialised route from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet - - if data is None: - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._id = data['id'] - - self._product_id = data['product_id'] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id] - ) # IngredientSet - - self._reactants = IngredientSet.from_json( - path=None, - data=data['reactants']['data'], - supplier=data['reactants']['supplier'], - ) - self._intermediates = IngredientSet.from_json( - path=None, - data=data['intermediates']['data'], - supplier=data['intermediates']['supplier'], - ) - self._reactions = ReactionSet( - Reaction.objects.filter(pk__in=data['reactions']['indices']) - ) # ReactionSet - - return self - - @classmethod - def get_route( - cls, - *, - id: int, - debug: bool = False, - ) -> 'RouteObj': - """Fetch a :class:`.Route` object stored in the :class:`.Database`. - - :param id: the ID of the :class:`.Route` to be retrieved - :param debug: increase verbosity for debugging, defaults to False - :returns: :class:`.Route` object - - """ - - # avoiding circular dependencies - from designdb.sets.compound import CompoundSet, IngredientSet - from designdb.sets.reaction import ReactionSet - - # multiples?? - route = Route.objects.get(pk=id) - - if debug: - mrich.var('product_id', route.product_compound) - - qs = Component.objects.filter(route=route).order_by('id') - - reaction_ids = [] - reactant_ids = [] - reactant_amounts = [] - intermediate_ids = [] - intermediate_amounts = [] - - # for ref, c_type, amount in triples: - for k in qs: - ref = k.component_ref - c_type = k.component_type - amount = k.component_amount - match c_type: - case 1: - reaction_ids.append(ref) - case 2: - reactant_ids.append(ref) - reactant_amounts.append(amount) - case 3: - intermediate_ids.append(ref) - intermediate_amounts.append(amount) - case _: - raise ValueError(f'Unknown component type {c_type}') - - if debug: - mrich.var('pairs', qs) - - products = CompoundSet([route.pk]) - reactants = CompoundSet(reactant_ids) - intermediates = CompoundSet(intermediate_ids) - - products = IngredientSet.from_compounds(compounds=products, amount=1) - reactants = IngredientSet.from_compounds( - compounds=reactants, amount=reactant_amounts - ) - intermediates = IngredientSet.from_compounds( - compounds=intermediates, amount=intermediate_amounts - ) - - reactions = ReactionSet(reaction_ids) - - recipe = RouteObj( - route_id=id, - product=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - ) - - if debug: - mrich.var('recipe', recipe) - - return recipe - - ### PROPERTIES - - @property - def product(self) -> 'Ingredient': - """Product ingredient""" - return self._products[0] - - @property - def product_compound(self) -> 'Compound': - """Product compound""" - return self.product.compound - - @property - def id(self) -> int: - """Route ID""" - return self._id - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.price - - ### METHODS - - def get_dict(self) -> dict: - """Serialisable dictionary""" - data = {} - - data['id'] = self.id - data['product_id'] = self.product.id - data['reactants'] = self.reactants.get_dict() - data['intermediates'] = self.intermediates.get_dict() - data['reactions'] = self.reactions.get_dict() - - return data - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'Route #{self.id}: {self.product_compound}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/src/designdb/services/__init__.py b/src/designdb/services/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/designdb/services/compound.py b/src/designdb/services/compound.py deleted file mode 100644 index 290c489..0000000 --- a/src/designdb/services/compound.py +++ /dev/null @@ -1,136 +0,0 @@ -import logging -import re - -import mrich -import rdkit -# from mypackage.services.compound import CompoundService -from rdkit import Chem - -# from rdkit.Chem import inchi -from designdb.models import Compound, CompoundTag -from designdb.utils import inchikey_from_smiles, sanitise_smiles - -# from .validation.compound import ValidationError, validate_compound_data - -SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' -) -SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' -) - - -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') -PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - -logger = logging.getLogger(__name__) - - -class CompoundBatchResult: - def __init__(self): - self.created = [] - self.errors = [] - - -class CompoundService: - @classmethod - def create( - cls, - *, - mol: Chem.rdchem.Mol, - smiles: str, - inchikey: str, - ) -> tuple[Compound, bool]: - - # TODO: new fields to consider, fingerprints and tautomer hashes - - # TODO and SQLITE_RELIC: inchikey is calculated by postgres in - # trigger. But I need to calculate it here as well, for - # queries. I feel like having two different calculation - # methods is not ideal. Are the versions guaranteed to be the - # same? And even if I do this in trigger, it's already here, - # why not just insert it? - - compound, created = Compound.objects.get_or_create( - compound_inchikey=inchikey, - # compound_smiles=smiles, - defaults={ - 'compound_mol': mol, - 'compound_smiles': smiles, - 'rdkit_version': rdkit.__version__, - 'inchi_version': Chem.inchi.GetInchiVersion(), - }, - ) - if not created and logger.level == logging.DEBUG: - mrich.warning( - f'Skipping compound {inchikey}, {smiles}, duplicate of {compound.pk}' - ) - - # there's a following block in the original code - # I don't understand what it is trying to achieve - # smiles and inchikey are both inserted, so compound existing - # but not reachable by inchikey should not happen. maybe this - # covers compounds loaded through different pathway? - - # compound_id = self.db.insert_compound( - # smiles=smiles, - # tags=tags, - # warn_duplicate=debug, - # commit=False, - # ) - - # if not compound_id: - # inchikey = inchikey_from_smiles(smiles) - # compound = self.compounds[inchikey] - - # if not compound: - # mrich.error( - # 'Compound exists in database but could not be found by inchikey' - # ) - # mrich.var('smiles', smiles) - # mrich.var('inchikey', inchikey) - # mrich.var('observation_shortname', name) - # raise Exception - - # else: - # count_compound_registered += 1 - # compound = self.compounds[compound_id] - - return compound, created - - @classmethod - def create_from_smiles( - cls, - smiles_list: list[str], - ) -> list[tuple[str, str]]: - result = [] - for smiles in smiles_list: - sane_smiles = sanitise_smiles( - smiles, verbosity=logger.level == logging.DEBUG - ) - mol = Chem.MolFromSmiles(sane_smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) - - cls.create( - mol=mol, - smiles=sane_smiles, - inchikey=sane_inchikey, - ) - - result.append((sane_inchikey, sane_smiles)) - - return result - - -class CompoundTagService: - @staticmethod - def tags_from_list(tag_list: list[str]): - assert tag_list is not None, '"None" passed as tag_list' - - CompoundTag.objects.bulk_create( - [CompoundTag(compound_tag_name=k.strip()) for k in tag_list if k.strip()], - ignore_conflicts=True, - ) - tags = CompoundTag.objects.filter(compound_tag_name__in=tag_list) - return tags diff --git a/src/designdb/services/ingestion.py b/src/designdb/services/ingestion.py deleted file mode 100644 index dfae0c7..0000000 --- a/src/designdb/services/ingestion.py +++ /dev/null @@ -1,1065 +0,0 @@ -import logging -import re -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import molparse as mp -import mrich -import pandas as pd -from numpy import isnan -from pandas import read_pickle -# from mypackage.services.compound import CompoundService -from rdkit import Chem -# from rdkit.Chem import inchi -from rdkit.Chem import PandasTools - -from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry -from designdb.ingredient import Ingredient -from designdb.models import Compound, Pose, Reactant, Reaction, Scaffold, Target -from designdb.recipe import Recipe -from designdb.route import RouteObj -from designdb.services.compound import CompoundService, CompoundTagService -from designdb.services.pose import PoseService, PoseTagService -from designdb.services.reaction import ReactionService -from designdb.services.route import RouteService -from designdb.services.score import ScoreService -from designdb.sets.compound import IngredientSet -from designdb.sets.reaction import ReactionSet -from designdb.utils import ( - SanitisationError, - inchikey_from_smiles, - remove_other_ligands, - sanitise_smiles, -) -from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode -from src.designdb.services.reaction import ReactionService - -# from .validation.compound import ValidationError, validate_compound_data - -SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' -) -SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' -) - - -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') -PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - -logger = logging.getLogger(__name__) - - -@dataclass -class FSRecord: - name: str - path: Path - sdf: Path - pdb: Path - - -def parse_sdf_pandas(sdf_path: Path) -> tuple[str, Chem.rdchem.Mol]: - df = PandasTools.LoadSDF( - str(sdf_path), molColName='ROMol', idName='ID', strictParsing=True - ) - # extract fields - longcode = df.ID[0] - mol = df.ROMol[0] - - return longcode, mol - - -def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: - logger.debug('Reading %s', pdb_path) - pdb = mp.parse(pdb_path, verbosity=0) - - # create the single ligand bound pdb - lig_residues = pdb.residues['LIG'] - if len(lig_residues) > 1 or any(r.contains_alternative_sites for r in lig_residues): - pdb = remove_other_ligands(pdb, residue, chain) - pdb.prune_alternative_sites('A', verbosity=0) - pose_path = str(pdb_path.resolve()).replace('.pdb', '_hippo.pdb') - # side effect: writes pdb into file - mp.write( - pose_path, pdb, shift_name=True, verbosity=logger.level == logging.DEBUG - ) - else: - pose_path = str(pdb_path.resolve()) - - return pose_path - - -def iter_fs_fragalysis(root_path, skip_records): - assert skip_records is not None, '"None" passed instead as skip_records' - - for dset_path in list(sorted(root_path.glob('*'))): - if dset_path.name in skip_records: - continue - - sdfs = [] - for sdf_path in dset_path.glob('*.sdf'): - sdf_name = sdf_path.name - - if ( - '_ligand' in sdf_name - ): # Quick fix, _ligand.sdf are exactly the same as .sdf - # in aligned_directory. - continue - - # fragalysis SDF - if SDF_FRAGALYSIS_PATTERN.match(sdf_name): - sdfs.append(sdf_path) - # fragalysis SDF from PDB id - elif PDBID_PATTERN.match(sdf_name): - sdfs.append(sdf_path) - else: - mrich.warning( - sdf_name, - "doesn't not follow neither Fragalysis nor PDB ID patterns", - ) - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) - continue - - pdbs = [ - p - for p in dset_path.glob('*.pdb') - if '_ligand' not in p.name - and '_apo' not in p.name - and '_hippo' not in p.name - ] - - if not len(pdbs) == 1: - mrich.error(dset_path.name, 'has invalid PDBs', pdbs) - continue - - record = FSRecord(name=dset_path.name, path=dset_path, sdf=sdfs[0], pdb=pdbs[0]) - - logger.debug('fs_frag record: %s', record) - - yield record - - -# unfinished, seems XCA data is not loaded now -def iter_fs_xca(root_path, skip): - for dset_path in sorted(root_path.glob('*[0-9][0-9][0-9][0-9]')): - if dset_path.name in skip: - continue - - sdfs = [] - - for sdf_path in sorted(dset_path.glob('*.sdf')): - sdf_name = sdf_path.name - - # TODO: switch between patterns?? - if SDF_XCAv2_PATTERN.match(sdf_name): - sdfs.append(sdf_path) - - if not sdfs: - mrich.error(dset_path.name, 'has no compatible SDFs', dset_path) - continue - - for i, sdf in enumerate(sdfs): - subname = dset_path.name + chr(ord('a') + i) - - pdb = dset_path / sdf.name.replace('_ligand.sdf', '.pdb') - - if not pdb.exists(): - mrich.error(dset_path.name, 'is missing PDB', pdb) - continue - - record = FSRecord(name=subname, path=dset_path, sdf=sdf, pdb=pdb) - - logger.debug('fs_frag record: %s', record) - - yield record - - -def read_df(path: Path): - if path.name.endswith('.sdf'): - df = PandasTools.LoadSDF(str(path.resolve())) - else: - df = read_pickle(path) - - return df - - -def validate_df( - df, - mol_col, - name_col, - inspiration_col, - inspirations, - reference_col, - reference, -): - - # TODO: these are part of input validation and should be removed. or - # at least rewritten - assert mol_col in df.columns, f'{mol_col=} not in {df.columns}' - - if name_col: - assert name_col in df.columns, f'{name_col=} not in {df.columns}' - - if inspiration_col and not inspirations: - assert inspiration_col in df.columns, f'{inspiration_col=} not in {df.columns}' - - if not reference and reference_col: - assert reference_col in df.columns, f'{reference_col=} not in {df.columns}' - - -def preprocess_df( - df, - *, - skip_equal, - skip_not_equal, - name_col: str, -) -> list[dict[str, Any]]: - - mrich.var('SDF entries (pre-filter)', len(df)) - - df = df[df['ID'] != 'ver_1.2'] - - for k, v in skip_equal.items(): - df = df[df[k] == v] - - for k, v in skip_not_equal.items(): - df = df[df[k] != v] - - mrich.var('SDF entries (post-filter)', len(df)) - - df[name_col] = df[name_col].str.strip() - - records = df.to_dict(orient='records') - - return records - - -def metadata_from_record( - record: dict[str, str], - ignore_fields: list[str | None], - convert_floats: bool, - field_warning=None, -) -> dict[str, str | float]: - - result = {} - skip = { - 'smiles', - 'inchikey', - 'compound_id', - 'target_id', - 'reference_id', - 'path', - 'exports', - } - - skip = skip.union(set([k for k in ignore_fields if k])) - - for key, value in record.items(): - if key in skip: - continue - - if isinstance(value, float) and isnan(value): - continue - - if convert_floats: - try: - value = float(value) - except TypeError: - pass - except ValueError: - pass - - if not (isinstance(value, str) or isinstance(value, float)): - if field_warning: - field_warning(mrich.warning(f'Skipping metadata from column={key}.')) - continue - - result[key] = value - - return result - - -@dataclass -class IngestionBatchResult: - attempts: int = 0 - compounds_created: int = 0 - poses_created: int = 0 - - -class IngestionService: - @classmethod - def ingest_filesystem( - cls, - *, - root_path: Path, - target: Target, - skip_records: list[str], - compound_tag_list: list[str], - metadata_file: Path | str, - ) -> IngestionBatchResult: - - # this is now strictly for loading frag data. cannot switch inner funcs easily - result = IngestionBatchResult() - compound_tags = CompoundTagService.tags_from_list(compound_tag_list) - pose_tagger = PoseTagService(metadata_file, other_tags=compound_tag_list) - - # if needs xca paths, need to pass or select function - for fs_record in iter_fs_fragalysis(root_path, skip_records): - longcode, mol = parse_sdf_pandas(fs_record.sdf) - logger.debug(fs_record.name, longcode) - result.attempts += 1 - - # TODO: this is the original procedure how it was - # calculated in hippo. I'm not touching it now, but this - # could use a rewrite, it converts smiles back to mol and - # then to inchikey - smiles = mp.rdkit.mol_to_smiles(mol) - sane_smiles = sanitise_smiles( - smiles, verbosity=logger.level == logging.DEBUG - ) - inchikey = inchikey_from_smiles(smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) - - # NB! different func if XCA data - try: - longcode_rec = parse_observation_longcode(longcode) - except UnsupportedFragalysisLongcodeError as exc: - # unhandled in original code. do what? - raise UnsupportedFragalysisLongcodeError from exc - - pose_path = parse_pdb_mp( - fs_record.pdb, longcode_rec.residue_number, longcode_rec.chain - ) - - compound, compound_created = CompoundService.create( - mol=mol, - smiles=sane_smiles, - inchikey=sane_inchikey, - ) - compound.tags.add(*compound_tags) - if compound_created: - result.compounds_created += 1 - - # pose_tags = PoseTagService.tags_from_list(pose_tag_set) - pose_tags, metadata = pose_tagger.tags_and_meta( - code=fs_record.name, - longcode=longcode, - ) - - metadata = {'fragalysis_longcode': longcode} - - pose, pose_created = PoseService.create( - compound=compound, - target=target, - mol=mol, - alias=fs_record.name, - path=pose_path, - metadata=metadata, - inchikey=inchikey, - smiles=smiles, - ) - if pose_created: - result.poses_created += 1 - - pose.tags.add(*pose_tags) - - # it seems fragalysis data is not expected to contain - # scores - - # in original code. what's that for? - # what I can think of is previously existing pose without mol - # if load_pose_mols: - # try: - # pose.mol - # except Exception as e: - # mrich.error('Could not load molecule', pose) - # mrich.error(e) - - return result - - @classmethod - def ingest_sdf( - cls, - *, - file_path: Path, - target, - compound_tag_list: list[str], - pose_tag_list: list[str], - mol_col: str, - name_col: str, - inspiration_col: str | None = None, - inspirations: list[int], - inspiration_map: dict[str, Pose], - reference: int | None, - reference_col: str, - skip_equal, - skip_not_equal, - convert_floats: bool = True, - field_warning=None, - ) -> IngestionBatchResult: - result = IngestionBatchResult() - - output_directory = Path(str(file_path.name).removesuffix('.sdf')) - output_directory.mkdir(parents=True, exist_ok=True) - - df = read_df(file_path) - validate_df( - df, - mol_col, - name_col, - inspiration_col, - inspirations, - reference_col, - reference, - ) - - compound_tags = CompoundTagService.tags_from_list(compound_tag_list) - pose_tags = PoseTagService.tags_from_list(pose_tag_list) - - # I need to know here one of two things: - # - which scores to create - # - which fields in sdf to ignore - # I mean, probs shouldn't cats smiles, etc as scores - - # it's probably the latter, isn't it? then I don't actually - # need to init scores at all, especially with central - # deisgndb, the scoring method likely exists - - # scorer = ScoreService(['energy_score', 'distance_score']) - scorer = ScoreService() - - records = preprocess_df( - df, - skip_equal=skip_equal, - skip_not_equal=skip_not_equal, - name_col=name_col, - ) - - for r in records: - result.attempts += 1 - - # TODO: this is the original procedure how it was - # calculated in hippo. I'm not touching it now, but this - # could use a rewrite, it converts smiles back to mol and - # then to inchikey - smiles = r.get('smiles', None) - if not smiles: - smiles = mp.rdkit.mol_to_smiles(r[mol_col]) - try: - sane_smiles = sanitise_smiles( - smiles, - sanitisation_failed='error', - radical='warning', - verbosity=logger.level == logging.DEBUG, - ) - except SanitisationError as e: - mrich.error(f'Could not sanitise {smiles=}') - mrich.error(str(e)) - continue - except AssertionError: - mrich.error(f'Could not sanitise {smiles=}') - continue - - inchikey = inchikey_from_smiles(smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) - - compound, compound_created = CompoundService.create( - mol=r[mol_col], - smiles=sane_smiles, - inchikey=sane_inchikey, - ) - compound.tags.add(*compound_tags) - if compound_created: - result.compounds_created += 1 - - pose_inspirations = PoseService.get_inspirations( - inspirations, - inspiration_map.get(r[name_col], []), - r.get(inspiration_col, []) if inspiration_col else None, - target=target, - ) - - if not reference and reference_col: - reference = PoseService.get_reference(r[reference_col], target) - - metadata = metadata_from_record( - r, - ignore_fields=[inspiration_col, name_col, mol_col], - convert_floats=convert_floats, - field_warning=field_warning, - ) - - pose_path = (output_directory / f'{r[name_col]}.fake.mol').resolve() - pose, pose_created = PoseService.create( - compound=compound, - target=target, - mol=r[mol_col], - alias=r[name_col], - path=pose_path, - metadata=metadata, - inchikey=inchikey, - smiles=smiles, - reference=reference, - ) - if pose_created: - result.poses_created += 1 - - pose.tags.add(*pose_tags) - pose.inspirations.add(*Pose.objects.filter(pk__in=pose_inspirations)) - scorer.add_scores_from_record(pose=pose, record=r) - - return result - - # how is that without target?? - @classmethod - def ingest_syndirella_routes( - cls, - pickle_path: str | Path, - CAR_only: bool = True, - pick_first: bool = True, - do_check_chemistry: bool = True, - register_routes: bool = True, - ): - # this is pretty much a copy from the original method now - df = read_pickle(pickle_path) - - for i, row in mrich.track(df.iterrows(), total=len(df)): - mrich.set_progress_field('i', i) - mrich.set_progress_field('n', len(df)) - - d = row.to_dict() - - # comp = self.compounds(smiles=d['smiles']) - - n_routes = 0 - for key in d: - if not key.startswith('route'): - continue - - if not key.endswith('_names'): - continue - - v = d[key] - - if isinstance(v, float) and pd.isna(v): - break - - n_routes += 1 - - if not n_routes: - # mrich.warning(comp, "#routes =", n_routes) - continue - - # routes = [] - for j in range(n_routes): - route_str = f'route{j}' - - route = d[route_str] - - if CAR_only and not d[route_str + '_CAR']: - continue - - reactions = ReactionSet() - reactants = IngredientSet() - intermediates = IngredientSet() - products = IngredientSet() - - # new models include Reaction, Reactant and - # Component. Should use these instead? - - try: - for k, reaction_struct in enumerate(route): - reaction_type = reaction_struct['name'] - - # product = self.compounds(smiles=reaction['productSmiles']) - # no error handling on sanitaiton, catchall at the end - # from original code - - smiles = reaction_struct['productSmiles'] - sane_smiles = sanitise_smiles( - smiles, - sanitisation_failed='error', - ) - - sane_inchikey = inchikey_from_smiles(sane_smiles) - product = Compound.objects.get(compound_inchikey=sane_inchikey) - - mrich.print(i, j, k, reaction_type, product) - - reaction, _ = Reaction.objects.get_or_create( - reaction_type=reaction_type, - product_compound=product, - ) - - rs = [] - print('reactant smiles', reaction_struct['reactantSmiles']) - for reactant_s in reaction_struct['reactantSmiles']: - reactant_comp, _ = Compound.objects.get_or_create( - compound_smiles=reactant_s, - ) - reactant, _ = Reactant.objects.get_or_create( - compound=reactant_comp, - reaction=reaction, - ) - rs.append(reactant.pk) - - if do_check_chemistry and not check_chemistry( - reaction_type, rs, product - ): - raise InvalidChemistryError( - f'{type=}, {rs=}, {product.id=}', - ) - - for r_id in rs: - if r_id in reactants: - intermediates.add(compound_id=r_id, amount=1) - else: - reactants.add(compound_id=r_id, amount=1) - - reactions.add(reaction) - - except InvalidChemistryError: - continue - except UnsupportedChemistryError: - mrich.warning('Skipping unsupported chemistry:', reaction_type) - continue - except Exception: - mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) - continue - - products.add(Ingredient.from_compound(product, amount=1)) - - recipe = Recipe( - reactions=reactions, - reactants=reactants, - intermediates=intermediates, - products=products, - ) - - if register_routes: - route, _ = RouteService.create_from_recipe( - recipe=recipe, - ) - mrich.success('registered route', route.pk) - - if pick_first: - break - - return df - - @classmethod - def ingest_syndirella_elabs( - cls, - *, - df: pd.DataFrame, - target: Target, - reject_flags: list[str], - pose_tag_list: list[str], - product_tag_list: list[str], - max_energy_score: float, - max_distance_score: float, - require_intra_geometry_pass: bool, - register_reactions: bool, - scaffold_route: RouteObj | None = None, - scaffold_compound: Compound | None = None, - ) -> pd.DataFrame: - - # work out number of reaction steps - num_steps = max( - [int(s.split('_')[0]) for s in df.columns if '_product_smiles' in s] - ) - mrich.var('num_steps', num_steps) - - # add is_scaffold row - df['is_scaffold'] = df[f'{num_steps}_product_name'].str.contains('scaffold') - - ###### PREP ###### - - # flags - - present_flags = set() - for step in range(num_steps): - step += 1 - - for flags in set(df[df[f'{step}_flag'].notna()][f'{step}_flag'].to_list()): - for flag in flags: - present_flags.add(flag) - - if present_flags: - mrich.warning('Flags in DataFrame:', present_flags) - - for flag in reject_flags: - if flag in present_flags: - for step in range(num_steps): - step += 1 - matches = df[f'{step}_flag'].apply( - lambda x: flag in x if x is not None else False - ) - mrich.print( - 'Filtering out', - len(df[matches]), - 'rows from step', - step, - 'due to', - flag, - ) - df = df[~matches] - - # poses - - n_null_mol = len(df[df['path_to_mol'].isna()]) - if n_null_mol: - df = df[df['path_to_mol'].notna()] - mrich.var('#rows skipped due to null path_to_mol', n_null_mol) - - if not len(df): - mrich.warning('No valid rows') - return None - - # inspirations - inspiration_sets = set(tuple(sorted(i)) for i in df['regarded']) - # smth like {('z0637a', 'z1040a')} - - if len(inspiration_sets) != 1: - mrich.error('Varying inspirations not supported') - return df - - (inspiration_set,) = inspiration_sets - - inspirations = Pose.objects.filter( - pose_alias__in=inspiration_set, - target=target, - ) - - if inspirations.count() != len(inspiration_set): - print('target', target) - print('inspiration_set', inspiration_set) - print('inspiration comparison', inspirations.count(), len(inspiration_set)) - assert inspirations.count() == len(inspiration_set) - - # reference - template_paths = set(df['template'].to_list()) - assert len(template_paths) == 1, 'Multiple references not supported' - (template_path,) = template_paths - template_path = Path(template_path) - mrich.var('template_path', template_path) - base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') - # reference = self.poses[base_name] - - # TODO: error handling - reference = Pose.objects.get( - pose_alias=base_name, - target=target, - ) - - assert reference, 'Could not determine reference structure' - mrich.var('reference', reference) - - # that's nice but I need it before that - # target = reference.target - - # subset of rows - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - mrich.var('#scaffold entries', len(scaffold_df)) - mrich.var('#elab entries', len(elab_df)) - - if not len(scaffold_df) and not scaffold_route and not scaffold_compound: - mrich.error('No valid scaffold rows') - return None - - elif scaffold_route: - ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE - - assert scaffold_route.num_reactions == 1 - - product = scaffold_route.products[0].compound - reaction = scaffold_route.reactions[0] - - assert reaction.reactants.count() == 2 - - scaffold_dict = { - 'scaffold_smiles': product.compound_smiles, - '1_reaction': reaction.reaction_type, - # this is so hacky - '1_r1_smiles': reaction.reactants.first().compound.compound_smiles, - '1_r2_smiles': reaction.reactants.last().compound.compound_smiles, - '1_product_smiles': product.compound_smiles, - '1_product_name': 'scaffold', - '1_single_reactant_elab': False, - '1_num_atom_diff': 0, - 'is_scaffold': True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - elif scaffold_compound: - ### SUPPLEMENT PARTIAL SCAFFOLD ROWS FROM KNOWN PRODUCT - - scaffold_dict = { - 'scaffold_smiles': scaffold_compound.smiles, - 'is_scaffold': True, - } - - scaffold_df = pd.DataFrame([scaffold_dict]) - - df = pd.concat([scaffold_df, df]) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - # if dry_run: - # mrich.error('Not registering records (dry_run)') - # return df - - ###### ELABS ###### - - # bulk register compounds - - smiles_cols = [ - c for c in df.columns if c.endswith('_smiles') and c != 'scaffold_smiles' - ] - - for smiles_col in smiles_cols: - inchikey_col = smiles_col.replace('_smiles', '_inchikey') - compound_id_col = smiles_col.replace('_smiles', '_compound_id') - - unique_smiles = df[smiles_col].dropna().unique() - - mrich.debug( - f'Registering {len(unique_smiles)} compounds from column: {smiles_col}' - ) - - # radical? - values = CompoundService.create_from_smiles(unique_smiles) - - orig_smiles_to_inchikey = { - orig_smiles: inchikey - for orig_smiles, (inchikey, new_smiles) in zip( - unique_smiles, values, strict=False - ) - } - - df[inchikey_col] = df[smiles_col].apply( - lambda x: orig_smiles_to_inchikey.get(x) - ) - - # get associated IDs - compound_inchikey_id_dict = { - k.compound_inchikey: k.pk - for k in Compound.objects.filter(compound_smiles__in=unique_smiles) - } - df[compound_id_col] = df[inchikey_col].apply( - lambda x: compound_inchikey_id_dict.get(x) - ) - - # bulk register reactions - - if register_reactions: - for step in range(num_steps): - step += 1 - - mrich.debug(f'Registering reactions for step {step}') - - reaction_dicts = [] - - for reaction_name, r1_id, r2_id, product_id in df[ - [ - f'{step}_reaction', - f'{step}_r1_compound_id', - f'{step}_r2_compound_id', - f'{step}_product_compound_id', - ] - ].values: - # skip invalid rows - if pd.isna(r1_id) or pd.isna(product_id): - mrich.warning("Can't insert reactions for missing scaffold") - continue - - # reactant IDs - - reactant_ids = set() - reactant_ids.add(int(r1_id)) - - if not pd.isna(r2_id): - reactant_ids.add(int(r2_id)) - - product_id = int(product_id) - - # registration data - - reaction_dicts.append( - dict( - reaction_name=reaction_name, - reactant_ids=reactant_ids, - product_id=int(product_id), - ) - ) - - # why is this outside of loop? - _ = ReactionService.create_from_lists( - reaction_types=[d['reaction_name'] for d in reaction_dicts], - product_ids=[d['product_id'] for d in reaction_dicts], - reactant_id_lists=[d['reactant_ids'] for d in reaction_dicts], - ) - - scaffold_df = df[df['is_scaffold']] - elab_df = df[~df['is_scaffold']] - - # tag product compounds: - - product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) - products = Compound.objects.filter(pk__in=product_ids) - product_tags = CompoundTagService.tags_from_list(product_tag_list) - for compound in products: - compound.tags.add(*product_tags) - - # bulk register scaffold relationships - - for step in range(num_steps): - step += 1 - - for role in ['r1', 'r2', 'product']: - key = f'{step}_{role}_compound_id' - - mrich.debug(f'Registering scaffold relatonships for {key}') - - if step == num_steps and role == 'product' and scaffold_compound: - scaffold_id = scaffold_compound.id - - else: - scaffold_ids = list(scaffold_df[key].dropna().unique()) - - if not scaffold_ids: - mrich.warning( - "Can't insert scaffold relationships due to missing", - key, - 'for all scaffold rows', - ) - continue - - if len(scaffold_ids) > 1: - mrich.error('Multiple scaffold row values in', key) - return scaffold_df - - scaffold_id = scaffold_ids[0] - - # original code didn't do dropna? how? filter in later step? - superstructure_ids = [ - i for i in elab_df[key].dropna().unique() if i != scaffold_id - ] - - # comp service? - for superstructure_id in superstructure_ids: - base = Compound.objects.get(pk=scaffold_id) - superstructure = Compound.objects.get(pk=int(superstructure_id)) - Scaffold.objects.get_or_create( - base_compound=base, - superstructure_compound=superstructure, - ) - - # filter poses - - ok = df - - try: - if require_intra_geometry_pass: - mrich.var( - '#poses !intra_geometry_pass', - len(df[df['intra_geometry_pass'] == False]), - ) - ok = ok[ok['intra_geometry_pass'] == True] - - if max_energy_score is not None: - mrich.var( - f'#poses ∆∆G > {max_energy_score}', - len(df[df['∆∆G'] > max_energy_score]), - ) - ok = ok[ok['∆∆G'] <= max_energy_score] - - if max_distance_score is not None: - mrich.var( - f'#poses comRMSD > {max_distance_score}', - len(df[df['comRMSD'] > max_energy_score]), - ) - ok = ok[ok['comRMSD'] <= max_distance_score] - - except Exception as e: - mrich.error('Problem filtering dataframe') - mrich.error(e) - return df - - mrich.var('#acceptable poses', len(ok)) - - if not len(ok): - mrich.warning('No valid poses') - return None - - # bulk register poses - - pose_ids = [] - scorer = ScoreService() - for _, row in ok.iterrows(): - path = Path(row.path_to_mol).resolve() - print('comp id in row', row[f'{num_steps}_product_compound_id']) - - # closed for testing - if not path.exists(): - mrich.warning('Skipping pose w/ non-exising file:', path) - continue - - if pd.isna(row[f'{num_steps}_product_compound_id']): - continue - - pose, created = PoseService.create_from_record( - compound_id=int(row[f'{num_steps}_product_compound_id']), - target_id=int(target.id), - reference=int(reference.id), - path=str(path), - ) - if created: - scores = { - 'energy_score': float(row['∆∆G']), - 'distance_score': float(row['comRMSD']), - } - pose_ids.append(pose.id) - scorer.add_scores_from_record(pose=pose, record=scores) - - if not pose_ids: - mrich.warning('No valid poses') - return None - - poses = Pose.objects.filter(pk__in=pose_ids) - mrich.success('Registered', poses.count(), 'new poses') - - # query relevant poses (also previously registered) - paths = poses.values_list('path', flat=True) - - # what the hell is this?? - records = Pose.objects.filter( - path__in=paths, - ) - for pose in records: - # pose.inspirations.add(*Pose.objects.filter(pk__in=inspiration.ids)) - pose.inspirations.add(*inspirations.queryset) - - # if pose_tags: - pose_tags = PoseTagService.tags_from_list(pose_tag_list) - for pose in poses: - pose.tags.add(*pose_tags) - - return df - - -# def create_compound(...): -# assert connection.in_atomic_block diff --git a/src/designdb/services/pose.py b/src/designdb/services/pose.py deleted file mode 100644 index 5e93a4e..0000000 --- a/src/designdb/services/pose.py +++ /dev/null @@ -1,208 +0,0 @@ -import json -import logging -import re -from collections.abc import Iterable -from pathlib import Path - -import mrich -import pandas as pd -import rdkit -from django.db.models import Q -# from mypackage.services.compound import CompoundService -from rdkit import Chem - -# from rdkit.Chem import inchi -from designdb.models import Compound, Pose, PoseTag, Target -from designdb.utils import normalize_string_list -from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS - -# from .validation.compound import ValidationError, validate_compound_data - -SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' -) -SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' -) - - -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') -PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - -logger = logging.getLogger(__name__) - - -class PoseService: - @classmethod - def create( - cls, - *, - compound: Compound, - target: Target, - mol: Chem.rdchem.Mol, - alias: str, - path: str, - metadata: dict[str, str], - inchikey: str, - smiles: str, - reference: int | None = None, - ): - - try: - pose = Pose.objects.get( - target=target, - compound=compound, - pose_alias=alias, - ) - # default is to overwrite metadata. what about other props? - # also, shoulnd't this be JSON? - pose.metadata = metadata - pose.save() - created = False - except Pose.DoesNotExist: - pose = Pose( - compound=compound, - target=target, - pose_alias=alias, - pose_path=path, - pose_inchikey=inchikey, # SQLITE_RELIC - pose_smiles=smiles, # SQLITE_RELIC - pose_metadata=json.dumps(metadata), - pose_mol=mol, - rdkit_version=rdkit.__version__, - inchi_version=Chem.inchi.GetInchiVersion(), - pose_reference=reference, - ) - pose.save() - created = True - # except MultipleObjectsReturned: - # pass - - return pose, created - - @classmethod - def create_from_record( - cls, - *, - compound_id: int, - target_id: int, - path: str, - reference: int | None = None, - ): - target = Target.objects.get(pk=target_id) - compound = Compound.objects.get(pk=compound_id) - pose, created = Pose.objects.get_or_create( - compound=compound, - target=target, - pose_path=path, - reference=reference, - ) - return pose, created - - # this is parsing input, maybe in ingestion? - @staticmethod - def get_inspirations(*args, target: Target | None = None): - parsed = [] - for el in args: - if isinstance(el, str): - parsed.extend(normalize_string_list(el)) - elif isinstance(el, Iterable) and not isinstance(el, dict): - parsed.extend(el) - else: - logger.warning( - 'Unsupported inspiration collection received: %s', - type(el), - ) - - # inputs can be pk or name - pks = [] - aliases = [] - - for val in parsed: - try: - pks.append(int(val)) - except ValueError: - # assume string alias - aliases.append(val) - - qs = Pose.objects.filter( - Q(pk__in=pks) | Q(pose_alias__in=aliases, target=target) - ) - - return qs - - @staticmethod - def get_reference(reference, target) -> int: - try: - reference = int(reference) - # should I check if exist here as well? - except ValueError: - try: - reference = Pose.objects.get( - pose_alias=reference, - target=target, - ).pk - except Pose.DoesNotExist as exp: - logger.error('Pose %s does not exist', reference) - raise Pose.DoesNotExist from exp - - return reference - - -class PoseTagService: - def __init__(self, metadata_file: Path | str, other_tags: list[str] | None = None): - self._df = pd.read_csv(metadata_file) - self._curated_tag_cols = [ - c - for c in self._df.columns - if c not in META_IGNORE_COLS + GENERATED_TAG_COLS - ] - # any other tags to be added - if other_tags: - self._other_tags = [k.strip() for k in other_tags if k.strip()] - else: - self._other_tags = [] - - mrich.var('curated_tag_cols', self._curated_tag_cols) - - @staticmethod - def tags_from_list(tag_list: list[str]): - assert tag_list is not None, '"None" passed as tag_list' - - PoseTag.objects.bulk_create( - [PoseTag(pose_tag_name=k.strip()) for k in tag_list if k.strip()], - ignore_conflicts=True, - ) - tags = PoseTag.objects.filter(pose_tag_name__in=tag_list) - return tags - - # might be a good idea to break meta and tags apart - def tags_and_meta( - self, - *, - code: str, - longcode: str, - ) -> tuple[list[PoseTag], dict[str, str]]: - meta_row = self._df[self._df['Code'] == code] - if not len(meta_row): - meta_row = self._df[self._df['Long code'] == longcode] - - # TODO: another unhandled exception, apprently not having - # meta_row is an option - - metadata = {'fragalysis_longcode': meta_row['Long code'].values[0]} - - for tag in GENERATED_TAG_COLS: - if tag in meta_row.columns: - metadata[tag] = meta_row[tag].values[0] - - pose_tag_set = set(self._other_tags) - - for tag in self._curated_tag_cols: - if meta_row[tag].values[0]: - pose_tag_set.add(tag) - - tags = PoseTagService.tags_from_list(pose_tag_set) - - return tags, metadata diff --git a/src/designdb/services/reaction.py b/src/designdb/services/reaction.py deleted file mode 100644 index cf54029..0000000 --- a/src/designdb/services/reaction.py +++ /dev/null @@ -1,109 +0,0 @@ -import logging - -import mrich - -# from mypackage.services.compound import CompoundService -# from rdkit.Chem import inchi -from designdb.models import Compound, Reactant, Reaction - -logger = logging.getLogger(__name__) - - -class ReactionService: - @classmethod - def create_from_lists( - cls, - *, - reaction_types: list[str], - product_ids: list[int], - reactant_id_lists: list[set[int]], - ) -> list[int]: - # insert reaction - - # insert reactant - - reaction_ids = [] - non_duplicates = {} - - # not entirely sure how the original query was meant to work - qs = Reactant.objects.filter(compound__pk__in=product_ids) - existing = {} - for r in qs: - reaction_type = r.reaction.reaction_type - reaction_product = r.reaction.product_compound.pk - reaction_id = r.reaction.pk - reactant_compound = r.compound.pk - - key = (reaction_type, reaction_product) - - if key not in existing: - existing[key] = {} - - if reaction_id not in existing[key]: - existing[key][reaction_id] = set() - - existing[key][reaction_id].add(reactant_compound) - - existing_count = 0 - - # why is strict false?? - for reaction_type, product_id, reactant_ids in zip( - reaction_types, product_ids, reactant_id_lists, strict=False - ): - key = (reaction_type, product_id) - - possible_matches = {k: v for k, v in existing.items() if k == key} - - assert len(possible_matches) < 2 - - if possible_matches: - possible_matches = list(possible_matches.values())[0] - - if any(reactant_ids == v for v in possible_matches.values()): - existing_count += 1 - continue - - non_duplicates[key] = reactant_ids - - if existing_count: - mrich.warning('Skipped', existing_count, 'existing reactions') - - if not non_duplicates: - mrich.warning('All reactions are duplicates') - return None - - for reaction_type, product_id in non_duplicates.keys(): - compound = Compound.objects.get(pk=product_id) - # if I understand the original procedure correctly, it - # should have already weeded out the duplicates - reaction, _ = Reaction.objects.get_or_create( - reaction_type=reaction_type, - product_compound=compound, - reaction_product_yield=1.0, - ) - reaction_ids.append(reaction.pk) - - payload = [] - for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( - reaction_ids, non_duplicates.items(), strict=False - ): - for reactant_id in reactant_ids: - payload.append((reaction_id, reactant_id)) - - for reaction_id, reactant_id in payload: - reaction = Reaction.objects.get(pk=reaction_id) - compound = Compound.objects.get(pk=reactant_id) - reaction, _ = Reactant.objects.get_or_create( - reaction=reaction, - compound=compound, - reactant_amount=1.0, - ) - - # delete orphaned reactions, srsly?? - Reaction.objects.filter( - pk__in=Reactant.objects.filter( - compound__isnull=True, - ).values('reaction'), - ).delete() - - return reaction_ids diff --git a/src/designdb/services/route.py b/src/designdb/services/route.py deleted file mode 100644 index 52d74fe..0000000 --- a/src/designdb/services/route.py +++ /dev/null @@ -1,80 +0,0 @@ -# from mypackage.services.compound import CompoundService - -# from rdkit.Chem import inchi -from designdb.models import Component, Route -from designdb.recipe import Recipe - - -class RouteService: - @classmethod - def create_from_recipe( - cls, - *, - recipe: Recipe, - ) -> tuple[Route, bool]: - - route, created = Route.objects.get_or_create( - product_compound=recipe.product.compound - ) - - # are you joking?? reactants and intermediates are all of the - # sudden components - - # reactions - components = [] - components.extend( - [ - Component(route=route, component_type=1, component_ref=ref.pk) - for ref in recipe.reactions - ], - ) - - # this part needs data from ingredient df, which I don't have - # and is not implemented - - # reactants - # for ref, amount in recipe.reactants.id_amount_pairs: - # self.insert_component( - # component_type=2, ref=ref, route=route_id, amount=amount, commit=False - # ) - - components.extend( - [ - Component( - route=route, - component_type=1, - component_ref=ref, - component_amount=amount, - ) - for ref, amount in recipe.reactants.id_amount_pairs - ], - ) - - # # intermediates - # for ref, amount in recipe.intermediates.id_amount_pairs: - # self.insert_component( - # component_type=3, ref=ref, route=route_id, amount=amount, commit=False - # ) - - components.extend( - [ - Component( - route=route, - component_type=1, - component_ref=ref, - component_amount=amount, - ) - for ref, amount in recipe.intermediates.id_amount_pairs - ], - ) - - Component.objects.bulk_create(components, ignore_conflicts=True) - - return route, created - - # @property - # def id_amount_pairs(self) -> list[tuple]: - # """Get a list of compound ID and amount pairs""" - # return [ - # (id, amount) for id, amount in self.df[['compound_id', 'amount']].values - # ] diff --git a/src/designdb/services/score.py b/src/designdb/services/score.py deleted file mode 100644 index f80a817..0000000 --- a/src/designdb/services/score.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import re - -# from mypackage.services.compound import CompoundService -# from rdkit.Chem import inchi -from designdb.models import Pose, ScoreValue, ScoringMethod - -# from .validation.compound import ValidationError, validate_compound_data - -SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' -) -SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' -) - - -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') -PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - -logger = logging.getLogger(__name__) - - -class ScoreService: - def __init__(self, scoring_method_list: list[str] | None = None): - self._scoring_method_list = scoring_method_list - # self._score_map = {} - self._scoring_method_cache = {} - - # unused, but I imagine this could take various arguments, - # like include or exclude list - - # if self._scoring_method_list: - # for m in self._scoring_method_list: - # sm, _ = ScoringMethod.objects.get_or_create( - # method_name=m, - # ) - # self._score_map[sm.method_name] = sm - - def add_scores_from_record( - self, - *, - pose: Pose, - record: dict[str, str | float], - ): - - # FIXME: this because don't know how to select - scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} - - for method_name, score_value in scores.items(): - try: - method = self.scoring_methods[method_name] - except KeyError: - # there's so many more fields, should I really be creating them? - method, _ = ScoringMethod.objects.get_or_create( - method_name=method_name, - ) - - score = ScoreValue( - pose=pose, - compound=pose.compound, - scoring_method=method, - score=score_value, - ) - score.save() - - # def bulk_scores(poses: list[pose], record: dict[str, str | float]): - # # potentially lots of scores, can do bulk insertion all at once - # pass - - @property - def scoring_methods(self) -> dict[str, ScoringMethod]: - return self._scoring_method_cache diff --git a/src/designdb/sets/__init__.py b/src/designdb/sets/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/designdb/sets/compound.py b/src/designdb/sets/compound.py deleted file mode 100644 index 9955cc8..0000000 --- a/src/designdb/sets/compound.py +++ /dev/null @@ -1,2511 +0,0 @@ -import json -from collections.abc import Callable -from pathlib import Path - -import mcol -import mrich -import pandas as pd -from django.db.models import Exists, OuterRef, Q -from pandas import DataFrame, concat, isna -from rdkit import Chem -# from rdkit.Chem import inchi -from rdkit.Chem import Mol - -from designdb.ingredient import Ingredient -from designdb.models import ( - CataloguePrice, - Compound, - CompoundTag, - CompoundTagJunction, - Reactant, - Reaction, -) -from designdb.price import Price - - -class CompoundSet: - """Object representing a subset of the 'compound' table in the :class:`.Database`. - - .. attention:: - - :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Compound` objects in the set: - - :: - - cset = animal.compounds[:100] - - for compound in cset: - ... - - Check membership - ================ - - To determine if a :class:`.Compound` is present in the set: - - :: - - is_member = compound in cset - - Selecting compounds in the set - ============================== - - The :class:`.CompoundSet` can be indexed like standard Python lists by their indices - - :: - - cset = animal.compounds[1:100] - - # indexing individual compounds - comp = cset[0] # get the first compound - comp = cset[1] # get the second compound - comp = cset[-1] # get the last compound - - # getting a subset of compounds using a slice - cset2 = cset[13:18] # using a slice - - Tags and scaffold compounds can also be used to filter: - - :: - - cset = animal.compounds(tag='hits') # select compounds tagged with 'hits' - cset = animal.compounds(scaffold=comp) # select elaborations of comp - - """ - - def __init__( - self, - queryset=None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """CompoundSet initialisation""" - - if queryset: - if isinstance(queryset, list): - self._queryset = Compound.objects.filter(pk__in=queryset) - else: - self._queryset = queryset - else: - self._queryset = Compound.objects.none() - - if sort: - self._queryset = self._queryset.order_by('pk') - - self._name = name - - ### DUNDERS - - def __len__(self) -> int: - """The number of compounds in this set""" - return self._queryset.count() - - def __iter__(self): - """Iterate through compounds in this set""" - return iter(self._queryset) - - def __getitem__( - self, - key: int | slice, - ) -> 'Compound | CompoundSet': - """Get compounds or subsets thereof from this set - - :param key: integer index or slice of indices - - """ - match key: - case int(): - index = self.indices[key] - try: - return Compound.objects.get(id=index) - except Compound.DoesNotExist: - raise Compound.DoesNotExist from exc - - case slice(): - return CompoundSet(Compound.objects.filter(pk__in=key)) - - case _: - raise NotImplementedError - - def __sub__( - self, - other: 'Compound | CompoundSet | IngredientSet', - ) -> 'CompoundSet': - """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - - match other: - case CompoundSet(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) - ), - sort=False, - ) - case int(): - return CompoundSet( - Compound.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), - sort=False, - ) - - def __add__( - self, - other: 'Compound | CompoundSet | IngredientSet | int', - ) -> 'CompoundSet': - """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" - - match other: - case Compound(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case int(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case CompoundSet(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case IngredientSet(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other._queryset) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __and__(self, other: 'CompoundSet'): - """AND set operation, returns only compounds in both sets""" - - match other: - case CompoundSet(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) & Q(pk__in=other.queryset) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __or__(self, other: 'CompoundSet'): - """OR set operation, returns union of both sets""" - - match other: - case CompoundSet(): - return CompoundSet( - Compound.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __xor__(self, other: 'CompoundSet'): - """Exclusive OR set operation, returns all compounds in either set but not both""" - - match other: - case CompoundSet(): - return CompoundSet( - Compound.objects.filter( - Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) - & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f'{self.name}: ' - else: - s = '' - - s += f'{{C × {len(self)}}}' - - return s - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - def __contains__(self, other: Compound | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - ik = other.pk - case int(): - pk = other - - return self._queryset.filter(pk=pk).exists() - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> 'CompoundSet': - """Get all child compounds with a certain tag""" - - self._queryset = self._queryset.annotate( - has_tag=Exists( - CompoundTagJunction.objects.filter( - pose=OuterRef('pk'), - pose_tag__pose_tag_name=tag, - ), - ), - ) - if inverse: - return CompoundSet(self._queryset.filter(has_tag=False)) - else: - return CompoundSet(self._queryset.filter(has_tag=True)) - - def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': - """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key - :param value: metadata value (Default value = None) - """ - - q = Q(compound_metadata__has_key=key) - if value: - q = Q(compound_metadata__key=value) - - qs = Compound.objects.filter(q) - - return CompoundSet(qs) - - def get_by_scaffold( - self, - scaffold: Compound | int, - none: str = 'error', - ) -> 'CompoundSet': - """Get all compounds that elaborate the given scaffold compound - - :param scaffold: :class:`.Compound` object or ID to search by - - """ - - if not isinstance(scaffold, int): - assert scaffold._table == 'compound' - scaffold = scaffold.id - - values = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', - multiple=True, - none=none, - ) - ids = [v for (v,) in values if v] - - if not ids: - return None - return CompoundSet(self.db, ids) - - def get_all_possible_reactants( - self, - debug: bool = False, - ) -> 'CompoundSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - - qs = Compound.objects.filter( - pk__in=Reactant.objects.filter( - reaction__in=self._queryset, - ), - ) - - seen = set(qs.values_list('id', flat=True)) - frontier = set(seen) - - while frontier: - new = ( - set( - Compound.objects.filter( - pk__in=Reactant.objects.filter( - reaction__in=self._queryset, - ), - ).values_list('pk', flat=True) - ) - - seen - ) - - seen |= new - frontier = new - - return CompoundSet(Compound.objects.filter(pk__in=seen)) - - def get_all_possible_reactions( - self, - debug: bool = False, - ) -> 'ReactionSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. - - :param debug: Increased verbosity for debugging (Default value = False) - - """ - qs = Compound.objects.filter( - pk__in=Reactant.objects.filter( - reaction__in=self._queryset, - ), - ) - - seen = set(qs.values_list('id', flat=True)) - frontier = set(seen) - - while frontier: - new = ( - set( - Compound.objects.filter( - pk__in=Reactant.objects.filter( - reaction__in=self._queryset, - ), - ).values_list('pk', flat=True) - ) - - seen - ) - - seen |= new - frontier = new - - return Reaction.objects.filter(product__compound__in=seen) - - def get_risk_diversity(self, debug: bool = False) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - variances = self.db.execute( - f""" - WITH nums AS ( - SELECT scaffold_base AS base, scaffold_superstructure AS elab, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id - WHERE scaffold_superstructure IN {self.str_ids} - ), - - means AS ( - SELECT base, AVG(diff) AS mean FROM nums - GROUP BY base - ) - - SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums - LEFT JOIN means - ON nums.base = means.base - GROUP BY nums.base - """ - ).fetchall() - - if not variances: - return None - - variances = [v for (v,) in variances] - - if debug: - mrich.debug(f'{variances=}') - - return mean(variances) - - def count_by_tag( - self, - tag: str, - ) -> 'CompoundSet': - """Count all child compounds with a certain tag - - :param tag: tag to filter by - - """ - return self._queryset.annotate( - has_tag=Exists( - CompoundTag.objects.filter( - compound=OuterRef('pk'), - compound_tag__compound_tag_name=tag, - ), - ), - ).count() - - ### CONSOLE / NOTEBOOK OUTPUT - - def draw(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - from molparse.rdkit import draw_grid - - data = [(str(c), c.mol) for c in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] - - display(draw_grid(mols, labels=labels)) - - def grid(self) -> None: - """Draw a grid of all contained molecules. - - .. attention:: - - This method is only intended for use within a Jupyter Notebook. - - """ - - self.draw() - - def summary(self, return_df: bool = False) -> None: - """Print a summary of this compound set""" - - mrich.header(self) - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index('tag') - - # poses - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_pose) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON pose_id = tag_pose - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_poses'] = count - - # compounds with poses - - sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON tag_pose = pose_id - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_posed_compounds'] = count - - df.loc['TOTAL', 'num_compounds'] = len(self) - df.loc['TOTAL', 'num_poses'] = self.num_poses - df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) - - df = df.fillna(0) - df = df.astype(int) - - if return_df: - return df - else: - mrich.print(df) - - def interactive( - self, - function: Callable | None = None, - ) -> None: - """Creates a ipywidget to interactively navigate this PoseSet.""" - - from IPython.display import display - from ipywidgets import ( - BoundedIntText, - Checkbox, - GridBox, - Layout, - VBox, - interactive, - interactive_output, - ) - - if function: - - def widget(i): - """interactive function widget""" - compound = self[i] - display(compound) - function(compound) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Comp (/{len(self)}):', - disabled=False, - ), - ) - - else: - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Comp (/{len(self)}):', - disabled=False, - ) - - b = Checkbox(description='Name', value=True) - c = Checkbox(description='Summary', value=False) - d = Checkbox(description='2D', value=True) - e = Checkbox(description='Poses', value=False) - f = Checkbox(description='Reactions', value=False) - g = Checkbox(description='Tags', value=False) - h = Checkbox(description='Quotes', value=False) - i = Checkbox(description='Metadata', value=False) - j = Checkbox(description='Classify', value=False) - - ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns='repeat(3, 100px)') - ) - ui2 = GridBox( - [e, f, g], layout=Layout(grid_template_columns='repeat(3, 100px)') - ) - ui3 = GridBox( - [h, i, j], layout=Layout(grid_template_columns='repeat(3, 100px)') - ) - ui = VBox([a, ui1, ui2, ui3]) - - def widget( - i, - name: bool = True, - summary: bool = True, - draw: bool = True, - poses: bool = True, - reactions: bool = True, - tags: bool = True, - quotes: bool = True, - metadata: bool = True, - classify: bool = True, - ): - """interactive default widget""" - """ - - :param i: param name: (Default value = True) - :param summary: Default value = True) - :param draw: Default value = True) - :param poses: Default value = True) - :param reactions: Default value = True) - :param metadata: Default value = True) - :param name: (Default value = True) - - """ - comp = self[i] - - if name and not summary: - print(repr(comp)) - - if summary: - comp.summary(metadata=False, draw=False, tags=False) - - if draw: - comp.draw() - - if poses and (pset := comp.poses): - for p in pset: - mrich.print(p) - pset.draw() - - if reactions and (reactions := comp.reactions): - for r in reactions: - mrich.print(r) - r.draw() - - if tags: - mrich.title('Tags') - mrich.print(comp.tags) - - if quotes: - mrich.title('Quotes') - display(comp.get_quotes(df=True)) - - if metadata: - mrich.title('Metadata:') - mrich.print(comp.metadata) - - if classify: - mrich.title('Classification:') - comp.classify() - - out = interactive_output( - widget, - { - 'i': a, - 'name': b, - 'summary': c, - 'draw': d, - 'poses': e, - 'reactions': f, - 'tags': g, - 'quotes': h, - 'metadata': i, - 'classify': j, - }, - ) - - display(ui, out) - - def tag_summary(self) -> 'pd.DataFrame': - """Print a summary table of tags with compound counts""" - - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index('tag') - - df = df.astype(int) - - mrich.print(df) - - return df - - ### OTHER METHODS - - def get_recipes( - self, - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = False, - permitted_reactions: 'ReactionSet | None' = None, - quoted_only: bool = False, - supplier: None | str = None, - **kwargs, - ): - """Generate the :class:`.Recipe` to make these compounds. - - See :meth:`.Recipe.from_compounds` - """ - - # avoiding circular imports - from designdb.recipe import Recipe - - return Recipe.from_compounds( - self, - amount=amount, - debug=debug, - pick_cheapest=pick_cheapest, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - **kwargs, - ) - - def get_routes( - self, - permitted_reactions: 'None | ReactionSet' = None, - return_ids: bool = False, - debug: bool = True, - ) -> 'RouteSet': - """Get a RoutSet to products in this set. - - :param permitted_reactions: optionally restrict reactions to those in this :class:`.ReactionSet` - - """ - - if 'route' not in self.db.table_names: - mrich.error('route table not in Database') - raise NotImplementedError - - if permitted_reactions is not None: - sql = f""" - SELECT route_id, route_product, component_ref - FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component - ON route_id = component_route - WHERE route_product IN {self.str_ids} - AND component_type = 1 - """ - - permitted_reactions = set(permitted_reactions.ids) - - if debug: - mrich.debug('Querying database for routes') - records = self.db.execute(sql).fetchall() - - if debug: - mrich.debug('Assembling route dictionary') - - routes = {} - for route_id, route_product, reaction_id in records: - if route_id not in routes: - routes[route_id] = dict(product=route_product, reactions=set()) - assert routes[route_id]['product'] == route_product - routes[route_id]['reactions'].add(reaction_id) - - if debug: - mrich.debug('Checking availability') - - available_routes = set() - for route_id, route_dict in routes.items(): - product = route_dict['product'] - assert product in self - reactions = route_dict['reactions'] - if all(r in permitted_reactions for r in reactions): - available_routes.add(route_id) - - if return_ids: - return list(available_routes) - - routes = [ - self.db.get_route(id=route_id) - for route_id in mrich.track(available_routes, prefix='Getting routes') - ] - - else: - sql = f""" - SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route - WHERE route_product IN {self.str_ids} - """ - - if debug: - mrich.debug('Querying database for routes') - records = self.db.execute(sql).fetchall() - - if return_ids: - return [i for (i,) in records] - - routes = [ - self.db.get_route(id=route_id) - for (route_id,) in mrich.track(records, prefix='Getting routes') - ] - - from .recipe import RouteSet - - return RouteSet(self.db, routes) - - def copy(self) -> 'CompoundSet': - """Returns a copy of this set""" - return CompoundSet(self.db, self.ids) - - def shuffled(self) -> 'CompoundSet': - """Returns a randomised copy of this set""" - copy = self.copy() - copy.shuffle() - return copy - - def pop(self) -> Compound: - """Pop the last compound in this set""" - c_id = self.pop_id() - return self.db.get_compound(id=c_id) - - def pop_id(self) -> int: - """Pop the last compound id in this set""" - return self._indices.pop() - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - from random import shuffle - - shuffle(self._indices) - - def get_df( - self, - smiles: bool = True, - inchikey: bool = False, - alias: bool = True, - mol: bool = False, - metadata: bool = False, - expand_metadata: bool = True, - poses: bool = False, - num_reactant: bool = False, - num_reactions: bool = False, - num_poses: bool = False, - tags: bool = False, - scaffolds: bool = False, - elabs: bool = False, - routes: bool = False, - debug: bool = False, - **kwargs, - ) -> 'DataFrame': - """Get a DataFrame representation of this set - - :param smiles: include SMILES column (Default value = True) - :param inchikey: include InChIKey column (Default value = False) - :param alias: include alias column (Default value = True) - :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) - :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) - :param poses: include poses in output (Default value = False) - :param num_reactant: include num_poses column - :param num_reactant: include num_reactant column (number of reactions where compound is a reactant) - :param num_reactions: include num_reactions column (number of reactions where compound is a product) - :param tags: include tags column - :param scaffolds: include scaffolds column - :param elabs: include elabs column - - """ - - data = [] - - query = ['compound_id'] - - if smiles: - query.append('compound_smiles') - - if inchikey: - query.append('compound_inchikey') - - if alias: - query.append('compound_alias') - - if mol: - query.append('mol_to_binary_mol(compound_mol)') - - if metadata: - query.append('compound_metadata') - - query = ', '.join(query) - - sql = f""" - SELECT {query} - FROM {self.db.SQL_SCHEMA_PREFIX}compound - WHERE compound_id IN {self.str_ids} - """ - - if debug: - mrich.debug('querying...') - records = self.db.execute(sql).fetchall() - - if debug: - generator = mrich.track(records) - else: - generator = records - - for row in generator: - row = list(row) - - d = dict(id=row.pop(0)) - - if smiles: - d['smiles'] = row.pop(0) - - if inchikey: - d['inchikey'] = row.pop(0) - - if alias: - d['alias'] = row.pop(0) - - if mol: - d['mol'] = Mol(row.pop(0)) - - if metadata and (meta_str := row.pop(0)): - meta_dict = loads(meta_str) - - if expand_metadata: - for k, v in meta_dict.items(): - d[k] = v - - else: - d['metadata'] = meta_dict - - data.append(d) - - df = DataFrame(data) - - if poses or num_poses: - if debug: - mrich.debug('adding pose column') - - lookup = self.db.get_compound_id_pose_ids_dict(self) - if poses: - df['poses'] = df['id'].apply(lambda x: lookup.get(x, {})) - if num_poses: - df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, {}))) - - if num_reactant or num_reactions: - if debug: - mrich.debug('adding reaction columns') - tuples = self.db.get_reactant_product_tuples(self.ids, deduplicated=False) - - if num_reactant: - lookup = {} - for r, p in tuples: - lookup.setdefault(r, 0) - lookup[r] += 1 - df['num_reactant'] = df['id'].apply(lambda x: lookup.get(x, 0)) - - if num_reactions: - lookup = {} - for r, p in tuples: - lookup.setdefault(p, 0) - lookup[p] += 1 - df['num_reactions'] = df['id'].apply(lambda x: lookup.get(x, 0)) - - if scaffolds or elabs: - if debug: - mrich.debug('adding scaffold columns') - tuples = self.db.get_scaffold_tuples(self.ids) - - if scaffolds: - lookup = {} - for b, e in tuples: - lookup.setdefault(e, set()) - lookup[e].add(b) - df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if elabs: - lookup = {} - for b, e in tuples: - lookup.setdefault(b, set()) - lookup[b].add(e) - df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if tags: - if debug: - mrich.debug('adding tag column') - lookup = self.db.get_compound_tag_dict() - df['tags'] = df['id'].apply(lambda x: lookup.get(x, {})) - - if routes: - if debug: - mrich.debug('adding route column') - lookup = self.db.get_product_id_routes_dict() - df['routes'] = df['id'].apply(lambda x: lookup.get(x, {})) - - df = df.set_index('id') - - return df - - def get_quoted( - self, - *, - supplier: str = 'any', - ) -> 'CompoundSet': - """Get all member compounds that have a quote from given supplier - - :param supplier: supplier name (Default value = 'any') - - """ - - if supplier == 'any': - key = f'quote_compound IN {self.str_ids}' - else: - key = f'quote_compound IN {self.str_ids} AND quote_supplier = "{supplier}"' - - ids = self.db.select_where( - table='quote', - query='DISTINCT quote_compound', - key=key, - multiple=True, - ) - - ids = [i for (i,) in ids] - return CompoundSet(self.db, ids) - - def get_unquoted( - self, - *, - supplier: str = 'any', - ) -> 'CompoundSet': - """Get all member compounds that do not have a quote from given supplier - - :param supplier: supplier name (Default value = 'any') - - """ - - quoted = self.get_quoted(supplier=supplier) - return self - quoted - - def get_dict(self) -> dict: - """Get a dictionary object with all serialisable data needed to reconstruct this set""" - return dict(db=str(self.db.path.resolve()), indices=self.indices) - - def write_smiles_csv( - self, file: str, tags: bool = True, split_tags: bool = True - ) -> None: - """Write a CSV of the smiles contained in this set to a file - - :param file: path of the CSV file - :param tags: include tags in output - :param split_tags: split tags into separate columns - - """ - from pandas import DataFrame - - if tags: - records = self.db.select_where( - table='tag', - query='tag_compound, tag_name', - key=f'tag_compound IN {self.str_ids}', - multiple=True, - none='quiet', - ) - TAGS = {} - if records: - for compound_id, tag_name in records: - if compound_id not in TAGS: - TAGS[compound_id] = set() - TAGS[compound_id].add(tag_name) - - records = self.db.select_where( - table=self.table, - query='compound_id, compound_smiles', - key=f'compound_id IN {self.str_ids}', - multiple=True, - ) - - data = [dict(id=id, smiles=smiles) for id, smiles in records] - - if tags: - for d in data: - tagset = TAGS.get(d['id'], set()) - - if split_tags: - for tag in tagset: - d[tag] = True - else: - d['tags'] = tagset - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - def write_postera_csv( - self, - file, - *, - supplier: str = 'Enamine', - prefix: str = 'fragment', - ) -> None: - """Write a CSV formatted for upload to Postera's Manifold - - :param file: path of the CSV file - :param supplier: supplier to use for quotes, (Default value = 'Enamine') - :param prefix: prefix to metadata columns, (Default value = 'fragment') - - """ - - from datetime import date as dt - - from pandas import DataFrame - - if prefix: - prefix = f'{prefix}_' - - data = [] - - for c in mrich.track(self, prefix='Creating DataFrame'): - # get props - smiles = c.smiles - tags = c.tags - metadata = c.metadata - poses = c.poses - scaffold = c.scaffold - - # method - assert len(tags) == 1, c - method = tags[0] - - # date - date = dt.today() - - # author - assert 'author' in metadata, c - author = metadata['author'] - - match len(poses): - case 1: - pose = poses[0] - case 0: - mrich.warning(f'{c} has no poses') - assert scaffold - pose = scaffold.poses[0] - case _: - mrich.warning(f'{c} has multiple poses') - pose = poses[0] - - # extract inspirations - inspirations = pose.inspirations - inspiration_names = ','.join(inspirations.names) - inspiration_smiles = '.'.join(inspirations.smiles) - - # quote info - quotes = c.get_quotes(supplier=supplier) - assert len(quotes) == 1, c - quote = quotes[0] - catalog_id = quote.entry - catalog_price = quote.price - catalog_lead_time = quote.lead_time - - # hippo string - hippo_str = f'compound={c.id}, pose={pose.id}' - - # create row - data.append( - { - 'SMILES': smiles, - f'{prefix}HIPPO_IDs': hippo_str, - f'{prefix}method': method, - f'{prefix}export_date': date, - f'{prefix}author': author, - f'{prefix}inspiration_names': inspiration_names, - f'{prefix}inspiration_SMILES': inspiration_smiles, - f'{prefix}supplier': supplier, - f'{prefix}supplier_catalogue': quote.catalogue, - f'{prefix}supplier_ID': catalog_id, - f'{prefix}supplier_price': catalog_price, - f'{prefix}supplier_lead_time': catalog_lead_time, - } - ) - - df = DataFrame(data) - - mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_CAR_csv( - self, - file: 'str | Path', - amount: float = 1, # in mg - return_df: bool = False, - # pick_cheapest: bool = False, - quoted_only: bool = False, - get_ingredient_quotes: bool = True, - **kwargs, - ) -> 'DataFrame | None': - """List of reactions for CAR - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: output file - :param amount: amount of each product in `mg` - :param quoted_only: only choose reactants that have quotes - :param supplier: only choose reactants that have quotes from this supplier - :param kwargs: passed to :meth:`.Recipe.from_reaction` - :param return_df: return a `DataFrame` (Default value = False) - - """ - - # avoiding circular imports - from designdb.recipe import Recipe - - file = str(Path(file).resolve()) - - rows = [] - - for r_id in mrich.track(self.reaction_ids, prefix='Solving compound recipes'): - reaction = self.db.get_reaction(id=r_id) - - recipes = Recipe.from_reaction( - reaction, - amount=amount, - pick_cheapest=False, - quoted_only=quoted_only, - get_ingredient_quotes=get_ingredient_quotes, - **kwargs, - ) - - for sub_recipe in recipes: - product = sub_recipe.product - - row = { - 'target-names': str(product.compound), - 'no-steps': 0, - 'concentration-required-mM': None, - 'amount-required-uL': None, - 'batch-tag': None, - } - - for i, reaction in enumerate(sub_recipe.reactions): - i = i + 1 - - row['no-steps'] += 1 - - match len(reaction.reactants): - case 1: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = None - case 2: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = reaction.reactants[1].smiles - case _: - raise NotImplementedError( - f'Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}' - ) - - row[f'reaction-product-smiles-{i}'] = reaction.product.smiles - row[f'reaction-name-{i}'] = reaction.type - row[f'reaction-recipe-{i}'] = None - row[f'reaction-groupby-column-{i}'] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - df = df.convert_dtypes() - - for n_steps in set(df['no-steps']): - subset = df[df['no-steps'] == n_steps] - this_file = file.replace('.csv', f'_{n_steps}steps.csv') - mrich.writing(this_file) - subset.to_csv(this_file, index=False) - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - - assert isinstance(tag, str) - - for i in self.indices: - self.db.insert_tag(name=tag, compound=i, commit=False) - - mrich.print(f'Tagged {self} w/ "{tag}"') - - self.db.commit() - - def plot_tsnee(self, **kwargs) -> 'go.Figure': - """Plot a tanimoto similarity plot of these compounds""" - from .plotting import plot_compound_tsnee - - return plot_compound_tsnee(self, **kwargs) - - def as_ingredientset( - self, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Get an :class:`.IngredientSet` for these compounds""" - return IngredientSet.from_compounds( - compounds=self, amount=amount, supplier=supplier - ) - - def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': - """Split this set into subsets clustered by scaffold compound""" - - cluster_dict = self.db.get_compound_cluster_dict(cset=self) - - subsets = {} - for cluster, elabs in cluster_dict.items(): - cluster = CompoundSet(self.db, list(cluster)) - subsets[cluster] = CompoundSet(self.db, list(elabs)) - - return subsets - - def despaghettify( - self, - register_missing_routes: bool = True, - supplier='Enamine', - ) -> 'CompoundSet': - """Reduce this set to only compounds that elaborate a single reactant at a time. - Requires routes to be present in the database.""" - - if register_missing_routes: - mrich.debug('registering_missing_routes...') - route_lookup = self.register_missing_routes( - missing_only=True, supplier=supplier - ) - - mrich.debug('clustering by scaffold...') - clustered = self.split_by_scaffolds() - - n = len(clustered) - mrich.var('#clusters', n) - - mrich.debug('getting route lookup...') - route_lookup = self.db.get_product_id_routes_dict() - - mrich.debug('getting reactant lookup...') - reactant_lookup = self.db.get_route_id_reactant_ids_dict() - - keep = set() - for i, (cluster, elabs) in enumerate(clustered.items()): - for scaffold in cluster: - mrich.debug( - f'{i}/{n}', - 'scaffold:', - scaffold.id, - '#elabs:', - len(elabs), - '#kept:', - len(keep), - ) - - route_ids = route_lookup.get(scaffold.id) - - if not route_ids: - mrich.error(f'scaffold {scaffold} has no routes') - continue - - elif len(route_ids) > 1: - mrich.warning(f'scaffold {scaffold} has multiple routes') - - for route_id in route_ids: - scaffold_reactants = reactant_lookup[route_id] - - for elab in elabs: - route_ids = route_lookup.get(elab.id, set()) - - if len(route_ids) != 1: - mrich.error(f'elab {elab.id} has {route_ids=}') - continue - - reactants = reactant_lookup[list(route_ids)[0]] - - common = scaffold_reactants & reactants - - if len(common) == len(scaffold_reactants) - 1: - keep.add(elab.id) - - return CompoundSet(self.db, keep) - - def register_missing_routes( - self, missing_only: bool = True, supplier: str = 'Enamine' - ) -> None: - """Calculate missing routes to compounds in this set""" - - if missing_only: - from .cset import CompoundSet - - records = self.db.select_where( - table='route', - key=f'route_product IN {self.str_ids}', - query='route_product', - multiple=True, - ) - existing = set(i for (i,) in records) - missing = set(self.ids) - existing - return CompoundSet(self.db, missing).register_missing_routes( - missing_only=False, supplier=supplier - ) - - mrich.var('#compounds', len(self)) - - for i, c in mrich.track(enumerate(self), total=len(self)): - try: - reactions = c.reactions - except Exception as e: - mrich.error(f"Error getting {c}'s reactions", e) - continue - - for reaction in reactions: - try: - recipes = reaction.get_recipes(supplier=supplier) - except Exception as e: - mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) - continue - - for recipe in recipes: - route = self.db.register_route(recipe=recipe) - - mrich.print(f'registered {route=}') - - self.db.prune_duplicate_routes() - - ### PROPERTIES - - @property - def queryset(self): - """Associated :class:`.Database` object""" - return self._queryset - - @property - def indices(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self._queryset.values_list('id', flat=True) - - @property - def ids(self) -> list[int]: - """Returns the ids of compounds in this set""" - return self.indices - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of compounds in this set""" - result = self.db.select_where( - query='compound_alias', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def smiles(self) -> list[str]: - """Returns the smiles of child compounds""" - result = self.db.select_where( - query='compound_smiles', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def mols(self) -> 'list[Chem.Mol]': - """Returns the molecules of child compounds""" - from rdkit.Chem import Mol - - result = self.db.select_where( - query='mol_to_binary_mol(compound_mol)', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [Mol(q) for (q,) in result] - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of compounds in this set""" - result = self.db.select_where( - query='compound_inchikey', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key=f'tag_compound in {self.str_ids}', - multiple=True, - ) - if not values: - return set() - return set(v for (v,) in values) - - @property - def num_poses(self) -> int: - """Count the poses associated to this set of compounds""" - - return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') - - @property - def poses(self) -> 'PoseSet': - """Get the poses associated to this set of compounds""" - from .pset import PoseSet - - ids = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_compound in {self.str_ids}', - multiple=True, - none='warning', - ) - - if not ids: - return PoseSet(self.db, {}) - - ids = [v for (v,) in ids] - return PoseSet(self.db, ids) - - @property - def best_placed_poses(self) -> 'PoseSet': - """Get the best placed pose for each compound in this set""" - from .pset import PoseSet - - query = self.db.select_where( - table='pose', - query='pose_id, MIN(pose_distance_score)', - key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', - multiple=True, - ) - ids = [i for i, s in query] - return PoseSet(self.db, ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def num_heavy_atoms(self) -> int: - """Get the total number of heavy atoms""" - return sum([c.num_heavy_atoms for c in self]) - - @property - def num_rings(self): - """Get the total number of molecular rings""" - return sum([c.num_rings for c in self]) - - @property - def formula(self) -> str: - """Get the combined chemical formula for all compounds""" - from molparse.atomtypes import atomtype_dict_to_formula - - return atomtype_dict_to_formula(self.atomtype_dict) - - @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 combine_atomtype_dicts - - atomtype_dicts = [c.atomtype_dict for c in self] - return combine_atomtype_dicts(atomtype_dicts) - - @property - def num_atoms_added(self) -> list[int]: - """Calculate the number of atoms added w.r.t the scaffold - - :returns: list of number of atoms added values - - """ - - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - - query = self.db.execute(sql).fetchall() - - lookup = {k: v for k, v in query} - - return [lookup[i] for i in self.indices] - - @property - def avg_num_atoms_added(self) -> float: - """Calculate the average number of atoms added w.r.t the scaffold - - :returns: average number of atoms added values for compounds which have a scaffold - - """ - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - INNER JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ - - (avg,) = self.db.execute().fetchone() - - return avg - - @property - def risk_diversity(self) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set - - :returns: average of the standard deviations of number of atoms added for each scaffold - - """ - - return self.get_risk_diversity() - - @property - def elaboration_balance(self) -> float: - """Measure of how evenly elaborations are distributed across scaffolds in this set""" - - sql = f""" - SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - GROUP BY scaffold_base - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] - - from hirsch import hirsch - - return hirsch(counts) - - # return -std(counts) - - @property - def num_scaffolds_elaborated(self) -> int: - """Count the number of scaffold compounds that have at least one elaboration in this set - - :returns: number of scaffold compounds - - """ - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - - return count - - @property - def scaffolds(self) -> 'CompoundSet': - """Get the scaffold compounds that have at least one elaboration in this set - - :returns: :class:`.CompoundSet` - - """ - return CompoundSet(self.db, self.scaffold_ids) - - @property - def scaffold_ids(self) -> list[int]: - """Return a list of :class:`.Compound` ID's for scaffolds of this set""" - scaffold_ids = self.db.execute( - f""" - SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchall() - return [i for (i,) in scaffold_ids] - - @property - def num_scaffolds(self) -> int: - """Return a count of scaffolds of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elabs(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" - - ids = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', - multiple=True, - none='quiet', - ) - - if not ids: - return None - - ids = [q for (q,) in ids] - from .cset import CompoundSet - - return CompoundSet(self.db, ids) - - @property - def num_elabs(self) -> int: - """Return a count of elaborations of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_base IN {self.str_ids} - """ - ).fetchone() - return count - - @property - def elab_df(self) -> 'pd.DataFrame': - """Get a DataFrame summarising the elaborations in this CompoundSet""" - from pandas import DataFrame - - cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) - - data = [] - for scaffold, elabs in cluster_dict.items(): - scaffold = self.db.get_compound(id=scaffold[0]) - elabs = CompoundSet(self.db, indices=elabs) - data.append( - dict( - scaffold_id=scaffold.id, - scaffold_compound=scaffold, - elabs=elabs, - num_elabs=len(elabs), - ) - ) - - return DataFrame(data) - - @property - def id_num_poses_dict(self) -> dict[int, int]: - """Get a dictionary mapping compound ids to the number of poses""" - - sql = f""" - SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_compound IN {self.str_ids} - GROUP BY pose_compound - """ - - records = self.db.execute(sql) - - assert records - - lookup = {k: v for k, v in records} - - for id in self.ids: - if id not in lookup: - lookup[id] = 0 - - return lookup - - @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 - - @property - def reaction_ids(self) -> list[int]: - """Returns a list of :class:`.Reaction` IDs that result in members of this set""" - records = self.db.select_where( - table='reaction', - query='reaction_id', - key=f'reaction_product IN {self.str_ids}', - multiple=True, - ) - if not records: - return None - return [r for (r,) in records] - - -class IngredientSet: - """An :class:`.Ingredient` is a :class:`.Compound` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. - - .. attention:: - - :class:`.IngredientSet` objects should not be created directly. Instead they are returned by several methods when working with :doc:`quoting` and :doc:`rgen`. - - Selecting ingredients in the set - ================================ - - The :class:`.IngredientSet` can be indexed like a Python list: - - :: - - ingredient = ingredient_set[0] # first ingredient - - To get the ingredient for a specific :class:`.Compound` ID: - - :: - - ingredient = ingredient_set(compound_id=13) - - """ - - _columns = [ - 'compound_id', - 'amount', - 'quote_id', - 'supplier', - 'max_lead_time', - 'quoted_amount', - ] - - def __init__( - self, - ingredients: 'None | list[Ingredient]' = None, - supplier: str | list | None = None, - debug: bool = False, - ) -> None: - """IngredientSet initialisation""" - - ingredients = ingredients or [] - - self._data = DataFrame(columns=self._columns, dtype=object) - - if debug: - mrich.debug(self._data) - - self._supplier = supplier - - for ingredient in ingredients: - self.add(ingredient) - - for col in self._columns: - assert col in self._data.columns, f'{col} not in df.columns' - - if debug: - mrich.debug(self._data) - - ### DUNDERS - - def __len__(self): - """The number of ingredients in this set""" - return len(self._data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Ingredient × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - def __add__(self, other): - """Add another :class:`.IngredientSet` this set""" - - for i, row in other._data.iterrows(): - self.add( - compound_id=row.compound_id, - amount=row.amount, - quote_id=row.quote_id, - supplier=row.supplier, - max_lead_time=row.max_lead_time, - quoted_amount=row.quoted_amount, - ) - - return self - - def __getitem__(self, key: int) -> 'Ingredient': - """Get a member by it's index""" - match key: - case int(): - series = self.df.loc[key] - return self._get_ingredient(series) - - case _: - raise NotImplementedError - - def __iter__(self): - """Iterate through the ingredients""" - return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) - - def __call__( - self, - *, - compound_id: int | None = None, - tag: str | None = None, - ) -> 'IngredientSet | Ingredient | CompoundSet': - """Get members based on a compound_id or tag""" - - if compound_id: - # get the ingredient with the matching compound ID - matches = self.df[self.df['compound_id'] == compound_id] - - if len(matches) == 0: - return None - - elif len(matches) != 1: - mrich.warning(f'Multiple ingredients in set with {compound_id=}') - # print(matches) - - return IngredientSet( - self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] - ) - - return self._get_ingredient(matches.iloc[0]) - - # elif tag: - # return self.compounds(tag=tag) - - else: - raise NotImplementedError - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.CompoundSet`""" - return getattr(self.compounds, key) - - def __contains__(self, other: Compound | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case Compound(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other - - return id in set(self.compound_ids) - - @classmethod - def from_ingredient_df( - cls, - df: 'DataFrame', - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from a DataFrame - - :param db: HIPPO Database - :param df: DataFrame of Ingredients - :param supplier: supplier to use for all quoting, (Default value = None) - - """ - # from numpy import nan - self = cls.__new__(cls) - - for col in cls._columns: - if col not in df.columns: - raise Exception(f'{col} not in df.columns') - df[col] = None - - self._data = df.copy() - self._supplier = supplier - - return self - - @classmethod - def from_json( - cls, - path: None | str, - supplier: str | list | None = None, - data: None | dict = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from JSON data or a JSON file - - :param db: HIPPO Database - :param path: path to JSON data (can be ``None`` if ``data`` provided) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - :param data: optional JSON data to parse, (Default value = ``None``) - - """ - - if not data: - data = json.load(open(path)) - - df = DataFrame(columns=cls._columns, dtype=object) - - for col in cls._columns: - df[col] = data[col] - - return cls.from_ingredient_df(df=df, supplier=supplier) - - @classmethod - def from_ingredient_dicts( - cls, - dicts: list[dict], - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries - - :param db: HIPPO Database - :param dicts: List of individual ingredient dictionaries - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - df = DataFrame(dicts, dtype=object) - return cls.from_ingredient_df(df=df, supplier=supplier) - - @classmethod - def from_compounds( - cls, - *, - compounds: 'CompoundSet | None' = None, - ids: list[int] | None = None, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs - - :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) - :param ids: Compound IDs (Default value = None) - :param db: HIPPO Database (Default value = None) - :param amount: Amount(s) in ``mg`` (Default value = 1) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - if not ids: - ids = compounds.ids - - df = DataFrame( - dict( - compound_id=ids, - amount=amount, - quote_id=None, - supplier=supplier, - max_lead_time=None, - quoted_amount=None, - ), - dtype=object, - ) - - return cls.from_ingredient_df(df) - - ### METHODS - - def get_price( - self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False - ) -> 'Price': - """Calculate the price with a given supplier - - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - pairs = {i: q for i, q in enumerate(self.df['quote_id'])} - - quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] - - if debug: - mrich.debug('quote_ids', quote_ids) - - if quote_ids: - qs = CataloguePrice.objects.filter(pk__in=quote_ids) - - if supplier: - qs = qs.filter(quote_supplier=supplier) - - if qs.exists(): - prices = [ - Price( - amount=k.quote_amount, - currency=k.quote_currency, - ) - for k in qs - ] - quoted = sum(prices, Price.null()) - else: - quoted = Price.null() - self.df['quote_id'] = None - pairs = {i: q for i, q in enumerate(self.df['quote_id'])} - - else: - quoted = Price.null() - - if debug: - mrich.debug('quoted', quoted) - - unquoted = [i for i, q in pairs.items() if q is None or isnan(q)] - - unquoted_price = Price.null() - - for i in unquoted: - ingredient = self[i] - - if debug: - mrich.debug('unquoted', i, ingredient) - - p = ingredient.price - - unquoted_price += p - - if debug: - mrich.debug(unquoted_price) - - quote = ingredient.quote - - if not quote: - mrich.warning('NULL Quote:', ingredient) - continue - - self.df.loc[i, 'quote_id'] = quote.id - - assert quote.amount - - self.df.loc[i, 'quoted_amount'] = quote.amount - - if debug: - mrich.debug('quoted', quoted) - mrich.debug('unquoted_price', unquoted_price) - mrich.error('end of IngredientSet.get_price()') - - return quoted + unquoted_price - - def interactive(self, **kwargs) -> None: - """Wrapper for :meth:`.CompoundSet.interactive`""" - self.compounds.interactive(**kwargs) - - def add( - self, - ingredient: 'Ingredient | None' = None, - *, - compound_id: int | None = None, - amount: float | None = None, - quote_id: int | None = None, - supplier: str | list[str] | None = None, - max_lead_time: float | None = None, - quoted_amount: float | None = None, - debug: bool = False, - ) -> None: - """Add an :class:`.Ingredient` to this set - - :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify other parameters, (Default value = None) - :param compound_id: :class:`.Compound` ID (Default value = None) - :param amount: amount in ``mg`` (Default value = None) - :param quote_id: :class:`.Quote` ID (Default value = None) - :param supplier: supplier name string or list (Default value = None) - :param max_lead_time: maximum lead-time for quoting (in days) (Default value = None) - :param quoted_amount: amount of associated :class:`.Quote` (Default value = None) - :param debug: increase verbosity for debugging (Default value = False) - - """ - - if ingredient: - compound_id = ingredient.compound.pk - amount = ingredient.amount - - if (q := ingredient.quote) and not ingredient.quote_id: - # I don't understand the logic for this. it's always - # true now. what was the meaning of storing id? - mrich.warning(f'Losing quote! {ingredient.quote=}') - - supplier = ingredient.supplier - max_lead_time = ingredient.max_lead_time - - if q is None: - quote_id = None - quoted_amount = None - else: - quote_id = q.id - quoted_amount = q.amount - - else: - assert compound_id - assert amount - - if quote_id: - # if not quoted_amount: - # mrich.warning(f'Requoting C{compound_id}...') - - assert quoted_amount - - supplier = self.supplier - - if self._data.empty: - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - self._data = addition - - else: - if compound_id in self._data['compound_id'].values: - index = self._data.index[ - self._data['compound_id'] == compound_id - ].tolist()[0] - self._data.loc[index, 'amount'] += amount - - # discard if the quote is no longer valid - if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ - index, 'amount' - ]: - self._data.loc[index, 'quote_id'] = None - self._data.loc[index, 'quoted_amount'] = None - - if debug and supplier: - mrich.debug('Adding to existing ingredient') - mrich.debug(f'{self._data.loc[index, "supplier"]=}') - mrich.debug(f'{supplier=}') - - else: - # from numpy import nan - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - - self._data = concat( - [self._data, addition], ignore_index=True, join='inner' - ) - - if debug: - mrich.out(addition) - - def _get_ingredient( - self, - series, - ) -> 'Ingredient': - """Get ingredient from one of the DataFrame rows""" - - q_id = series['quote_id'] - - if isinstance(q_id, float) and isnan(q_id): - q_id = None - - return Ingredient( - compound=Compound.objects.get(pk=series['compound_id']), - amount=series['amount'], - quote=q_id, - supplier=series['supplier'], - max_lead_time=series['max_lead_time'], - ) - - def copy(self) -> 'IngredientSet': - """Return a copy of this :class:`.IngredientSet`""" - return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) - - def draw(self) -> None: - """Wrapper for :meth:`.CompoundSet.draw`""" - self.compounds.draw() - - def set_amounts( - self, - amount: float | list[float], - ) -> None: - """Set the amount(s) for all ingredients in this set, and update quotes - - :param amount: amount in ``mg`` - - """ - - self.df['amount'] = amount - - # if amounts are modified the quotes should be cleared - self.df['quote_id'] = None - - assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) - - # # update quotes - # pairs = self.db.execute( - # f""" - # WITH matching_quotes AS ( - # SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote - # WHERE quote_compound IN {self.str_compound_ids} - # AND quote_amount >= {amount} - # GROUP BY quote_compound - # ) - # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - # LEFT JOIN matching_quotes ON quote_compound = compound_id - # WHERE compound_id IN {self.str_compound_ids} - # """ - # ).fetchall() - - qs = CataloguePrice.objects.filter( - compound__pk__in=self.compound_ids, - quote_amount__gte=amount, - ) - - for k in qs: - match = self.df.index[self.df['compound_id'] == k.compound.pk][0] - self.df.loc[match, 'quote_id'] = k.quote.pk - - def get_dict(self, data_orient: str = 'list') -> dict: - """Get serialisable dictionary - - :param data_orient: passed to ``pandas.DataFrame.to_dict`` (Default value = 'list') - - """ - return dict( - supplier=self.supplier, - data=self.df.to_dict(orient=data_orient), - ) - - def pop(self) -> Ingredient: - """Pop the last compound in this set""" - item = self[self.df.index[-1]] - self.df.drop(self.df.index[-1], inplace=True) - return item - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - self._data = self.df.sample(frac=1).reset_index(drop=True) - - ### PROPERTIES - - @property - def df(self) -> 'DataFrame': - """Access the raw DataFrame""" - return self._data - - @property - def price_df(self) -> 'DataFrame': - """DataFrame including prices""" - df = self.df.copy() - tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] - df['price'] = [t[0] for t in tuples] - df['lead_time'] = [t[1] for t in tuples] - df['quote_supplier'] = [t[2] for t in tuples] - return df - - @property - def price(self) -> 'Price': - """Total price of these ingredients""" - return self.get_price() - - @property - def supplier(self) -> str | list[str]: - """Supplier(s)""" - return self._supplier - - @supplier.setter - def supplier(self, s): - if isinstance(s, list) or isinstance(s, tuple): - for x in s: - assert isinstance(x, str) - else: - assert isinstance(s, str) - - self._supplier = s - self.df['supplier'] = [s] * len(self) - - @property - def smiles(self) -> list[str]: - """SMILES for all ingredients""" - compound_ids = list(self.df['compound_id']) - return Compound.objects.filter( - pk__in=compound_ids, - ).values_list('compound_smiles', flat=True) - - @property - def inchikeys(self) -> list[str]: - """InChI-keys for all ingredients""" - compound_ids = list(self.df['compound_id']) - return Compound.objects.filter( - pk__in=compound_ids, - ).values_list('compound_inchikeys', flat=True) - - @property - def compound_ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return list(self.df['compound_id'].values) - - @property - def ids(self) -> list[int]: - """Compound IDs for all ingredients""" - return self.compound_ids - - @property - def id_amount_pairs(self) -> list[tuple]: - """Get a list of compound ID and amount pairs""" - return [ - (id, amount) for id, amount in self.df[['compound_id', 'amount']].values - ] - - @property - def str_compound_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" - return str(tuple(self.df['compound_id'].values)).replace(',)', ')') - - @property - def compounds(self) -> 'CompoundSet': - """:class:`.CompoundSet` of all compounds in this set""" - return CompoundSet(self.compound_ids) - - @property - def quote_ids(self) -> list[int]: - """Get a list of quote ID's""" - - return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/src/designdb/sets/interaction.py b/src/designdb/sets/interaction.py deleted file mode 100644 index 9f75150..0000000 --- a/src/designdb/sets/interaction.py +++ /dev/null @@ -1,802 +0,0 @@ -"""Classes for working with sets of interactions""" - -import mcol -import mrich - -from designdb.models import Interaction - - -class InteractionTable: - """Class representing all :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.InteractionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.interactions` property. - - """ - - def __init__(self, db: 'Database', table: str = 'interaction') -> None: - """InteractionTable initialisation""" - - self._db = db - self._df = None - self._table = table - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def df(self) -> 'pandas.DataFrame': - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table='interaction', key='interaction_id > 0', multiple=True - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - - ### DUNDERS - - def __len__(self) -> int: - """The total number of interactions""" - return self.db.count(self.table) - - def __str__(self) -> str: - """Unformatted command-line representation""" - return f'{{I × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI formatted command-line representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich formatted command-line representation""" - return f'[bold underline]{self}' - - -class InteractionSet: - """Class representing a subset of the :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. - - .. attention:: - - :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. - - """ - - def __init__( - self, - indices: list = None, - ) -> None: - """InteractionSet initialisation""" - - indices = indices or [] - - if not isinstance(indices, list): - indices = list(indices) - - indices = [int(i) for i in indices] - - self._indices = sorted(list(set(indices))) - self._df = None - self._qs = Interaction.objects.filter(pk__in=indices) - - ### FACTORIES - - @classmethod - def from_pose( - cls, - pose: 'Pose | PoseSet', - table: str = 'interaction', - db: 'Database | None' = None, - ) -> 'InteractionSet': - """Construct a :class:`.InteractionSet` from one or more poses. - - :param pose: a :class:`.Pose` or :class:`.PoseSet` object - :param table: Database table name - :param db: Use this instead of Pose's Database - :returns: an :class:`.InteractionSet` - """ - - self = cls.__new__(cls) - - db = db or pose.db - - ### get the ID's - - from .pset import PoseSet - - if isinstance(pose, PoseSet): - # check if all poses have fingerprints - (has_invalid_fps,) = db.select_where( - query='COUNT(1)', - table='pose', - key=f'pose_id IN {pose.str_ids} AND pose_fingerprint = 0', - ) - - if has_invalid_fps: - mrich.warning(f'{has_invalid_fps} Poses have not been fingerprinted') - - sql = f""" - SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_pose IN {pose.str_ids} - """ - - else: - sql = f""" - SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_pose = {pose.id} - """ - - ids = db.execute(sql).fetchall() - - ids = [i for (i,) in ids] - - self.__init__(db, ids, table=table) - - return self - - @classmethod - def all( - cls, - ) -> 'InteractionSet': - """Construct a :class:`.InteractionSet` for all interactions in the table. - - :returns: an :class:`.InteractionSet` - - """ - - # bit of a round-trip - ids = Interaction.objects.values_list('pk', flat=True) - self = cls.__new__(cls) - self.__init__(ids) - - return self - - @classmethod - def from_residue( - cls, - db: 'Database', - residue_number: int, - chain: None | str = None, - target: 'Target | int' = 1, - ) -> 'InteractionSet': - """Get the set of interactions for a given residue number (and chain) - - :param db: HIPPO :class:`.Database` - :param residue_number: the residue number - :param chain: the chain name / letter, defaults to any chain - :param target: the protein :class:`.Target` object or ID, defaults to first target in database - :returns: a :class:`.InteractionSet` object - """ - - from .target import Target - - self = cls.__new__(cls) - - if isinstance(target, Target): - target = target.id - - sql = f""" - SELECT interaction_id FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE feature_target = {target} - AND feature_residue_number = {residue_number} - """ - - if chain: - sql += f' AND feature_chain_name = "{chain}"' - - ids = db.execute(sql).fetchall() - - ids = [i for (i,) in ids] - - self.__init__(db, ids) - - return self - - ### PROPERTIES - - @property - def indices(self) -> list[int]: - """Returns the ids of interactions in this set""" - return self._indices - - @property - def ids(self) -> list[int]: - """Returns the ids of interactions in this set""" - return self._indices - - @property - def types(self) -> list[str]: - """Returns the ids of interactions in this set""" - records = self.db.select_where( - query='interaction_type', - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - return [r for (r,) in records] - - @property - def db(self) -> 'Database': - """The associated HIPPO :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Get the name of the database table""" - return self._table - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def feature_ids(self) -> list[int]: - """Return a list of :class:`.Feature` ID's""" - records = self.db.select_where( - query='DISTINCT interaction_feature', - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - return [r for (r,) in records] - - @property - def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - return self.get_classic_fingerprint() - - @property - def df(self) -> 'pandas.DataFrame': - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - - @property - def residue_number_chain_pairs(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() - - @property - def avg_num_residues_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - - from numpy import mean - - d = defaultdict(set) - - for pose_id, res_num, chain_name in records: - d[pose_id].add((res_num, chain_name)) - - return mean(list(len(v) for v in d.values())) - - @property - def avg_num_interactions_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT interaction_pose FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - - from numpy import mean - - d = defaultdict(int) - - for (pose_id,) in records: - d[pose_id] += 1 - - return mean(list(d.values())) - - @property - def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - - from collections import defaultdict - - from numpy import mean - - d = defaultdict(set) - - for pose_id, type, res_num, chain_name in records: - d[pose_id].add((res_num, type, chain_name)) - - return mean(list(len(v) for v in d.values())) - - @property - def type_residue_number_chain_triples(self) -> list[tuple]: - """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() - - @property - def num_features(self) -> int: - """Count the funmber of protein :class:`.Feature`s with which interactions are formed""" - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT interaction_feature) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - """ - ).fetchone() - - return count - - @property - def avg_num_interactions_per_feature(self) -> float: - """Average number of interactions formed with each protein :class:`.Feature`""" - - (count,) = self.db.execute( - f""" - WITH counts AS - ( - SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - ) - - SELECT AVG(count) FROM counts - """ - ).fetchone() - - return count - - @property - def per_feature_count_hirsch(self) -> float: - """A measure for how evenly protein :class:`.Feature`s are being interacted with""" - - counts = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - counts = [count for f_id, count in counts] - - # return -std(counts) - - from hirsch import hirsch - - if not counts: - return 0 - - return hirsch(counts) - - ### METHODS - - def summary( - self, - families: bool = False, - ) -> None: - """Print a summary of this :class:`.InteractionSet`""" - - mrich.header(self) - - for interaction in self: - # print(interaction) - - # mrich.var(f'{interaction.family_str}', f'{interaction.distance:.1f}') - s = f'{interaction.description}' - - if families: - s += f' {interaction.feature.family} ~ {interaction.family}' - - mrich.var(s, f'{interaction.distance:.1f}', 'Å') - - def get_classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" - - pairs = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - return {f: c for f, c in pairs} - - def resolve( - self, - debug: bool = False, - commit: bool = True, - feature_cache: dict | None = None, - # table: str = 'interaction', - ) -> 'InteractionSet': - """Resolve into predicted key interactions. In place modification. - - :param debug: Increased verbosity for debugging (Default value = False) - :param commit: commit the changes (Default value = True) - :param feature_cache: lookup dictionary for feature data - :returns: a filtered :class:`.InteractionSet` - """ - - keep_list = [] - - table = self.table - - # get feature cache - - feature_cache = feature_cache or { - i: self.db.get_feature(id=i) for i in self.feature_ids - } - - ### H-Bonds (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrogen Bond" - GROUP BY interaction_atom_ids - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-stacking (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-stacking" - GROUP BY interaction_feature - """ - # INNER JOIN feature - # ON feature_id = interaction_feature - # GROUP BY feature_atom_names - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-cation (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-cation" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### electrostatic (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Electrostatic" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### sulfur-sulfur (all) - - sql = f""" - SELECT interaction_id - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Sulfur-Sulfur" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for (a,) in records] - keep_list += ids - - ### hydrophobic - - sql = f""" - SELECT interaction_id, interaction_distance - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrophobic" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - subset = InteractionSet(self.db, ids, table=table) - - # aggregate lumped - - hydrophobic_interactions_in_lumped = {} - lumped_hydrophobic_in_lumped_lumped = {} - - for interaction in subset: - feature = feature_cache[interaction.feature_id] - - families = (feature.family, interaction.family) - - if families == ('LumpedHydrophobe', 'Hydrophobe'): - for name in feature.atom_names.split(): - key = (name, interaction.atom_ids[0]) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ('Hydrophobe', 'LumpedHydrophobe'): - for atom_id in interaction.atom_ids: - key = (feature.atom_names, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): - for name in feature.atom_names.split(): - for atom_id in interaction.atom_ids: - key = (name, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - key = feature.atom_names - lumped_hydrophobic_in_lumped_lumped[key] = tuple(interaction.atom_ids) - - keep_hydrophobic_ids = set(subset.ids) - rev_hydrophobic_in_lumped_lumped = { - v: k for k, v in lumped_hydrophobic_in_lumped_lumped.items() - } - - # modify keep list by those covered in lumped - - for interaction in subset: - feature = feature_cache[interaction.feature_id] - - families = (feature.family, interaction.family) - - if families == ('Hydrophobe', 'Hydrophobe'): - key = (feature.atom_names, interaction.atom_ids[0]) - - if key in hydrophobic_interactions_in_lumped: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ('LumpedHydrophobe', 'Hydrophobe'): - key = feature.atom_names - - if key in lumped_hydrophobic_in_lumped_lumped: - atom_id = interaction.atom_ids[0] - value = lumped_hydrophobic_in_lumped_lumped[key] - if atom_id in value: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ('Hydrophobe', 'LumpedHydrophobe'): - key = tuple(interaction.atom_ids) - - if key in rev_hydrophobic_in_lumped_lumped: - atom_name = feature.atom_names - value = rev_hydrophobic_in_lumped_lumped[key] - - if atom_name in value: - keep_hydrophobic_ids -= set([interaction.id]) - - keep_list += list(keep_hydrophobic_ids) - - ### cull non-keepers - - cull_list = set(self.ids) - set(keep_list) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f'interaction_id IN {cull_iset.str_ids}', - commit=commit, - ) - self._indices = sorted(list(set(keep_list))) - - ### revisit hydrophobes - - # for a given protein feature, choose the closest interaction - - cull_list = [] - - hydrophobic_keeper_iset = InteractionSet(self.db, keep_hydrophobic_ids) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {hydrophobic_keeper_iset.str_ids} - GROUP BY interaction_feature - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - - cull_list = set(hydrophobic_keeper_iset.ids) - set(ids) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f'interaction_id IN {cull_iset.str_ids}', - commit=commit, - ) - self._indices = sorted(list(set(keep_list) - cull_list)) - - ### Summary - - # if debug: - # self.summary() - - ### DUNDERS - - def __len__(self) -> int: - """The number of interactions in this set""" - return len(self.indices) - - def __str__(self) -> str: - """Unformatted command-line representation""" - return f'{{I × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI formatted command-line representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich formatted command-line representation""" - return f'[bold underline]{self}' - - def __iter__(self): - """Iterate through interactions in this set""" - return iter( - self.db.get_interaction(id=i, table=self.table) for i in self.indices - ) - - def __getitem__(self, key) -> 'Interaction | InteractionSet': - """Get interaction or subsets thereof from this set""" - match key: - case int(): - index = self.indices[key] - return self.db.get_interaction(id=index, table=self.table) - - case slice(): - indices = self.indices[key] - return InteractionSet(self.db, indices, table=self.table) - - case _: - raise NotImplementedError - - -def df_from_interaction_records( - db: 'Database', - records: list[tuple], -) -> 'pandas.DataFrame': - """Construct a dataframe from the 'interaction' table records""" - - import json - - from pandas import DataFrame - - data = [] - for record in records: - ( - id, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) = record - - feature = db.get_feature(id=feature_id) - - d = dict(id=id) - - d['feature_id'] = feature_id - d['pose_id'] = pose_id - d['target_id'] = feature.target - - # d['type'] = INTERACTION_TYPES[(feature.family, family)] - d['type'] = type - - d['prot_family'] = feature.family - d['lig_family'] = family - - d['residue_name'] = feature.residue_name - d['residue_number'] = feature.residue_number - d['chain_name'] = feature.chain_name - - d['distance'] = distance - d['angle'] = angle - d['energy'] = energy - - d['prot_coord'] = json.loads(prot_coord) - d['lig_coord'] = json.loads(lig_coord) - - d['prot_atoms'] = feature.atom_names - d['lig_atoms'] = atom_ids - - d['backbone'] = feature.backbone - d['sidechain'] = feature.sidechain - - data.append(d) - - df = DataFrame.from_records(data=data) - - return df diff --git a/src/designdb/sets/pose.py b/src/designdb/sets/pose.py deleted file mode 100644 index 377f8f8..0000000 --- a/src/designdb/sets/pose.py +++ /dev/null @@ -1,2243 +0,0 @@ -import inspect -import json -import logging -import re -import shutil -from collections.abc import Callable -from itertools import combinations -from os.path import relpath -from pathlib import Path -from pprint import pprint -from zipfile import ZipFile - -import community as louvain -import mcol -import molparse as mp -import mrich -import networkx as nx -import pandas as pd -from django.conf import settings -from django.db import IntegrityError -from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery -from IPython.display import display -from ipywidgets import ( - BoundedIntText, - Checkbox, - GridBox, - Layout, - VBox, - interactive, - interactive_output, -) -from molparse.rdkit import draw_grid, draw_mols -from pandas import DataFrame -# from mypackage.services.compound import CompoundService -from rdkit import Chem -# from rdkit.Chem import inchi -from rdkit.Chem import PandasTools, SDWriter - -from designdb.models import ( - Compound, - Inspiration, - Interaction, - Pose, - PoseTag, - PoseTagJunction, - Subsite, - SubsiteTag, - Target, -) -from designdb.sets.interaction import InteractionSet -from designdb.utils import ScoreSubquery, normalize_string_list -from designdb.utils_frag import generate_header - -if settings.MANAGE_MODELS: - from designdb.utils import JsonGroupArray as ArrayAgg -else: - from django.contrib.postgres.aggregates import ArrayAgg - - -# from .validation.compound import ValidationError, validate_compound_data - -SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' -) -SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' -) - - -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') -PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') - - -logger = logging.getLogger(__name__) - - -class PoseSet: - """Object representing a subset of the 'pose' table in the :class:`.Database`. - - .. attention:: - - :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Pose` objects in the set: - - :: - - pset = animal.poses[:100] - - for pose in pset: - ... - - Check membership - ================ - - To determine if a :class:`.Pose` is present in the set: - - :: - - is_member = pose in cset - - Selecting compounds in the set - ============================== - - The :class:`.PoseSet` can be indexed like standard Python lists by their indices - - :: - - pset = animal.poses[1:100] - - # indexing individual compounds - pose = pset[0] # get the first pose - pose = pset[1] # get the second pose - pose = pset[-1] # get the last pose - - # getting a subset of compounds using a slice - pset2 = pset[13:18] # using a slice - - """ - - def __init__( - self, - queryset=None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """PoseSet initialisation""" - - # let's have a queryset no matter what. - if queryset: - self._queryset = queryset - else: - self._queryset = Pose.objects.none() - - self._name = name - if sort: - self._queryset = self._queryset.order_by('pk') - - self._interactions = None - self._metadata_dict = None - - ### DUNDERS - - def __str__(self): - """Unformatted string representation""" - if self.name: - s = f'{self._name}: ' - else: - s = '' - - s += f'{{P × {len(self)}}}' - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __len__(self) -> int: - """The number of poses in this set""" - return self._queryset.count() - - def __iter__(self): - """Iterate through poses in this set""" - return iter(self._queryset) - - def __getitem__( - self, - key: int | slice, - ) -> 'Pose | PoseSet': - """Get poses or subsets thereof from this set - - :param key: integer index or slice of indices - - """ - match key: - case int(): - try: - pose = Pose.objects.get(pk=key) - except Pose.DoesNotExist as exc: - mrich.error(f'list index out of range: {key=} for {self}') - raise Pose.DoesNotExist from exc - - return pose - - case slice(): - return PoseSet(Pose.objects.filter(pk__in=key)) - - case _: - raise NotImplementedError - - def __add__( - self, - other: 'PoseSet', - ) -> 'PoseSet': - """Add a :class:`.PoseSet` to this set""" - if isinstance(other, PoseSet): - return PoseSet( - Pose.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) - elif isinstance(other, Pose): - return PoseSet( - Pose.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), - sort=False, - ) - else: - raise NotImplementedError - - def __sub__( - self, - other: 'PoseSet', - ) -> 'PoseSet': - """Substract a :class:`.PoseSet` from this set""" - match other: - case PoseSet(): - return PoseSet( - Pose.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) - ), - sort=False, - ) - case int(): - return PoseSet( - Pose.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), - sort=False, - ) - - def __and__(self, other: 'PoseSet'): - """AND set operation, returns only poses in both sets""" - - match other: - case PoseSet(): - return PoseSet( - Pose.objects.filter( - Q(pk__in=self._queryset) & Q(pk__in=other.queryset) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __or__(self, other: 'PoseSet'): - """OR set operation, returns union of both sets""" - - match other: - case PoseSet(): - return PoseSet( - Pose.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __xor__(self, other: 'PoseSet'): - """Exclusive OR set operation, returns all poses in either set but not both""" - - match other: - case PoseSet(): - return PoseSet( - Pose.objects.filter( - Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) - & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) - ), - sort=False, - ) - - case _: - raise NotImplementedError - - def __call__( - self, - *, - tag: str = None, - target: int = None, - subsite: int = None, - ) -> 'PoseSet': - """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" - - if tag: - return self.get_by_tag(tag) - elif target: - return self.get_by_target(target=Target.objects.get(pk=target)) - elif subsite: - return self.get_by_subsite(subsite=Subsite.objects.get(pk=subsite)) - else: - raise NotImplementedError - - @classmethod - def get_by_references(cls, poseset: 'PoseSet') -> 'PoseSet': - return PoseSet( - Pose.objects.filter(pk__in=poseset._queryset.values('pose_reference')) - ) - - # there's a method get_by_inspiration - @classmethod - def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': - return PoseSet( - Pose.objects.filter( - pk__in=Inspiration.objects.filter( - derivative_pose__in=self._queryset, - ).values( - 'original_pose', - ), - ), - ) - - ### FILTERING - - def get_by_tag( - self, - tag: str, - inverse: bool = False, - ) -> 'PoseSet': - """Get all child poses with a certain tag - - :param tag: tag to filter by - :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) - - """ - self._queryset = self._queryset.annotate( - has_tag=Exists( - PoseTagJunction.objects.filter( - pose=OuterRef('pk'), - pose_tag__pose_tag_name=tag, - ), - ), - ) - if inverse: - return PoseSet(self._queryset.filter(has_tag=False)) - else: - return PoseSet(self._queryset.filter(has_tag=True)) - - def get_by_metadata( - self, key: str, value: str | None = None, debug: bool = False - ) -> 'PoseSet': - """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient - - :param key: metadata key to search for - :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) - - """ - results = self.db.select_where( - query='pose_id, pose_metadata', - key=f'pose_id IN {self.str_ids}', - table='pose', - multiple=True, - ) - - if value is None: - # metadata stored as string - return PoseSet( - self._queryset.filter(pose_metadata__contains=f'"{key}"'), - ) - - else: - if isinstance(value, str): - value = f'"{value}"' - - return PoseSet( - self._queryset.filter(pose_metadata__contains=f'"{key}: {value}"'), - ) - - def get_by_inspiration(self, inspiration: Pose, inverse: bool = False): - """Get all child poses with with this inspiration. - - :param inspiration: inspiration :class:`.Pose` ID or object - :param inverse: invert the selection (Default value = False) - - """ - # not entirely sure which way the filtering should go - qs = ( - Inspiration.objects.filter( - derivative_pose=inspiration, - ).values('original_pose'), - ) - - if inverse: - return PoseSet(self._queryset.exclude(pk__in=qs)) - else: - return PoseSet(self._queryset.filter(pk__in=qs)) - - def get_df( - self, - smiles: bool = True, - inchikey: bool = True, - alias: bool = True, - name: bool = True, - compound_id: bool = False, - target_id: bool = False, - reference_id: bool = False, - reference_alias: bool = False, - path: bool = False, - mol: bool = False, - energy_score: bool = False, - distance_score: bool = False, - inspiration_score: bool = False, - metadata: bool = False, - expand_metadata: bool = True, - debug: bool = True, - inspiration_ids: bool = False, - inspiration_aliases: bool = False, - derivative_ids: bool = False, - tags: bool = False, - expand_tags: bool = False, - subsites: bool = False, - # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs - ) -> 'pandas.DataFrame': - """Get a DataFrame of the poses in this set. - - :param smiles: include SMILES column (Default value = True) - :param inchikey: include InChIKey column (Default value = True) - :param alias: include alias column (Default value = True) - :param name: include name column (Default value = True) - :param compound_id: include :class:`.Compound` ID column (Default value = False) - :param reference_id: include reference :class:`.Pose` ID column (Default value = False) - :param target_id: include reference :class:`.Target` ID column (Default value = False) - :param path: include path column (Default value = False) - :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) - :param energy_score: include energy_score column (Default value = False) - :param distance_score: include distance_score column (Default value = False) - :param inspiration_score: include inspiration_score column (Default value = False) - :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param derivative_ids: include derivative :class:`.Pose` ID column - :param tags: include tags column - :param subsites: include subsites column - """ - - sig = inspect.signature(self.get_df) - flags = { - name: locals()[name] - for name in sig.parameters - if name not in ('self', 'debug', 'expand_tags', 'expand_metadata') - } - # need id in output - flags['id'] = True - - print('input flags', flags) - - # alias and name both point to same thing. prefer 'name' - if flags.get('name', False): - flags['alias'] = True - - # this is still not working right and I don't understand. What - # was the original code doing here? simply adding both fields, - # name and alias? - - # dict :: func arg: (col title, qs field lookup, queryset annotation) - # this is going to get out of hand with multiple scoring methods - fields = { - 'id': ('id', 'id', None), - 'smiles': ('smiles', 'pose_smiles', None), - 'inchikey': ('inchikey', 'pose_inchikey', None), - # 'alias': ('alias', 'pose_alias', None), - 'name': ('name', 'pose_alias', None), - 'compound_id': ('compound_id', 'compound__id', None), - 'target_id': ('target_id', 'target__id', None), - 'reference_id': ('reference_id', 'pose_reference', None), - 'reference_alias': ( - 'reference_alias', - 'reference_alias', - Subquery( - Pose.objects.filter( - pk=OuterRef('pose_reference'), - ).values('pose_alias')[0:1] - ), - ), - 'path': ('pose_path', 'pose_path', None), - 'mol': ('mol', 'pose_mol', None), - 'energy_score': ( - 'energy_score', - 'energy_score', - ScoreSubquery('energy_score'), - ), - 'distance_score': ( - 'distance_score', - 'distance_score', - ScoreSubquery('distance_score'), - ), - 'inspiration_score': ( - 'inspiration_score', - 'inspiration_score', - ScoreSubquery('inspiration_score'), - ), - 'metadata': ('metadata', 'pose_metadata', None), - 'inspiration_ids': ( - 'inspiration_ids', - 'inspiration_ids', - ArrayAgg('inspirations__id'), - # JsonGroupArray('inspirations__id'), - ), - 'inspiration_aliases': ( - 'inspiration_aliases', - 'inspiration_aliases', - ArrayAgg('inspirations__pose_alias'), - # JsonGroupArray('inspirations__pose_alias'), - ), - 'derivative_ids': ( - 'derivative_ids', - 'derivative_ids', - ArrayAgg('inspirations__id'), - # JsonGroupArray('inspirations__id'), - ), - 'tags': ( - 'tags', - 'tag_names', - ArrayAgg('tags__pose_tag_name'), - # JsonGroupArray('tags__pose_tag_name'), - ), - 'subsites': ( - 'subsites', - 'subsites_names', - ArrayAgg( - 'subsites__subsite_name', - filter=Q(subsites__isnull=False), - ), - # JsonGroupArray('subsites__subsite_name', filter=Q(subsites__isnull=False),), - ), - } - - annotations = { - v[1]: v[2] for k, v in fields.items() if flags.get(k, False) and v[2] - } - values = [v[1] for k, v in fields.items() if flags.get(k, False)] - columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} - - print('df values', values) - print('df columns', columns) - qs = self._queryset.annotate(**annotations).values(*values) - - print('queryset', self._queryset.count(), self._queryset) - - df = pd.DataFrame(qs) - print(df) - print('df columns from df before', df.columns) - df = df.rename(columns=columns) - print('df columns from df after', df.columns) - df = df.set_index('id') - - if alias: - df['alias'] = df.name - - if metadata and expand_metadata: - # TODO: code specific to my current situation. have to - # parse string to json (does postgres handle this - # automatically?) - # expanded = pd.json_normalize( - # df["metadata"].apply(lambda x: json.loads(x) if x else {}), - # ) - expanded = pd.json_normalize(df['metadata']) - # dropping columns is due to confusion with scores. can't be - # permanent solution, for now, drop the common ones - expanded = expanded.drop( - columns=set(expanded.columns).intersection(set(df.columns)), - ) - - df = df.drop(columns=['metadata']).join(expanded) - - if tags and expand_tags: - # surprisingly manual compared to expand_metadata, but - # kept running into problems - df['tags'] = df['tags'].apply(normalize_string_list) - # get all unique tags - all_tags = sorted(set(tag for tags in df['tags'] for tag in tags)) - - # build boolean columns - for tag in all_tags: - df[tag] = df['tags'].apply(lambda tags: tag in tags) - - df = df.drop(columns=['tags']) - - # custom aggreagte field is giving me string, parse to list - for col in [ - 'inspiration_aliases', - ]: - if col in df.columns: - df[col] = df[col].apply(normalize_string_list) - - return df - - def get_by_reference( - self, - ref_id: int, - ) -> 'PoseSet | None': - """Get poses with a certain reference id - - :param ref_id: reference :class:`.Pose` ID - - """ - qs = self._queryset.filter(pose_reference=ref_id) - if not qs.exists(): - # odd, but keeping now - return None - - return PoseSet(qs) - - def get_by_compound( - self, - *, - compound: 'int | Compound | CompoundSet', - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. - - :param compound: :class:`.Compound` object or ID - :returns: a :class:`.PoseSet` of the selection - - """ - if isinstance(compound, int): - return PoseSet(self._queryset.filter(compound__id=compound)) - elif isinstance(compound, Compound): - return PoseSet(self._queryset.filter(compound=compound)) - else: - # possible crash point: assuming CompoundSet but not - # testing type, still trying to fiugre out circular - # imports - return PoseSet(self._queryset.filter(compound__in=compound.queryset)) - - def get_by_target( - self, - *, - target: Target, - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. - - :param id: :class:`.Target` ID - :returns: a :class:`.PoseSet` of the selection - - """ - # where would you need this method?? do you ever create sets - # of poses from different targets? - return PoseSet(self._queryset.filter(target=target)) - - def get_by_subsite( - self, - *, - subsite: Subsite, - ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. - - :param id: :class:`.Subsite` ID - :returns: a :class:`.PoseSet` of the selection - - """ - qs = self._queryset.filter( - id__in=SubsiteTag.objects.filter( - subsite=subsite, - ).values('pose'), - ) - - if self.name: - name = f'{self.name} & subsite={subsite.pk}' - else: - name = None - - return PoseSet(qs, name=name) - - # def get_best_placed_poses_per_compound(self): - # """Choose the best placed pose (best distance_score) grouped by compound""" - - # sql = f""" - # SELECT pose_id, MIN(pose_distance_score) - # FROM {self.db.SQL_SCHEMA_PREFIX}pose - # WHERE pose_id IN {self.str_ids} - # GROUP BY pose_compound - # """ - - # cursor = self.db.execute(sql) - - # ids = [i for i, _ in cursor] - - # return PoseSet(self._queryset) - - # def filter( - # self, - # function=None, - # *, - # key: str = None, - # value: str = None, - # operator='=', - # inverse: bool = False, - # ): - # """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values - - # :param function: callable object - # :param key: database field for 'pose' table ('pose_' prefix not needed) - # :param value: value to compare to - # :param operator: comparison operator (default = "=") - # :param inverse: invert the selection (Default value = False) - - # """ - - # if function: - # ids = set() - # for pose in self: - # value = function(pose) - # # mrich.debug(f'{pose=} {value=}') - # if value and not inverse: - # ids.add(pose.id) - # elif not value and inverse: - # ids.add(pose.id) - - # return PoseSet(self.db, ids) - - # sql = f""" - # SELECT pose_id FROM {self.db.SQL_SCHEMA_PREFIX}pose - # WHERE pose_id IN {self.str_ids} - # AND pose_{key} {operator} {value} - # """ - - # cursor = self.db.execute(sql) - - # ids = [i for (i,) in cursor] - - # return PoseSet(self.db, ids) - - def add_tag( - self, - tag: str, - ) -> None: - """Add this tag to every member of the set""" - - assert isinstance(tag, str) - - pose_tag = PoseTag(pose_tag_name=tag) - pose_tag.save() - - PoseTagJunction.objects.bulk_create( - [PoseTagJunction(pose=pose, pose_tag=pose_tag) for pose in self._queryset], - ignore_conflicts=True, - ) - - mrich.print(f'Tagged {self} w/ "{tag}"') - - # refetch in case was evaluated - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - - # NB! I'm now realizing this is potentially a huge - # problem. with every evaluation and refretch some attributes - # may be lost. how can this be kept clean? - - # unused? the original method didn't save object - def append_to_metadata( - self, - key, - value, - ) -> None: - """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries - - :param key: the :class:`.Metadata` key to match - :param value: the value to append to the list - - """ - for pose in self._queryset: - # metadata = json.loads(pose.payload) - metadata = pose.pose_metadata - try: - metadata.append(key, value) - except AttributeError: - mrich.error(f'Could not append to metadata {key=}. Not a list?') - - pose.save() - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - - def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: - """Create and assign subsite entries from a metadata field - - :param field: the metadata field to use - - """ - for pose in self._queryset: - metadata = json.loads(pose.payload) - key = metadata.get(field) - if not key: - mrich.warning(field, 'not in metadata pose_id=', pose_id) - continue - - # I'm still not entirely clear can you really have - # posesets from different target, if not, and it really - # seems that not, this should be a single subsite - subsite, _ = Subsite.get_or_create(target=pose.target, subsite_name=key) - subsite_tag = SubsiteTag(subsite=subsite, pose=pose) - subsite_tag.save() - - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - - # TODO: implement scores - # def calculate_inspiration_scores( - # self, - # alpha: float = 0.95, - # beta: float = 0.05, - # score_type: str = 'combo', - # ) -> 'pd.DataFrame': - # """Set inspiration_score values using MoCASSIn.calculate_mocassin_tversky - - # :param alpha: Tversky alpha parameter - # :param beta: Tversky beta parameter - # :param score_type: Score type to add to database, choose from "combo", "shape", "colour" - # :returns: Pandas DataFrame with molecules and scores - # """ - - # from mocassin.mocassin import calculate_mocassin_tversky - - # df = self.get_df( - # alias=False, - # smiles=False, - # inchikey=False, - # inspiration_ids=True, - # mol=True, - # ) - - # inspirations = {p.id: p for p in self.inspirations} - - # df['inspiration_mols'] = df['inspiration_ids'].apply( - # lambda x: [inspirations[i].mol for i in x] - # ) - - # n = len(df) - - # for j, (i, row) in mrich.track( - # enumerate(df.iterrows()), prefix='MoCASSIn', total=n - # ): - # mrich.set_progress_field('j', j) - # mrich.set_progress_field('n', n) - - # try: - # combo, shape, colour = calculate_mocassin_tversky( - # row['inspiration_mols'], - # row['mol'], - # alpha=0.95, - # beta=0.05, - # ) - # df.loc[i, f'mocassin_combo({alpha},{beta})'] = combo - # df.loc[i, f'mocassin_shape({alpha},{beta})'] = shape - # df.loc[i, f'mocassin_colour({alpha},{beta})'] = colour - # except Exception as e: - # mrich.error(e) - - # tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() - - # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" - - # mrich.debug('Updating pose_inspiration_score values') - # self.db.executemany(sql, [(b, a) for a, b in tuples]) - # self.db.commit() - - # return df - - ### SPLITTING - - def split_by_reference(self) -> 'dict[int,PoseSet]': - """Split this :class:`.PoseSet` into subsets grouped by reference ID - - :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values - - """ - sets = {} - for ref_id in self.reference_ids: - sets[ref_id] = self.get_by_reference(ref_id) - return sets - - def split_by_inspirations( - self, - single_set: bool = False, - ) -> 'dict[PoseSet,PoseSet] | PoseSet': - """Split this :class:`.PoseSet` into subsets grouped by inspirations - - :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) - :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values - - """ - - sets = {} - - for pose in self._queryset: - insp_ids = list(pose.inspirations.distinct().values_list('pk', flat=True)) - key = tuple(insp_ids) - sets.setdefault(key, set()) - sets[key].add(pose.pk) - - mrich.var('#unique inspiration combinations', len(sets)) - - if single_set: - return PoseSet( - Pose.objects.filter( - pk__in=[id for s in sets.values() for id in s.ids], - sort=False, - ) - ) - - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) - - return { - PoseSet(Pose.objects.filter(pk__in=insp_ids)): PoseSet( - Pose.objects.filter(pk__in=pose_ids) - ) - for insp_ids, pose_ids in sets.items() - } - - ### EXPORTING - - def write_sdf( - self, - out_path: str, - name_col: str = 'alias', - inspiration_ids: bool = False, - inspiration_aliases: bool = False, - **kwargs, - ) -> None: - """Write an SDF - - :param out_path: filepath of the output - :param name_col: pose property to use as the name column, can be ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param fragalysis_inspirations: create inspirations column "ref_mols" - """ - - df = self.get_df( - mol=True, - inspiration_ids=inspiration_ids, - inspiration_aliases=inspiration_aliases, - name=name_col == 'name', - **kwargs, - ) - - print('what do I have for name col', name_col) - print(df.columns) - - if name_col not in ['name', 'alias', 'inchikey', 'id']: - # try getting name from metadata - records = self._queryset.values('id', 'pose_metadata') - - longcode_lookup = {} - for i, d in records: - if d: - metadata = json.loads(d) - else: - metadata = {} - - longcode_lookup[i] = metadata.get(name_col, None) - - values = [] - for i, row in df.iterrows(): - values.append(longcode_lookup[row['id']]) - - df[name_col] = values - - df = df.rename(columns={name_col: '_Name', 'mol': 'ROMol'}) - - mrich.writing(out_path) - - PandasTools.WriteSDF(df, out_path, 'ROMol', '_Name', list(df.columns)) - - # keep record of export - value = str(Path(out_path).resolve()) - # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) - self.append_to_metadata(key='exports', value=value) - - def to_fragalysis( - self, - out_path: str, - *, - method: str, - ref_url: str = 'https://hippo.winokan.com', - submitter_name: str, - submitter_email: str, - submitter_institution: str, - metadata: bool = True, - sort_by: str | None = None, - sort_reverse: bool = False, - generate_pdbs: bool = False, - copy_reference_pdbs: bool = False, - # ingredients: IngredientSet = None, - skip_no_reference: bool = True, - skip_no_inspirations: bool = True, - skip_metadata: list[str] | None = None, - tags: bool = True, - subsites: bool = True, - extra_cols: dict[str, list] = None, - inspiration_score: bool = True, - # name_col: str = "name", - **kwargs, - ): - """Prepare an SDF for upload to the RHS of Fragalysis. - - :param out_path: the file path to write to - :param method: method used to generate the compounds - :param ref_url: reference URL for the method - :param submitter_name: name of the person submitting the compounds - :param submitter_email: email of the person submitting the compounds - :param submitter_institution: institution name of the person submitting the compounds - :param metadata: include metadata in the output? (Default value = True) - :param skipmetadata: exclude metadata keys from output - :param sort_by: if set will sort the SDF by this column/field (Default value = None) - :param sort_reverse: reverse the sorting (Default value = False) - :param generate_pdbs: generate accompanying protein-ligand complex PDBs (Default value = False) - :param ingredients: get procurement and amount information from this :class:`.IngredientSet` (Default value = None) - :param tags: include a column for tags in the output (Default value = True) - :param subsites: include a column for subsites in the output (Default value = True) - :param extra_cols: extra_cols should be a dictionary with a key for each column name, and list values where the first element is the field description, and all subsequent elements are values for each pose. - - """ - - assert out_path.endswith('.sdf') - - _name_col = '_Name' - mol_col = 'ROMol' - mol_col = 'mol' - - # make sure references are defined: - logger.debug('entering') - - mrich.debug(len(self), 'poses in set') - poses = None - - if skip_no_reference: - values = self._queryset.filter(pose_reference__isnull=False) - - if not values.exists(): - mrich.debug('no references, quitting') - logger.warning('no references, quitting') - return - - poses = PoseSet(values) - - mrich.debug(len(poses), 'remaining after skipping null reference') - - if skip_no_inspirations: - if not poses: - poses = self - - values = Inspiration.objects.filter( - derivative_pose__in=self._queryset, - ).values( - 'derivative_pose', - ) - - if not values.exists(): - rich.debug('no inspirations, quitting') - logger.warning('no inspirations, quitting') - return - - poses = PoseSet(Pose.objects.filter(pk__in=values)) - - mrich.debug(len(poses), 'remaining after skipping null inspirations') - - if not poses: - # huh? - poses = PoseSet(self._queryset) - - mrich.var('#poses', len(poses)) - logger.debug('about to create df') - # get the dataframe of poses - - # TODO: this should not go through the df - - # Scope issue - this code expect access to all poses in the db - self._queryset = Pose.objects.all() - - pose_df = poses.get_df( - mol=True, - inspiration_ids=True, - # duplicate_name="original ID", - name=True, - compound_id=True, - reference_id=True, - metadata=metadata, - tags=tags, - subsites=subsites, - energy_score=True, - distance_score=True, - inspiration_score=inspiration_score, - # sanitise_null_metadata_values=True, - expand_tags=False, - # sanitise_tag_list_separator=";", - # sanitise_metadata_list_separator=";", - # skip_metadata=skip_metadata, - # **kwargs, - ) - - pose_df = pose_df.reset_index() - - # fix inspirations and reference column (comma separated aliases) - - lookup = {k.pk: k.pose_alias for k in self._queryset} - - inspiration_strs = [] - # for i, row in pose_df.iterrows(): - # strs = [] - # for i in normalize_string_list(row['inspiration_ids']): - # # this is what it did in original code - # alias = self._queryset.get(pk=i).pose_alias - # if not alias: - # continue - # strs.append(alias) - # inspiration_strs.append(','.join(strs)) - - # comma separate subsites - if subsites: - - def fix_subsites(subsite_list: list[str]): - """Fix subsites""" - if not subsite_list: - logger.warning('no subsite list') - return 'None' - return ','.join(subsite_list) - - pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) - - if tags: - pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(x)) - - # pose_df['ref_mols'] = inspiration_strs - pose_df['ref_mols'] = 'inspiration_strs' - pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup[x]) - - # add compound identifier column (inchikey?) - - drops = ['inspiration_ids', 'reference_id'] - - # if ingredients: - # drops.pop(drops.index("compound")) - - if skip_no_reference: - prev = len(pose_df) - pose_df = pose_df[pose_df['reference_id'].notna()] - if len(pose_df) < prev: - mrich.warning(f'Skipping {prev - len(pose_df)} Poses with no reference') - - pose_df = pose_df.drop(columns=drops, errors='ignore') - - pose_df[_name_col] = pose_df['name'] - - pose_df.rename( - inplace=True, - columns={ - 'id': 'HIPPO Pose ID', - 'compound_id': 'HIPPO Compound ID', - 'mol': mol_col, - # "smiles": "original SMILES", - # "compound_id": "compound inchikey", - }, - ) - - extras = { - 'HIPPO Pose ID': 'HIPPO Pose ID', - 'HIPPO Compound ID': 'HIPPO Compound ID', - 'smiles': 'smiles', - 'ref_pdb': 'protein reference', - 'ref_mols': 'fragment inspirations', - 'alias': 'alias', - # "compound inchikey": "compound inchikey", - 'distance_score': 'distance_score', - 'energy_score': 'energy_score', - 'inspiration_score': 'inspiration_score', - } - - if subsites: - extras['subsites'] = 'subsites' - - if tags: - extras['tags'] = 'tags' - - if extra_cols: - for key, value in extra_cols.items(): - extras[key] = value[0] - - # if ingredients: - - # q_entries = [] - # q_prices = [] - # q_lead_times = [] - # q_amounts = [] - - # currency = None - - # for i, row in pose_df.iterrows(): - - # compound_id = self.db.get_compound_id(inchikey=row["compound inchikey"]) - - # ingredient = ingredients(compound_id=compound_id) - - # if isinstance(ingredient, IngredientSet): - # ingredient = sorted( - # [i for i in ingredient], key=lambda x: x.quote.price - # )[0] - - # quote = ingredient.quote - # if not currency: - # currency = quote.currency - # else: - # assert quote.currency == currency - - # q_entries.append(quote.entry_str) - # q_prices.append(quote.price) - # q_lead_times.append(quote.lead_time) - # q_amounts.append(quote.amount) - - # pose_df["Supplier Catalogue Entry"] = q_entries - # # pose_df['Supplier:Catalogue:Entry'] = q_entries - # pose_df[f"Price ({currency})"] = q_prices - # pose_df["Lead time (working days)"] = q_lead_times - # pose_df["Amount (mg)"] = q_amounts - - # extras["Supplier Catalogue Entry"] = "Supplier Catalogue Entry string" - # extras[f"Price ({currency})"] = "Quoted price" - # extras["Lead time (working days)"] = "Quoted lead-time" - # extras["Amount (mg)"] = "Quoted amount" - - out_path = Path(out_path).resolve() - mrich.var('out_path', out_path) - - if generate_pdbs: - # output subdirectory - out_key = Path(out_path).name.removesuffix('.sdf') - pdb_dir = Path(out_path).parent / Path(out_key) - pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f'{out_key}_pdbs.zip' - - # create the zip archive - with ZipFile(str(zip_path.resolve()), 'w') as z: - # loop over poses - for (i, row), pose in zip(pose_df.iterrows(), poses, strict=False): - # filenames - pdb_name = f'{out_key}_{row._Name}.pdb' - pdb_path = pdb_dir / pdb_name - pose_df.loc[i, 'ref_pdb'] = pdb_name - - # generate the PL-complex - sys = pose.complex_system - - # write the PDB - mrich.writing(pdb_path) - sys.write(pdb_path, verbosity=0) - z.write(pdb_path) - - mrich.writing(f'{out_key}_pdbs.zip') - - if copy_reference_pdbs: - # output subdirectory - out_key = Path(out_path).name.removesuffix('.sdf') - pdb_dir = Path(out_path).parent / Path(out_key) - pdb_dir.mkdir(exist_ok=True) - zip_path = Path(out_path).parent / f'{out_key}_refs.zip' - - references = self.references - # lookup = self.db.get_pose_alias_path_dict(references) - lookup = {k.pose_alias: k.pose_path for k in self._queryset} - - zips = set() - for ref_alias in pose_df['ref_pdb'].values: - source_path = Path(lookup[ref_alias]) - - apo_path = source_path.parent / source_path.name.replace( - '_hippo.pdb', '.pdb' - ).replace('.pdb', '_apo-desolv.pdb') - - if not apo_path.exists(): - sys = mp.parse(source_path).protein_system - sys.write(apo_path, verbosity=0) - - target_path = pdb_dir / f'{ref_alias}.pdb' - - if not target_path.exists(): - mrich.writing(target_path) - shutil.copy(apo_path, target_path) - - zips.add(target_path) - - # create the zip archive - with ZipFile(str(zip_path.resolve()), 'w') as z: - for path in zips: - z.write(path, arcname=path.name) - - mrich.writing(f'{out_key}_refs.zip') - - # create the header molecule - - df_cols = set(pose_df.columns) - - header = generate_header( - # self[0], # <- what does that do?? - self._queryset.first(), - method=method, - ref_url=ref_url, - submitter_name=submitter_name, - submitter_email=submitter_email, - submitter_institution=submitter_institution, - extras=extras, - metadata=metadata, - ) - - header_cols = set(header.GetPropNames()) - - # # empty properties - # pose_df["generation_date"] = [None] * len(pose_df) - # pose_df["submitter_name"] = [None] * len(pose_df) - # pose_df["method"] = [None] * len(pose_df) - # pose_df["submitter_email"] = [None] * len(pose_df) - # pose_df["ref_url"] = [None] * len(pose_df) - - if extra_cols: - for key, value in extra_cols.items(): - if len(value) != len(pose_df) + 1: - mrich.error( - f'extra_col "{key}" does not have the correct number of values' - ) - raise ValueError( - f'extra_col "{key}" does not have the correct number of values' - ) - pose_df[key] = value[1:] - - if sort_by: - pose_df = pose_df.sort_values(by=sort_by, ascending=not sort_reverse) - - fields = [] - - mrich.writing(out_path) - - with open(out_path, 'w') as sdfh: - with SDWriter(sdfh) as w: - w.write(header) - PandasTools.WriteSDF( - pose_df, sdfh, mol_col, _name_col, set(pose_df.columns) - ) - - # keep record of export - value = str(Path(out_path).resolve()) - - # FIXME - # self.db.remove_metadata_list_item(table='pose', key='exports', value=value) - - self.append_to_metadata(key='exports', value=value) - - return pose_df - - def to_pymol(self, prefix: str | None = None) -> None: - """Group the poses by reference protein and inspirations and output relevant PDBs and SDFs. - - :param prefix: prefix to give all output subdirectories (Default value = None) - - """ - - commands = [] - - prefix = prefix or '' - if prefix: - prefix = f'{prefix}_' - - from pathlib import Path - - for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): - ref_pose = Pose.objects.get(id=ref_id) - ref_name = ref_pose.pose_alias or ref_id - - # create the subdirectory - ref_dir = Path(f'{prefix}ref_{ref_name}') - mrich.writing(ref_dir) - ref_dir.mkdir(parents=True, exist_ok=True) - - # write the reference protein - ref_pdb = ref_dir / f'ref_{ref_name}.pdb' - ref_pose.protein_system.write(ref_pdb, verbosity=0) - - # color the reference: - commands.append(f'load {ref_pdb.resolve()}') - commands.append('hide') - commands.append('show lines') - commands.append('show surface') - commands.append('util.cbaw') - commands.append('set surface_color, white') - commands.append('set transparency, 0.4') - - for j, (insp_ids, poses) in enumerate( - poses.split_by_inspirations().items() - ): - inspirations = PoseSet(self.db, insp_ids) - insp_names = '-'.join(inspirations.names) - - # create the subdirectory - insp_dir = ref_dir / insp_names - insp_dir.mkdir(parents=True, exist_ok=True) - - # write the inspirations - insp_sdf = insp_dir / f'{insp_names}_frags.sdf' - inspirations.write_sdf(insp_sdf) - - commands.append(f'load {insp_sdf.resolve()}') - commands.append( - f'set all_states, on, {insp_sdf.name.removesuffix(".sdf")}' - ) - commands.append(f'util.rainbow "{insp_sdf.name.removesuffix(".sdf")}"') - - # write the poses - pose_sdf = insp_dir / f'{insp_names}_derivatives.sdf' - poses.write_sdf(pose_sdf) - - commands.append(f'load {pose_sdf.resolve()}') - commands.append(f'util.cbaw "{pose_sdf.name.removesuffix(".sdf")}"') - - if j > 0: - commands.append(f'disable "{insp_sdf.name.removesuffix(".sdf")}"') - commands.append(f'disable "{pose_sdf.name.removesuffix(".sdf")}"') - - return '; '.join(commands) - - def to_knitwork( - self, out_path: str, path_root: str = '.', aligned_files_dir: str | None = None - ) -> None: - """Knitwork takes a CSV input with: - - - observation shortcode - - smiles - - path_to_ligand_mol - - path_to_pdb - - :param out_path: path to output CSV - :param path_root: paths in CSV will be relative to here - - """ - - out_path = Path(out_path).resolve() - path_root = Path(path_root).resolve() - mrich.var('out_path', out_path) - mrich.var('path_root', path_root) - mrich.var('aligned_files_dir', aligned_files_dir) - - assert out_path.name.endswith('.csv') - - with open(out_path, 'w') as f: - mrich.writing(out_path) - - for pose in self._queryset: - assert pose.pose_alias - assert pose.tags.filter(pose_tag_name='hits').exists() - - if aligned_files_dir: - mol = str(pose.mol_path) - pdb = str(pose.apo_path) - - assert 'aligned_files' in mol - assert 'aligned_files' in pdb - - mol = mol.split('aligned_files/')[-1] - pdb = pdb.split('aligned_files/')[-1] - - aligned_files_dir = Path(aligned_files_dir) - - mol = relpath(aligned_files_dir / mol, path_root) - pdb = relpath(aligned_files_dir / pdb, path_root) - - else: - mol = relpath(pose.mol_path, path_root) - pdb = relpath(pose.apo_path, path_root) - - data = [pose.pose_alias, pose.compound.compound_smiles, mol, pdb] - - f.write(','.join(data)) - f.write('\n') - - def to_syndirella( - self, out_key: 'str | Path', separate: bool = False - ) -> 'DataFrame': - """Create syndirella inputs""" - - out_key = Path('.') / out_key - - out_dir = out_key.parent - out_key = out_key.name - - mrich.var('out_key', out_key) - mrich.var('#poses', len(self)) - - out_dir.mkdir(parents=True, exist_ok=True) - - ### Prepare Syndirella CSV data - - df = self.get_df( - inchikey=False, alias=False, reference_alias=True, inspiration_aliases=True - ) - df = df.rename(columns={'reference_alias': 'template'}) - - # compound_set - - if separate: - df['compound_set'] = df.apply( - lambda row: f'{out_key}_{row["name"]}', axis=1 - ) - - else: - df['compound_set'] = out_key - - # template - - null_template = df['template'].isnull() - if null_template.any(): - mrich.warning( - len(null_template), 'poses have no reference. Setting to self' - ) - mrich.print(df.loc[null_template, 'name'].values) - df['template'] = df['template'].fillna(df['name']) - - # inspirations - - null_inspirations = df['inspiration_aliases'].apply(lambda x: not x) - - if null_inspirations.any(): - mrich.warning( - len(null_inspirations), 'poses have no inspirations. Setting to self' - ) - mrich.print(df.loc[null_inspirations, 'name'].values) - df.loc[null_inspirations, 'inspiration_aliases'] = df.loc[ - null_inspirations - ].apply(lambda row: set([row['name']]), axis=1) - - for i, row in df.iterrows(): - for j, inspiration in enumerate(row['inspiration_aliases']): - df.loc[i, f'hit{j + 1}'] = inspiration - - # this from original code. looking at the data type I have, - # this cannot possibly work. did I get something wrong filling - # the df? - # all_inspirations = set.union(*list(df['inspiration_aliases'].values)) - all_inspirations = set().union(*df['inspiration_aliases']) - - df = df.drop(columns=['name', 'inspiration_aliases']) - - ### Copy Templates - - template_dir = out_dir / 'templates' - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) - - templates = df['template'].unique() - - # records = self._queryset.filter(pose_alias__in=templates) - records = Pose.objects.filter( - target__in=self.targets, - pose_alias__in=templates, - ) - - templates = PoseSet(records) - - for ref in templates: - template = template_dir / ref.apo_path.name - if not template.exists(): - mrich.writing(template) - shutil.copy(ref.apo_path, template) - - ### Inspirations - print('all inspirations', all_inspirations) - # records = self._queryset.filter(pose_alias__in=all_inspirations) - # isn't this overwriting the one few lines above?? - records = Pose.objects.filter( - target__in=self.targets, pose_alias__in=all_inspirations - ) - - all_inspirations = PoseSet(records) - - ### Write CSV - - if separate: - for i, row in df.iterrows(): - csv_name = out_dir / f'{row["compound_set"]}_syndirella_input.csv' - mrich.writing(csv_name) - row.to_frame().T.to_csv(csv_name, index=False) - - else: - csv_name = out_dir / f'{out_key}_syndirella_input.csv' - mrich.writing(csv_name) - df.to_csv(csv_name, index=False) - - ### Write Inspirations - - sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' - all_inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col='name', - ) - - return df - - ### OUTPUT - - def interactive( - self, - print_name: str = True, - method: str | None = None, - function: Callable | None = None, - **kwargs, - ): - """Interactive widget to navigate compounds in the table - - :param print_name: print the :class:`.Pose` name (Default value = True) - :param method: pass the name of a :class:`.Pose` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) - :param function: pass a callable which will be called as `function(pose)` - - """ - - if method: - - def widget(i): - """Method widget""" - pose = self[i] - if print_name: - print(repr(pose)) - value = getattr(pose, method)(**kwargs) - if value: - display(value) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description='Pose:', - disabled=False, - ), - ) - - elif function: - - def widget(i): - """Function widget""" - pose = self[i] - if print_name: - display(pose) - function(pose) - - return interactive( - widget, - i=BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description='Pose:', - disabled=False, - ), - ) - - else: - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Pose (/{len(self)}):', - disabled=False, - ) - - b = Checkbox(description='Name', value=True) - c = Checkbox(description='Summary', value=False) - h = Checkbox(description='Tags', value=False) - i = Checkbox(description='Subsites', value=False) - d = Checkbox(description='2D (Comp.)', value=False) - e = Checkbox(description='2D (Pose)', value=False) - f = Checkbox(description='3D', value=True) - g = Checkbox(description='Metadata', value=False) - - ui1 = GridBox( - [b, c, d, h], - layout=Layout(grid_template_columns='repeat(4, 100px)'), - ) - ui2 = GridBox( - [e, f, g, i], - layout=Layout(grid_template_columns='repeat(4, 100px)'), - ) - ui = VBox([a, ui1, ui2]) - - def widget( - i, - name: bool = True, - summary: bool = True, - grid: bool = True, - draw2d: bool = True, - draw: bool = True, - tags: bool = True, - subsites: bool = True, - metadata: bool = True, - ): - """Default widget""" - pose = self._queryset.get(pk=i) - if name: - print(repr(pose)) - - if summary: - pose.summary(metadata=False, tags=False, subsites=False) - if tags: - print(pose.tags) - if subsites: - print(pose.subsites) - if grid: - pose.grid() - if draw2d: - pose.draw2d() - if draw: - pose.draw() - if metadata: - mrich.title('Metadata:') - pprint(pose.metadata) - - out = interactive_output( - widget, - { - 'i': a, - 'name': b, - 'summary': c, - 'grid': d, - 'draw2d': e, - 'draw': f, - 'metadata': g, - 'tags': h, - 'subsites': i, - }, - ) - - display(ui, out) - - def summary(self) -> None: - """Print a summary of this pose set""" - mrich.header('PoseSet()') - mrich.var('#poses', len(self)) - mrich.var('#compounds', self.num_compounds) - mrich.var('tags', self.tags) - - def draw(self) -> None: - """Render this pose set with Py3Dmol""" - - mols = [p.mol for p in self] - - drawing = draw_mols(mols) - # display(drawing) - - def grid(self) -> None: - """Draw a grid of all contained molecules""" - - data = [(p.name, p.compound.mol) for p in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] - - drawing = draw_grid(mols, labels=labels) - display(drawing) - - # TODO: disabled, the field subsite_tag_ref doesn't exist anymore, - # don't know what the query is doing - # def subsite_summary(self) -> 'pd.DataFrame': - # """Print a table counting poses by subsite""" - - # sql = f""" - # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite - # INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag - # ON subsite_id = subsite_tag_ref - # WHERE subsite_tag_pose IN {self.str_ids} - # GROUP BY subsite_name - # """ - - # cursor = self.db.execute(sql) - - # df = DataFrame( - # [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] - # ) - - # df = df.set_index('id') - - # df = df.sort_values(by='num_poses', ascending=False) - - # mrich.print(df) - - # return df - - def get_interaction_overlaps(self, return_pairs: bool = False) -> int: - """Count the number of member pose pairs which share at least one but not all interactions""" - - records = Interaction.objects.filter( - pose__in=self._queryset, - ).values( - 'pose', - 'feature', - 'interaction_type', - ) - - ISETS = {} - for r in records: - pose_id = r['pose'] - feature_id = r['feature'] - interaction_type = r['interaction_type'] - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_id)) - ISETS[pose_id] = values - - ids = [i for i in self.ids if i in ISETS] - - count = 0 - - pairs = set() - - for pose_j, pose_k in combinations(ids, 2): - iset_j = ISETS[pose_j] - iset_k = ISETS[pose_k] - - intersection = iset_j & iset_k - diff1 = iset_j - iset_k - diff2 = iset_k - iset_j - - if intersection and diff1 and diff2: - count += 1 - pairs.add((pose_j, pose_k)) - - if return_pairs: - return [PoseSet(Pose.objects.filter(pk__in[a, b])) for a, b in pairs] - - return count - - def get_interaction_clusters(self) -> 'dict[int, PoseSet]': - """Cluster poses based on shared interactions.""" - - # get interaction records - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - records = Interaction.objects.filter( - pose__in=self._queryset, - ).values( - 'pose', - 'feature__feature_residue_name', - 'feature__feature_residue_number', - 'interaction_type', - ) - - ISETS = {} - for r in records: - pose_id = r['pose'] - feature_residue_name = r['feature_residue_name'] - feature_residue_number = r['feature_residue_number'] - interaction_type = r['interaction_type'] - values = ISETS.get(pose_id, set()) - values.add((interaction_type, feature_residue_name, feature_residue_number)) - ISETS[pose_id] = values - - pairs = combinations(ISETS.keys(), 2) - - # construct overlap dictionary - - OVERLAPS = {} - for id1, id2 in pairs: - iset1 = ISETS[id1] - iset2 = ISETS[id2] - OVERLAPS[(id1, id2)] = len(iset1 & iset2) - - # make the graph - G = nx.Graph() - - for (id1, id2), count in OVERLAPS.items(): - G.add_edge(id1, id2, weight=count) - - # partition the graph - - partition = louvain.best_partition(G, weight='weight') - - # find the clusters - - clusters = {} - for node, cluster_id in partition.items(): - clusters.setdefault(cluster_id, set()).add(node) - - # create the PoseSets - - psets = { - i: PoseSet(Pose.objects.filter(pk__in=ids), name=f'Cluster {i}') - for i, ids in enumerate(clusters.values()) - } - - all_ids = set(sum((pset.ids for pset in psets.values()), [])) - - # calculate modal interactions - - for i, cluster in psets.items(): - mrich.var(cluster.name, len(cluster), unit='poses') - - df = cluster.interactions.df - - unique_counts = df.groupby(['type', 'residue_name', 'residue_number'])[ - 'pose_id' - ].nunique() - - max_count = unique_counts.max() - max_pairs = unique_counts[unique_counts == max_count] - - for ( - interaction_type, - residue_name, - residue_number, - ) in max_pairs.index.values: - mrich.print(interaction_type, 'w/', residue_name, residue_number) - - # unclustered - unclustered = set(i for i in self.ids if i not in all_ids) - psets[None] = PoseSet( - Pose.objects.filter(pk__in=unclustered), name='Unclustered' - ) - - return psets - - ### PROPERTIES - - @property - def queryset(self) -> QuerySet[Pose]: - """Returns the ids of poses in this set""" - return self._queryset - - @property - def indices(self) -> list[int]: - """Returns the ids of poses in this set""" - return self.queryset.values_list('id', flat=True) - - @property - def ids(self) -> list[int]: - """Returns the ids of poses in this set""" - return self.indices - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def names(self) -> list[str]: - """Returns the aliases of poses in this set""" - return self._queryset.values_list('pose_alias', flat=True) - - @property - def aliases(self) -> list[str]: - """Returns the aliases of child poses""" - return self._queryset.values_list('pose_alias', flat=True) - - @property - def inchikeys(self) -> list[str]: - """Returns the inchikeys of child poses""" - return self._queryset.values_list('pose_inchikey', flat=True) - - @property - def id_name_dict(self) -> dict: - """Return a dictionary mapping pose ID's to their name""" - return {p.pk: p.pose_alias for p in Pose.objects.all()} - - @property - def smiles(self) -> list[str]: - """Returns the smiles of poses in this set""" - return self._queryset.values_list('pose_smiles', flat=True) - - @property - def tags(self) -> set[str]: - """Returns the set of unique tags present in this pose set""" - return self._queryset.values_list('tags__pose_tag_name', flat=True).distinct() - - @property - def num_fingerprinted(self) -> int: - """Count the number of fingerprinted poses""" - # that's one field suspect not in use - return self._queryset.filter(pose_fingerprint=1).count() - - # seems unused and causes circular dependency - # @property - # def compounds(self) -> 'CompoundSet': - # """Get the compounds associated to this set of poses""" - # from .cset import CompoundSet - - # ids = self.db.select_where( - # table='pose', - # query='DISTINCT pose_compound', - # key=f'pose_id in {self.str_ids}', - # multiple=True, - # ) - # ids = [v for (v,) in ids] - # return CompoundSet(self.db, ids) - - @property - def mols(self) -> list[Chem.rdchem.Mol]: - """Get the rdkit Molecules contained in this set""" - return self._queryset.values_list('pose_mol', flat=True) - - @property - def num_compounds(self) -> int: - """Count the compounds associated to this set of poses""" - return self._queryset.values('compound').distinct().count() - - @property - def df(self) -> pd.DataFrame: - """Get a DataFrame of the poses in this set""" - return self.get_df(mol=True) - - @property - def references(self) -> 'PoseSet': - """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" - # TODO: call through proper factory method - return self.get_by_references(self) - - @property - def reference_ids(self) -> set[int]: - """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" - return self.get_by_references(self).values_list('pk', flat=True) - - @property - def inspiration_sets(self) -> list[set[int]]: - """Return a list of unique sets of inspiration :class:`.Pose` IDs""" - - pairs = Inspiration.objects.filter(derivative_pose__in=self._queryset) - data = {} - for p in pairs: - if p.derivative_pose not in data: - data[p.derivative_pose] = set() - data[p.derivative_pose].add(p.original_pose) - - data = {k: tuple(sorted(list(v))) for k, v in data.items()} - - unique = set(data.values()) - - return unique - - @property - def num_inspiration_sets(self) -> int: - """Return the number of unique sets of inspirations""" - return len(self.inspiration_sets) - - @property - def num_inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - # fmt: off - return Inspiration.objects.filter( - derivative_pose__in=self._queryset, - ).values( - 'original_pose', - ).distinct().count() - # fmt: on - - @property - def inspirations(self) -> int: - """Return the number of unique inspirations for poses in this set""" - return self.get_by_inspirations(self._queryset) - - # @property - # def str_ids(self) -> str: - # """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" - # return str(tuple(self.ids)).replace(',)', ')') - - @property - def targets(self) -> QuerySet[Target]: - """Returns the :class:`.Target` objects of poses in this set""" - return Target.objects.filter(pk__in=self._queryset.values('target')) - - @property - def target_names(self) -> list[str]: - """Returns the :class:`.Target` objects of poses in this set""" - return self.targets.values_list('target_name', flat=True) - - @property - def target_ids(self) -> list[int]: - """Returns the :class:`.Target` objects ID's of poses in this set""" - return self.targets.values_list('id', flat=True) - - @property - def best_placed_pose(self) -> Pose: - """Returns the pose with the best distance_score in this subset""" - return self._queryset.get(pk=self.best_placed_pose_id) - - @property - def best_placed_pose_id(self) -> int: - """Get the id of the pose with the best distance_score in this subset""" - - # if len(self) == 1: - # return self.ids[0] - - # query = 'pose_id, MIN(pose_distance_score)' - # query = self.db.select_where( - # table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False - # ) - # return query[0] - - # TODO: scoring not implemented yet - return self.queryset.first().pk - - @property - def interactions(self) -> 'InteractionSet': - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" - if self._interactions is None: - self._interactions = InteractionSet.from_pose(self) - return self._interactions - - @property - def pose_id_metadata_dict(self) -> dict[int, dict]: - """Get a dictionary mapping pose_ids to metadata dicts""" - if self._metadata_dict is None: - metadata = {} - for p in self._queryset: - metadata[p.pk] = p.pose_metadata - self._metadata_dict = metadata - return self._metadata_dict - - @property - def fraction_fingerprinted(self) -> float: - """Return the fraction of fingerprinted poses in this set""" - return self.num_fingerprinted / len(self) - - @property - def num_subsites(self) -> int: - """Count the number of subsites that poses in this set come into contact with""" - return Subsite.objects.filter(pose__in=self._queryset).distinct().count() - - @property - def subsite_balance(self) -> float: - """Measure of how evenly subsite counts are distributed across poses in this set""" - # TODO: subsites not implemented yet - # from numpy import std - - # sql = f""" - # SELECT COUNT(DISTINCT subsite_tag_ref) - # FROM {self.db.SQL_SCHEMA_PREFIX}subsite_tag - # WHERE subsite_tag_pose IN {self.str_ids} - # GROUP BY subsite_tag_pose - # """ - - # counts = self.db.execute(sql).fetchall() - - # counts = [c for (c,) in counts] + [0 for _ in range(len(self) - len(counts))] - - # return -std(counts) - return 4 - - @property - def subsite_ids(self) -> set[int]: - """Return a list of subsite id's of member poses""" - return Subsite.objects.filter( - pk__in=SubsiteTag.objects.filter( - pose__in=self._queryset, - ).values(subsite), - ).values_list('pk', flat=True) - - @property - def avg_energy_score(self) -> float: - """Average energy score of poses in this set""" - # TODO: scores not implemented - # from numpy import mean - - # sql = f""" - # SELECT pose_energy_score - # FROM {self.db.SQL_SCHEMA_PREFIX}pose - # WHERE pose_id IN {self.str_ids} - # """ - - # scores = self.db.execute(sql).fetchall() - # return mean([s for (s,) in scores if s is not None]) - return 4 - - @property - def avg_distance_score(self) -> float: - """Average distance score of poses in this set""" - # TODO: scores not implemented yet - # from numpy import mean - - # sql = f""" - # SELECT pose_distance_score - # FROM {self.db.SQL_SCHEMA_PREFIX}pose - # WHERE pose_id IN {self.str_ids} - # """ - - # scores = self.db.execute(sql).fetchall() - - # return mean([s for (s,) in scores if s is not None]) - return 4 - - @property - def derivatives(self) -> 'PoseSet': - """Get the :class:`.PoseSet` of derivatives""" - return PoseSet( - Pose.objects.filter( - pk__in=Inspiration.objects.filter( - original_pose__in=self._queryset, - ).values( - 'derivative_pose', - ), - ), - ) - - @property - def reference(self): - """Bulk set the references for poses in this set""" - raise NotImplementedError( - 'This attribute only allows setting, ``PoseSet.reference = ...``' - ) - - @reference.setter - def reference(self, r) -> None: - """Bulk set the references for poses in this set""" - self._queryset.update(pose_reference=r) - - ### PRIVATE - - def _delete(self, *, force: bool = False) -> None: - """Delete poses in this set""" - - if not force: - mrich.warning('Deleting Poses is risky! Set force=True to continue') - return - - try: - with transaction.atomic(): - Inspiration.objects.filter(original_pose__in=self._queryset).delete() - Inspiration.objects.filter(derivative_pose__in=self._queryset).delete() - SubsiteTag.objects.filter(pose__in=self._queryset).delete() - Interaction.objects.filter(pose__in=self._queryset).delete() - self._queryset.delete() - except IntegrityError as exc: - mrich.error(exc) diff --git a/src/designdb/sets/reaction.py b/src/designdb/sets/reaction.py deleted file mode 100644 index 8756d31..0000000 --- a/src/designdb/sets/reaction.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Classes for working with sets of :class:`.Reaction` objects""" - -import mcol -import mrich -import pandas as pd -from django.db.models import Q -from hippo.recipe import Recipe -from IPython.display import display -from ipywidgets import BoundedIntText, Checkbox, GridBox, Layout, VBox, interactive_output - -from designdb.models import Compound, Reactant, Reaction -from designdb.sets.compound import CompoundSet - - -class ReactionSet: - """Object representing a subset of the 'reaction' table in the :class:`.Database`. - - .. attention:: - - :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. - - Use as an iterable - ================== - - Iterate through :class:`.Reaction` objects in the set: - - :: - - rset = animal.reactions[:100] - - for reaction in rset: - ... - - Check membership - ================ - - To determine if a :class:`.Reaction` is present in the set: - - :: - - is_member = reaction in cset - - Selecting compounds in the set - ============================== - - The :class:`.ReactionSet` can be indexed like standard Python lists by their indices - - :: - - rset = animal.reactions[1:100] - - # indexing individual compounds - reaction = rset[0] # get the first reaction - reaction = rset[1] # get the second reaction - reaction = rset[-1] # get the last reaction - - # getting a subset of compounds using a slice - rset2 = rset[13:18] # using a slice - - """ - - def __init__( - self, - queryset=None, - *, - sort: bool = True, - name: str | None = None, - ) -> None: - """ReactionSet initialisation""" - - if queryset: - if isinstance(queryset, list): - self._queryset = Reaction.objects.filter(pk__in=queryset) - else: - self._queryset = queryset - else: - self._queryset = Reaction.objects.none() - - self._name = name - if sort: - self._queryset = self._queryset.order_by('pk') - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.name: - s = f'{self.name}: ' - else: - s = '' - - s += f'{{R × {len(self)}}}' - - return s - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __len__(self) -> int: - """Number of member :class:`.Reaction` objects""" - return self._queryset.count() - - def __iter__(self): - """Iterate through member :class:`.Reaction` objects""" - return iter(self._queryset) - - def __getitem__(self, key) -> 'Reaction | ReactionSet': - """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" - - match key: - case int(): - try: - # reaction = Reaction.objects.get(pk=key) - reaction = self._queryset[key] - except Reaction.DoesNotExist as exc: - mrich.error(f'list index out of range: {key=} for {self}') - raise Reaction.DoesNotExist from exc - - return reaction - - case slice(): - return ReactionSet(Reaction.objects.filter(pk__in=key)) - - case _: - mrich.error( - f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' - ) - - return None - - def __add__(self, other: 'ReactionSet') -> 'ReactionSet': - """Add a :class:`.ReactionSet` to this one""" - if other: - return ReactionSet( - Reaction.objects.filter( - Q(pk__in=self._queryset) | Q(pk__in=other.queryset) - ), - sort=False, - ) - - def __sub__( - self, - other: 'ReactionSet', - ) -> 'ReactionSet': - """Substract a :class:`.ReactionSet` from this set""" - match other: - case ReactionSet(): - return ReactionSet( - Reaction.objects.filter( - Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) - ), - sort=False, - ) - - ### METHODS - - def add(self, r: Reaction) -> None: - """Add a :class:`.Reaction` to this set - - :param r: :class:`.Reaction` to be added - - """ - assert isinstance(r, Reaction) - self._queryset = Reaction.objects.filter( - pk__in=list(self._queryset.values_list('pk', flat=True)) + [r.pk], - ) - - def interactive(self): - """Creates a ipywidget to interactively navigate this PoseSet.""" - - a = BoundedIntText( - value=0, - min=0, - max=len(self) - 1, - step=1, - description=f'Rs (/{len(self)}):', - disabled=False, - ) - - b = Checkbox(description='Name', value=True) - c = Checkbox(description='Summary', value=False) - d = Checkbox(description='Draw', value=True) - e = Checkbox(description='Check chemistry', value=False) - f = Checkbox(description='Reactant Quotes', value=False) - - ui1 = GridBox( - [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') - ) - ui2 = GridBox([e, f], layout=Layout(grid_template_columns='repeat(2, 150px)')) - ui = VBox([a, ui1, ui2]) - - def widget( - i, name=True, summary=True, draw=True, check_chemistry=True, reactants=False - ): - """ - - :param i: - :param name: (Default value = True) - :param summary: (Default value = True) - :param draw: (Default value = True) - :param check_chemistry: (Default value = True) - :param reactants: (Default value = False) - - """ - reaction = self[i] - if name: - print(repr(reaction)) - if summary: - reaction.summary(draw=False) - if draw: - reaction.draw() - if check_chemistry: - reaction.check_chemistry(debug=True) - if reactants: - for comp in reaction.reactants: - # if summary: - # comp.summary(draw=False) - # elif name: - print(repr(comp)) - - quotes = comp.get_quotes(df=True) - display(quotes) - - # break - - # if draw: - # comp.draw() - - out = interactive_output( - widget, - { - 'i': a, - 'name': b, - 'summary': c, - 'draw': d, - 'check_chemistry': e, - 'reactants': f, - }, - ) - - display(ui, out) - - def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: - """Construct a pandas.DataFrame of this ReactionSet - - :param smiles: Include smiles column (Default value = True) - :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: - - """ - - mrich.debug('Using slower Reaction.dict rather than direct SQL query...') - - data = [] - for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): - data.append(r.get_dict(smiles=smiles, mols=mols, **kwargs)) - - return pd.DataFrame(data) - - def copy(self) -> 'ReactionSet': - """Return a copy of this set""" - return ReactionSet(self._queryset.all(), sort=False, name=self.name) - - def get_recipes( - self, amounts: float | list[float] = 1.0, **kwargs - ) -> Recipe | list[Recipe]: - """Get the :class:`.Recipe` object(s) from this set of recipes - - :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) - :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: - - """ - # avoiding circular imports - from designdb.recipe import Recipe - - return Recipe.from_reactions(reactions=self, amounts=1, **kwargs) - - def summary(self) -> None: - """Print a summary of the Reactions""" - - mrich.header(self) - for reaction in self: - print(repr(reaction)) - - ### PROPERTIES - - @property - def name(self) -> str | None: - """Returns the name of set""" - return self._name - - @property - def indices(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._queryset.values_list('pk', flat=True) - - @property - def ids(self) -> list[int]: - """Returns the ids of reactions in this set""" - return self._indices - - @property - def types(self) -> list[str]: - """Returns the types of reactions in this set""" - return self._queryset.values('reaction_type').distinct() - - @property - def num_types(self) -> int: - """Returns the number of reaction types in this set""" - return self._queryset.values('reaction_type').distinct().count() - - @property - def products(self) -> CompoundSet: - """Get all product compounds that can be synthesised with these reactions (no intermediates)""" - - qs = Compound.objects.filter( - pk__in=self._queryset.values('product_compound'), - ).exclude( - pk__in=self.intermediates.queryset.values('pk'), - ) - cset = CompoundSet(qs) - if self.name: - cset._name = f'products of {self}' - return cset - - @property - def intermediates(self) -> CompoundSet: - """Get all intermediate compounds that can be synthesised with these reactions""" - - # NB! not 100% sure about this queryset - qs = Compound.objects.filter( - Q( - pk__in=Reactant.objects.values('compound'), - ) - & Q(pk__in=self._queryset.values('product_compound')), - ) - cset = CompoundSet(qs) - - if self.name: - cset._name = f'intermediates of {self}' - return cset - - @property - def reactants(self) -> 'CompoundSet': - """Get all reactant compounds that are used by these reactions""" - - qs = Reactant.objects.filter( - reaction__in=self._queryset, - ).values('compound') - cset = CompoundSet(qs) - if self.name: - cset._name = f'reactants of {self}' - return cset - - @property - def get_dict(self) -> dict[str]: - """Serializable dictionary""" - return dict(indices=self.indices) diff --git a/src/designdb/sets/route.py b/src/designdb/sets/route.py deleted file mode 100644 index e1fa364..0000000 --- a/src/designdb/sets/route.py +++ /dev/null @@ -1,427 +0,0 @@ -import json - -import mcol -import mrich - -from designdb.models import Component, Route -from designdb.sets.compound import CompoundSet - - -class RouteSet: - """A set of Route objects""" - - def __init__(self, routes: 'list[Route]') -> None: - """RouteSet initialisation""" - - data = {} - for route in routes: - # assert isinstance(route, Route) - data[route.id] = route - - self._data = data - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - ### FACTORIES - - @classmethod - def from_ids(cls, ids: list | set, progress: bool = True): - """Generate a routeset from a set of :class:`.Route` IDs - - :param db: database to link - :param ids: :class:`.Route` database IDs - :param progress: show progress bar - """ - - # this gets stuck - # if progress: - # ids = mrich.track(ids, prefix='Getting routes') - - # avoiding circular reference - # avoiding name conflict - from designdb.route import RouteObj - - routes = [RouteObj.get_route(id=r) for r in ids] - - # self = cls.__new__(cls) - return RouteSet(routes) - - @classmethod - def from_product_ids(cls, ids: list | set, progress: bool = True): - """Generate a routeset from a set of product :class:`.Compound` IDs - - :param db: database to link - :param ids: :class:`.Compound` database IDs - """ - - # str_ids = str(tuple(ids)).replace(',)', ')') - - # records = db.select_where( - # table='route', - # query='route_id', - # key=f'route_product IN {str_ids}', - # multiple=True, - # ) - records = Route.objects.filter( - product_compound__pk__in=ids, - ) - - # route_ids = [i for (i,) in records] - - return cls.from_ids(records.values_list('id', flat=True), progress=progress) - - @classmethod - def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': - """Load a serialised routeset from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - self = cls.__new__(cls) - - if data is None: - data = json.load(open(path)) - - new_data = {} - for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): - route_id = d['id'] - new_data[route_id] = Route.from_json(db=db, path=None, data=d) - - self._data = new_data - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - return self - - ### PROPERTIES - - @property - def data(self) -> 'dict[int, Route]': - """Get internal data dictionary""" - return self._data - - @property - def db(self): - """Get associated database""" - return self._db - - @property - def routes(self) -> 'list[Route]': - """Get route objects""" - return self.data.values() - - @property - def product_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the products""" - return Route.objects.values_list('product_compound__id', flat=True).distinct() - - @property - def reactant_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the reactants""" - - return Component.objects.filter( - route__in=self.ids, - component_type=2, - ).values_list('component_ref', flat=True) - - @property - def products(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route products""" - return CompoundSet(self.product_ids) - - @property - def reactants(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route reactants""" - return CompoundSet(self.reactant_ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Route` ID's""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def ids(self) -> list[int]: - """Return the :class:`.Route` IDs""" - return self.data.keys() - - @property - def cluster_map(self) -> dict[tuple, set]: - """Create a dictionary grouping routes by their scaffold/base cluster. - - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. - """ - - if self._cluster_map is None: - # get route mapping - pairs = self.db.select_where( - query='route_product, route_id', - key=f'route_id IN {self.str_ids}', - table='route', - multiple=True, - ) - - route_map = {route_product: route_id for route_product, route_id in pairs} - - # group compounds by cluster - compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) - - # create the map - self._cluster_map = {} - for cluster, compounds in compound_clusters.items(): - self._cluster_map[cluster] = [] - for compound in compounds: - route_id = route_map.get(compound, None) - if not route_id: - continue - self._cluster_map[cluster].append(route_id) - - if not self._cluster_map[cluster]: - del self._cluster_map[cluster] - - return self._cluster_map - - ### METHODS - - def copy(self) -> 'RouteSet': - """Copy this RouteSet""" - return RouteSet(self.db, self.data.values()) - - def set_db_pointers(self, db: 'Database') -> None: - """ - - :param db: - - """ - self._db = db - for route in self.data.values(): - route._db = db - - # def clear_db_pointers(self): - # """ """ - # self._db = None - # for route in self.data.values(): - # route._db = None - - def get_dict(self): - """Get serialisable dictionary""" - - data = dict(db=str(self.db), routes={}) - - # populate with routes - for route_id, route in self.data.items(): - data['routes'][route_id] = route.get_dict() - - return data - - def prune_unavailable(self, suppliers: list[str]): - """Remove routes that don't have all reactants available from given suppliers""" - - suppliers_str = str(tuple(suppliers)).replace(',)', ')') - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] - FROM {self.db.SQL_SCHEMA_PREFIX}quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - AND route_id IN {self.str_ids} - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for (i,) in route_ids] - - mrich.var('#routes before pruning', len(self)) - mrich.var('#routes after pruning', len(route_ids)) - - return RouteSet.from_ids(self.db, route_ids) - - def pop_id(self) -> int: - """Pop the last route from the set and return it's id""" - route_id, route = self.data.popitem() - return route_id - - def pop(self) -> 'Route': - """Pop the last route from the set and return it's object""" - route_id, route = self.data.popitem() - return route - - def balanced_pop( - self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> 'Route': - """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" - - if not self._data: - mrich.print('RouteSet depleted') - return None - - if not self.cluster_map: - # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") - return self.pop() - - # store the permitted clusters (or all clusters) list as property - - if self._permitted_clusters is None: - if permitted_clusters: - permitted_clusters = set( - (cluster,) if isinstance(cluster, int) else cluster - for cluster in permitted_clusters - ) - - self._permitted_clusters = [] - for cluster in permitted_clusters: - if cluster not in self.cluster_map: - mrich.warning( - cluster, 'in permitted_clusters but not cluster_map' - ) - else: - self._permitted_clusters.append(cluster) - - else: - self._permitted_clusters = list(self.cluster_map.keys()) - - if self._current_cluster is None: - self._current_cluster = self._permitted_clusters[0] - - ### pop a Route - - if debug: - mrich.debug(f'Would pop Route from {self._current_cluster=}') - - cluster = self._current_cluster - - # pop the last route id from the given cluster - - try: - route_id = self.cluster_map[cluster].pop() - except IndexError: - mrich.print(self._permitted_clusters) - mrich.print(self.cluster_map) - raise - except AttributeError: - mrich.print(cluster) - mrich.print(self.cluster_map) - raise - except KeyError: - mrich.print('cluster', cluster) - mrich.print('self._permitted_clusters', self._permitted_clusters) - mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) - raise - - # clean up empty clusters - - if debug: - mrich.debug('Popped route', route_id) - - # get the Route object - - if route_id in self._data: - route = self._data[route_id] - del self._data[route_id] - else: - # if debug: - mrich.debug('Route not present') - return self.balanced_pop() - - ### increment cluster - - # def increment_cluster(cluster): - n = len(self._permitted_clusters) - if n > 1: - for i, cluster in enumerate(self._permitted_clusters): - if cluster == self._current_cluster: - if i == n - 1: - self._current_cluster = self._permitted_clusters[0] - else: - self._current_cluster = self._permitted_clusters[i + 1] - break - else: - raise IndexError('This should never be reached...') - - # increment_cluster() - - if not self.cluster_map[cluster]: - del self.cluster_map[cluster] - if not self.cluster_map: - mrich.debug('RouteSet.cluster_map depleted') - self._permitted_clusters = [ - c for c in self._permitted_clusters if c != cluster - ] - # if debug: - mrich.debug('Depleted cluster', cluster) - - if not self._permitted_clusters: - mrich.debug('Depleted all permitted clusters', cluster) - mrich.debug('Removing cluster restriction', cluster) - self._permitted_clusters = list(self.cluster_map.keys()) - self._current_cluster = None - - if debug: - mrich.debug('#Routes in set', len(self._data)) - - return route - - def shuffle(self): - """Randomly shuffle the routes in this set""" - import random - - items = list(self.data.items()) - random.shuffle(items) - self._data = dict(items) - - ### shuffle the cluster map as well - - for cluster, routes in self.cluster_map.items(): - random.shuffle(routes) - self.cluster_map[cluster] = routes - - ### DUNDERS - - def __len__(self) -> int: - """Number of routes in this set""" - return len(self.data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Route × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __iter__(self): - """Iterate over routes in this set""" - return iter(self.data.values()) - - def __getitem__(self, key): - """Get a specific route in this set""" - return list(self.data.values())[key] diff --git a/src/designdb/tests.py b/src/designdb/tests.py deleted file mode 100644 index a39b155..0000000 --- a/src/designdb/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/src/designdb/utils.py b/src/designdb/utils.py deleted file mode 100644 index 592b574..0000000 --- a/src/designdb/utils.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Generic tools for use in the HIPPO package""" - -import ast -import json -import re -from datetime import datetime -from string import ascii_uppercase - -import mcol -import molparse as mp -import mrich -import numpy as np -from django.db.models import Aggregate, OuterRef, Subquery -from molparse.rdkit import mol_from_smiles -from rdkit import Chem -from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RemoveHs -from rdkit.Chem.inchi import MolToInchiKey - -from .models import Pose, ScoreValue - - -def strip_sql(sql) -> str: - """Reduce unecessary whitespace in SQL""" - return re.sub(r'\s+', ' ', sql).strip() - - -def df_row_to_dict(df_row) -> dict: - """Convert a dataframe row to a dictionary - - :param df_row: pandas dataframe row / series - """ - - assert len(df_row) == 1, f'{len(df_row)=}' - - data = {} - - for col in df_row.columns: - if col == 'Unnamed: 0': - continue - - value = df_row[col].values[0] - - if not isinstance(value, str) and np.isnan(value): - value = None - - data[col] = value - - return data - - -def remove_other_ligands(sys: mp.System, residue_number: int, chain: str) -> mp.System: - """Remove ligands other than the specified one""" - - ligand_residues = [r.number for r in sys['rLIG'] if r.number != residue_number] - - # if ligand_residues: - for c in sys.chains: - if c.name != chain: - c.remove_residues(names=['LIG'], verbosity=0) - elif ligand_residues: - c.remove_residues(numbers=ligand_residues, verbosity=0) - - # print([r.name_number_str for r in sys['rLIG']]) - - assert len([r.name_number_str for r in sys['rLIG']]) == 1, ( - f'{sys.name} {[r.name_number_str for r in sys["rLIG"]]}' - ) - - return sys - - -def inchikey_from_smiles(smiles: str) -> str: - """InChI-Key from smiles string""" - mol = mol_from_smiles(smiles) - return MolToInchiKey(mol) - - -def flat_inchikey(smiles: str) -> str: - """Stereochemistry-flattened InChI-Key from smiles string""" - smiles = sanitise_smiles(smiles) - return inchikey_from_smiles(smiles) - - -def remove_isotopes_from_smiles(smiles: str) -> str: - """Remove isotopes from smiles string""" - - mol = MolFromSmiles(smiles) - - atom_data = [(atom, atom.GetIsotope()) for atom in mol.GetAtoms()] - - for atom, isotope in atom_data: - if isotope: - atom.SetIsotope(0) - - return MolToSmiles(mol) - - -def smiles_has_isotope(smiles: str, regex=True) -> bool: - """Does provided smiles string contain isotopes?""" - if regex: - return re.search(r'([\[][0-9]+[A-Z]+\])', smiles) - else: - mol = MolFromSmiles(smiles) - return any(atom.GetIsotope() for atom in mol.GetAtoms()) - - -REPLACE = { - '[STB]': '[S]', -} - - -def sanitise_smiles( - s: str, - verbosity: bool = False, - sanitisation_failed: str = 'error', - radical: str = 'error', -) -> str: - """Sanitise smiles by: - - - Taking largest fragment - - Flattening stereochemistry - - Removing isotopes - - RDKit round-trip - - Treating radicals - - :param s: input smiles string - :param verbosity: print smiles changes (Default value = False) - :param sanitisation_failed: behvaiour when sanitisation fails, - choose from ["error", "warning", "quiet"] (Default value = 'error') - :param radical: behvaiour when radicals occur, choose from - ["error", "warning", "remove"] (Default value = 'error') - :returns: SMILES string - """ - - assert isinstance(s, str), f'non-string smiles={s}' - - orig_smiles = s - - # if multiple molecules take the largest - if '.' in s: - s = sorted(s.split('.'), key=lambda x: len(x))[-1] - - # flatten the smiles - # stereo_smiles = s - smiles = s.replace('@', '') - smiles = smiles.replace('/', '') - smiles = smiles.replace('\\', '') - - # remove isotopic stuff - if smiles_has_isotope(smiles): - mrich.warning(f'Isotope(s) in SMILES: {smiles}') - smiles = remove_isotopes_from_smiles(smiles) - - # replace specific sequences - for key in REPLACE: - if key in smiles: - smiles = smiles.replace(key, REPLACE[key]) - - # canonicalise - mol = MolFromSmiles(smiles) - if mol: - smiles = MolToSmiles(mol, True) - elif sanitisation_failed == 'error': - raise SanitisationError - elif sanitisation_failed == 'warning': - mrich.warning(f'sanitisation failed for {smiles=}') - - # check radicals - reconstruct = False - for atom in mol.GetAtoms(): - if not atom.GetNumRadicalElectrons(): - continue - - if radical == 'warning': - mrich.warning(f'Radical atom in {smiles=}') - elif radical == 'error': - raise SanitisationError(f'Radical atom in {smiles=}') - elif radical == 'remove': - mrich.warning('Removed radical atom') - atom.SetNumRadicalElectrons(0) - smiles = MolToSmiles(mol, True) - reconstruct = True - # atom.SetFormalCharge(0) - else: - raise NotImplementedError(f'Unknown option {radical=}') - - if reconstruct: - mol = AddHs(mol) - mol = RemoveHs(mol, implicitOnly=True) - smiles = MolToSmiles(mol, True) - mrich.warning(f'New {smiles=}') - - if verbosity: - if smiles != orig_smiles: - annotated_smiles_str = orig_smiles.replace( - '.', f'{mcol.error}{mcol.underline}.{mcol.clear}{mcol.warning}' - ) - annotated_smiles_str = annotated_smiles_str.replace( - '@', f'{mcol.error}{mcol.underline}@{mcol.clear}{mcol.warning}' - ) - - mrich.warning(f'SMILES was changed: {annotated_smiles_str} --> {smiles}') - - return smiles - - -def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: - """Sanitise by RDKit round-trip""" - from rdkit.Chem import MolFromMolBlock, MolToMolBlock - - return MolFromMolBlock(MolToMolBlock(m)) - - -def pose_gap(a: Pose, b: Pose) -> float: - """Calculate minimum distance between two :class:`.Pose` objects""" - - from molparse.rdkit import mol_to_AtomGroup - from numpy.linalg import norm - - min_dist = None - - a = mol_to_AtomGroup(a.mol) - b = mol_to_AtomGroup(b.mol) - - for atom1 in a.atoms: - for atom2 in b.atoms: - dist = norm(atom1.np_pos - atom2.np_pos) - if min_dist is None or dist < min_dist: - min_dist = dist - - return min_dist - - -ALPHANUMERIC_CHARS = '0123456789' + ascii_uppercase - - -def number_to_base(n: int, b: int) -> int: - """Convert an integer `n` into base `b` representation""" - if n == 0: - return [0] - digits = [] - while n: - digits.append(int(n % b)) - n //= b - return digits[::-1] - - -def dt_hash() -> str: - """Create 7 alphanumeric-character hash based on current timestamp""" - dt = datetime.now() - x = int( - dt.month * 36000 * 24 * 365.25 - + dt.day * 36000 * 24 - + dt.hour * 36000 - + dt.minute * 600 - + dt.second * 10 - + dt.microsecond / 10000 - ) - timehash = ''.join([ALPHANUMERIC_CHARS[v] for v in number_to_base(x, 36)]) - return f'{timehash:>07}' - - -class SanitisationError(Exception): - """Something went wrong in Molecule/SMILES sanitisation""" - - ... - - -def make_warn_once_per_key(): - """Warn once per field type in sdf file. - - When attribute is defined but broken in all molecules, no need to - complain every time. - - Instatiate at the beginning of the loading process and pass where - needed. - - """ - warned = set() - - def warn(key, msg): - if key not in warned: - print(f'WARNING: {msg}') - warned.add(key) - - return warn - - -class ScoreSubquery(Subquery): - def __init__(self, scoring_method): - query = ScoreValue.objects.filter( - pose=OuterRef('pk'), - compound=OuterRef('compound'), - scoring_method__method_name=scoring_method, - ).values('score')[:1] - super().__init__(query) - - -# Don't understand the distinct here. Shouldn't have to use it. -# Workaround for missing ArrayAgg in sqlite, can get rid of when -# moving to postgres -class JsonGroupArray(Aggregate): - function = 'json_group_array' - # template = "%(function)s(%(expressions)s)" - template = '%(function)s(DISTINCT %(expressions)s)' - - -def normalize_string_list(x): - """Convert string representation of list to proper list""" - if not x: - return [] - if isinstance(x, list): - # return list(set(x)) - return x - if isinstance(x, str): - # try JSON first - try: - parsed = json.loads(x) - if isinstance(parsed, list): - # return list(set(parsed)) - return parsed - except Exception: - pass - - # fallback for python-style strings - try: - parsed = ast.literal_eval(x) - if isinstance(parsed, list): - # return list(set(parsed)) - return parsed - except Exception: - pass - - # ultimate fallback, comma-separated string - try: - splits = x.split(',') - if isinstance(splits, list): - return splits - except Exception: - pass - return [] diff --git a/src/designdb/utils_frag.py b/src/designdb/utils_frag.py deleted file mode 100644 index de75c23..0000000 --- a/src/designdb/utils_frag.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Functions for interfacing with Fragalysis data""" - -from dataclasses import dataclass, fields - -import mrich -from rdkit import Chem - -GENERATED_TAG_COLS = [ - 'ConformerSites alias', - 'CanonSites alias', - 'CrystalformSites alias', - 'Quatassemblies alias', - 'Crystalforms alias', - 'ConformerSites upload name', - 'CanonSites upload name', - 'CrystalformSites upload name', - 'Quatassemblies upload name', - 'Crystalforms upload name', - 'ConformerSites short tag', - 'CanonSites short tag', - 'CrystalformSites short tag', - 'Quatassemblies short tag', - 'Crystalforms short tag', - 'Centroid res', - 'Experiment code', - 'Pose', -] - - -META_IGNORE_COLS = [ - 'Code', - 'Long code', - 'Compound code', - 'Smiles', - 'Downloaded', - 'Main status', - 'GOOD count', - 'MEDIOCRE count', - 'BAD count', - 'RefinementResolution', -] - - -def generate_header( - pose, - method, - ref_url, - submitter_name, - submitter_email, - submitter_institution, - generation_date: str | None = None, - extras=None, - metadata: bool = True, -) -> Chem.rdchem.Mol: - """Generate a header molecule for Fragalysis RHS upload""" - - extras = extras or {} - - from datetime import date - - from molparse.rdkit import mol_from_smiles - from rdkit.Chem.AllChem import EmbedMolecule - - header = mol_from_smiles(pose.compound.compound_smiles) - - header.SetProp('_Name', 'ver_1.2') - EmbedMolecule(header) - - generation_date = str(generation_date or date.today()) - - header.SetProp('ref_url', ref_url) - header.SetProp('submitter_name', submitter_name) - header.SetProp('submitter_email', submitter_email) - header.SetProp('submitter_institution', submitter_institution) - header.SetProp('generation_date', generation_date) - header.SetProp('method', method) - - if metadata: - for k, _ in pose.pose_metadata.items(): - header.SetProp(k, str(k)) - - for k, v in extras.items(): - header.SetProp(k, str(v)) - - return header - - -@dataclass -class LongcodeRecord: - target: str - crystal: str - chain: str - residue_number: int - version: int - - -def parse_observation_longcode(longcode: str) -> LongcodeRecord: - """Parse a Fragalysis longcode and try to extract the following information: - - - Target name (target) - - Crystal/dataset code (crystal) - - Chain letter (chain) - - Residue number (residue_number) - - Version number (version) - - :returns: dictionary of the above keys in parentheses - """ - - import re - - match = re.search( - r'(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG', longcode - ) - - if not match: - raise UnsupportedFragalysisLongcodeError(longcode) - - cryst_str, lig_str, _, _ = match.groups() - - chain, residue_number, version = lig_str.split('_') - - residue_number = int(residue_number) - version = int(version) - - if match := re.search(r'(.*)-(\w[0-9]{4})', cryst_str): - target_name = match.group(0) - crystal = match.group(1) - - else: - target_name = '' - crystal = cryst_str - - return LongcodeRecord( - target=target_name, - crystal=crystal, - chain=chain, - residue_number=residue_number, - version=version, - ) - - -def find_observation_longcode_matches( - query: str, codes: list[str], debug: bool = False, allow_version_none: bool = False -) -> list[str]: - """find_observation_longcode_matches""" - - dq = parse_observation_longcode(query) - - if debug: - mrich.var('allow_version_none', allow_version_none) - mrich.var('dq', str(dq)) - - matches = [] - - for code in codes: - if code == query: - if debug: - mrich.debug('exact match') - matches.append(code) - continue - - dc = parse_observation_longcode(code) - - for key in fields(dq): - if ( - allow_version_none - and key.name == 'version' - and (getattr(dc, key.name) is None or getattr(dq, key.name) is None) - ): - continue - - if getattr(dc, key.name) != getattr(dq, key.name): - break - else: - if debug: - mrich.debug(f'{query} matches {code}') - matches.append(code) - - if debug: - mrich.var('#matches', len(matches)) - - if len(matches) < 1 and not allow_version_none: - return find_observation_longcode_matches(query, codes, allow_version_none=True) - - return matches - - -STACK_URLS = { - 'production': 'https://fragalysis.diamond.ac.uk', - 'staging': 'https://fragalysis.xchem.diamond.ac.uk', -} - - -class UnsupportedFragalysisLongcodeError(NotImplementedError): - """Provided Fragalysis observation long code syntax is not supported""" - - ... diff --git a/src/designdb/utils_xca.py b/src/designdb/utils_xca.py deleted file mode 100644 index 4bb8a56..0000000 --- a/src/designdb/utils_xca.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Functions for interfacing with XChemAlign data""" - -import re - - -def parse_observation_longcode(longcode: str) -> dict[str]: - """Parse an XChemAlign longcode and try to extract the following information: - - - Target name (target) - - Crystal/dataset code (crystal) - - Chain letter (chain) - - Residue number (residue_number) - - Version number (version) - - :returns: dictionary of the above keys in parentheses - """ - - match = re.search( - r'^(.*)-(.\d{4})_(.)_(\d*)_(\d)_.*-.\d{4}\+.\+\d*\+\d_.LIG$', longcode - ) - - if not match: - raise UnsupportedXCALongcodeError(longcode) - - target_name, crystal, chain, residue_number, version = match.groups() - - return dict( - target=target_name, - crystal=crystal, - chain=chain, - residue_number=int(residue_number), - version=int(version), - ) - - -class UnsupportedXCALongcodeError(NotImplementedError): - """XChemAlign longcode has unsupported syntax""" - - ... diff --git a/src/designdb/views.py b/src/designdb/views.py deleted file mode 100644 index 60f00ef..0000000 --- a/src/designdb/views.py +++ /dev/null @@ -1 +0,0 @@ -# Create your views here. diff --git a/src/manage.py b/src/manage.py deleted file mode 100755 index 8333a56..0000000 --- a/src/manage.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" - -import os -import sys - - -def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - 'available on your PYTHONPATH environment variable? Did you ' - 'forget to activate a virtual environment?' - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == '__main__': - main() diff --git a/src/xchem_hippo/__init__.py b/src/xchem_hippo/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/xchem_hippo/asgi.py b/src/xchem_hippo/asgi.py deleted file mode 100644 index a393821..0000000 --- a/src/xchem_hippo/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for xchem_hippo project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') - -application = get_asgi_application() diff --git a/src/xchem_hippo/urls.py b/src/xchem_hippo/urls.py deleted file mode 100644 index dcf76c7..0000000 --- a/src/xchem_hippo/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -URL configuration for xchem_hippo project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/6.0/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" - -from django.contrib import admin -from django.urls import path - -urlpatterns = [ - path('admin/', admin.site.urls), -] diff --git a/src/xchem_hippo/wsgi.py b/src/xchem_hippo/wsgi.py deleted file mode 100644 index bc49854..0000000 --- a/src/xchem_hippo/wsgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -WSGI config for xchem_hippo project. - -It exposes the WSGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ -""" - -import os - -from django.core.wsgi import get_wsgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'xchem_hippo.settings') - -application = get_wsgi_application() diff --git a/tests/test_05_scaffolds.py b/tests/test_05_scaffolds.py index 7d6a547..03baf48 100644 --- a/tests/test_05_scaffolds.py +++ b/tests/test_05_scaffolds.py @@ -1,7 +1,7 @@ -from config import * - import hippo +from config import * + def test_calculate_all_scaffolds(): if SCAFFOLDS: From a75852d5d79c86f1bbb673df5e20352c095047f0 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 10 Apr 2026 12:36:59 +0100 Subject: [PATCH 126/163] fix: workflow working as written Apart from potential issues with data --- Dockerfile | 6 ++++-- hippo/designdb/animal.py | 3 ++- hippo/designdb/chem.py | 1 - hippo/designdb/ingredient.py | 3 +-- hippo/designdb/recipe.py | 1 - hippo/designdb/route.py | 1 - hippo/designdb/services/compound.py | 5 ++--- hippo/designdb/services/ingestion.py | 14 ++++++-------- hippo/designdb/services/pose.py | 7 +++---- hippo/designdb/services/reaction.py | 1 - hippo/designdb/sets/compound.py | 11 +++++------ hippo/designdb/sets/interaction.py | 1 - hippo/designdb/sets/pose.py | 29 ++++++++++++++-------------- hippo/designdb/sets/reaction.py | 8 +++----- hippo/designdb/sets/route.py | 1 - 15 files changed, 40 insertions(+), 52 deletions(-) diff --git a/Dockerfile b/Dockerfile index 57fa28d..eab397b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,8 +40,6 @@ ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" -# NB! force-install numpy because need newer version -RUN pip install numpy --upgrade # notebooks @@ -49,4 +47,8 @@ USER 0 RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y USER ${NB_USER} + WORKDIR "/home/code/HIPPO" + +# NB! force-install numpy because need newer version +RUN pip install numpy --upgrade diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 64ca39d..6b6b8a7 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -74,7 +74,8 @@ def target(self) -> Target: @property def poses(self): """Return pose instances for this target""" - return Pose.objects.filter(target=self._target) + # return Pose.objects.filter(target=self._target) + return PoseSet(Pose.objects.filter(target=self._target)) @property def num_poses(self) -> int: diff --git a/hippo/designdb/chem.py b/hippo/designdb/chem.py index 654072d..8e3cd4b 100644 --- a/hippo/designdb/chem.py +++ b/hippo/designdb/chem.py @@ -1,7 +1,6 @@ """functions for validating chemistry""" import mrich - from designdb.models import Compound """ diff --git a/hippo/designdb/ingredient.py b/hippo/designdb/ingredient.py index 8bc0860..2a84315 100644 --- a/hippo/designdb/ingredient.py +++ b/hippo/designdb/ingredient.py @@ -1,9 +1,8 @@ import mcol import mrich import pandas as pd -from django.db.models import Exists, OuterRef, Q - from designdb.models import CataloguePrice, CataloguePriceCompoundJunction, Compound +from django.db.models import Exists, OuterRef, Q class Ingredient: diff --git a/hippo/designdb/recipe.py b/hippo/designdb/recipe.py index 5150c10..fc95f33 100644 --- a/hippo/designdb/recipe.py +++ b/hippo/designdb/recipe.py @@ -2,7 +2,6 @@ import mcol import mrich - from designdb.models import Compound, Reaction from designdb.sets.compound import IngredientSet from designdb.sets.reaction import ReactionSet diff --git a/hippo/designdb/route.py b/hippo/designdb/route.py index 47c3753..1406c08 100644 --- a/hippo/designdb/route.py +++ b/hippo/designdb/route.py @@ -2,7 +2,6 @@ import mcol import mrich - from designdb.models import Component, Reaction, Route from .recipe import Recipe diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index 290c489..eb20ec1 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -3,12 +3,11 @@ import mrich import rdkit -# from mypackage.services.compound import CompoundService -from rdkit import Chem - # from rdkit.Chem import inchi from designdb.models import Compound, CompoundTag from designdb.utils import inchikey_from_smiles, sanitise_smiles +# from mypackage.services.compound import CompoundService +from rdkit import Chem # from .validation.compound import ValidationError, validate_compound_data diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index dfae0c7..6fa7e64 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -7,13 +7,6 @@ import molparse as mp import mrich import pandas as pd -from numpy import isnan -from pandas import read_pickle -# from mypackage.services.compound import CompoundService -from rdkit import Chem -# from rdkit.Chem import inchi -from rdkit.Chem import PandasTools - from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry from designdb.ingredient import Ingredient from designdb.models import Compound, Pose, Reactant, Reaction, Scaffold, Target @@ -33,7 +26,12 @@ sanitise_smiles, ) from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode -from src.designdb.services.reaction import ReactionService +from numpy import isnan +from pandas import read_pickle +# from mypackage.services.compound import CompoundService +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import PandasTools # from .validation.compound import ValidationError, validate_compound_data diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index 5e93a4e..f079eb9 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -7,14 +7,13 @@ import mrich import pandas as pd import rdkit -from django.db.models import Q -# from mypackage.services.compound import CompoundService -from rdkit import Chem - # from rdkit.Chem import inchi from designdb.models import Compound, Pose, PoseTag, Target from designdb.utils import normalize_string_list from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS +from django.db.models import Q +# from mypackage.services.compound import CompoundService +from rdkit import Chem # from .validation.compound import ValidationError, validate_compound_data diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py index cf54029..81cb61b 100644 --- a/hippo/designdb/services/reaction.py +++ b/hippo/designdb/services/reaction.py @@ -1,7 +1,6 @@ import logging import mrich - # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi from designdb.models import Compound, Reactant, Reaction diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index 9955cc8..6933275 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -5,12 +5,6 @@ import mcol import mrich import pandas as pd -from django.db.models import Exists, OuterRef, Q -from pandas import DataFrame, concat, isna -from rdkit import Chem -# from rdkit.Chem import inchi -from rdkit.Chem import Mol - from designdb.ingredient import Ingredient from designdb.models import ( CataloguePrice, @@ -21,6 +15,11 @@ Reaction, ) from designdb.price import Price +from django.db.models import Exists, OuterRef, Q +from pandas import DataFrame, concat, isna +from rdkit import Chem +# from rdkit.Chem import inchi +from rdkit.Chem import Mol class CompoundSet: diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index 9f75150..7b2f6f7 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -2,7 +2,6 @@ import mcol import mrich - from designdb.models import Interaction diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 377f8f8..bf41b09 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -16,6 +16,20 @@ import mrich import networkx as nx import pandas as pd +from designdb.models import ( + Compound, + Inspiration, + Interaction, + Pose, + PoseTag, + PoseTagJunction, + Subsite, + SubsiteTag, + Target, +) +from designdb.sets.interaction import InteractionSet +from designdb.utils import ScoreSubquery, normalize_string_list +from designdb.utils_frag import generate_header from django.conf import settings from django.db import IntegrityError from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery @@ -36,21 +50,6 @@ # from rdkit.Chem import inchi from rdkit.Chem import PandasTools, SDWriter -from designdb.models import ( - Compound, - Inspiration, - Interaction, - Pose, - PoseTag, - PoseTagJunction, - Subsite, - SubsiteTag, - Target, -) -from designdb.sets.interaction import InteractionSet -from designdb.utils import ScoreSubquery, normalize_string_list -from designdb.utils_frag import generate_header - if settings.MANAGE_MODELS: from designdb.utils import JsonGroupArray as ArrayAgg else: diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index 8756d31..4d6e957 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -3,14 +3,12 @@ import mcol import mrich import pandas as pd +from designdb.models import Compound, Reactant, Reaction +from designdb.sets.compound import CompoundSet from django.db.models import Q -from hippo.recipe import Recipe from IPython.display import display from ipywidgets import BoundedIntText, Checkbox, GridBox, Layout, VBox, interactive_output -from designdb.models import Compound, Reactant, Reaction -from designdb.sets.compound import CompoundSet - class ReactionSet: """Object representing a subset of the 'reaction' table in the :class:`.Database`. @@ -267,7 +265,7 @@ def copy(self) -> 'ReactionSet': def get_recipes( self, amounts: float | list[float] = 1.0, **kwargs - ) -> Recipe | list[Recipe]: + ): """Get the :class:`.Recipe` object(s) from this set of recipes :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index e1fa364..c1f3b54 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -2,7 +2,6 @@ import mcol import mrich - from designdb.models import Component, Route from designdb.sets.compound import CompoundSet From 037a8d4140b583a20226a596fc45d6702f9ef69e Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 23 Apr 2026 11:23:42 +0100 Subject: [PATCH 127/163] fix: final minor issues, ready to go to testing --- Dockerfile | 3 +- hippo/bootstrap.py | 23 +- hippo/designdb/animal.py | 7 +- hippo/designdb/managers.py | 52 ++ hippo/designdb/models.py | 18 +- hippo/designdb/services/compound.py | 81 +- hippo/designdb/services/ingestion.py | 58 +- hippo/designdb/services/pose.py | 1 + hippo/designdb/services/score.py | 2 +- hippo/designdb/utils.py | 31 +- images/xchem-designdb/01_schema.sql | 658 +++++++++++--- images/xchem-designdb/init-db/01_schema.sql | 952 ++++++++++++-------- pyproject.toml | 1 - uv.lock | 72 -- 14 files changed, 1331 insertions(+), 628 deletions(-) create mode 100644 hippo/designdb/managers.py diff --git a/Dockerfile b/Dockerfile index eab397b..6b6e310 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,4 +51,5 @@ USER ${NB_USER} WORKDIR "/home/code/HIPPO" # NB! force-install numpy because need newer version -RUN pip install numpy --upgrade +# RUN pip install numpy --upgrade +RUN pip install numpy==2.2.4 diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index 24bec1d..7725e8d 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -1,3 +1,4 @@ +import os import sys from pathlib import Path @@ -23,14 +24,14 @@ def configure_django(db_config, manage_models: bool): } else: # postgres, existing installation, don't touch - # TODO: pass vars from dbconfig + database = { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'designdb', - 'USER': 'postgres', - 'PASSWORD': 's_URzt7CWfWZ.AXD7RcF', - 'HOST': 'database', - 'PORT': '5432', + 'NAME': db_config['DB_NAME'], + 'USER': db_config['DB_USER'], + 'PASSWORD': db_config['DB_PASSWORD'], + 'HOST': db_config['DB_HOST'], + 'PORT': db_config['POSTGRES_PORT'], 'OPTIONS': { # sets the schema 'options': '-c search_path=rdkit,designdb' @@ -70,7 +71,15 @@ def load_hippo( mrich.var('target_name', target_name, color='arg') if db is None: - db = {} + # populate from env + + db = { + 'DB_NAME': os.environ.get('DB_NAME', ''), + 'DB_USER': os.environ.get('DB_USER', ''), + 'DB_PASSWORD': os.environ.get('DB_PASSWORD', ''), + 'DB_HOST': os.environ.get('DB_HOST', ''), + 'POSTGRES_PORT': os.environ.get('POSTGRES_PORT', '5432'), + } if isinstance(db, str): # sqlite db diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 6b6b8a7..2679fdb 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -9,7 +9,7 @@ import pandas as pd from django.db import transaction -from .models import Pose, Target +from .models import Compound, Pose, Target from .services.ingestion import IngestionBatchResult, IngestionService from .sets.pose import PoseSet from .utils import make_warn_once_per_key @@ -77,6 +77,11 @@ def poses(self): # return Pose.objects.filter(target=self._target) return PoseSet(Pose.objects.filter(target=self._target)) + @property + def compounds(self): + """Return compound instances for this target""" + return Compound.compound_filter.all() + @property def num_poses(self) -> int: """Total number of Poses in the Database""" diff --git a/hippo/designdb/managers.py b/hippo/designdb/managers.py new file mode 100644 index 0000000..2e82c26 --- /dev/null +++ b/hippo/designdb/managers.py @@ -0,0 +1,52 @@ +from django.apps import apps +from django.db.models import ( + BooleanField, + Case, + F, + Func, + Manager, + OuterRef, + QuerySet, + Subquery, + When, +) +from rdkit import Chem + +from .utils import registration_hash_tautomer_insensitive, superparent + + +class CompoundQueryset(QuerySet): + def filter_qs(self): + Compound = apps.get_model("designdb", "Compound") + qs = Compound.objects.all() + return qs + + + def get_by_smiles(self, smiles): + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f"SuperParent failed: {e}") from e + + h = registration_hash_tautomer_insensitive(sp) + + return self.filter_qs().get(compound_hash=h) + + + +class CompoundManager(Manager): + def get_queryset(self): + return CompoundQueryset(self.model, using=self._db) + + # probably not needed here? + def get_by_smiles(self, smiles): + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f"SuperParent failed: {e}") from e + + h = registration_hash_tautomer_insensitive(sp) + + return self.get_queryset().get(compound_hash=h) diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index 9ffdacd..03bce5c 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -6,8 +6,11 @@ from django.db import models from django.db.models import Q from django.utils import timezone +from pandas._libs.hashtable import objects_are_equal from rdkit import Chem +from .managers import CompoundManager + _MANAGE_MODELS = settings.MANAGE_MODELS @@ -72,7 +75,7 @@ def deconstruct(self): # shouldn't this be binary as well? from django.db.models import BinaryField as BfpField - from .models import RDKitMolField as MolField + # from .models import RDKitMolField as MolField else: from django_rdkit.models import BfpField, MolField @@ -110,7 +113,6 @@ class Meta(BaseModel.Meta): ] -# TODO: tautomer hashes class Compound(BaseModel): id = models.BigAutoField(primary_key=True) compound_inchikey = models.TextField(null=True, blank=True) @@ -130,9 +132,12 @@ class Compound(BaseModel): # compound_mol = models.TextField(null=True, blank=True) # compound_pattern_bfp = models.TextField(null=True, blank=True) # compound_morgan_bfp = models.TextField(null=True, blank=True) - compound_mol = MolField(null=True) - compound_pattern_bfp = BfpField(null=True) - compound_morgan_bfp = BfpField(null=True) + # compound_mol = MolField(null=True) + compound_mol = models.TextField(null=True, blank=True) + # compound_pattern_bfp = BfpField(null=True) + # compound_morgan_bfp = BfpField(null=True) + compound_pattern_bfp = models.BinaryField(max_length=2048, null=True) + compound_morgan_bfp = models.BinaryField(max_length=2048, null=True) compound_metadata = models.TextField(null=True, blank=True) note = models.TextField(null=True, blank=True) @@ -158,6 +163,9 @@ class Compound(BaseModel): through='Scaffold', ) + objects = models.Manager() + compound_filter = CompoundManager() + class Meta(BaseModel.Meta): db_table = 'compounds' constraints = [ diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index eb20ec1..7531e9a 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -3,11 +3,21 @@ import mrich import rdkit -# from rdkit.Chem import inchi from designdb.models import Compound, CompoundTag -from designdb.utils import inchikey_from_smiles, sanitise_smiles +from designdb.utils import ( + inchikey_from_smiles, + registration_hash_tautomer_insensitive, + sanitise_smiles, + superparent, +) # from mypackage.services.compound import CompoundService from rdkit import Chem +from rdkit.Chem import RegistrationHash +from rdkit.Chem import inchi as rdkit_inchi +from rdkit.Chem.MolStandardize import rdMolStandardize + +# from rdkit.Chem import inchi + # from .validation.compound import ValidationError, validate_compound_data @@ -26,6 +36,10 @@ logger = logging.getLogger(__name__) + + + + class CompoundBatchResult: def __init__(self): self.created = [] @@ -37,25 +51,26 @@ class CompoundService: def create( cls, *, - mol: Chem.rdchem.Mol, + # mol: Chem.rdchem.Mol, smiles: str, - inchikey: str, + # inchikey: str, ) -> tuple[Compound, bool]: - # TODO: new fields to consider, fingerprints and tautomer hashes + # designdb expects smils as input, so this is the entrypoint + # for insertion + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f"SuperParent failed: {e}") from e - # TODO and SQLITE_RELIC: inchikey is calculated by postgres in - # trigger. But I need to calculate it here as well, for - # queries. I feel like having two different calculation - # methods is not ideal. Are the versions guaranteed to be the - # same? And even if I do this in trigger, it's already here, - # why not just insert it? + h = registration_hash_tautomer_insensitive(sp) compound, created = Compound.objects.get_or_create( - compound_inchikey=inchikey, - # compound_smiles=smiles, + compound_hash=h, defaults={ - 'compound_mol': mol, + # 'compound_mol': mol, + # 'compound_inchikey': inchikey, 'compound_smiles': smiles, 'rdkit_version': rdkit.__version__, 'inchi_version': Chem.inchi.GetInchiVersion(), @@ -63,7 +78,7 @@ def create( ) if not created and logger.level == logging.DEBUG: mrich.warning( - f'Skipping compound {inchikey}, {smiles}, duplicate of {compound.pk}' + f'Skipping compound {h}, duplicate of {compound.pk}' ) # there's a following block in the original code @@ -98,8 +113,17 @@ def create( return compound, created + # @classmethod + # def create_from_smiles( + # cls, + # smiles: str, + # ) -> tuple[Compound, bool]: + # mol = Chem.MolFromSmiles(smiles, sanitize=True) + # compound, created = cls.create(mol=mol) + # return compound, created + @classmethod - def create_from_smiles( + def create_from_smiles_list( cls, smiles_list: list[str], ) -> list[tuple[str, str]]: @@ -108,18 +132,25 @@ def create_from_smiles( sane_smiles = sanitise_smiles( smiles, verbosity=logger.level == logging.DEBUG ) - mol = Chem.MolFromSmiles(sane_smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) + # compound, _ = cls.create_from_smiles(sane_smiles) + compound, _ = cls.create(smiles=sane_smiles) + result.append((compound.compound_inchikey, compound.compound_smiles)) - cls.create( - mol=mol, - smiles=sane_smiles, - inchikey=sane_inchikey, - ) + return result - result.append((sane_inchikey, sane_smiles)) - return result + @classmethod + def get_by_smiles(cls, smiles: str) -> Compound | None: + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f"SuperParent failed: {e}") from e + + h = registration_hash_tautomer_insensitive(sp) + + return Compound.objects.get(compound_hash=h) + class CompoundTagService: diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 6fa7e64..b031dff 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -26,6 +26,7 @@ sanitise_smiles, ) from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode +from django.db import connection from numpy import isnan from pandas import read_pickle # from mypackage.services.compound import CompoundService @@ -317,11 +318,11 @@ def ingest_filesystem( # could use a rewrite, it converts smiles back to mol and # then to inchikey smiles = mp.rdkit.mol_to_smiles(mol) - sane_smiles = sanitise_smiles( - smiles, verbosity=logger.level == logging.DEBUG - ) + # sane_smiles = sanitise_smiles( + # smiles, verbosity=logger.level == logging.DEBUG + # ) inchikey = inchikey_from_smiles(smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) + # sane_inchikey = inchikey_from_smiles(sane_smiles) # NB! different func if XCA data try: @@ -335,9 +336,10 @@ def ingest_filesystem( ) compound, compound_created = CompoundService.create( - mol=mol, - smiles=sane_smiles, - inchikey=sane_inchikey, + # mol=mol, + # smiles=sane_smiles, + smiles=smiles, + # inchikey=sane_inchikey, ) compound.tags.add(*compound_tags) if compound_created: @@ -438,6 +440,11 @@ def ingest_sdf( name_col=name_col, ) + # temp hack: disable a trigger that runs on every score + # insertion and later enable it + cursor = connection.cursor() + cursor.execute("ALTER TABLE designdb.score_values DISABLE TRIGGER trg_score_values_refresh_pivoted_mv;") + for r in records: result.attempts += 1 @@ -464,12 +471,13 @@ def ingest_sdf( continue inchikey = inchikey_from_smiles(smiles) - sane_inchikey = inchikey_from_smiles(sane_smiles) + # sane_inchikey = inchikey_from_smiles(sane_smiles) compound, compound_created = CompoundService.create( - mol=r[mol_col], - smiles=sane_smiles, - inchikey=sane_inchikey, + smiles=smiles, + # mol=r[mol_col], + # smiles=sane_smiles, + # inchikey=sane_inchikey, ) compound.tags.add(*compound_tags) if compound_created: @@ -511,6 +519,10 @@ def ingest_sdf( pose.inspirations.add(*Pose.objects.filter(pk__in=pose_inspirations)) scorer.add_scores_from_record(pose=pose, record=r) + # re-enable trigger and populate matview + cursor.execute("ALTER TABLE designdb.score_values ENABLE TRIGGER trg_score_values_refresh_pivoted_mv;") + cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv;") + return result # how is that without target?? @@ -579,13 +591,13 @@ def ingest_syndirella_routes( # from original code smiles = reaction_struct['productSmiles'] - sane_smiles = sanitise_smiles( - smiles, - sanitisation_failed='error', - ) + # sane_smiles = sanitise_smiles( + # smiles, + # sanitisation_failed='error', + # ) - sane_inchikey = inchikey_from_smiles(sane_smiles) - product = Compound.objects.get(compound_inchikey=sane_inchikey) + # sane_inchikey = inchikey_from_smiles(sane_smiles) + product = CompoundService.get_by_smiles(smiles=smiles) mrich.print(i, j, k, reaction_type, product) @@ -597,9 +609,7 @@ def ingest_syndirella_routes( rs = [] print('reactant smiles', reaction_struct['reactantSmiles']) for reactant_s in reaction_struct['reactantSmiles']: - reactant_comp, _ = Compound.objects.get_or_create( - compound_smiles=reactant_s, - ) + reactant_comp, _ = CompoundService.create(smiles=reactant_s) reactant, _ = Reactant.objects.get_or_create( compound=reactant_comp, reaction=reaction, @@ -626,9 +636,9 @@ def ingest_syndirella_routes( except UnsupportedChemistryError: mrich.warning('Skipping unsupported chemistry:', reaction_type) continue - except Exception: - mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) - continue + # except Exception: + # mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) + # continue products.add(Ingredient.from_compound(product, amount=1)) @@ -839,7 +849,7 @@ def ingest_syndirella_elabs( ) # radical? - values = CompoundService.create_from_smiles(unique_smiles) + values = CompoundService.create_from_smiles_list(unique_smiles) orig_smiles_to_inchikey = { orig_smiles: inchikey diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index f079eb9..0e3e382 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -69,6 +69,7 @@ def create( pose_smiles=smiles, # SQLITE_RELIC pose_metadata=json.dumps(metadata), pose_mol=mol, + # pose_mol=Chem.MolToMolBlock(mol), rdkit_version=rdkit.__version__, inchi_version=Chem.inchi.GetInchiVersion(), pose_reference=reference, diff --git a/hippo/designdb/services/score.py b/hippo/designdb/services/score.py index f80a817..fbadeb7 100644 --- a/hippo/designdb/services/score.py +++ b/hippo/designdb/services/score.py @@ -61,7 +61,7 @@ def add_scores_from_record( pose=pose, compound=pose.compound, scoring_method=method, - score=score_value, + score={'score': score_value}, ) score.save() diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py index 592b574..c6ace54 100644 --- a/hippo/designdb/utils.py +++ b/hippo/designdb/utils.py @@ -13,10 +13,10 @@ from django.db.models import Aggregate, OuterRef, Subquery from molparse.rdkit import mol_from_smiles from rdkit import Chem -from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RemoveHs +from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RegistrationHash, RemoveHs +from rdkit.Chem import inchi as rdkit_inchi from rdkit.Chem.inchi import MolToInchiKey - -from .models import Pose, ScoreValue +from rdkit.Chem.MolStandardize import rdMolStandardize def strip_sql(sql) -> str: @@ -211,12 +211,16 @@ def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: Pose, b: Pose) -> float: +def pose_gap(a: 'Pose', b: 'Pose') -> float: """Calculate minimum distance between two :class:`.Pose` objects""" from molparse.rdkit import mol_to_AtomGroup from numpy.linalg import norm + # avoiding circular imports + from .models import Pose, ScoreValue + + min_dist = None a = mol_to_AtomGroup(a.mol) @@ -286,8 +290,11 @@ def warn(key, msg): return warn +# TODO: move class ScoreSubquery(Subquery): def __init__(self, scoring_method): + # avoiding circular imports + from .models import Pose, ScoreValue query = ScoreValue.objects.filter( pose=OuterRef('pk'), compound=OuterRef('compound'), @@ -339,3 +346,19 @@ def normalize_string_list(x): except Exception: pass return [] + + +def superparent(mol: Chem.Mol) -> Chem.Mol: + return rdMolStandardize.SuperParent(mol) + + +def registration_hash_tautomer_insensitive(mol: Chem.Mol) -> str: + layers = RegistrationHash.GetMolLayers( + mol, + escape="", + enable_tautomer_hash_v2=True, + ) + return RegistrationHash.GetMolHash( + layers, + RegistrationHash.HashScheme.TAUTOMER_INSENSITIVE_LAYERS, + ) diff --git a/images/xchem-designdb/01_schema.sql b/images/xchem-designdb/01_schema.sql index 2f1b536..ff13746 100644 --- a/images/xchem-designdb/01_schema.sql +++ b/images/xchem-designdb/01_schema.sql @@ -27,7 +27,7 @@ REVOKE CREATE ON SCHEMA public FROM PUBLIC; CREATE TABLE IF NOT EXISTS designdb.targets ( id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis external_target_id BIGINT, -- ID of this target in the external database (Scarab link) - target_name TEXT, --Insert from HIPPO codebase. Must be a link to Scarab protein production target + target_name TEXT NOT NULL, --Insert from HIPPO codebase. Must be a link to Scarab protein production target target_metadata TEXT, -- Not populated by code created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now(), @@ -39,14 +39,20 @@ CREATE TABLE IF NOT EXISTS designdb.compounds ( compound_inchikey TEXT, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Maybe insert by the codebase or jupyter. Do we need to write this by code or can be calculated by the cartridge? compound_alias TEXT, -- Maybe insert by the codebase. compound_smiles TEXT, -- Inserted by the codebase. Trigger populates compound_mol and compound_inchikey. 2D flat SMILES. Is this 2d flat smiles without any stereochemistry? LR - Yes 2D. Looks like designdb function sanitise_smiles does remove stereochemistry - will this be a problem when a user wants to register a design with defined stereochemistry? + compound_hash TEXT NOT NULL, -- Canonical identity hash from application pipeline (e.g. RDKit RegistrationHash after SuperParent); pair with rdkit_version for reproducibility base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code - compound_mol rdkit.mol, -- Populated by RDKit cartridge trigger from compound_smiles (do not insert by code). Originally, maybe insert from codebase and/or Chemicalite/Postgres RDKit cartridge - compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. + compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) + -- compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + -- compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + + compound_pattern_bfp bfp, -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bfp, -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_metadata TEXT, -- currently Null note TEXT, -- New column - rdkit_version TEXT, --Can be done by RDkit cartridge - inchi_version TEXT, -- Must be done by codebase + rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) + inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now(), CONSTRAINT uc_compound_alias UNIQUE (compound_alias), @@ -73,19 +79,19 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( pose_path TEXT, compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, - pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge + pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge, Check with Kalev!!!!! pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. - --pose_energy_score REAL, -- LR - this may become redundant once the scores table is implemented - --pose_distance_score REAL, -- LR - this may become redundant once the scores table is implemented - --pose_inspiration_score REAL, -- LR - this may become redundant once the scores table is implemented + --pose_energy_score REAL, -- LR - redundant; use designdb.score_values + --pose_distance_score REAL, -- LR - redundant; use designdb.score_values + --pose_inspiration_score REAL, -- LR - redundant; use designdb.score_values pose_metadata TEXT, note TEXT, -- New column rdkit_version TEXT, --Can be done by RDkit cartridge inchi_version TEXT, -- Must be done by codebase created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_pose_alias UNIQUE (pose_alias), - CONSTRAINT uc_pose_path UNIQUE (pose_path) + updated_on TIMESTAMPTZ DEFAULT now() + -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed + -- CONSTRAINT uc_pose_path UNIQUE (pose_path) -- Removed ); CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( @@ -108,7 +114,8 @@ CREATE TABLE IF NOT EXISTS designdb.pose_methods ( pose_method_link TEXT, pose_method_note TEXT, created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_method UNIQUE NULLS NOT DISTINCT (pose_method_name, pose_method_version) ); -- New table @@ -123,11 +130,12 @@ CREATE TABLE IF NOT EXISTS designdb.has_pose_methods ( -- New table CREATE TABLE IF NOT EXISTS designdb.pose_tags ( id BIGSERIAL PRIMARY KEY, - pose_tag_name TEXT, + pose_tag_name TEXT NOT NULL, pose_tag_description TEXT, pose_tag_note TEXT, created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_pose_tag_name UNIQUE (pose_tag_name) ); -- New table @@ -181,11 +189,12 @@ CREATE TABLE IF NOT EXISTS designdb.interactions ( -- New table CREATE TABLE IF NOT EXISTS designdb.compound_tags ( id BIGSERIAL PRIMARY KEY, - compound_tag_name TEXT, + compound_tag_name TEXT NOT NULL, compound_tag_description TEXT, compound_tag_note TEXT, created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_tag_name UNIQUE (compound_tag_name) ); -- New table @@ -207,7 +216,8 @@ CREATE TABLE IF NOT EXISTS designdb.enumeration_methods ( enum_link TEXT, enum_note TEXT, created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_enumeration_method UNIQUE NULLS NOT DISTINCT (enum_name, enum_version) ); -- New table @@ -219,18 +229,6 @@ CREATE TABLE IF NOT EXISTS designdb.has_enumeration_methods ( PRIMARY KEY (compound_id, enumeration_method_id) ); --- New table --- score JSONB: one key per method (use scoring_method.method_name as key). -CREATE TABLE IF NOT EXISTS designdb.scores ( - id BIGSERIAL PRIMARY KEY, - pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, - compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, - score JSONB, -- {"vina": {"score": -7.2, "version": "1.0"}, "gnina": {"score": 0.85, "version": "2.1"}} - note TEXT, - created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() -); - -- New table CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( id BIGSERIAL PRIMARY KEY, @@ -241,7 +239,19 @@ CREATE TABLE IF NOT EXISTS designdb.scoring_methods ( method_link TEXT, note TEXT, created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_scoring_method UNIQUE NULLS NOT DISTINCT (method_name, method_version) +); + +-- One row per (pose_id, compound_id, scoring_method_id). score JSONB stores {"score": value} (numeric or text). +CREATE TABLE IF NOT EXISTS designdb.score_values ( + pose_id BIGINT NOT NULL REFERENCES designdb.poses (id) ON DELETE RESTRICT, + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, + scoring_method_id BIGINT NOT NULL REFERENCES designdb.scoring_methods (id) ON DELETE RESTRICT, + score JSONB NOT NULL, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT pk_score_values PRIMARY KEY (pose_id, compound_id, scoring_method_id) ); CREATE TABLE IF NOT EXISTS designdb.reactions ( @@ -264,22 +274,57 @@ CREATE TABLE IF NOT EXISTS designdb.reactants ( CONSTRAINT uc_reactant UNIQUE (reaction_id, compound_id) ); -CREATE TABLE IF NOT EXISTS designdb.quotes ( +-- Registration identity: one row per distinct catalogue_smiles (unique). catalogue_inchikey is NOT unique — +-- different SMILES strings can map to the same Standard InChIKey after cartridge normalization (trigger from SMILES). +-- catalogue_hash: application / loader pipeline (same algorithm as compounds.compound_hash); NOT unique — multiple +-- rows may share a hash when SuperParent/registration hash collapses stereoisomers differently than stored SMILES and that's the correct behaviour +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds ( id BIGSERIAL PRIMARY KEY, - quote_smiles TEXT, - quote_amount REAL, - quote_supplier TEXT, - quote_catalogue TEXT, -- Catalogue (there are null values, plus BB, Full stock etc.) - quote_entry TEXT, -- This the catalogue number (supplier id) - quote_lead_time INTEGER, -- Days, weeks? - quote_price REAL, - quote_currency TEXT, - quote_purity REAL, -- Not percentage (e.g. 0.99) - quote_date TEXT, - compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, --Quote compound originally, mapped with compound_id + catalogue_smiles TEXT NOT NULL, + catalogue_inchikey TEXT NOT NULL, -- Populated by RDKit cartridge trigger from catalogue_smiles + catalogue_hash TEXT NOT NULL, -- Set by Enamine parsing script on insert/update; links via designdb.compound_catalogue_map + rdkit_version TEXT NOT NULL, + inchi_version TEXT NOT NULL, created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now(), - CONSTRAINT uc_quote UNIQUE (quote_amount, quote_supplier, quote_catalogue, quote_entry) + CONSTRAINT uq_catalogue_compounds_smiles UNIQUE (catalogue_smiles), + CONSTRAINT ck_catalogue_compounds_hash_nonempty CHECK (length(trim(catalogue_hash)) > 0) +); + +-- Former quotes rows split out. supplier = old quote_catalogue; supplier_id = old quote_entry; vendor = old quote_supplier. +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices ( + id BIGSERIAL PRIMARY KEY, + catalogue_id BIGINT NOT NULL REFERENCES designdb.catalogue_compounds (id) ON DELETE CASCADE, + vendor TEXT NOT NULL, + supplier TEXT, + supplier_id TEXT NOT NULL, + amount REAL NOT NULL, + price REAL, + currency TEXT, + purity REAL, + lead_time INTEGER, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_catalogue_price UNIQUE (catalogue_id, vendor, supplier, supplier_id, amount) +); + +-- Many-to-many: compounds - catalogue price lines matched on shared identity hash (compound_hash = catalogue_hash). +CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( + compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, + catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, + match_hash TEXT NOT NULL, + -- Will be deleted automatically from db from here: + -- HIPPO temporary columns, remove when HIPPO can handle catalogue_prices and catalogue_compounds: + catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- Will be deleted automatically until here + created_on TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (compound_id, catalogue_price_id), + CONSTRAINT ck_compound_catalogue_map_match_hash_nonempty CHECK (length(trim(match_hash)) > 0) ); CREATE TABLE IF NOT EXISTS designdb.scaffolds ( @@ -328,11 +373,22 @@ CREATE TABLE IF NOT EXISTS designdb.components ( -- AUDIT TABLES -- ========================================================= --- Event audit for quotes (tracks INSERT/UPDATE/DELETE for data load change tracking) -CREATE TABLE IF NOT EXISTS designdb.quotes_event_audit ( +-- Event audit for catalogue_compounds (chemistry / identity rows) +CREATE TABLE IF NOT EXISTS designdb.catalogue_compounds_event_audit ( + audit_pk BIGSERIAL PRIMARY KEY, + id BIGINT NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), + old_values JSONB, + new_values JSONB, + changed_by TEXT, + changed_at TIMESTAMPTZ DEFAULT now() +); + +-- Event audit for catalogue_prices (vendor / pricing lines) +CREATE TABLE IF NOT EXISTS designdb.catalogue_prices_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, -- 'I'|'U'|'D' + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -343,7 +399,7 @@ CREATE TABLE IF NOT EXISTS designdb.quotes_event_audit ( CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -354,7 +410,7 @@ CREATE TABLE IF NOT EXISTS designdb.pose_tags_event_audit ( CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -365,7 +421,7 @@ CREATE TABLE IF NOT EXISTS designdb.compound_tags_event_audit ( CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -376,7 +432,7 @@ CREATE TABLE IF NOT EXISTS designdb.pose_methods_event_audit ( CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -387,7 +443,7 @@ CREATE TABLE IF NOT EXISTS designdb.enumeration_methods_event_audit ( CREATE TABLE IF NOT EXISTS designdb.scoring_methods_event_audit ( audit_pk BIGSERIAL PRIMARY KEY, id BIGINT NOT NULL, - operation CHAR(1) NOT NULL, + operation CHAR(1) NOT NULL CHECK (operation IN ('I','U','D')), old_values JSONB, new_values JSONB, changed_by TEXT, @@ -413,6 +469,7 @@ CREATE INDEX IF NOT EXISTS idx_pose_method_created ON designdb.pose_methods(crea CREATE INDEX IF NOT EXISTS idx_compound_base_compound_id ON designdb.compounds(base_compound_id); CREATE INDEX IF NOT EXISTS idx_compound_inchikey ON designdb.compounds(compound_inchikey); CREATE INDEX IF NOT EXISTS idx_compound_smiles ON designdb.compounds(compound_smiles); +CREATE INDEX IF NOT EXISTS idx_compound_compound_hash ON designdb.compounds(compound_hash); CREATE INDEX IF NOT EXISTS idx_compound_created ON designdb.compounds(created_on); CREATE INDEX IF NOT EXISTS idx_feature_target_id ON designdb.features(target_id); @@ -429,11 +486,10 @@ CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.poses(pose_path); CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); -CREATE INDEX IF NOT EXISTS idx_scores_pose_id ON designdb.scores(pose_id); -CREATE INDEX IF NOT EXISTS idx_scores_compound_id ON designdb.scores(compound_id); -CREATE INDEX IF NOT EXISTS idx_scores_pose_id_compound_id ON designdb.scores(pose_id, compound_id); -CREATE INDEX IF NOT EXISTS idx_scores_created ON designdb.scores(created_on); -CREATE INDEX IF NOT EXISTS idx_scores_score_gin ON designdb.scores USING GIN (score); +CREATE INDEX IF NOT EXISTS idx_score_values_pose_id ON designdb.score_values(pose_id); +CREATE INDEX IF NOT EXISTS idx_score_values_compound_id ON designdb.score_values(compound_id); +CREATE INDEX IF NOT EXISTS idx_score_values_scoring_method_id ON designdb.score_values(scoring_method_id); +CREATE INDEX IF NOT EXISTS idx_score_values_created ON designdb.score_values(created_on); CREATE INDEX IF NOT EXISTS idx_subsite_target_id ON designdb.subsites(target_id); CREATE INDEX IF NOT EXISTS idx_subsite_created ON designdb.subsites(created_on); @@ -449,20 +505,37 @@ CREATE INDEX IF NOT EXISTS idx_interaction_feature_id ON designdb.interactions(f CREATE INDEX IF NOT EXISTS idx_interaction_pose_id ON designdb.interactions(pose_id); CREATE INDEX IF NOT EXISTS idx_interaction_created ON designdb.interactions(created_on); -CREATE INDEX IF NOT EXISTS idx_quote_compound_id ON designdb.quotes(compound_id); -CREATE INDEX IF NOT EXISTS idx_quote_created ON designdb.quotes(created_on); +-- UNIQUE(catalogue_smiles) supplies btree on catalogue_smiles; btree on catalogue_inchikey (non-unique) and catalogue_hash for lookups +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_created ON designdb.catalogue_compounds(created_on); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_inchikey ON designdb.catalogue_compounds(catalogue_inchikey); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_hash ON designdb.catalogue_compounds(catalogue_hash); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_catalogue_id ON designdb.catalogue_prices(catalogue_id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_created ON designdb.catalogue_prices(created_on); + +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_match_hash ON designdb.compound_catalogue_map(match_hash); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_catalogue_price_id ON designdb.compound_catalogue_map(catalogue_price_id); +CREATE INDEX IF NOT EXISTS idx_compound_catalogue_map_created ON designdb.compound_catalogue_map(created_on); -- ========================================================= -- AUDIT INDEXES -- ========================================================= -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id ON designdb.quotes_event_audit(id); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_operation ON designdb.quotes_event_audit(operation); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_at ON designdb.quotes_event_audit(changed_at); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_changed_by ON designdb.quotes_event_audit(changed_by); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_old_gin ON designdb.quotes_event_audit USING GIN (old_values); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_new_gin ON designdb.quotes_event_audit USING GIN (new_values); -CREATE INDEX IF NOT EXISTS idx_quotes_event_audit_id_changed ON designdb.quotes_event_audit(id, changed_at DESC); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id ON designdb.catalogue_compounds_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_operation ON designdb.catalogue_compounds_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_at ON designdb.catalogue_compounds_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_changed_by ON designdb.catalogue_compounds_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_old_gin ON designdb.catalogue_compounds_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_new_gin ON designdb.catalogue_compounds_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_compounds_event_audit_id_changed ON designdb.catalogue_compounds_event_audit(id, changed_at DESC); + +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id ON designdb.catalogue_prices_event_audit(id); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_operation ON designdb.catalogue_prices_event_audit(operation); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_at ON designdb.catalogue_prices_event_audit(changed_at); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_changed_by ON designdb.catalogue_prices_event_audit(changed_by); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_old_gin ON designdb.catalogue_prices_event_audit USING GIN (old_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_new_gin ON designdb.catalogue_prices_event_audit USING GIN (new_values); +CREATE INDEX IF NOT EXISTS idx_catalogue_prices_event_audit_id_changed ON designdb.catalogue_prices_event_audit(id, changed_at DESC); CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_id ON designdb.pose_tags_event_audit(id); CREATE INDEX IF NOT EXISTS idx_pose_tags_event_audit_operation ON designdb.pose_tags_event_audit(operation); @@ -534,29 +607,29 @@ CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound -- ========================================================= -- MATERIALIZED VIEWS -- ========================================================= --- designdb.scores_per_pose_pivoted_mv: score_id, pose_id, compound_id + one column per --- (method_name, method_version) from scoring_methods, filled from scores.score JSONB. Dymanically re-generated from scores table when new method added +-- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. -- ========================================================= -- VIEWS -- ========================================================= --- Shows quote updates captured via designdb.quotes_event_audit -CREATE OR REPLACE VIEW designdb.quotes_price_changes_v AS +-- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) +CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS SELECT - a.id AS quote_id, - (NULLIF(COALESCE(a.new_values->>'compound_id', a.old_values->>'compound_id'), ''))::BIGINT AS compound_id, - COALESCE(a.new_values->>'quote_smiles', a.old_values->>'quote_smiles') AS quote_smiles, - (NULLIF(COALESCE(a.new_values->>'quote_amount', a.old_values->>'quote_amount'), ''))::DOUBLE PRECISION AS quote_amount, - COALESCE(a.new_values->>'quote_supplier', a.old_values->>'quote_supplier') AS quote_supplier, - COALESCE(a.new_values->>'quote_catalogue', a.old_values->>'quote_catalogue') AS quote_catalogue, - COALESCE(a.new_values->>'quote_entry', a.old_values->>'quote_entry') AS quote_entry, - (NULLIF(a.old_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_old, - (NULLIF(a.new_values->>'quote_price', ''))::DOUBLE PRECISION AS quote_price_new, - COALESCE(a.new_values->>'quote_currency', a.old_values->>'quote_currency') AS quote_currency, - (NULLIF(COALESCE(a.new_values->>'quote_purity', a.old_values->>'quote_purity'), ''))::DOUBLE PRECISION AS quote_purity, + a.id AS catalogue_price_id, + (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, + COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, + COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, + COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, + (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, + (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, + (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, + COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, + (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, + (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, a.changed_at -FROM designdb.quotes_event_audit a +FROM designdb.catalogue_prices_event_audit a WHERE a.operation = 'U'; -- Pose tags: UPDATE events with old/new name, description, note. @@ -641,7 +714,7 @@ WHERE a.operation = 'U'; -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND WRAPPERS -- ========================================================= --- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey (used by compound, pose, and quote triggers). +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (used by compound, pose, and catalogue_compounds triggers). CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; @@ -652,10 +725,13 @@ CREATE OR REPLACE FUNCTION designdb.mol_to_smiles(m rdkit.mol) RETURNS text CREATE OR REPLACE FUNCTION designdb.mol_to_inchikey(m rdkit.mol) RETURNS text LANGUAGE SQL AS $$ SELECT rdkit.mol_inchikey(m); $$; +CREATE OR REPLACE FUNCTION designdb.mol_to_ctab(m rdkit.mol) RETURNS text + LANGUAGE SQL AS $$ SELECT rdkit.mol_to_ctab(m); $$; + -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND TRIGGER -- ========================================================= --- Input: compound_smiles (inserted by application). Populates compound_mol and compound_inchikey. +-- Input: compound_smiles (inserted by application). Populates compound_mol (CTAB) and compound_inchikey. CREATE OR REPLACE FUNCTION designdb.populate_compound_cartridge_from_smiles() RETURNS trigger @@ -668,7 +744,8 @@ BEGIN BEGIN v_mol := designdb.mol_from_smiles(NEW.compound_smiles); IF v_mol IS NOT NULL THEN - NEW.compound_mol := v_mol; + -- NEW.compound_mol := v_mol; -- store rdkit.mol (was default text form ~ SMILES over JDBC) + NEW.compound_mol := designdb.mol_to_ctab(v_mol); NEW.compound_inchikey := designdb.mol_to_inchikey(v_mol); END IF; EXCEPTION WHEN OTHERS THEN @@ -685,6 +762,41 @@ CREATE TRIGGER trg_populate_compound_cartridge_from_smiles FOR EACH ROW EXECUTE FUNCTION designdb.populate_compound_cartridge_from_smiles(); +-- ========================================================= +-- RDKIT CARTRIDGE – CATALOGUE_COMPOUNDS TRIGGER +-- ========================================================= +-- Input: catalogue_smiles (inserted by application). Populates catalogue_inchikey (NOT NULL column). + +CREATE OR REPLACE FUNCTION designdb.populate_catalogue_cartridge_from_smiles() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_mol rdkit.mol; + v_ik TEXT; +BEGIN + IF NEW.catalogue_smiles IS NULL OR btrim(NEW.catalogue_smiles) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: catalogue_smiles is required'; + END IF; + v_mol := designdb.mol_from_smiles(NEW.catalogue_smiles); + IF v_mol IS NULL THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_from_smiles returned NULL for catalogue_smiles'; + END IF; + v_ik := designdb.mol_to_inchikey(v_mol); + IF v_ik IS NULL OR btrim(v_ik) = '' THEN + RAISE EXCEPTION 'designdb.catalogue_compounds: mol_to_inchikey returned empty for catalogue_smiles'; + END IF; + NEW.catalogue_inchikey := v_ik; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_populate_catalogue_cartridge_from_smiles ON designdb.catalogue_compounds; +CREATE TRIGGER trg_populate_catalogue_cartridge_from_smiles + BEFORE INSERT OR UPDATE OF catalogue_smiles ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.populate_catalogue_cartridge_from_smiles(); + -- ========================================================= -- RDKIT CARTRIDGE – POSE TRIGGER -- ========================================================= @@ -713,10 +825,41 @@ CREATE TRIGGER trg_populate_pose_cartridge_from_mol FOR EACH ROW EXECUTE FUNCTION designdb.populate_pose_cartridge_from_mol(); +-- ========================================================= +-- SCORE_VALUES – ENFORCE compound_id MATCHES pose's compound_id +-- ========================================================= +CREATE OR REPLACE FUNCTION designdb.check_score_values_compound_matches_pose() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + v_pose_compound_id BIGINT; +BEGIN + SELECT compound_id INTO v_pose_compound_id + FROM designdb.poses + WHERE id = NEW.pose_id; + IF v_pose_compound_id IS NULL THEN + RAISE EXCEPTION 'pose_id % does not exist', NEW.pose_id; + END IF; + IF NEW.compound_id IS DISTINCT FROM v_pose_compound_id THEN + RAISE EXCEPTION 'score_values.compound_id (%) must match poses.compound_id (%) for pose_id %', + NEW.compound_id, v_pose_compound_id, NEW.pose_id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_check_score_values_compound_matches_pose ON designdb.score_values; +CREATE TRIGGER trg_check_score_values_compound_matches_pose + BEFORE INSERT OR UPDATE OF pose_id, compound_id ON designdb.score_values + FOR EACH ROW + EXECUTE FUNCTION designdb.check_score_values_compound_matches_pose(); + -- ========================================================= -- (Re)creates materialized view designdb.scores_per_pose_pivoted_mv with columns from scoring_method. --- Columns: score_id, pose_id, compound_id, then one JSONB column per (method_name, method_version). --- Value in column: if score is numeric then JSONB number, if text then JSONB string (so numeric stays numeric, text stays text). +-- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). +-- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). +-- Value in column: if score is numeric then JSONB number, if text then JSONB string. CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() RETURNS void LANGUAGE plpgsql @@ -729,9 +872,9 @@ DECLARE value_expr text; numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; BEGIN - select_qry := 'SELECT s.id AS score_id, s.pose_id, s.compound_id'; + select_qry := 'SELECT sv.pose_id, sv.compound_id'; FOR method_rec IN - SELECT m.method_name, m.method_version + SELECT m.id, m.method_name, m.method_version FROM designdb.scoring_methods m ORDER BY m.id LOOP @@ -741,21 +884,20 @@ BEGIN '' ), '[^a-zA-Z0-9_]', '_', 'g' - ); + ) || '_m' || method_rec.id; IF col <> '' AND col <> '_' THEN col := quote_ident(col); - score_txt := '(s.score->' || quote_literal(trim(method_rec.method_name)) || '->>' || quote_literal('score') || ')'; + score_txt := '(sv.score->>' || quote_literal('score') || ')'; value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; - select_qry := select_qry || ', (CASE WHEN s.score ? ' || quote_literal(trim(method_rec.method_name)) - || ' AND (s.score->' || quote_literal(trim(method_rec.method_name)) || '->>' || quote_literal('version') - || ') IS NOT DISTINCT FROM ' || quote_nullable(method_rec.method_version) - || ' THEN ' || value_expr || ' END) AS ' || col; + select_qry := select_qry || ', (array_agg(' || value_expr + || ') FILTER (WHERE sv.scoring_method_id = ' || method_rec.id || '))[1] AS ' || col; END IF; END LOOP; - select_qry := select_qry || ' FROM designdb.scores s WHERE s.score IS NOT NULL'; + select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; + EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; END; $$; @@ -774,7 +916,7 @@ RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN - REFRESH MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv; + REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; RETURN NULL; END; $$; @@ -787,6 +929,130 @@ BEGIN END; $$ LANGUAGE plpgsql; +-- Will be deleted automatically from db from here: +-- === HIPPO TEMPORARY: denormalized map columns; remove when HIPPO ready === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_hippo_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT NEW.id, cp.id, NEW.compound_hash, + cat.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, cp.id, c.compound_hash, + NEW.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, NEW.id, c.compound_hash, + cat.catalogue_inchikey, NEW.supplier, NEW.amount, NEW.price, NEW.lead_time + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE designdb.compound_catalogue_map m + SET supplier = NEW.supplier, + amount = NEW.amount, + price = NEW.price, + lead_time = NEW.lead_time, + updated_on = now() + WHERE m.catalogue_price_id = NEW.id; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_inchikey IS NOT DISTINCT FROM NEW.catalogue_inchikey THEN + RETURN NEW; + END IF; + END IF; + UPDATE designdb.compound_catalogue_map m + SET catalogue_inchikey = NEW.catalogue_inchikey, + updated_on = now() + WHERE m.catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + RETURN NEW; +END; +$$; +-- Will be deleted automatically until here + -- ========================================================= -- AUDIT FUNCTIONS -- ========================================================= @@ -907,6 +1173,16 @@ CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +-- Will be deleted automatically from db from here: +-- Production trigger names reserved (see /* */ block at end of file); active HIPPO temp triggers: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_hippo_temp ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound_hippo_temp + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_hippo_temp(); +-- Will be deleted automatically until here + DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); @@ -919,12 +1195,12 @@ CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR E DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -DROP TRIGGER IF EXISTS trg_scores_updated_on ON designdb.scores; -CREATE TRIGGER trg_scores_updated_on BEFORE UPDATE ON designdb.scores FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -DROP TRIGGER IF EXISTS trg_scores_refresh_pivoted_mv ON designdb.scores; -CREATE TRIGGER trg_scores_refresh_pivoted_mv - AFTER INSERT OR UPDATE OR DELETE ON designdb.scores +DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +CREATE TRIGGER trg_score_values_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; @@ -939,19 +1215,66 @@ CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -DROP TRIGGER IF EXISTS trg_quote_updated_on ON designdb.quotes; -CREATE TRIGGER trg_quote_updated_on BEFORE UPDATE ON designdb.quotes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- Will be deleted automatically from db from here: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_hippo_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_hippo_temp + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_hippo_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_hippo_temp + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp + AFTER UPDATE OF supplier, amount, price, lead_time ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp + AFTER UPDATE OF catalogue_inchikey ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp(); +-- Will be deleted automatically until here + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; +CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -- ========================================================= -- AUDIT TRIGGERS -- ========================================================= -DROP TRIGGER IF EXISTS trg_quotes_event_audit ON designdb.quotes; -CREATE TRIGGER trg_quotes_event_audit - AFTER INSERT OR UPDATE OR DELETE ON designdb.quotes +DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_compounds_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.event_audit_trigger( - 'designdb.quotes_event_audit', + 'designdb.catalogue_compounds_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_prices_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_prices_event_audit', 'id', 'created_on,updated_on', '' @@ -1045,3 +1368,120 @@ CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb. -- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods SELECT designdb.create_scores_per_pose_pivoted_mv(); + +-- ============================================================================= +-- HIPPO → production revert (manual step on live DB): strip leading "-- " from the +-- DROP/ALTER lines below and run in order; then remove the "/*" and "*/" around the +-- production block so it can be manually executed. +-- +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_hippo_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_hippo_temp ON designdb.compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp ON designdb.catalogue_prices; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_hippo_temp ON designdb.catalogue_prices; +-- +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_hippo_temp(); +-- +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS catalogue_inchikey; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS supplier; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS amount; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS price; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS lead_time; +-- ============================================================================= +/* +-- === PRODUCTION: compound_catalogue_map (INSERT only compound_id, catalogue_price_id, match_hash) === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); +*/ diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index c384eed..0e3ce0d 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -43,21 +43,17 @@ CREATE TABLE IF NOT EXISTS designdb.compounds ( base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) - -- compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - -- compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken - -- compound_mol mol, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) - compound_pattern_bfp bfp, -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - compound_morgan_bfp bfp, -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken compound_metadata TEXT, -- currently Null note TEXT, -- New column rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) - -- inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) - inchi_version TEXT, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) + inchi_version TEXT NOT NULL, -- InChI software version (rdkit.Chem.inchi.GetInchiVersion) created_on TIMESTAMPTZ DEFAULT now(), - updated_on TIMESTAMPTZ DEFAULT now() --comma thingy - -- CONSTRAINT uc_compound_alias UNIQUE (compound_alias), - -- CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), -- comma thingy - -- CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_compound_alias UNIQUE (compound_alias), + CONSTRAINT uc_compound_inchikey UNIQUE (compound_inchikey), + CONSTRAINT uc_compound_smiles UNIQUE (compound_smiles) ); CREATE TABLE IF NOT EXISTS designdb.subsites ( @@ -79,7 +75,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( pose_path TEXT, compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, - pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge, Check with Kalev!!!!! + pose_mol rdkit.mol, -- Insert by codebase. Trigger populates pose_inchikey and pose_smiles via cartridge. pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. --pose_energy_score REAL, -- LR - redundant; use designdb.score_values --pose_distance_score REAL, -- LR - redundant; use designdb.score_values @@ -313,6 +309,14 @@ CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, match_hash TEXT NOT NULL, + -- Will be deleted automatically from db from here: + -- HIPPO temporary columns, remove when HIPPO can handle catalogue_prices and catalogue_compounds: + -- catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- Will be deleted automatically until here created_on TIMESTAMPTZ NOT NULL DEFAULT now(), updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (compound_id, catalogue_price_id), @@ -596,108 +600,108 @@ CREATE INDEX IF NOT EXISTS idx_has_enumeration_methods_created ON designdb.has_e CREATE INDEX IF NOT EXISTS idx_has_compound_tag_compound_tag_id ON designdb.has_compound_tags(compound_tag_id); CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound_tags(created_on); --- -- ========================================================= --- -- MATERIALIZED VIEWS --- -- ========================================================= --- -- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). --- -- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. - --- -- ========================================================= --- -- VIEWS --- -- ========================================================= - --- -- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) --- CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS --- SELECT --- a.id AS catalogue_price_id, --- (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, --- COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, --- COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, --- COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, --- (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, --- (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, --- (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, --- COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, --- (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, --- (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, --- a.changed_at --- FROM designdb.catalogue_prices_event_audit a --- WHERE a.operation = 'U'; - --- -- Pose tags: UPDATE events with old/new name, description, note. --- CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS --- SELECT --- a.id AS pose_tag_id, --- a.old_values->>'pose_tag_name' AS pose_tag_name_old, --- a.new_values->>'pose_tag_name' AS pose_tag_name_new, --- a.old_values->>'pose_tag_description' AS pose_tag_description_old, --- a.new_values->>'pose_tag_description' AS pose_tag_description_new, --- a.old_values->>'pose_tag_note' AS pose_tag_note_old, --- a.new_values->>'pose_tag_note' AS pose_tag_note_new, --- a.changed_by, --- a.changed_at --- FROM designdb.pose_tags_event_audit a --- WHERE a.operation = 'U'; - --- -- Compound tags: UPDATE events with old/new name, description, note. --- CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS --- SELECT --- a.id AS compound_tag_id, --- a.old_values->>'compound_tag_name' AS compound_tag_name_old, --- a.new_values->>'compound_tag_name' AS compound_tag_name_new, --- a.old_values->>'compound_tag_description' AS compound_tag_description_old, --- a.new_values->>'compound_tag_description' AS compound_tag_description_new, --- a.old_values->>'compound_tag_note' AS compound_tag_note_old, --- a.new_values->>'compound_tag_note' AS compound_tag_note_new, --- a.changed_by, --- a.changed_at --- FROM designdb.compound_tags_event_audit a --- WHERE a.operation = 'U'; - --- -- Pose methods: UPDATE events with old/new name, description, version, etc. --- CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS --- SELECT --- a.id AS pose_method_id, --- a.old_values->>'pose_method_name' AS pose_method_name_old, --- a.new_values->>'pose_method_name' AS pose_method_name_new, --- a.old_values->>'pose_method_description' AS pose_method_description_old, --- a.new_values->>'pose_method_description' AS pose_method_description_new, --- a.old_values->>'pose_method_version' AS pose_method_version_old, --- a.new_values->>'pose_method_version' AS pose_method_version_new, --- a.changed_by, --- a.changed_at --- FROM designdb.pose_methods_event_audit a --- WHERE a.operation = 'U'; - --- -- Enumeration methods: UPDATE events with old/new name, description, version, etc. --- CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS --- SELECT --- a.id AS enumeration_method_id, --- a.old_values->>'enum_name' AS enum_name_old, --- a.new_values->>'enum_name' AS enum_name_new, --- a.old_values->>'enum_description' AS enum_description_old, --- a.new_values->>'enum_description' AS enum_description_new, --- a.old_values->>'enum_version' AS enum_version_old, --- a.new_values->>'enum_version' AS enum_version_new, --- a.changed_by, --- a.changed_at --- FROM designdb.enumeration_methods_event_audit a --- WHERE a.operation = 'U'; - --- -- Scoring methods: UPDATE events with old/new name, description, version, etc. --- CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS --- SELECT --- a.id AS scoring_method_id, --- a.old_values->>'method_name' AS method_name_old, --- a.new_values->>'method_name' AS method_name_new, --- a.old_values->>'method_description' AS method_description_old, --- a.new_values->>'method_description' AS method_description_new, --- a.old_values->>'method_version' AS method_version_old, --- a.new_values->>'method_version' AS method_version_new, --- a.changed_by, --- a.changed_at --- FROM designdb.scoring_methods_event_audit a --- WHERE a.operation = 'U'; +-- ========================================================= +-- MATERIALIZED VIEWS +-- ========================================================= +-- designdb.scores_per_pose_pivoted_mv: pose_id, compound_id + one column per (method_name, method_version). +-- Pivoted from score_values joined with scoring_methods. Dynamically re-generated when new method added. + +-- ========================================================= +-- VIEWS +-- ========================================================= + +-- Price-line UPDATEs from catalogue_prices_event_audit (JSON keys match catalogue_prices column names) +CREATE OR REPLACE VIEW designdb.catalogue_prices_price_changes_v AS +SELECT + a.id AS catalogue_price_id, + (NULLIF(COALESCE(a.new_values->>'catalogue_id', a.old_values->>'catalogue_id'), ''))::BIGINT AS catalogue_id, + COALESCE(a.new_values->>'vendor', a.old_values->>'vendor') AS vendor, + COALESCE(a.new_values->>'supplier', a.old_values->>'supplier') AS supplier, + COALESCE(a.new_values->>'supplier_id', a.old_values->>'supplier_id') AS supplier_id, + (NULLIF(COALESCE(a.new_values->>'amount', a.old_values->>'amount'), ''))::DOUBLE PRECISION AS amount, + (NULLIF(a.old_values->>'price', ''))::DOUBLE PRECISION AS price_old, + (NULLIF(a.new_values->>'price', ''))::DOUBLE PRECISION AS price_new, + COALESCE(a.new_values->>'currency', a.old_values->>'currency') AS currency, + (NULLIF(COALESCE(a.new_values->>'purity', a.old_values->>'purity'), ''))::DOUBLE PRECISION AS purity, + (NULLIF(COALESCE(a.new_values->>'lead_time', a.old_values->>'lead_time'), ''))::INTEGER AS lead_time, + a.changed_at +FROM designdb.catalogue_prices_event_audit a +WHERE a.operation = 'U'; + +-- Pose tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.pose_tags_changes_v AS +SELECT + a.id AS pose_tag_id, + a.old_values->>'pose_tag_name' AS pose_tag_name_old, + a.new_values->>'pose_tag_name' AS pose_tag_name_new, + a.old_values->>'pose_tag_description' AS pose_tag_description_old, + a.new_values->>'pose_tag_description' AS pose_tag_description_new, + a.old_values->>'pose_tag_note' AS pose_tag_note_old, + a.new_values->>'pose_tag_note' AS pose_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.pose_tags_event_audit a +WHERE a.operation = 'U'; + +-- Compound tags: UPDATE events with old/new name, description, note. +CREATE OR REPLACE VIEW designdb.compound_tags_changes_v AS +SELECT + a.id AS compound_tag_id, + a.old_values->>'compound_tag_name' AS compound_tag_name_old, + a.new_values->>'compound_tag_name' AS compound_tag_name_new, + a.old_values->>'compound_tag_description' AS compound_tag_description_old, + a.new_values->>'compound_tag_description' AS compound_tag_description_new, + a.old_values->>'compound_tag_note' AS compound_tag_note_old, + a.new_values->>'compound_tag_note' AS compound_tag_note_new, + a.changed_by, + a.changed_at +FROM designdb.compound_tags_event_audit a +WHERE a.operation = 'U'; + +-- Pose methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.pose_methods_changes_v AS +SELECT + a.id AS pose_method_id, + a.old_values->>'pose_method_name' AS pose_method_name_old, + a.new_values->>'pose_method_name' AS pose_method_name_new, + a.old_values->>'pose_method_description' AS pose_method_description_old, + a.new_values->>'pose_method_description' AS pose_method_description_new, + a.old_values->>'pose_method_version' AS pose_method_version_old, + a.new_values->>'pose_method_version' AS pose_method_version_new, + a.changed_by, + a.changed_at +FROM designdb.pose_methods_event_audit a +WHERE a.operation = 'U'; + +-- Enumeration methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.enumeration_methods_changes_v AS +SELECT + a.id AS enumeration_method_id, + a.old_values->>'enum_name' AS enum_name_old, + a.new_values->>'enum_name' AS enum_name_new, + a.old_values->>'enum_description' AS enum_description_old, + a.new_values->>'enum_description' AS enum_description_new, + a.old_values->>'enum_version' AS enum_version_old, + a.new_values->>'enum_version' AS enum_version_new, + a.changed_by, + a.changed_at +FROM designdb.enumeration_methods_event_audit a +WHERE a.operation = 'U'; + +-- Scoring methods: UPDATE events with old/new name, description, version, etc. +CREATE OR REPLACE VIEW designdb.scoring_methods_changes_v AS +SELECT + a.id AS scoring_method_id, + a.old_values->>'method_name' AS method_name_old, + a.new_values->>'method_name' AS method_name_new, + a.old_values->>'method_description' AS method_description_old, + a.new_values->>'method_description' AS method_description_new, + a.old_values->>'method_version' AS method_version_old, + a.new_values->>'method_version' AS method_version_new, + a.changed_by, + a.changed_at +FROM designdb.scoring_methods_event_audit a +WHERE a.operation = 'U'; -- ========================================================= -- FUNCTIONS @@ -706,7 +710,7 @@ CREATE INDEX IF NOT EXISTS idx_has_compound_tag_created ON designdb.has_compound -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND WRAPPERS -- ========================================================= --- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (used by compound, pose, and catalogue_compounds triggers). +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (compound, pose, catalogue_compounds triggers). CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; @@ -852,66 +856,66 @@ CREATE TRIGGER trg_check_score_values_compound_matches_pose -- Columns: pose_id, compound_id, then one JSONB column per (method_name, method_version). -- Column names use suffix _m{scoring_method_id} to avoid collisions (e.g. vina_1_0_m1). -- Value in column: if score is numeric then JSONB number, if text then JSONB string. --- CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() --- RETURNS void --- LANGUAGE plpgsql --- AS $$ --- DECLARE --- select_qry text; --- col text; --- method_rec record; --- score_txt text; --- value_expr text; --- numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; --- BEGIN --- select_qry := 'SELECT sv.pose_id, sv.compound_id'; --- FOR method_rec IN --- SELECT m.id, m.method_name, m.method_version --- FROM designdb.scoring_methods m --- ORDER BY m.id --- LOOP --- col := regexp_replace( --- trim(method_rec.method_name) || '_' || coalesce( --- replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), --- '' --- ), --- '[^a-zA-Z0-9_]', '_', 'g' --- ) || '_m' || method_rec.id; --- IF col <> '' AND col <> '_' THEN --- col := quote_ident(col); --- score_txt := '(sv.score->>' || quote_literal('score') || ')'; --- value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) --- || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; --- select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id --- || ' THEN ' || value_expr || ' END) AS ' || col; --- END IF; --- END LOOP; --- select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; --- EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; --- EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; --- EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; --- END; --- $$; - --- CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() --- RETURNS trigger --- LANGUAGE plpgsql --- AS $$ --- BEGIN --- PERFORM designdb.create_scores_per_pose_pivoted_mv(); --- RETURN NULL; --- END; --- $$; - --- CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() --- RETURNS trigger --- LANGUAGE plpgsql --- AS $$ --- BEGIN --- REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; --- RETURN NULL; --- END; --- $$; +CREATE OR REPLACE FUNCTION designdb.create_scores_per_pose_pivoted_mv() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + select_qry text; + col text; + method_rec record; + score_txt text; + value_expr text; + numeric_pat text := '^\-?[0-9]*\.?[0-9]+([eE][\-+]?[0-9]+)?$'; +BEGIN + select_qry := 'SELECT sv.pose_id, sv.compound_id'; + FOR method_rec IN + SELECT m.id, m.method_name, m.method_version + FROM designdb.scoring_methods m + ORDER BY m.id + LOOP + col := regexp_replace( + trim(method_rec.method_name) || '_' || coalesce( + replace(replace(trim(coalesce(method_rec.method_version, '')), ' ', '_'), '.', '_'), + '' + ), + '[^a-zA-Z0-9_]', '_', 'g' + ) || '_m' || method_rec.id; + IF col <> '' AND col <> '_' THEN + col := quote_ident(col); + score_txt := '(sv.score->>' || quote_literal('score') || ')'; + value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) + || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; + select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id + || ' THEN ' || value_expr || ' END) AS ' || col; + END IF; + END LOOP; + select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; + EXECUTE 'DROP MATERIALIZED VIEW IF EXISTS designdb.scores_per_pose_pivoted_mv CASCADE'; + EXECUTE 'CREATE MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv AS ' || select_qry; + EXECUTE 'CREATE UNIQUE INDEX ON designdb.scores_per_pose_pivoted_mv (pose_id, compound_id)'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_recreate_scores_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + PERFORM designdb.create_scores_per_pose_pivoted_mv(); + RETURN NULL; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv; + RETURN NULL; +END; +$$; CREATE OR REPLACE FUNCTION designdb.update_updated_on() RETURNS trigger AS $$ @@ -921,9 +925,11 @@ BEGIN END; $$ LANGUAGE plpgsql; --- ========================================================= --- Populate compound_catalogue_map when compounds and/or catalogue prices exist for the same registration hash. -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +/* +-- Will be deleted automatically from db from here: +-- === HIPPO TEMPORARY: denormalized map columns; remove when HIPPO ready === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -935,8 +941,12 @@ BEGIN DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; END IF; - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT NEW.id, cp.id, NEW.compound_hash + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT NEW.id, cp.id, NEW.compound_hash, + cat.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time FROM designdb.catalogue_prices cp JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id WHERE cat.catalogue_hash = NEW.compound_hash @@ -946,8 +956,7 @@ BEGIN END; $$; --- When catalogue_hash changes on catalogue_compounds: refresh map rows for all price lines under that compound row. -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -962,8 +971,12 @@ BEGIN ); END IF; - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT c.id, cp.id, c.compound_hash + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, cp.id, c.compound_hash, + NEW.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time FROM designdb.compounds c CROSS JOIN designdb.catalogue_prices cp WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash @@ -973,8 +986,7 @@ BEGIN END; $$; --- When a catalogue_price row is inserted or its catalogue_id changes: link compounds by parent hash. -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -986,8 +998,12 @@ BEGIN DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; END IF; - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT c.id, NEW.id, c.compound_hash + INSERT INTO designdb.compound_catalogue_map ( + compound_id, catalogue_price_id, match_hash, + catalogue_inchikey, supplier, amount, price, lead_time + ) + SELECT c.id, NEW.id, c.compound_hash, + cat.catalogue_inchikey, NEW.supplier, NEW.amount, NEW.price, NEW.lead_time FROM designdb.compounds c JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id WHERE c.compound_hash = cat.catalogue_hash @@ -997,6 +1013,44 @@ BEGIN END; $$; +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + UPDATE designdb.compound_catalogue_map m + SET supplier = NEW.supplier, + amount = NEW.amount, + price = NEW.price, + lead_time = NEW.lead_time, + updated_on = now() + WHERE m.catalogue_price_id = NEW.id; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_inchikey IS NOT DISTINCT FROM NEW.catalogue_inchikey THEN + RETURN NEW; + END IF; + END IF; + UPDATE designdb.compound_catalogue_map m + SET catalogue_inchikey = NEW.catalogue_inchikey, + updated_on = now() + WHERE m.catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + RETURN NEW; +END; +$$; +-- Will be deleted automatically until here +*/ + -- ========================================================= -- AUDIT FUNCTIONS -- ========================================================= @@ -1097,196 +1151,338 @@ $$ LANGUAGE plpgsql VOLATILE; -- TRIGGERS (updated_on) -- ========================================================= --- DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; --- CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; --- CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +DROP TRIGGER IF EXISTS trg_target_updated_on ON designdb.targets; +CREATE TRIGGER trg_target_updated_on BEFORE UPDATE ON designdb.targets FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_updated_on ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_updated_on BEFORE UPDATE ON designdb.scoring_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; +CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; +CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +/* +-- Will be deleted automatically from db from here: +-- Production trigger names reserved (see /* */ block at end of file); active temp triggers: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound_temp + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp(); +-- Will be deleted automatically until here +*/ + +DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; +CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; +CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; +CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; +CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; +CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; +CREATE TRIGGER trg_score_values_refresh_pivoted_mv + AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values + FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); + +DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; +CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; +CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; +CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; +CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +/* +-- Will be deleted automatically from db from here: +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_temp + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_temp + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price ON designdb.catalogue_prices; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp + AFTER UPDATE OF supplier, amount, price, lead_time ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound ON designdb.catalogue_compounds; +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp + AFTER UPDATE OF catalogue_inchikey ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); +-- Will be deleted automatically until here +*/ + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; +CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +-- ========================================================= +-- AUDIT TRIGGERS +-- ========================================================= + +DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; +CREATE TRIGGER trg_catalogue_compounds_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_compounds_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; +CREATE TRIGGER trg_catalogue_prices_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.catalogue_prices_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tags_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.compound_tags_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; +CREATE TRIGGER trg_pose_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.pose_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; +CREATE TRIGGER trg_enumeration_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.enumeration_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; +CREATE TRIGGER trg_scoring_methods_event_audit + AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods + FOR EACH ROW + EXECUTE FUNCTION designdb.event_audit_trigger( + 'designdb.scoring_methods_event_audit', + 'id', + 'created_on,updated_on', + '' + ); + +DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; +CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; +CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; +CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); --- -- DROP TRIGGER IF EXISTS trg_scoring_method_recreate_pivoted_mv ON designdb.scoring_methods; --- -- CREATE TRIGGER trg_scoring_method_recreate_pivoted_mv --- -- AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods --- -- FOR EACH STATEMENT EXECUTE FUNCTION designdb.trg_recreate_scores_pivoted_mv(); - --- DROP TRIGGER IF EXISTS trg_enumeration_method_updated_on ON designdb.enumeration_methods; --- CREATE TRIGGER trg_enumeration_method_updated_on BEFORE UPDATE ON designdb.enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_pose_method_updated_on ON designdb.pose_methods; --- CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; --- CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; --- CREATE TRIGGER trg_compound_catalogue_map_sync_compound --- AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds --- FOR EACH ROW --- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); - --- DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; --- CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_route_updated_on ON designdb.routes; --- CREATE TRIGGER trg_route_updated_on BEFORE UPDATE ON designdb.routes FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_reaction_updated_on ON designdb.reactions; --- CREATE TRIGGER trg_reaction_updated_on BEFORE UPDATE ON designdb.reactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_pose_updated_on ON designdb.poses; --- CREATE TRIGGER trg_pose_updated_on BEFORE UPDATE ON designdb.poses FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_score_values_updated_on ON designdb.score_values; --- CREATE TRIGGER trg_score_values_updated_on BEFORE UPDATE ON designdb.score_values FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- -- DROP TRIGGER IF EXISTS trg_score_values_refresh_pivoted_mv ON designdb.score_values; --- -- CREATE TRIGGER trg_score_values_refresh_pivoted_mv --- -- AFTER INSERT OR UPDATE OR DELETE ON designdb.score_values --- -- FOR EACH STATEMENT EXECUTE FUNCTION designdb.refresh_scores_per_pose_pivoted_mv(); - --- DROP TRIGGER IF EXISTS trg_subsite_updated_on ON designdb.subsites; --- CREATE TRIGGER trg_subsite_updated_on BEFORE UPDATE ON designdb.subsites FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_component_updated_on ON designdb.components; --- CREATE TRIGGER trg_component_updated_on BEFORE UPDATE ON designdb.components FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_inspiration_updated_on ON designdb.inspirations; --- CREATE TRIGGER trg_inspiration_updated_on BEFORE UPDATE ON designdb.inspirations FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_interaction_updated_on ON designdb.interactions; --- CREATE TRIGGER trg_interaction_updated_on BEFORE UPDATE ON designdb.interactions FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_catalogue_updated_on ON designdb.catalogue_compounds; --- CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; --- CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; --- CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue --- AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds --- FOR EACH ROW --- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); - --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; --- CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price --- AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices --- FOR EACH ROW --- EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); - --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; --- CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- -- ========================================================= --- -- AUDIT TRIGGERS --- -- ========================================================= - --- DROP TRIGGER IF EXISTS trg_catalogue_compounds_event_audit ON designdb.catalogue_compounds; --- CREATE TRIGGER trg_catalogue_compounds_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_compounds --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.catalogue_compounds_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_catalogue_prices_event_audit ON designdb.catalogue_prices; --- CREATE TRIGGER trg_catalogue_prices_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.catalogue_prices --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.catalogue_prices_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_pose_tags_event_audit ON designdb.pose_tags; --- CREATE TRIGGER trg_pose_tags_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_tags --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.pose_tags_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_compound_tags_event_audit ON designdb.compound_tags; --- CREATE TRIGGER trg_compound_tags_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.compound_tags --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.compound_tags_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_pose_methods_event_audit ON designdb.pose_methods; --- CREATE TRIGGER trg_pose_methods_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.pose_methods --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.pose_methods_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_enumeration_methods_event_audit ON designdb.enumeration_methods; --- CREATE TRIGGER trg_enumeration_methods_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.enumeration_methods --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.enumeration_methods_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_scoring_methods_event_audit ON designdb.scoring_methods; --- CREATE TRIGGER trg_scoring_methods_event_audit --- AFTER INSERT OR UPDATE OR DELETE ON designdb.scoring_methods --- FOR EACH ROW --- EXECUTE FUNCTION designdb.event_audit_trigger( --- 'designdb.scoring_methods_event_audit', --- 'id', --- 'created_on,updated_on', --- '' --- ); - --- DROP TRIGGER IF EXISTS trg_reactant_updated_on ON designdb.reactants; --- CREATE TRIGGER trg_reactant_updated_on BEFORE UPDATE ON designdb.reactants FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_scaffold_updated_on ON designdb.scaffolds; --- CREATE TRIGGER trg_scaffold_updated_on BEFORE UPDATE ON designdb.scaffolds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_subsite_tag_updated_on ON designdb.subsite_tags; --- CREATE TRIGGER trg_subsite_tag_updated_on BEFORE UPDATE ON designdb.subsite_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- -- Removed due to replaced tables --- -- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; --- -- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; --- CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; --- CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; --- CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; --- CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; --- CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); - --- DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; --- CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +-- Removed due to replaced tables +-- DROP TRIGGER IF EXISTS trg_tag_updated_on ON designdb.tags; +-- CREATE TRIGGER trg_tag_updated_on BEFORE UPDATE ON designdb.tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_pose_tag_updated_on ON designdb.pose_tags; +CREATE TRIGGER trg_pose_tag_updated_on BEFORE UPDATE ON designdb.pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_compound_tag_updated_on ON designdb.compound_tags; +CREATE TRIGGER trg_compound_tag_updated_on BEFORE UPDATE ON designdb.compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_tag_updated_on ON designdb.has_pose_tags; +CREATE TRIGGER trg_has_pose_tag_updated_on BEFORE UPDATE ON designdb.has_pose_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_pose_methods_updated_on ON designdb.has_pose_methods; +CREATE TRIGGER trg_has_pose_methods_updated_on BEFORE UPDATE ON designdb.has_pose_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_compound_tag_updated_on ON designdb.has_compound_tags; +CREATE TRIGGER trg_has_compound_tag_updated_on BEFORE UPDATE ON designdb.has_compound_tags FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); + +DROP TRIGGER IF EXISTS trg_has_enumeration_methods_updated_on ON designdb.has_enumeration_methods; +CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb.has_enumeration_methods FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods --- SELECT designdb.create_scores_per_pose_pivoted_mv(); +SELECT designdb.create_scores_per_pose_pivoted_mv(); + +-- ============================================================================= +-- HIPPO → production revert (manual step on live DB): strip leading "-- " from the +-- DROP/ALTER lines below and run in order; then remove the "/*" and "*/" around the +-- production block so it can be manually executed. +-- +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; +-- +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_temp(); +-- +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS catalogue_inchikey; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS supplier; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS amount; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS price; +-- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS lead_time; +-- ============================================================================= + +-- === PRODUCTION: compound_catalogue_map (INSERT only compound_id, catalogue_price_id, match_hash) === + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash + FROM designdb.catalogue_prices cp + JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id + WHERE cat.catalogue_hash = NEW.compound_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN + RETURN NEW; + END IF; + DELETE FROM designdb.compound_catalogue_map + WHERE catalogue_price_id IN ( + SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id + ); + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash + FROM designdb.compounds c + CROSS JOIN designdb.catalogue_prices cp + WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN + RETURN NEW; + END IF; + IF TG_OP = 'UPDATE' THEN + DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; + END IF; + + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash + FROM designdb.compounds c + JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id + WHERE c.compound_hash = cat.catalogue_hash + ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound + AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue + AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); + +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price + AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices + FOR EACH ROW + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); diff --git a/pyproject.toml b/pyproject.toml index a1ed5b3..1f838b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,6 @@ dependencies = [ # - can use conda install in container # - but.. probs don't even need it, it's only used for expressions and I got that covered "django-rdkit", - "environs>=14.6.0", "numpy>=1.26.4", # "numpy>=2", # need it but not working ] diff --git a/uv.lock b/uv.lock index f08d2e1..097ee8f 100644 --- a/uv.lock +++ b/uv.lock @@ -241,40 +241,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] -[[package]] -name = "backports-datetime-fromisoformat" -version = "2.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/81/eff3184acb1d9dc3ce95a98b6f3c81a49b4be296e664db8e1c2eeabef3d9/backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d", size = 23588, upload-time = "2024-12-28T20:18:15.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/4b/d6b051ca4b3d76f23c2c436a9669f3be616b8cf6461a7e8061c7c4269642/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4", size = 27561, upload-time = "2024-12-28T20:16:47.974Z" }, - { url = "https://files.pythonhosted.org/packages/6d/40/e39b0d471e55eb1b5c7c81edab605c02f71c786d59fb875f0a6f23318747/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c", size = 34448, upload-time = "2024-12-28T20:16:50.712Z" }, - { url = "https://files.pythonhosted.org/packages/f2/28/7a5c87c5561d14f1c9af979231fdf85d8f9fad7a95ff94e56d2205e2520a/backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b", size = 27093, upload-time = "2024-12-28T20:16:52.994Z" }, - { url = "https://files.pythonhosted.org/packages/80/ba/f00296c5c4536967c7d1136107fdb91c48404fe769a4a6fd5ab045629af8/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4", size = 52836, upload-time = "2024-12-28T20:16:55.283Z" }, - { url = "https://files.pythonhosted.org/packages/e3/92/bb1da57a069ddd601aee352a87262c7ae93467e66721d5762f59df5021a6/backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22", size = 52798, upload-time = "2024-12-28T20:16:56.64Z" }, - { url = "https://files.pythonhosted.org/packages/df/ef/b6cfd355982e817ccdb8d8d109f720cab6e06f900784b034b30efa8fa832/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf", size = 52891, upload-time = "2024-12-28T20:16:58.887Z" }, - { url = "https://files.pythonhosted.org/packages/37/39/b13e3ae8a7c5d88b68a6e9248ffe7066534b0cfe504bf521963e61b6282d/backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58", size = 52955, upload-time = "2024-12-28T20:17:00.028Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e4/70cffa3ce1eb4f2ff0c0d6f5d56285aacead6bd3879b27a2ba57ab261172/backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc", size = 29323, upload-time = "2024-12-28T20:17:01.125Z" }, - { url = "https://files.pythonhosted.org/packages/62/f5/5bc92030deadf34c365d908d4533709341fb05d0082db318774fdf1b2bcb/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443", size = 27626, upload-time = "2024-12-28T20:17:03.448Z" }, - { url = "https://files.pythonhosted.org/packages/28/45/5885737d51f81dfcd0911dd5c16b510b249d4c4cf6f4a991176e0358a42a/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0", size = 34588, upload-time = "2024-12-28T20:17:04.459Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6d/bd74de70953f5dd3e768c8fc774af942af0ce9f211e7c38dd478fa7ea910/backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005", size = 27162, upload-time = "2024-12-28T20:17:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/47/ba/1d14b097f13cce45b2b35db9898957578b7fcc984e79af3b35189e0d332f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75", size = 54482, upload-time = "2024-12-28T20:17:08.15Z" }, - { url = "https://files.pythonhosted.org/packages/25/e9/a2a7927d053b6fa148b64b5e13ca741ca254c13edca99d8251e9a8a09cfe/backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45", size = 54362, upload-time = "2024-12-28T20:17:10.605Z" }, - { url = "https://files.pythonhosted.org/packages/c1/99/394fb5e80131a7d58c49b89e78a61733a9994885804a0bb582416dd10c6f/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01", size = 54162, upload-time = "2024-12-28T20:17:12.301Z" }, - { url = "https://files.pythonhosted.org/packages/88/25/1940369de573c752889646d70b3fe8645e77b9e17984e72a554b9b51ffc4/backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6", size = 54118, upload-time = "2024-12-28T20:17:13.609Z" }, - { url = "https://files.pythonhosted.org/packages/b7/46/f275bf6c61683414acaf42b2df7286d68cfef03e98b45c168323d7707778/backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061", size = 29329, upload-time = "2024-12-28T20:17:16.124Z" }, - { url = "https://files.pythonhosted.org/packages/a2/0f/69bbdde2e1e57c09b5f01788804c50e68b29890aada999f2b1a40519def9/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147", size = 27630, upload-time = "2024-12-28T20:17:19.442Z" }, - { url = "https://files.pythonhosted.org/packages/d5/1d/1c84a50c673c87518b1adfeafcfd149991ed1f7aedc45d6e5eac2f7d19d7/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4", size = 34707, upload-time = "2024-12-28T20:17:21.79Z" }, - { url = "https://files.pythonhosted.org/packages/71/44/27eae384e7e045cda83f70b551d04b4a0b294f9822d32dea1cbf1592de59/backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35", size = 27280, upload-time = "2024-12-28T20:17:24.503Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7a/a4075187eb6bbb1ff6beb7229db5f66d1070e6968abeb61e056fa51afa5e/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1", size = 55094, upload-time = "2024-12-28T20:17:25.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/3fced4230c10af14aacadc195fe58e2ced91d011217b450c2e16a09a98c8/backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32", size = 55605, upload-time = "2024-12-28T20:17:29.208Z" }, - { url = "https://files.pythonhosted.org/packages/f6/0a/4b34a838c57bd16d3e5861ab963845e73a1041034651f7459e9935289cfd/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d", size = 55353, upload-time = "2024-12-28T20:17:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/d9/68/07d13c6e98e1cad85606a876367ede2de46af859833a1da12c413c201d78/backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7", size = 55298, upload-time = "2024-12-28T20:17:34.919Z" }, - { url = "https://files.pythonhosted.org/packages/60/33/45b4d5311f42360f9b900dea53ab2bb20a3d61d7f9b7c37ddfcb3962f86f/backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d", size = 29375, upload-time = "2024-12-28T20:17:36.018Z" }, - { url = "https://files.pythonhosted.org/packages/be/03/7eaa9f9bf290395d57fd30d7f1f2f9dff60c06a31c237dc2beb477e8f899/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039", size = 28980, upload-time = "2024-12-28T20:18:06.554Z" }, - { url = "https://files.pythonhosted.org/packages/47/80/a0ecf33446c7349e79f54cc532933780341d20cff0ee12b5bfdcaa47067e/backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06", size = 28449, upload-time = "2024-12-28T20:18:07.77Z" }, -] - [[package]] name = "beautifulsoup4" version = "4.14.3" @@ -920,20 +886,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, ] -[[package]] -name = "environs" -version = "14.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "marshmallow" }, - { name = "python-dotenv" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/c7/94f97e6e74482a50b5fc798856b6cc06e8d072ab05a0b74cb5d87bd0d065/environs-14.6.0.tar.gz", hash = "sha256:ed2767588deb503209ffe4dd9bb2b39311c2e4e7e27ce2c64bf62ca83328d068", size = 35563, upload-time = "2026-02-20T04:02:08.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/a8/c070e1340636acb38d4e6a7e45c46d168a462b48b9b3257e14ca0e5af79b/environs-14.6.0-py3-none-any.whl", hash = "sha256:f8fb3d6c6a55872b0c6db077a28f5a8c7b8984b7c32029613d44cef95cfc0812", size = 17205, upload-time = "2026-02-20T04:02:07.299Z" }, -] - [[package]] name = "et-xmlfile" version = "2.0.0" @@ -1967,19 +1919,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] -[[package]] -name = "marshmallow" -version = "4.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-datetime-fromisoformat", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/03/261af5efb3d3ce0e2db3fd1e11dc5a96b74a4fb76e488da1c845a8f12345/marshmallow-4.2.2.tar.gz", hash = "sha256:ba40340683a2d1c15103647994ff2f6bc2c8c80da01904cbe5d96ee4baa78d9f", size = 221404, upload-time = "2026-02-04T15:47:03.401Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/70/bb89f807a6a6704bdc4d6f850d5d32954f6c1965e3248e31455defdf2f30/marshmallow-4.2.2-py3-none-any.whl", hash = "sha256:084a9466111b7ec7183ca3a65aed758739af919fedc5ebdab60fb39d6b4dc121", size = 48454, upload-time = "2026-02-04T15:47:02.013Z" }, -] - [[package]] name = "matplotlib" version = "3.10.8" @@ -3139,15 +3078,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-json-logger" version = "4.0.0" @@ -4357,7 +4287,6 @@ dependencies = [ { name = "django", version = "5.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "django", version = "6.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "django-rdkit" }, - { name = "environs" }, { name = "gemmi" }, { name = "hippo-plot" }, { name = "hirsch" }, @@ -4402,7 +4331,6 @@ requires-dist = [ { name = "chardet", specifier = ">=7" }, { name = "django", specifier = ">=5.2.12" }, { name = "django-rdkit", git = "https://github.com/rdkit/django-rdkit" }, - { name = "environs", specifier = ">=14.6.0" }, { name = "gemmi", specifier = ">=0.7.5" }, { name = "hippo-plot", specifier = ">=0.0.1" }, { name = "hirsch", specifier = ">=0.1" }, From cfa6417c355df911bf53d06889a7715aa79ff047 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 23 Apr 2026 11:24:36 +0100 Subject: [PATCH 128/163] fix: adding compose file --- docker-compose.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d2a22a..54f3480 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -62,9 +62,9 @@ services: - "8888:8888" networks: - app_network - depends_on: - database: - condition: service_healthy + # depends_on: + # database: + # condition: service_healthy networks: From 110b08d72dc0021e4a004a2d1d6c150385f69ea8 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 28 Apr 2026 09:29:05 +0100 Subject: [PATCH 129/163] fix: specify conda-forge in Dockerfile --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6b6e310..0345b5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ WORKDIR "/home/code/HIPPO" COPY . ./ # need this from conda. See comment in pyproject.toml, possibly can get rid of this -RUN mamba install --yes \ +RUN mamba install --yes -c conda-forge \ chemicalite=2024.05.1 && \ mamba clean --all -f -y && \ fix-permissions "${CONDA_DIR}" && \ @@ -51,5 +51,4 @@ USER ${NB_USER} WORKDIR "/home/code/HIPPO" # NB! force-install numpy because need newer version -# RUN pip install numpy --upgrade RUN pip install numpy==2.2.4 From 2f32be4c46efa5f077f283258dea7eac5ab731f4 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 28 Apr 2026 16:09:16 +0100 Subject: [PATCH 130/163] fix: remove unnecessary dependencies and fix dockerfile --- .dockerignore | 5 + Dockerfile | 24 +- images/xchem-designdb/01_schema.sql | 97 ++++---- images/xchem-designdb/init-db/01_schema.sql | 242 ++------------------ pyproject.toml | 15 +- uv.lock | 2 +- 6 files changed, 93 insertions(+), 292 deletions(-) diff --git a/.dockerignore b/.dockerignore index 70d691f..1c63810 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,6 +34,11 @@ Thumbs.db # Virtual environment .venv +venv #Python cache files __pycache__ +*.pyc + +dist +build diff --git a/Dockerfile b/Dockerfile index 0345b5b..69eef57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,41 +14,37 @@ ARG PYTHON_VERSION=3.12 WORKDIR "/home/code/HIPPO" -COPY . ./ - -# need this from conda. See comment in pyproject.toml, possibly can get rid of this -RUN mamba install --yes -c conda-forge \ - chemicalite=2024.05.1 && \ - mamba clean --all -f -y && \ - fix-permissions "${CONDA_DIR}" && \ - fix-permissions "/home/${NB_USER}" +USER 0 # install package dependencies into different virtual env COPY uv.lock pyproject.toml ./ -RUN python -m pip install --upgrade pip && python -m pip install uv +RUN pip install --upgrade pip && python -m pip install uv # install all dependencies into active environment without updating lockfile -RUN python -m uv sync --frozen --quiet --active +RUN uv sync --frozen --quiet --active # now add venv python to path so conda python can find it ENV PATH="/home/code/HIPPO/.venv/bin:$PATH" ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages:$PYTHONPATH" +# copy files from host +COPY . ./ + # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" # notebooks -USER 0 +# USER 0 RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y + +# force update numpy +RUN /opt/conda/bin/python -m pip install -v --upgrade numpy==2.4.4 USER ${NB_USER} WORKDIR "/home/code/HIPPO" - -# NB! force-install numpy because need newer version -RUN pip install numpy==2.2.4 diff --git a/images/xchem-designdb/01_schema.sql b/images/xchem-designdb/01_schema.sql index ff13746..0e3ce0d 100644 --- a/images/xchem-designdb/01_schema.sql +++ b/images/xchem-designdb/01_schema.sql @@ -43,12 +43,8 @@ CREATE TABLE IF NOT EXISTS designdb.compounds ( base_compound_id BIGINT REFERENCES designdb.compounds (id) ON DELETE SET NULL, -- Not populated by code -- compound_mol rdkit.mol, -- Replaced by TEXT CTAB below: JDBC showed SMILES text; mol_to_ctab gives a molfile string that Scarab will easily convert to structure. compound_mol TEXT, -- V2000 CTAB (mol block) from rdkit.mol_to_ctab(mol_from_smiles(...)) - -- compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - -- compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken - - compound_pattern_bfp bfp, -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken - compound_morgan_bfp bfp, -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken - + compound_pattern_bfp bit(2048), -- Postgres RDkit cartridge can calc this, Chemicalite does, not sure if insert by codebase. Currently seems broken + compound_morgan_bfp bit(2048), -- Postgres cartridge can't calc this. Must be inserted by codebase, but currently its broken compound_metadata TEXT, -- currently Null note TEXT, -- New column rdkit_version TEXT, -- RDKit version string used when computing compound_hash (application-set; cartridge may also populate) @@ -79,7 +75,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( pose_path TEXT, compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, - pose_mol rdkit.mol, -- Insert by the codebase. Trigger populates pose_inchikey and pose_smiles. Originally, inserted by the codebase and /or Chemicalite/Postgres RDkit cartridge, Check with Kalev!!!!! + pose_mol rdkit.mol, -- Insert by codebase. Trigger populates pose_inchikey and pose_smiles via cartridge. pose_fingerprint INTEGER, --Not sure if it null or actually calcualated somewhere. --pose_energy_score REAL, -- LR - redundant; use designdb.score_values --pose_distance_score REAL, -- LR - redundant; use designdb.score_values @@ -315,11 +311,11 @@ CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( match_hash TEXT NOT NULL, -- Will be deleted automatically from db from here: -- HIPPO temporary columns, remove when HIPPO can handle catalogue_prices and catalogue_compounds: - catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. - supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. - amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. - price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. - lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. + -- lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. -- Will be deleted automatically until here created_on TIMESTAMPTZ NOT NULL DEFAULT now(), updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -714,7 +710,7 @@ WHERE a.operation = 'U'; -- ========================================================= -- RDKIT CARTRIDGE – COMPOUND WRAPPERS -- ========================================================= --- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (used by compound, pose, and catalogue_compounds triggers). +-- Schema-qualified wrappers for RDKit mol_from_smiles, mol_to_smiles, mol_inchikey, mol_to_ctab (compound, pose, catalogue_compounds triggers). CREATE OR REPLACE FUNCTION designdb.mol_from_smiles(smiles TEXT) RETURNS rdkit.mol LANGUAGE SQL AS $$ SELECT rdkit.mol_from_smiles(smiles::cstring); $$; @@ -890,8 +886,8 @@ BEGIN score_txt := '(sv.score->>' || quote_literal('score') || ')'; value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; - select_qry := select_qry || ', (array_agg(' || value_expr - || ') FILTER (WHERE sv.scoring_method_id = ' || method_rec.id || '))[1] AS ' || col; + select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id + || ' THEN ' || value_expr || ' END) AS ' || col; END IF; END LOOP; select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; @@ -929,10 +925,11 @@ BEGIN END; $$ LANGUAGE plpgsql; +/* -- Will be deleted automatically from db from here: -- === HIPPO TEMPORARY: denormalized map columns; remove when HIPPO ready === -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_hippo_temp() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -959,7 +956,7 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -989,7 +986,7 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1016,7 +1013,7 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1032,7 +1029,7 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp() +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -1052,6 +1049,7 @@ BEGIN END; $$; -- Will be deleted automatically until here +*/ -- ========================================================= -- AUDIT FUNCTIONS @@ -1173,15 +1171,17 @@ CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +/* -- Will be deleted automatically from db from here: --- Production trigger names reserved (see /* */ block at end of file); active HIPPO temp triggers: +-- Production trigger names reserved (see /* */ block at end of file); active temp triggers: DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_hippo_temp ON designdb.compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_compound_hippo_temp +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_compound_temp AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_hippo_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp(); -- Will be deleted automatically until here +*/ DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); @@ -1221,35 +1221,37 @@ CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_comp DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); +/* -- Will be deleted automatically from db from here: DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_hippo_temp ON designdb.catalogue_compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_hippo_temp +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_temp AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp(); DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_hippo_temp ON designdb.catalogue_prices; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_hippo_temp +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_temp AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price ON designdb.catalogue_prices; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp ON designdb.catalogue_prices; -CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp AFTER UPDATE OF supplier, amount, price, lead_time ON designdb.catalogue_prices FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound ON designdb.catalogue_compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp ON designdb.catalogue_compounds; -CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp +DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp AFTER UPDATE OF catalogue_inchikey ON designdb.catalogue_compounds FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); -- Will be deleted automatically until here +*/ DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); @@ -1374,17 +1376,17 @@ SELECT designdb.create_scores_per_pose_pivoted_mv(); -- DROP/ALTER lines below and run in order; then remove the "/*" and "*/" around the -- production block so it can be manually executed. -- --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp ON designdb.catalogue_compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_hippo_temp ON designdb.catalogue_compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_hippo_temp ON designdb.compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp ON designdb.catalogue_prices; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_hippo_temp ON designdb.catalogue_prices; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; +-- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; -- --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_hippo_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_hippo_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_hippo_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_hippo_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_hippo_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); +-- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_temp(); -- -- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS catalogue_inchikey; -- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS supplier; @@ -1392,7 +1394,7 @@ SELECT designdb.create_scores_per_pose_pivoted_mv(); -- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS price; -- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS lead_time; -- ============================================================================= -/* + -- === PRODUCTION: compound_catalogue_map (INSERT only compound_id, catalogue_price_id, match_hash) === CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() @@ -1484,4 +1486,3 @@ CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); -*/ diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index 0e3ce0d..dddc1a0 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -309,14 +309,6 @@ CREATE TABLE IF NOT EXISTS designdb.compound_catalogue_map ( compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE CASCADE, catalogue_price_id BIGINT NOT NULL REFERENCES designdb.catalogue_prices (id) ON DELETE CASCADE, match_hash TEXT NOT NULL, - -- Will be deleted automatically from db from here: - -- HIPPO temporary columns, remove when HIPPO can handle catalogue_prices and catalogue_compounds: - -- catalogue_inchikey TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. - -- supplier TEXT, --Needs to be removed when HIPPO codebase ready to handle prices properly. - -- amount REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. - -- price REAL, --Needs to be removed when HIPPO codebase ready to handle prices properly. - -- lead_time INTEGER, --Needs to be removed when HIPPO codebase ready to handle prices properly. - -- Will be deleted automatically until here created_on TIMESTAMPTZ NOT NULL DEFAULT now(), updated_on TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (compound_id, catalogue_price_id), @@ -886,8 +878,8 @@ BEGIN score_txt := '(sv.score->>' || quote_literal('score') || ')'; value_expr := '(CASE WHEN ' || score_txt || ' IS NOT NULL AND ' || score_txt || ' ~ ' || quote_literal(numeric_pat) || ' THEN to_jsonb((' || score_txt || ')::numeric) ELSE to_jsonb(' || score_txt || ') END)'; - select_qry := select_qry || ', MAX(CASE WHEN sv.scoring_method_id = ' || method_rec.id - || ' THEN ' || value_expr || ' END) AS ' || col; + select_qry := select_qry || ', (array_agg(' || value_expr + || ') FILTER (WHERE sv.scoring_method_id = ' || method_rec.id || '))[1] AS ' || col; END IF; END LOOP; select_qry := select_qry || ' FROM designdb.score_values sv GROUP BY sv.pose_id, sv.compound_id'; @@ -925,11 +917,9 @@ BEGIN END; $$ LANGUAGE plpgsql; -/* --- Will be deleted automatically from db from here: --- === HIPPO TEMPORARY: denormalized map columns; remove when HIPPO ready === - -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp() +-- ========================================================= +-- Populate compound_catalogue_map when compounds and/or catalogue prices exist for the same registration hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -941,12 +931,8 @@ BEGIN DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; END IF; - INSERT INTO designdb.compound_catalogue_map ( - compound_id, catalogue_price_id, match_hash, - catalogue_inchikey, supplier, amount, price, lead_time - ) - SELECT NEW.id, cp.id, NEW.compound_hash, - cat.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT NEW.id, cp.id, NEW.compound_hash FROM designdb.catalogue_prices cp JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id WHERE cat.catalogue_hash = NEW.compound_hash @@ -956,7 +942,8 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp() +-- When catalogue_hash changes on catalogue_compounds: refresh map rows for all price lines under that compound row. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -971,12 +958,8 @@ BEGIN ); END IF; - INSERT INTO designdb.compound_catalogue_map ( - compound_id, catalogue_price_id, match_hash, - catalogue_inchikey, supplier, amount, price, lead_time - ) - SELECT c.id, cp.id, c.compound_hash, - NEW.catalogue_inchikey, cp.supplier, cp.amount, cp.price, cp.lead_time + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, cp.id, c.compound_hash FROM designdb.compounds c CROSS JOIN designdb.catalogue_prices cp WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash @@ -986,7 +969,8 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp() +-- When a catalogue_price row is inserted or its catalogue_id changes: link compounds by parent hash. +CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -998,12 +982,8 @@ BEGIN DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; END IF; - INSERT INTO designdb.compound_catalogue_map ( - compound_id, catalogue_price_id, match_hash, - catalogue_inchikey, supplier, amount, price, lead_time - ) - SELECT c.id, NEW.id, c.compound_hash, - cat.catalogue_inchikey, NEW.supplier, NEW.amount, NEW.price, NEW.lead_time + INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) + SELECT c.id, NEW.id, c.compound_hash FROM designdb.compounds c JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id WHERE c.compound_hash = cat.catalogue_hash @@ -1013,44 +993,6 @@ BEGIN END; $$; -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - UPDATE designdb.compound_catalogue_map m - SET supplier = NEW.supplier, - amount = NEW.amount, - price = NEW.price, - lead_time = NEW.lead_time, - updated_on = now() - WHERE m.catalogue_price_id = NEW.id; - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF TG_OP = 'UPDATE' THEN - IF OLD.catalogue_inchikey IS NOT DISTINCT FROM NEW.catalogue_inchikey THEN - RETURN NEW; - END IF; - END IF; - UPDATE designdb.compound_catalogue_map m - SET catalogue_inchikey = NEW.catalogue_inchikey, - updated_on = now() - WHERE m.catalogue_price_id IN ( - SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id - ); - RETURN NEW; -END; -$$; --- Will be deleted automatically until here -*/ - -- ========================================================= -- AUDIT FUNCTIONS -- ========================================================= @@ -1171,17 +1113,11 @@ CREATE TRIGGER trg_pose_method_updated_on BEFORE UPDATE ON designdb.pose_methods DROP TRIGGER IF EXISTS trg_compound_updated_on ON designdb.compounds; CREATE TRIGGER trg_compound_updated_on BEFORE UPDATE ON designdb.compounds FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -/* --- Will be deleted automatically from db from here: --- Production trigger names reserved (see /* */ block at end of file); active temp triggers: DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_compound_temp +CREATE TRIGGER trg_compound_catalogue_map_sync_compound AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound_temp(); --- Will be deleted automatically until here -*/ + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); DROP TRIGGER IF EXISTS trg_feature_updated_on ON designdb.features; CREATE TRIGGER trg_feature_updated_on BEFORE UPDATE ON designdb.features FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); @@ -1221,37 +1157,17 @@ CREATE TRIGGER trg_catalogue_updated_on BEFORE UPDATE ON designdb.catalogue_comp DROP TRIGGER IF EXISTS trg_catalogue_price_updated_on ON designdb.catalogue_prices; CREATE TRIGGER trg_catalogue_price_updated_on BEFORE UPDATE ON designdb.catalogue_prices FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); -/* --- Will be deleted automatically from db from here: DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_temp +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_temp(); + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price_temp +CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); - -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price ON designdb.catalogue_prices; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; -CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp - AFTER UPDATE OF supplier, amount, price, lead_time ON designdb.catalogue_prices - FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); - -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound ON designdb.catalogue_compounds; -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; -CREATE TRIGGER trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp - AFTER UPDATE OF catalogue_inchikey ON designdb.catalogue_compounds - FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); --- Will be deleted automatically until here -*/ + EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); DROP TRIGGER IF EXISTS trg_compound_catalogue_map_updated_on ON designdb.compound_catalogue_map; CREATE TRIGGER trg_compound_catalogue_map_updated_on BEFORE UPDATE ON designdb.compound_catalogue_map FOR EACH ROW EXECUTE FUNCTION designdb.update_updated_on(); @@ -1370,119 +1286,3 @@ CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb. -- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods SELECT designdb.create_scores_per_pose_pivoted_mv(); - --- ============================================================================= --- HIPPO → production revert (manual step on live DB): strip leading "-- " from the --- DROP/ALTER lines below and run in order; then remove the "/*" and "*/" around the --- production block so it can be manually executed. --- --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp ON designdb.catalogue_compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_temp ON designdb.catalogue_compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound_temp ON designdb.compounds; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp ON designdb.catalogue_prices; --- DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price_temp ON designdb.catalogue_prices; --- --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_compound_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_refresh_denorm_from_catalogue_price_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_catalogue_price_temp(); --- DROP FUNCTION IF EXISTS designdb.trg_compound_catalogue_map_from_compound_temp(); --- --- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS catalogue_inchikey; --- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS supplier; --- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS amount; --- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS price; --- ALTER TABLE designdb.compound_catalogue_map DROP COLUMN IF EXISTS lead_time; --- ============================================================================= - --- === PRODUCTION: compound_catalogue_map (INSERT only compound_id, catalogue_price_id, match_hash) === - -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_compound() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF TG_OP = 'UPDATE' THEN - IF OLD.compound_hash IS NOT DISTINCT FROM NEW.compound_hash THEN - RETURN NEW; - END IF; - DELETE FROM designdb.compound_catalogue_map WHERE compound_id = NEW.id; - END IF; - - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT NEW.id, cp.id, NEW.compound_hash - FROM designdb.catalogue_prices cp - JOIN designdb.catalogue_compounds cat ON cat.id = cp.catalogue_id - WHERE cat.catalogue_hash = NEW.compound_hash - ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; - - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF TG_OP = 'UPDATE' THEN - IF OLD.catalogue_hash IS NOT DISTINCT FROM NEW.catalogue_hash THEN - RETURN NEW; - END IF; - DELETE FROM designdb.compound_catalogue_map - WHERE catalogue_price_id IN ( - SELECT id FROM designdb.catalogue_prices WHERE catalogue_id = NEW.id - ); - END IF; - - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT c.id, cp.id, c.compound_hash - FROM designdb.compounds c - CROSS JOIN designdb.catalogue_prices cp - WHERE cp.catalogue_id = NEW.id AND c.compound_hash = NEW.catalogue_hash - ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; - - RETURN NEW; -END; -$$; - -CREATE OR REPLACE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price() -RETURNS trigger -LANGUAGE plpgsql -AS $$ -BEGIN - IF TG_OP = 'UPDATE' AND OLD.catalogue_id IS NOT DISTINCT FROM NEW.catalogue_id THEN - RETURN NEW; - END IF; - IF TG_OP = 'UPDATE' THEN - DELETE FROM designdb.compound_catalogue_map WHERE catalogue_price_id = NEW.id; - END IF; - - INSERT INTO designdb.compound_catalogue_map (compound_id, catalogue_price_id, match_hash) - SELECT c.id, NEW.id, c.compound_hash - FROM designdb.compounds c - JOIN designdb.catalogue_compounds cat ON cat.id = NEW.catalogue_id - WHERE c.compound_hash = cat.catalogue_hash - ON CONFLICT (compound_id, catalogue_price_id) DO NOTHING; - - RETURN NEW; -END; -$$; - -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_compound ON designdb.compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_compound - AFTER INSERT OR UPDATE OF compound_hash ON designdb.compounds - FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_compound(); - -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue ON designdb.catalogue_compounds; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue - AFTER INSERT OR UPDATE OF catalogue_hash ON designdb.catalogue_compounds - FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue(); - -DROP TRIGGER IF EXISTS trg_compound_catalogue_map_sync_catalogue_price ON designdb.catalogue_prices; -CREATE TRIGGER trg_compound_catalogue_map_sync_catalogue_price - AFTER INSERT OR UPDATE OF catalogue_id ON designdb.catalogue_prices - FOR EACH ROW - EXECUTE FUNCTION designdb.trg_compound_catalogue_map_from_catalogue_price(); diff --git a/pyproject.toml b/pyproject.toml index 1f838b6..873e6dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +# [build-system] +# requires = ["hatchling"] +# build-backend = "hatchling.build" [project] name = "xchem-hippo" version = "1.0.0" @@ -38,7 +38,6 @@ dependencies = [ "psycopg[binary]>=3.3", "django>=5.2.12", "syndirella>=5.0.7a0", - # "syndirella==4.0.1a0",# have to pin that apparently "typer>=0.24.1", # used? "neo4j>=6.1.0", "gemmi>=0.7.5", @@ -125,10 +124,10 @@ exclude = [ profile = "hug" src_paths = ["src", "tests"] -[tool.hatch.build] -include = [ - "hippo/*.py", -] +# [tool.hatch.build] +# include = [ +# "hippo/*.py", +# ] [tool.uv.sources] django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } diff --git a/uv.lock b/uv.lock index 097ee8f..2dac298 100644 --- a/uv.lock +++ b/uv.lock @@ -4280,7 +4280,7 @@ wheels = [ [[package]] name = "xchem-hippo" version = "1.0.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "apsw" }, { name = "chardet" }, From c2dd6dae9a4dc4fd7d83d2560999ea4cb7d88fc7 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 29 Apr 2026 11:15:34 +0100 Subject: [PATCH 131/163] fix: specify platform info in pyproject.toml --- Dockerfile | 6 +++--- pyproject.toml | 6 ++++++ uv.lock | 42 +++++++++++++++++++++++++----------------- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 69eef57..463fb71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,11 +39,11 @@ RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" # notebooks -# USER 0 RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y -# force update numpy -RUN /opt/conda/bin/python -m pip install -v --upgrade numpy==2.4.4 +# force numpy update. since there's now a new venv, make sure conda is +# used (othewise numpy will be invisible) +RUN /opt/conda/bin/python -m pip install --upgrade numpy==2.4.4 USER ${NB_USER} diff --git a/pyproject.toml b/pyproject.toml index 873e6dc..9062dfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,3 +131,9 @@ src_paths = ["src", "tests"] [tool.uv.sources] django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } + +[tool.uv] +required-environments = [ + "sys_platform == 'linux' and platform_machine == 'aarch64'", + "sys_platform == 'linux' and platform_machine == 'x86_64'", +] diff --git a/uv.lock b/uv.lock index 2dac298..9b75ef8 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.11'", ] +required-markers = [ + "platform_machine == 'aarch64' and sys_platform == 'linux'", + "platform_machine == 'x86_64' and sys_platform == 'linux'", +] [[package]] name = "annotated-doc" @@ -2310,28 +2314,32 @@ wheels = [ [[package]] name = "openmm" -version = "8.4.0.post2" +version = "8.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/75/8aa4a2f989d35da193b1674be86a13504f1b368c9a419b80095fe04f6fad/openmm-8.4.0.post2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f5466e34b1201ac26a0bd41c8239a7a1849735bcba3c953e4524c1dd0e0015b6", size = 13228640, upload-time = "2025-11-24T21:40:48.893Z" }, - { url = "https://files.pythonhosted.org/packages/aa/02/8ac8f2949fcb8122c9afbfba2b6a57db4f6bf8b8fd180edf4a18522e7891/openmm-8.4.0.post2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4b918707cbdcf517a18d1122e45e5721e9d6ad57005dba5183334cb18765803a", size = 12742263, upload-time = "2025-11-24T21:40:53.807Z" }, - { url = "https://files.pythonhosted.org/packages/6b/39/275dcf2099d6f28a6a9331aea9491b545dff9fc54984dea5c4bf28ee4a09/openmm-8.4.0.post2-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:eaadcafd6839e7f61153808de66ef47b56c2f909193d86e2b107d77c64017494", size = 14248895, upload-time = "2025-11-24T21:41:00.416Z" }, - { url = "https://files.pythonhosted.org/packages/aa/9f/4c486ef74c5dcfce2f4fd797bb7e663d591ea6e19897deeb3b945b3c576e/openmm-8.4.0.post2-cp310-cp310-win_amd64.whl", hash = "sha256:2eed655113d0e78ae9fb539e63b15dd38270c116e96bf1785037fef35e3acc3c", size = 13095815, upload-time = "2025-11-24T21:41:06.351Z" }, - { url = "https://files.pythonhosted.org/packages/56/3e/091c18ae7efb9eb0ceacd31cb516510fd8b7b31927f57b1da46f094ff781/openmm-8.4.0.post2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9399dd46cf7bf345ad28e77e3457a11298874d2e2e407860cc0e46cceb6d3d11", size = 13228102, upload-time = "2025-11-24T21:41:14.647Z" }, - { url = "https://files.pythonhosted.org/packages/42/ac/e19f750374532e70fde81f01132e0537b48719ccc2ff2483c5b30b0e7d99/openmm-8.4.0.post2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:49d0e5a0c41ff471f5a24cb73de71ee517b59cc34beaa17aad429892f57e3962", size = 12741400, upload-time = "2025-11-24T21:41:23.031Z" }, - { url = "https://files.pythonhosted.org/packages/f9/01/8fea59390d19ef600a7af46a9edb48d05f7f28ba04cf02c6b1f5d8411402/openmm-8.4.0.post2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:12ffcd82d596bded1382e30af55907754ee481aafc0fc4a921a97de2ea7a8c55", size = 14248574, upload-time = "2025-11-24T21:41:29.188Z" }, - { url = "https://files.pythonhosted.org/packages/b5/82/573cf4b24e9ad17bbb1e0582e3848631bfd34c2b14b0aa217273adee0f0a/openmm-8.4.0.post2-cp311-cp311-win_amd64.whl", hash = "sha256:b18fb1fb3128df8f2cedb23af33c484701a6fa51da8204824a445f800581be13", size = 13096083, upload-time = "2025-11-24T21:41:35.644Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c8/1344fa71c59e891f8dbb107aae192f661a72073ed84064c7828b1a26d9ee/openmm-8.4.0.post2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3c8a012810928b1c0ae91a755d3197e81e7d03d2058d92dd4924d283edeae44", size = 13224672, upload-time = "2025-11-24T21:41:41.97Z" }, - { url = "https://files.pythonhosted.org/packages/a7/0a/e9d1080eb107349ef090cbe0bd8335f3920708f1435b943df8c1c5496f50/openmm-8.4.0.post2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1bde30736f7b4b595041caf083bb4e4e79a794ab164faedd0664eda0348a299f", size = 12739310, upload-time = "2025-11-24T21:41:49.437Z" }, - { url = "https://files.pythonhosted.org/packages/69/45/ab3937509f5dcde71fe7ac300f24a8d0684448d9b4820470360202bb95e4/openmm-8.4.0.post2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:168544c0b388ae71cc3f85e27c36c4f393854a313c4203f9e9957d6214836c13", size = 14253728, upload-time = "2025-11-24T21:41:55.662Z" }, - { url = "https://files.pythonhosted.org/packages/06/d4/a6022476db6cd0baf0218fa33ed70182f1447e0b6bf5ded124a846c4dab9/openmm-8.4.0.post2-cp312-cp312-win_amd64.whl", hash = "sha256:6e9fd826aedf34b4c27a4dcda83da93e90f3e81305c58bcc07dafee22460a469", size = 13098413, upload-time = "2025-11-24T21:42:00.409Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4f/be754e36197e075281ce333e906515bc33a326d3f9f9a0e6b97bbf81159a/openmm-8.4.0.post2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:07dfe965a205e57eff525edad4a65f5b426daa068e491536019bc9b3957bc1f3", size = 13224644, upload-time = "2025-11-24T21:42:07.488Z" }, - { url = "https://files.pythonhosted.org/packages/9a/50/0cf408fecd04c23aa2767b10293404b513f37a76d0c41f60eba113d85c3b/openmm-8.4.0.post2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fdfae1f8c7612520f3bd2f7c0dccb9d9df5f085e219272ca43617574b88c3a6", size = 12738320, upload-time = "2025-11-24T21:42:14.283Z" }, - { url = "https://files.pythonhosted.org/packages/2e/da/5534914daa40455f5ed92b4d82c980e5f346922841c4d86b32ed6cb1382b/openmm-8.4.0.post2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:70cdd3309064b95bd304af1f195377e54b6f7060b15b37af725d15764b756b09", size = 14251710, upload-time = "2025-11-24T21:42:22.865Z" }, - { url = "https://files.pythonhosted.org/packages/34/9e/e42c8c6d29050dd8188d33c79c9a40291bf8885d1588f755246bb734effe/openmm-8.4.0.post2-cp313-cp313-win_amd64.whl", hash = "sha256:e2912d803e7473048351cddb59176df191db3cbe21d0ab1a6f83e7020b92f01f", size = 13096685, upload-time = "2025-11-24T21:42:29.885Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/5b22b7e35ce3ecb91506f55ea33165ff253027404cc2ee08baebaeb94c19/openmm-8.5.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c4403cdfc42fbcec9e7cb3f6fa12c1b9d3f951152903f0a62876c475c62e618d", size = 13338163, upload-time = "2026-04-29T02:46:56.995Z" }, + { url = "https://files.pythonhosted.org/packages/49/3c/d4aa304f6aae9f33366d12e686f28395d912045710d24cb7bed5e09a9dae/openmm-8.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1630a4eff9209bac6b713336c64550416fab400cae59fd9953619128aa3408c1", size = 12841997, upload-time = "2026-04-10T21:14:51.653Z" }, + { url = "https://files.pythonhosted.org/packages/94/ae/85b82886e3953a603ecf54f37e7ba9902560067b92ef96724837338c1d6a/openmm-8.5.1-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:c393489bf1a926be7e019eb5e145d0fe5c94b5934f8e996d711cfa0cdfeb2777", size = 13987439, upload-time = "2026-04-10T21:14:57.461Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1b/02bad5dec5375f2557f80e2a9bb4d9f6483f65f78651a540d8fcd8677f76/openmm-8.5.1-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0e41eb743f9c541d6e4c947a45d88477acaf0487ade42fb4e994a0a0cc103e3a", size = 14381656, upload-time = "2026-04-10T21:15:02.046Z" }, + { url = "https://files.pythonhosted.org/packages/09/cd/b0d9581b3db9f9fa0a49a90bc6a5b5defd62fb83919f454216296f6c0d3b/openmm-8.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:d1c7e8adc3bfeed7f66eae5163720fc2d48bd813ed4fd218565a5aa26894f657", size = 13206368, upload-time = "2026-04-10T21:15:06.633Z" }, + { url = "https://files.pythonhosted.org/packages/23/65/595fd0088cfe8f4fa7d3ec22a5a5898480ca7fe4863bbecbd18f43248bc4/openmm-8.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d0cb5e4f632b0f96f3768ce1b30af1da38444b79b04f320ab8a8dde8c6b91afe", size = 13337563, upload-time = "2026-04-10T21:15:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2a/32da6ff84d96f703257ebcb5915d2efc6545a19738112a918a77cc7a6894/openmm-8.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4deed68b0a275b307384d07364100652e32eaf404cda61f73d936bedad8f95d9", size = 12841135, upload-time = "2026-04-10T21:15:15.683Z" }, + { url = "https://files.pythonhosted.org/packages/72/a4/fd6b180c710ec074d8e16cc3aa1b4b5fc96d7d59ad24fdaee86775096750/openmm-8.5.1-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:3a1d8618ee89b1a044699099529372a70603fdb3ef5c7a834a7b39700f313204", size = 13986926, upload-time = "2026-04-10T21:15:20.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/72/07ee4e3042fc469d3cc156f02f9bebd49bea4320a048b8a762902d7999bd/openmm-8.5.1-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:61c3ce28d7bc1e309ac5a7fe182af8099f0185ed8c6c6a845741c9c9345a214c", size = 14381116, upload-time = "2026-04-10T21:15:25.275Z" }, + { url = "https://files.pythonhosted.org/packages/e0/96/ff5f633d17d02f25233ed9c4627b640f93e4f49eeab6256214c2ea242ad8/openmm-8.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:057cb320e5c37dc16c675c896c0af2ea0fcec1611f73c6a4086ff39c3bf3e08a", size = 13206469, upload-time = "2026-04-10T21:15:29.723Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a8/1f6fc3e36e444621d026be9a145c0f6400c1de11c4a0426d1a60d37d0e91/openmm-8.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fc0f52337cf12706bdb9c157dc4f02079dc95cdf270e2057a21de1f00317652", size = 13336987, upload-time = "2026-04-10T21:15:34.091Z" }, + { url = "https://files.pythonhosted.org/packages/64/27/afd4f608560e439ff9f425afe9e2b3b7fb9e9eeb0cdf1b8533db49eb1a40/openmm-8.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac0db7b6b488f29925d7e3135cc5f11b6021e4b1f3588b3a7518fb74336e79a2", size = 12836895, upload-time = "2026-04-10T21:15:38.295Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/aa385017888e58de81037451deca484ca5f69e8dcf6806fad1c3e113eb07/openmm-8.5.1-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:ed5853a2745c35b4ac7c1aabf356ac47c7c10baee5e093167f463c4b6d27f2a6", size = 13981598, upload-time = "2026-04-10T21:15:42.939Z" }, + { url = "https://files.pythonhosted.org/packages/58/dc/958ea1a61d53379a6ba49209cbfeffe1c5239dea4d63306f82f86a441cd2/openmm-8.5.1-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:8349602a76107eb130d07573be44d7ed439f286d254687114e2bfe43e463b048", size = 14386765, upload-time = "2026-04-10T21:15:47.876Z" }, + { url = "https://files.pythonhosted.org/packages/03/5e/c4a926ad73b9d791abf5cb37720b73cec55766f2d8f4c602dd5d3d536c2a/openmm-8.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:56e447955539037aedf62f169c35a4f7c463720a36fff5d07e6b00b4addeb7b2", size = 13207985, upload-time = "2026-04-10T21:15:52.187Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2b/a20b958d14c459e89fcea50015e4e2d5f22e1b0aec67b85d5c79b3a16f48/openmm-8.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:15d0fe8f0849202046aa4776fcfe1d909a48765661cd4bb2ffb2adb16b750d42", size = 13337010, upload-time = "2026-04-10T21:15:56.445Z" }, + { url = "https://files.pythonhosted.org/packages/78/75/e59a9db6b3a4f08e4368e6fca745dd71b2aa6e4013f1ae72bb6f60ccedca/openmm-8.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aea6d7ff26930e63f3be9a415c64a2cad6c8db11c1b6ab58d566bd119b538bc9", size = 12835917, upload-time = "2026-04-10T21:16:01.221Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c8/ac33f3cbef9ca913c64dc62834d2c0ea0f1ee1549a72f258ddfda59a8938/openmm-8.5.1-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:85fd99de00060fb96ca22edaa411121c7ad251a242ce75b021e083cce97d459e", size = 13883007, upload-time = "2026-04-10T21:16:05.802Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f9/f5d81f33fa5160317a1cf4abeaf0334fbf8ab4375c54dac789e7b6cbec43/openmm-8.5.1-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:d278fc670bb33b0887c9562e11c1573e3f7f8a17bbddf217fe87e1f946d6175e", size = 14383980, upload-time = "2026-04-29T02:46:43.325Z" }, + { url = "https://files.pythonhosted.org/packages/ff/50/7cea3b822874edd80d26dd8d335da45e4003678db361f966fda6e77580d5/openmm-8.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:fd6318d6eaaf2475a227f6358757bf45df5d24f874ce14fbfbb13f2616784a5a", size = 13206502, upload-time = "2026-04-10T21:16:10.319Z" }, ] [[package]] From 2ef29e0fb20bb078cd3804e2ae6c873610613612 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 30 Apr 2026 10:48:58 +0100 Subject: [PATCH 132/163] fix: explicit venv creation --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 463fb71..f4ee1d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,9 @@ COPY uv.lock pyproject.toml ./ RUN pip install --upgrade pip && python -m pip install uv # install all dependencies into active environment without updating lockfile -RUN uv sync --frozen --quiet --active +# RUN uv sync --frozen --active +RUN uv venv /home/code/HIPPO/.venv +RUN uv sync --frozen --python /home/code/HIPPO/.venv/bin/python # now add venv python to path so conda python can find it ENV PATH="/home/code/HIPPO/.venv/bin:$PATH" From bc211346d5ef656e95156620b17cd92ff401fd87 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 30 Apr 2026 11:20:30 +0100 Subject: [PATCH 133/163] fix: prevent compose file from overwriting container --- docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 54f3480..14af8f1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,8 +54,8 @@ services: build: context: . dockerfile: Dockerfile - volumes: - - .:/home/code/HIPPO + # volumes: + # - .:/home/code/HIPPO env_file: - .env ports: From 3d04921bb4420892de47416c33077840df5bc2bf Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 30 Apr 2026 14:22:09 +0100 Subject: [PATCH 134/163] fix: rich -> mrich in sets.pose --- hippo/designdb/sets/pose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index bf41b09..3f396ec 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -1052,7 +1052,7 @@ def to_fragalysis( ) if not values.exists(): - rich.debug('no inspirations, quitting') + mrich.debug('no inspirations, quitting') logger.warning('no inspirations, quitting') return From 3a98aedb0c942dec340bfb850fb57f64e6c62aeb Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 13 May 2026 09:38:17 +0100 Subject: [PATCH 135/163] fix: testing-compatible compose file --- docker-compose.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 14af8f1..d7c31f3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -54,8 +54,6 @@ services: build: context: . dockerfile: Dockerfile - # volumes: - # - .:/home/code/HIPPO env_file: - .env ports: From 005e744b54a642aa92d877d4af4c3f81972587a3 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 15 May 2026 14:46:43 +0100 Subject: [PATCH 136/163] refactoring --- Dockerfile | 4 +- hippo/designdb/animal.py | 40 +- hippo/designdb/chem.py | 12 +- hippo/designdb/ingredient.py | 266 --- hippo/designdb/managers.py | 4 +- hippo/designdb/models.py | 143 +- hippo/designdb/price.py | 249 --- hippo/designdb/recipe.py | 3073 -------------------------- hippo/designdb/route.py | 218 -- hippo/designdb/services/compound.py | 20 +- hippo/designdb/services/ingestion.py | 54 +- hippo/designdb/services/pose.py | 38 +- hippo/designdb/services/reaction.py | 18 +- hippo/designdb/services/route.py | 16 +- hippo/designdb/services/score.py | 12 +- hippo/designdb/sets/compound.py | 146 +- hippo/designdb/sets/interaction.py | 44 +- hippo/designdb/sets/pose.py | 226 +- hippo/designdb/sets/reaction.py | 58 +- hippo/designdb/sets/route.py | 48 +- hippo/designdb/utils.py | 10 +- hippo/designdb/utils_frag.py | 6 +- hippo/designdb/utils_xca.py | 2 +- 23 files changed, 452 insertions(+), 4255 deletions(-) delete mode 100644 hippo/designdb/ingredient.py delete mode 100644 hippo/designdb/price.py delete mode 100644 hippo/designdb/recipe.py delete mode 100644 hippo/designdb/route.py diff --git a/Dockerfile b/Dockerfile index f4ee1d8..11cbdff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,7 +33,7 @@ ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages # copy files from host -COPY . ./ +# COPY . ./ # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" @@ -45,7 +45,7 @@ RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen # force numpy update. since there's now a new venv, make sure conda is # used (othewise numpy will be invisible) -RUN /opt/conda/bin/python -m pip install --upgrade numpy==2.4.4 +RUN uv pip install --upgrade numpy==2.4.4 USER ${NB_USER} diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 2679fdb..f9bcb39 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -9,7 +9,7 @@ import pandas as pd from django.db import transaction -from .models import Compound, Pose, Target +from .models import CompoundModel, PoseModel, TargetModel from .services.ingestion import IngestionBatchResult, IngestionService from .sets.pose import PoseSet from .utils import make_warn_once_per_key @@ -29,7 +29,7 @@ def __init__( ) -> None: # TODO: user- or project based targets - self._target, _ = Target.objects.get_or_create(target_name=target_name) + self._target, _ = TargetModel.objects.get_or_create(target_name=target_name) # TODO: the way this worked previously was it gave the HIPPO # instance full access to the pose table. When working with @@ -39,7 +39,7 @@ def __init__( # along this target? # self._compounds = CompoundTable(self.db) - # self._poses = PoseSet(Pose.objects.all()) # <- NB! for testing + # self._poses = PoseSet(PoseModel.objects.all()) # <- NB! for testing # self._tags = TagTable(self.db) # self._reactions = ReactionTable(self.db) @@ -59,7 +59,7 @@ def __init__( # return self._name @property - def target(self) -> Target: + def target(self) -> TargetModel: """Returns the target instance""" return self._target @@ -74,13 +74,13 @@ def target(self) -> Target: @property def poses(self): """Return pose instances for this target""" - # return Pose.objects.filter(target=self._target) - return PoseSet(Pose.objects.filter(target=self._target)) + # return PoseModel.objects.filter(target=self._target) + return PoseSet(PoseModel.objects.filter(target=self._target)) @property def compounds(self): """Return compound instances for this target""" - return Compound.compound_filter.all() + return CompoundModel.compound_filter.all() @property def num_poses(self) -> int: @@ -105,7 +105,7 @@ def add_hits( For an XChemAlign dataset the `aligned_directory` should point to the `aligned_files`. - :param target_name: Name of this protein :class:`.Target` + :param target_name: Name of this protein :class:`.TargetModel` :param metadata_csv: Path to the metadata.csv from the Fragalysis download :param aligned_directory: Path to the aligned_files directory from the Fragalysis download @@ -194,7 +194,7 @@ def __str__(self) -> str: mrich.var('#valid observations', result.attempts) # n_poses = self.num_poses - # n_poses = Pose.objects.count() + # n_poses = PoseModel.objects.count() mrich.var('#directories parsed', result.attempts) mrich.var('#compounds registered', result.compounds_created) @@ -204,7 +204,7 @@ def load_sdf( self, *, path: str | Path, - reference: int | Pose | None = None, + reference: int | PoseModel | None = None, inspirations: list[int] | PoseSet | None = None, compound_tags: None | list[str] = None, pose_tags: None | list[str] = None, @@ -219,24 +219,24 @@ def load_sdf( ) -> None: """Add posed virtual hits from an SDF into the database. - :param target: Name of the protein :class:`.Target` + :param target: Name of the protein :class:`.TargetModel` :param path: Path to the SDF - :param reference: Optional single reference :class:`.Pose` to use as the protein conformation for all poses, defaults to ``None`` - :param reference_col: Column that contains reference :class:`.Pose` aliases or ID's + :param reference: Optional single reference :class:`.PoseModel` to use as the protein conformation for all poses, defaults to ``None`` + :param reference_col: Column that contains reference :class:`.PoseModel` aliases or ID's :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` - :param inspiration_col: Name of the column containing the list of inspiration :class:`.Pose` names or ID's, defaults to ``"ref_mols"`` - :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.Pose` ids + :param inspiration_col: Name of the column containing the list of inspiration :class:`.PoseModel` names or ID's, defaults to ``"ref_mols"`` + :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.PoseModel` ids :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` - All non-name columns are added to the Pose metadata. + All non-name columns are added to the PoseModel metadata. N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. """ # TODO: original code reads sdf into data frame. I don't see @@ -262,7 +262,7 @@ def load_sdf( else: inspiration_list = [] - if reference and isinstance(reference, Pose): + if reference and isinstance(reference, PoseModel): reference_id = reference.id else: reference_id = None @@ -350,8 +350,8 @@ def add_syndirella_elabs( reject_flags: list[str] | None = None, register_reactions: bool = True, dry_run: bool = False, - scaffold_route: 'Route | None' = None, - scaffold_compound: 'Compound | None' = None, + scaffold_route: 'RouteModel | None' = None, + scaffold_compound: 'CompoundModel | None' = None, pose_tags: list[str] | None = None, product_tags: list[str] | None = None, ) -> pd.DataFrame: @@ -364,7 +364,7 @@ def add_syndirella_elabs( :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing - :param scaffold_compound: Supply a :class:`.Compound` for the scaffold product to use if scaffold placements are missing + :param scaffold_compound: Supply a :class:`.CompoundModel` for the scaffold product to use if scaffold placements are missing :param dry_run: Don't insert new records into the database (for debugging/testing) :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] diff --git a/hippo/designdb/chem.py b/hippo/designdb/chem.py index 8e3cd4b..ca9d406 100644 --- a/hippo/designdb/chem.py +++ b/hippo/designdb/chem.py @@ -1,7 +1,7 @@ """functions for validating chemistry""" import mrich -from designdb.models import Compound +from designdb.models import CompoundModel """ @@ -156,7 +156,7 @@ def check_reaction_types(types: list[str]) -> None: def check_chemistry( reaction_type: str, reactants: 'CompoundSet', - product: Compound, + product: CompoundModel, debug: bool = False, ) -> bool: """Check chemistry of given reaction""" @@ -201,7 +201,7 @@ def check_count_diff( check_type: str, reaction_type: str, reactants: 'CompoundSet', - product: 'Compound', + product: 'CompoundModel', debug: bool = False, ): """Check integer difference""" @@ -251,7 +251,7 @@ def check_count_diff( def check_atomtype_diff( reaction_type: str, reactants: 'CompoundSet', - product: 'Compound', + product: 'CompoundModel', debug: bool = False, ) -> bool: """check atomtypes""" @@ -290,8 +290,8 @@ def check_atomtype_diff( def check_specific_atomtype_diff( reaction_type: str, - prod: 'Compound', - reac: 'Compound', + prod: 'CompoundModel', + reac: 'CompoundModel', removal: bool = False, debug: bool = False, ) -> bool: diff --git a/hippo/designdb/ingredient.py b/hippo/designdb/ingredient.py deleted file mode 100644 index 2a84315..0000000 --- a/hippo/designdb/ingredient.py +++ /dev/null @@ -1,266 +0,0 @@ -import mcol -import mrich -import pandas as pd -from designdb.models import CataloguePrice, CataloguePriceCompoundJunction, Compound -from django.db.models import Exists, OuterRef, Q - - -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, - compound: Compound, # or CatalogueCompound? - amount: float, - quote: CataloguePrice, - max_lead_time: float | None = None, - supplier: str | None = None, - ): - """Ingredient initialisation""" - - self._compound = compound - self._quote = quote - self._amount = amount - self._max_lead_time = max_lead_time - self._supplier = supplier - - def __str__(self) -> str: - """Plain string representation""" - return f'{self.amount:.2f}mg of C{self._compound.id}' - - 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)}' - - def __eq__(self, other) -> bool: - """Equality operator""" - - if self.compound != other.compound: - return False - - return self.amount == other.amount - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.Compound`""" - return getattr(self.compound, key) - - @classmethod - def from_compound( - cls, - compound: Compound, - 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 - - quote = cls.get_quotes( - compound=compound, - pick_cheapest=True, - min_amount=amount, - max_lead_time=max_lead_time, - supplier=supplier, - none=quote_none, - ) - - else: - quote = None - - return Ingredient( - compound=compound, - amount=amount, - quote=quote, - supplier=supplier, - max_lead_time=max_lead_time, - ) - - @classmethod - def get_quotes( - cls, - compound: Compound, - 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, - ): - """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 - - """ - - qs = CataloguePrice.objects.annotate( - has_compound=Exists( - CataloguePriceCompoundJunction.objects.filter( - compound=compound, - catalogue_price=OuterRef('pk'), - ), - ), - ).filter( - has_compound=True, - ) - - if supplier: - if isinstance(supplier, str): - qs = qs.filter(supplier=supplier) - else: - qs = qs.filter(supplier__in=supplier) - - if not qs.exists(): - return None - - if max_lead_time: - qs = qs.filter(lead_time__lte=max_lead_time) - - if min_amount: - qs = qs.filter(amount__gte=min_amount) - - if not qs.exists(): - mrich.debug( - f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' - ) - - if pick_cheapest: - return qs.order_by('price').first() - - if df: - return pd.DataFrame(qs.values()).drop(columns='compound') - - return qs - - ### METHODS - - def get_cheapest_quote_id( - self, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - ) -> 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. - """ - - query = Q(compound=self.compound) - - if supplier: - query &= Q(quote_supplier=supplier) - - if min_amount: - query &= Q(quote_amount__gte=min_amount) - - if max_lead_time: - query &= Q(quote_lead_time__lte=max_lead_time) - - return CataloguePrice.objects.filter(query).order_by('quote_price').first() - - ### PROPERTIES - - @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(self) -> int: - """Returns the ID of the associated :class:`.Quote`""" - return self._quote - - @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 = self.get_cheapest_quote_id( - min_amount=a, - max_lead_time=self._max_lead_time, - supplier=self._supplier, - none='quiet', - ) - - self._quote = quote - - 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 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 diff --git a/hippo/designdb/managers.py b/hippo/designdb/managers.py index 2e82c26..c055a90 100644 --- a/hippo/designdb/managers.py +++ b/hippo/designdb/managers.py @@ -17,8 +17,8 @@ class CompoundQueryset(QuerySet): def filter_qs(self): - Compound = apps.get_model("designdb", "Compound") - qs = Compound.objects.all() + CompoundModel = apps.get_model("designdb", "CompoundModel") + qs = CompoundModel.objects.all() return qs diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index 03bce5c..fa15214 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -91,7 +91,7 @@ class Meta: default_related_name = '%(class)ss' -class Target(BaseModel): +class TargetModel(BaseModel): id = models.BigAutoField(primary_key=True) external_target_id = models.BigIntegerField(null=True, blank=True) target_name = models.TextField() @@ -113,7 +113,7 @@ class Meta(BaseModel.Meta): ] -class Compound(BaseModel): +class CompoundModel(BaseModel): id = models.BigAutoField(primary_key=True) compound_inchikey = models.TextField(null=True, blank=True) compound_alias = models.TextField(null=True, blank=True) @@ -145,14 +145,14 @@ class Compound(BaseModel): inchi_version = models.TextField(null=True, blank=True) tags = models.ManyToManyField( - 'CompoundTag', - through='CompoundTagJunction', + 'CompoundTagModel', + through='CompoundTagJunctionModel', related_name='compounds', ) enumeration_methods = models.ManyToManyField( - 'EnumerationMethod', - through='CompoundEnumerationMethodJunction', + 'EnumerationMethodModel', + through='CompoundEnumerationMethodJunctionModel', related_name='compounds', ) @@ -160,7 +160,7 @@ class Compound(BaseModel): # to keep it scaffolds = models.ManyToManyField( 'self', - through='Scaffold', + through='ScaffoldModel', ) objects = models.Manager() @@ -198,10 +198,10 @@ class Meta(BaseModel.Meta): ] -class Subsite(BaseModel): +class SubsiteModel(BaseModel): id = models.BigAutoField(primary_key=True) target = models.ForeignKey( - Target, + TargetModel, on_delete=models.RESTRICT, db_column='target_id', ) @@ -226,7 +226,7 @@ class Meta(BaseModel.Meta): ] -class Pose(BaseModel): +class PoseModel(BaseModel): id = models.BigAutoField(primary_key=True) pose_inchikey = models.TextField(null=True, blank=True) @@ -237,13 +237,13 @@ class Pose(BaseModel): pose_path = models.TextField(null=True, blank=True) compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.RESTRICT, db_column='compound_id', ) target = models.ForeignKey( - Target, + TargetModel, on_delete=models.RESTRICT, db_column='target_id', ) @@ -263,26 +263,26 @@ class Pose(BaseModel): inchi_version = models.TextField(null=True, blank=True) methods = models.ManyToManyField( - 'PoseMethod', - through='PoseMethodJunction', + 'PoseMethodModel', + through='PoseMethodJunctionModel', related_name='poses', ) tags = models.ManyToManyField( - 'PoseTag', - through='PoseTagJunction', + 'PoseTagModel', + through='PoseTagJunctionModel', related_name='poses', ) # unlike others, this wasn't clearly defined as m2m. may not want # to keep it inspirations = models.ManyToManyField( 'self', - through='Inspiration', + through='InspirationModel', symmetrical=False, ) subsites = models.ManyToManyField( - Subsite, - through='SubsiteTag', + SubsiteModel, + through='SubsiteTagModel', ) class Meta(BaseModel.Meta): @@ -336,15 +336,15 @@ def apo_path(self) -> Path | None: raise NotImplementedError -class SubsiteTag(BaseModel): +class SubsiteTagModel(BaseModel): id = models.BigAutoField(primary_key=True) pose = models.ForeignKey( - Pose, + PoseModel, on_delete=models.RESTRICT, db_column='pose_id', ) subsite = models.ForeignKey( - Subsite, + SubsiteModel, on_delete=models.RESTRICT, db_column='subsite_id', ) @@ -369,7 +369,7 @@ class Meta(BaseModel.Meta): ] -class PoseMethod(BaseModel): +class PoseMethodModel(BaseModel): id = models.BigAutoField(primary_key=True) pose_method_name = models.TextField(null=True, blank=True) pose_method_description = models.TextField(null=True, blank=True) @@ -396,16 +396,16 @@ class Meta(BaseModel.Meta): ] -class PoseMethodJunction(BaseModel): +class PoseMethodJunctionModel(BaseModel): pk = models.CompositePrimaryKey('pose_id', 'pose_method_id') pose = models.ForeignKey( - 'Pose', + 'PoseModel', on_delete=models.CASCADE, db_column='pose_id', ) pose_method = models.ForeignKey( - 'PoseMethod', + 'PoseMethodModel', on_delete=models.CASCADE, db_column='pose_method_id', ) @@ -422,7 +422,7 @@ class Meta(BaseModel.Meta): ] -class PoseTag(BaseModel): +class PoseTagModel(BaseModel): id = models.BigAutoField(primary_key=True) pose_tag_name = models.TextField() pose_tag_description = models.TextField(null=True, blank=True) @@ -443,15 +443,15 @@ class Meta(BaseModel.Meta): ] -class PoseTagJunction(BaseModel): +class PoseTagJunctionModel(BaseModel): pk = models.CompositePrimaryKey('pose_id', 'pose_tag_id') pose = models.ForeignKey( - Pose, + PoseModel, on_delete=models.CASCADE, db_column='pose_id', ) pose_tag = models.ForeignKey( - PoseTag, + PoseTagModel, on_delete=models.CASCADE, db_column='pose_tag_id', ) @@ -465,20 +465,20 @@ class Meta(BaseModel.Meta): # this was missing.. is this a m2m table as well? really looks like it -class Inspiration(BaseModel): +class InspirationModel(BaseModel): id = models.BigAutoField(primary_key=True) # original behaviour described in schema was SET_NULL but I don't # see how that makes sense. if either original or derivative is # deleted, you'll have orphaned entries original_pose = models.ForeignKey( - Pose, + PoseModel, # on_delete=models.SET_NULL, on_delete=models.CASCADE, db_column='original_pose_id', related_name='+', ) derivative_pose = models.ForeignKey( - Pose, + PoseModel, # on_delete=models.SET_NULL, on_delete=models.CASCADE, db_column='derivative_pose_id', @@ -507,11 +507,11 @@ class Meta(BaseModel.Meta): ] -class Feature(BaseModel): +class FeatureModel(BaseModel): id = models.BigAutoField(primary_key=True) feature_family = models.TextField(null=True, blank=True) target = models.ForeignKey( - Target, + TargetModel, on_delete=models.RESTRICT, db_column='target_id', ) @@ -542,15 +542,15 @@ class Meta(BaseModel.Meta): ] -class Interaction(BaseModel): +class InteractionModel(BaseModel): id = models.BigAutoField(primary_key=True) feature = models.ForeignKey( - Feature, + FeatureModel, on_delete=models.RESTRICT, db_column='feature_id', ) pose = models.ForeignKey( - Pose, + PoseModel, on_delete=models.RESTRICT, db_column='pose_id', ) @@ -588,7 +588,7 @@ class Meta(BaseModel.Meta): ] -class CompoundTag(BaseModel): +class CompoundTagModel(BaseModel): id = models.BigAutoField(primary_key=True) compound_tag_name = models.TextField() compound_tag_description = models.TextField(null=True, blank=True) @@ -609,15 +609,15 @@ class Meta(BaseModel.Meta): ] -class CompoundTagJunction(BaseModel): +class CompoundTagJunctionModel(BaseModel): pk = models.CompositePrimaryKey('compound_id', 'compound_tag_id') compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.CASCADE, db_column='compound_id', ) compound_tag = models.ForeignKey( - CompoundTag, + CompoundTagModel, on_delete=models.CASCADE, db_column='compound_tag_id', ) @@ -632,7 +632,7 @@ class Meta(BaseModel.Meta): ] -class EnumerationMethod(BaseModel): +class EnumerationMethodModel(BaseModel): id = models.BigAutoField(primary_key=True) enum_name = models.TextField(null=True, blank=True) enum_description = models.TextField(null=True, blank=True) @@ -659,15 +659,15 @@ class Meta(BaseModel.Meta): ] -class CompoundEnumerationMethodJunction(BaseModel): +class CompoundEnumerationMethodJunctionModel(BaseModel): pk = models.CompositePrimaryKey('compound_id', 'enumeration_method_id') compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.CASCADE, db_column='compound_id', ) enumeration_method = models.ForeignKey( - EnumerationMethod, + EnumerationMethodModel, on_delete=models.CASCADE, db_column='enumeration_method_id', ) @@ -685,7 +685,7 @@ class Meta(BaseModel.Meta): ] -class ScoringMethod(BaseModel): +class ScoringMethodModel(BaseModel): id = models.BigAutoField(primary_key=True) method_name = models.TextField(null=True, blank=True) method_description = models.TextField(null=True, blank=True) @@ -712,24 +712,24 @@ class Meta(BaseModel.Meta): ] -class ScoreValue(BaseModel): +class ScoreValueModel(BaseModel): pk = models.CompositePrimaryKey('pose_id', 'compound_id', 'scoring_method_id') pose = models.ForeignKey( - Pose, + PoseModel, on_delete=models.RESTRICT, db_column='pose_id', related_name='scores', ) compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.RESTRICT, db_column='compound_id', related_name='scores', ) scoring_method = models.ForeignKey( - ScoringMethod, + ScoringMethodModel, on_delete=models.RESTRICT, db_column='scoring_method_id', related_name='scores', @@ -749,11 +749,11 @@ class Meta(BaseModel.Meta): ] -class Reaction(BaseModel): +class ReactionModel(BaseModel): id = models.BigAutoField(primary_key=True) reaction_type = models.TextField(null=True, blank=True) product_compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.RESTRICT, db_column='product_compound_id', ) @@ -771,16 +771,17 @@ class Meta(BaseModel.Meta): ] -class Reactant(BaseModel): +class ReactantModel(BaseModel): id = models.BigAutoField(primary_key=True) reactant_amount = models.FloatField(null=True, blank=True) reaction = models.ForeignKey( - Reaction, + ReactionModel, on_delete=models.CASCADE, db_column='reaction_id', + related_name='reactants', ) compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.RESTRICT, db_column='compound_id', ) @@ -803,7 +804,7 @@ class Meta(BaseModel.Meta): ] -class CatalogueCompound(BaseModel): +class CatalogueCompoundModel(BaseModel): id = models.BigAutoField(primary_key=True) catalogue_smiles = models.TextField(null=False, blank=True) catalogue_inchikey = models.TextField(null=False, blank=True) @@ -827,10 +828,10 @@ class Meta(BaseModel.Meta): ] -class CataloguePrice(BaseModel): +class CataloguePriceModel(BaseModel): id = models.BigAutoField(primary_key=True) catalogue_compound = models.ForeignKey( - CatalogueCompound, + CatalogueCompoundModel, null=True, on_delete=models.CASCADE, db_column='catalogue_id', @@ -845,8 +846,8 @@ class CataloguePrice(BaseModel): lead_time = models.IntegerField(null=True, blank=True) compounds = models.ManyToManyField( - Compound, - through='CataloguePriceCompoundJunction', + CompoundModel, + through='CataloguePriceCompoundJunctionModel', related_name='prices', ) @@ -866,15 +867,15 @@ class Meta(BaseModel.Meta): ] -class CataloguePriceCompoundJunction(BaseModel): +class CataloguePriceCompoundJunctionModel(BaseModel): ipk = models.CompositePrimaryKey('compound_id', 'catalogue_price_id') catalogue_price = models.ForeignKey( - CataloguePrice, + CataloguePriceModel, on_delete=models.CASCADE, db_column='catalogue_price_id', ) compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.CASCADE, db_column='compound_id', ) @@ -898,19 +899,19 @@ class Meta(BaseModel.Meta): ] -class Scaffold(BaseModel): +class ScaffoldModel(BaseModel): id = models.BigAutoField(primary_key=True) # same comment as with inspiratons. original schema says SET_NULL # but doesn't seem right base_compound = models.ForeignKey( - Compound, + CompoundModel, # on_delete=models.SET_NULL, on_delete=models.CASCADE, db_column='base_compound_id', related_name='scaffold_bases', ) superstructure_compound = models.ForeignKey( - Compound, + CompoundModel, # on_delete=models.SET_NULL, on_delete=models.CASCADE, db_column='superstructure_compound_id', @@ -940,10 +941,10 @@ class Meta(BaseModel.Meta): ] -class Route(BaseModel): +class RouteModel(BaseModel): id = models.BigAutoField(primary_key=True) product_compound = models.ForeignKey( - Compound, + CompoundModel, on_delete=models.RESTRICT, db_column='product_compound_id', ) @@ -958,10 +959,10 @@ class Meta(BaseModel.Meta): ] -class Component(BaseModel): +class ComponentModel(BaseModel): id = models.BigAutoField(primary_key=True) route = models.ForeignKey( - Route, + RouteModel, on_delete=models.RESTRICT, db_column='route_id', ) diff --git a/hippo/designdb/price.py b/hippo/designdb/price.py deleted file mode 100644 index 397a605..0000000 --- a/hippo/designdb/price.py +++ /dev/null @@ -1,249 +0,0 @@ -"""Class for working with prices""" - -import mcol - -CURRENCIES = { - 'USD': '$', - 'EUR': '€', - 'GBP': '£', -} - - -class Price: - """Class to represent a certain amount of currency. Supported currencies: - - :: - - CURRENCIES = { - 'USD':'$', - 'EUR':'€', - 'GBP':'£', - } - - """ - - def __init__(self, amount: float | None, currency: str | None): - """Price initialisation""" - - if currency not in CURRENCIES: - assert currency is None, f'Unrecognised {currency=}' - assert not amount, f"Null Price can't have {amount=}" - amount = None - - if amount is not None: - amount = float(amount) - - self._amount = amount - self._currency = currency - - ### FACTORIES - - @classmethod - def null(cls) -> 'Price': - """Zero in any currency""" - self = cls.__new__(cls) - self.__init__(None, None) - return self - - @classmethod - def from_dict( - cls, - d: dict, - ) -> 'Price': - """Create a :class:`.Price` object from a dictionary: - - :: - - dict(amount: float, currency: str) - - :param d: dictionary in the above format: - - """ - self = cls.__new__(cls) - self.__init__(d['amount'], d['currency']) - return self - - ### PROPERTIES - - @property - def symbol(self) -> str: - """Currency symbol""" - return CURRENCIES[self.currency] - - @property - def currency(self) -> str: - """Currency string""" - return self._currency - - @property - def amount(self) -> float: - """Amount""" - return self._amountb - - @property - def is_null(self) -> bool: - """Is this :meth:`.Price.null` or zero?""" - return self.amount is None - - ### METHODS - - def get_dict(self) -> dict: - """Dictionary in the format: - - :: - - dict(amount: float, currency: str) - - """ - return dict(amount=self.amount, currency=self.currency) - - def copy(self) -> 'Price': - """Return a copy of this :class:`.Price`""" - return Price(amount=self.amount, currency=self.currency) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - if self.currency is None: - return 'Null Price' - - return f'{self.symbol}{self.amount:.2f} {self.currency}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __add__(self, other: 'Price') -> 'Price': - """Add two :class:`.Price` objects - - :param other: :class:`.Price` object - :returns: :class:`.Price` object - - """ - - if other is None: - return self - - if other.is_null: - return self - - if self.is_null: - return other - - if self.currency != other.currency: - raise NotImplementedError( - f'Adding two different currencies: {self.currency} != {other.currency}' - ) - return Price(self.amount + other.amount, self.currency) - - def __truediv__(self, other: 'Price | float | int') -> 'Price | float': - """Divide this :class:`.Price` by another object - - :param other: :class:`.Price` or float or int - :returns: :class:`.Price` object or float - - """ - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return self - return Price(amount=self.amount / other, currency=self.currency) - - elif isinstance(other, Price): - assert self.currency == other.currency - assert not other.is_null - return self.amount / other.amount - - raise TypeError(f'Division not supported between Price and {type(other)}') - - def __mul__(self, other: 'Price | float | int') -> 'Price | float': - """Multiply this :class:`.Price` by another object - - :param other: :class:`.Price` or float or int - :returns: :class:`.Price` object or float - - """ - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return self - return Price(amount=self.amount * other, currency=self.currency) - - raise TypeError(f'Multiplication not supported between Price and {type(other)}') - - def __eq__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return other == 0 - return self.amount == other - - if self.is_null and other.is_null: - return True - - if self.is_null and not other.is_null: - return False - - if not self.is_null and other.is_null: - return False - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount == other.amount - - def __lt__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return False - return self.amount > other - - if self.is_null and other.is_null: - return False - - if self.is_null and not other.is_null: - return True - - if not self.is_null and other.is_null: - return False - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount < other.amount - - def __gt__(self, other: 'Price') -> bool: - """Compare two :class:`.Price` objects""" - - if isinstance(other, int) or isinstance(other, float): - if self.is_null: - return False - return self.amount < other - - if self.is_null and other.is_null: - return False - - if self.is_null and not other.is_null: - return False - - if not self.is_null and other.is_null: - return True - - assert self.currency == other.currency, ( - f'Comparing different currencies: {self.currency} != {other.currency}' - ) - return self.amount > other.amount - - def __hash__(self) -> int: - """Allow for Prices to be hashed for comparison""" - if self.is_null: - return hash('NULL') - return hash(f'{self.currency} {self.amount}') diff --git a/hippo/designdb/recipe.py b/hippo/designdb/recipe.py deleted file mode 100644 index fc95f33..0000000 --- a/hippo/designdb/recipe.py +++ /dev/null @@ -1,3073 +0,0 @@ -"""Classes for working with Recipes (reaction networks)""" - -import mcol -import mrich -from designdb.models import Compound, Reaction -from designdb.sets.compound import IngredientSet -from designdb.sets.reaction import ReactionSet - - -class Recipe: - """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" - - def __init__( - self, - *, - products: 'IngredientSet | None' = None, - reactants: 'IngredientSet | None' = None, - intermediates: 'IngredientSet | None' = None, - reactions: 'ReactionSet | None' = None, - compounds: 'IngredientSet | None' = None, - ) -> None: - """Recipe initialisation""" - - if products is None: - products = IngredientSet() - - if reactants is None: - reactants = IngredientSet() - - if intermediates is None: - intermediates = IngredientSet() - - if compounds is None: - compounds = IngredientSet() - - if reactions is None: - reactions = ReactionSet() - - # check typing - assert isinstance(products, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(compounds, IngredientSet) - assert isinstance(reactions, ReactionSet) - - self._products = products - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._compounds = compounds - self._hash = None - - self._score = None - - # caches - self._product_compounds = None - self._poses = None - self._interactions = None - self._combined_compounds = None - - ### FACTORIES - - @classmethod - def from_reaction( - cls, - reaction, - amount=1, - *, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - quoted_only: bool = False, - supplier: None | str = None, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] = None, - reaction_reactant_cache: dict[int, bool] = None, - inner: bool = False, - get_ingredient_quotes: bool = True, - ) -> 'Recipe | list[Recipe]': - """Create a :class:`.Recipe` from a :class:`.Reaction` and its upstream dependencies - - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - :param inner: used to indicate that this is a recursive call (Default value = False) - :param get_ingredient_quotes: get quotes for ingredients in this recipe - - """ - - assert isinstance(reaction, Reaction) - - if debug: - mrich.debug( - f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' - ) - mrich.debug(f'{reaction.product.id=}') - mrich.debug(f'{reaction.reactants.ids=}') - - if permitted_reactions: - assert reaction in permitted_reactions - # raise NotImplementedError - - recipe = cls.__new__(cls) - recipe.__init__( - products=IngredientSet( - [ - reaction.product.as_ingredient( - amount=amount, get_quote=get_ingredient_quotes - ) - ], - ), - reactants=IngredientSet([], supplier=supplier), - intermediates=IngredientSet([]), - reactions=ReactionSet([reaction.id], sort=False), - ) - - recipes = [recipe] - - if quoted_only or supplier: - if debug: - mrich.debug(f'Checking reactant_availability: {reaction=}') - if reaction_checking_cache and reaction.id in reaction_checking_cache: - ok = reaction_checking_cache[reaction.id] - print('reaction_checking_cache used') - else: - ok = reaction.check_reactant_availability(supplier=supplier) - # print('cache not used') - if reaction_checking_cache is not None: - reaction_checking_cache[reaction.id] = ok - if not ok: - if unavailable_reaction == 'error': - mrich.error(f'Reactants not available for {reaction=}') - if pick_cheapest: - return None - else: - return [] - - def get_reactant_amount_pairs(reaction: 'Reaction') -> list[tuple[int, float]]: - """Get pairs of reactant ID and float amounts""" - if reaction_reactant_cache and reaction.id in reaction_reactant_cache: - print('reaction_reactant_cache used') - return reaction_reactant_cache[reaction.id] - else: - pairs = reaction.get_reactant_amount_pairs(compound_object=False) - if reaction_reactant_cache is not None: - reaction_reactant_cache[reaction.id] = pairs - return pairs - - if debug: - mrich.debug(f'get_reactant_amount_pairs({reaction.id})') - pairs = get_reactant_amount_pairs(reaction) - - for reactant, reactant_amount in pairs: - # reactant = db.get_compound(id=reactant) - reactant = Compound.objects.get(pk=reactant) - - if debug: - mrich.debug(f'{reactant.id=}, {reactant_amount=}') - - # scale amount - reactant_amount *= amount - reactant_amount /= reaction.product_yield - - inner_reactions = reactant.get_reactions( - none='quiet', permitted_reactions=permitted_reactions - ) - - if inner_reactions: - if debug: - if len(inner_reactions) == 1: - mrich.debug('Reactant has ONE inner reaction') - else: - mrich.warning(f'{reactant=} has MULTIPLE inner reactions') - - new_recipes = [] - - inner_recipes = [] - for reaction in inner_reactions: - reaction_recipes = Recipe.from_reaction( - reaction=reaction, - amount=reactant_amount, - debug=debug, - pick_cheapest=False, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - inner=True, - ) - inner_recipes += reaction_recipes - - for recipe in recipes: - for inner_recipe in inner_recipes: - combined_recipe = recipe.copy() - - combined_recipe.reactants += inner_recipe.reactants - combined_recipe.intermediates += inner_recipe.intermediates - combined_recipe.reactions += inner_recipe.reactions - combined_recipe.intermediates.add( - reactant.as_ingredient(reactant_amount, supplier=supplier) - ) - - new_recipes.append(combined_recipe) - - recipes = new_recipes - - else: - ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) - for recipe in recipes: - recipe.reactants.add(ingredient) - - # reverse ReactionSet's - if not inner: - for recipe in recipes: - recipe.reactions.reverse() - - if pick_cheapest: - if debug: - mrich.debug('Picking cheapest') - priced = [r for r in recipes if r.get_price(supplier=supplier)] - # priced = [r for r in recipes if r.price] - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return recipes - sorted_recipes = sorted( - priced, key=lambda r: r.get_price(supplier=supplier) - ) - - if debug: - for recipe in recipes: - mrich.debug(f'{recipe}, {recipe.price}') - - return sorted_recipes[0] - # return sorted(priced, key=lambda r: r.price)[0] - - return recipes - - @classmethod - def from_reactions( - cls, - reactions: 'ReactionSet', - amount: float = 1, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - final_products_only: bool = True, - return_products: bool = False, - supplier: str | None = None, - use_routes: bool = False, - debug: bool = False, - **kwargs, - ) -> 'Recipe | list[Recipe] | CompoundSet': - """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies - - :param reactions: reactions to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param final_products_only: don't get routes to intermediates (Default value = True) - :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) - - """ - - from .cset import CompoundSet - from .rset import ReactionSet - - assert isinstance(reactions, ReactionSet) - - if debug: - mrich.debug('Recipe.from_reactions()') - mrich.var('reactions', reactions) - mrich.var('amount', amount) - mrich.var('final_products_only', final_products_only) - mrich.var('permitted_reactions', permitted_reactions) - - # get all the products - products = reactions.products - - if debug: - mrich.var('products', products) - - # return products - - if final_products_only: - if debug: - mrich.var('products.str_ids', products.str_ids) - - # raise NotImplementedError - ids = reactions.db.execute( - f""" - SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound - WHERE reactant_compound IS NULL - AND compound_id IN {products.str_ids} - """ - ).fetchall() - - ids = [i for (i,) in ids] - - products = CompoundSet(db, ids) - if debug: - mrich.var('final products', products) - - # return ids - - if return_products: - return products - - recipe = Recipe.from_compounds( - compounds=products, - amount=amount, - permitted_reactions=reactions, - pick_cheapest=pick_cheapest, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_compounds( - cls, - compounds: 'CompoundSet', - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions=None, - quoted_only: bool = False, - supplier: None | str = None, - solve_combinations: bool = True, - pick_first: bool = False, - warn_multiple_solutions: bool = True, - pick_cheapest_inner_routes: bool = False, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] | None = None, - reaction_reactant_cache: dict[int, bool] | None = None, - use_routes: bool = False, - **kwargs, - ): - """Create recipe(s) to synthesis products in the :class:`.CompoundSet` - - :param compounds: set of compounds to find routes for - :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) - :param pick_first: return the first solution without comparison (Default value = False) - :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) - :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - - """ - - from .cset import CompoundSet - - assert isinstance(compounds, CompoundSet) - - db = compounds.db - - n_comps = len(compounds) - - assert n_comps - - if not hasattr(amount, '__iter__'): - amount = [amount] * n_comps - - if use_routes: - route_lookup = db.get_product_id_routes_dict() - - if supplier: - raise NotImplementedError - # supplier_lookup = db.get_compound_id_suppliers_dict() - - options = [] - - ok = 0 - mrich.var('#compounds', n_comps) - - for comp, a in mrich.track( - zip(compounds, amount, strict=False), - prefix='Solving individual compound recipes...', - total=n_comps, - ): - comp_options = [] - - if use_routes: - if comp.id not in route_lookup: - mrich.error('No routes to', comp) - continue - - comp_options = [] - for route_id in route_lookup[comp.id]: - route = db.get_route(id=route_id) - comp_options.append(route) - - else: - for reaction in comp.reactions: - if permitted_reactions and reaction not in permitted_reactions: - continue - - sol = Recipe.from_reaction( - reaction=reaction, - amount=a, - pick_cheapest=pick_cheapest_inner_routes, - debug=debug, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - **kwargs, - ) - - if pick_cheapest_inner_routes: - if sol: - comp_options.append(sol) - else: - assert isinstance(sol, list) - comp_options += sol - - if not comp_options: - mrich.error( - f'No solutions for compound={comp} ({comp.reactions.ids=})' - ) - continue - - if pick_cheapest and len(comp_options) > 1: - if warn_multiple_solutions: - mrich.warning( - 'Multiple solutions for', comp, '(', len(comp_options), ')' - ) - if debug: - mrich.debug('Picking cheapest...') - priced = [r for r in comp_options if r.price] - comp_options = sorted(priced, key=lambda r: r.price)[:1] - - if warn_multiple_solutions and len(comp_options) > 1: - mrich.warning(f'Multiple solutions for compound={comp}') - if debug: - mrich.debug(f'{comp_options=}') - else: - if n_comps <= 200: - mrich.success(f'Found solution for compound={comp}') - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', n_comps) - - options.append(comp_options) - - assert all(options) - - from itertools import product - - mrich.print('Solving recipe combinations...') - combinations = list(product(*options)) - - if not solve_combinations: - return combinations - - solutions = [] - - if n_comps > 1: - generator = mrich.track( - combinations, prefix='Combining recipes...', total=len(combinations) - ) - else: - generator = combinations - - ok = 0 - for combo in generator: - if debug: - mrich.debug(f'Combination of {len(combo)} recipes') - - if not combo: - continue - - solution = combo[0] - - for i, recipe in enumerate(combo[1:]): - if debug: - mrich.debug(i + 1) - solution += recipe - - solutions.append(solution) - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', len(combinations)) - - if not solutions: - mrich.error('No solutions') - return None - - if pick_first: - return solutions[0] - - if pick_cheapest: - mrich.debug('Calculating prices...') - priced = [r for r in solutions if r.price] - mrich.print('Picking cheapest from', len(priced), 'options') - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return solutions - return sorted(priced, key=lambda r: r.price)[0] - - return solutions - - @classmethod - def from_reactants( - cls, - reactants: 'CompoundSet | IngredientSet', - amount: float = 1, - debug: bool = False, - return_products: bool = False, - supplier: str | None = None, - pick_cheapest: bool = False, - use_routes: bool = False, - **kwargs, - ) -> 'list[Recipe] | Recipe | CompoundSet': - """Find the maximal recipe from a given set of reactants - - :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored - :param amount: amount of each product needed (Default value = 1) - :param debug: increase verbosity (Default value = False) - :param return_products: return products instead of recipe (Default value = False) - :param kwargs: passed to :meth:`.Recipe.from_reactions` - - """ - - from .cset import IngredientSet - - if isinstance(reactants, IngredientSet): - reactant_ids = reactants.compound_ids - else: - reactant_ids = reactants.ids - - db = reactants.db - - all_reactants = set(reactant_ids) - - possible_reactions = [] - - # recursively search for possible reactions - for i in range(300): - if debug: - mrich.debug(i) - - # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) - reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) - - if not reaction_ids: - break - - if debug: - mrich.debug(f'Adding {len(reaction_ids)} reactions') - - possible_reactions += reaction_ids - - if debug: - mrich.var('reaction_ids', reaction_ids) - - product_ids = db.get_possible_reaction_product_ids( - reaction_ids=reaction_ids - ) - - if debug: - mrich.var('product_ids', product_ids) - - n_prev = len(all_reactants) - - all_reactants |= set(product_ids) - - if n_prev == len(all_reactants): - break - - else: - raise NotImplementedError('Maximum recursion depth exceeded') - - possible_reactions = list(set(possible_reactions)) - - if debug: - mrich.var('all possible reactions', possible_reactions) - - from .rset import ReactionSet - - rset = ReactionSet(db, possible_reactions, sort=False) - - recipe = cls.from_reactions( - rset, - amount=amount, - permitted_reactions=rset, - debug=debug, - return_products=return_products, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) - - return recipe - - @classmethod - def from_json( - cls, - db: 'Database', - path: 'str | Path', - debug: bool = True, - allow_db_mismatch: bool = False, - clear_quotes: bool = False, - data: dict = None, - db_mismatch_warning: bool = True, - ): - """Load a serialised recipe from a JSON file - - :param db: database to link - :param path: path to JSON - :param debug: increase verbosity (Default value = True) - :param allow_db_mismatch: allow a database mismatch (Default value = False) - :param clear_quotes: ignore reactant quotes (Default value = False) - :param data: serialised data (Default value = None) - - """ - - # imports - import json - - from .cset import IngredientSet - from .rset import ReactionSet - - # load JSON - if not data: - if debug: - mrich.reading(path) - data = json.load(open(path)) - - # check metadata - if str(db.path.resolve()) != data['database']: - if db_mismatch_warning: - mrich.var('session', str(db.path.resolve())) - mrich.var('in file', data['database']) - if allow_db_mismatch: - if db_mismatch_warning: - mrich.warning('Database path mismatch') - else: - mrich.error( - 'Database path mismatch, set allow_db_mismatch=True to ignore' - ) - return None - - if debug: - mrich.print(f'Recipe was generated at: {data["timestamp"]}') - price = data['price'] - - # IngredientSets - products = IngredientSet.from_ingredient_dicts(db, data['products']) - intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) - reactants = IngredientSet.from_ingredient_dicts( - db, data['reactants'], supplier=data['reactant_supplier'] - ) - - if 'compounds' in data: - compounds = IngredientSet.from_ingredient_dicts( - db, data['compounds'], supplier=data['compound_supplier'] - ) - else: - compounds = IngredientSet(db) - - if clear_quotes: - reactants.df['quote_id'] = None - reactants.df['quoted_amount'] = None - compounds.df['quote_id'] = None - compounds.df['quoted_amount'] = None - - # ReactionSet - reactions = ReactionSet(db, data['reaction_ids'], sort=False) - - if debug: - mrich.var('reactants', reactants) - mrich.var('intermediates', intermediates) - mrich.var('products', products) - mrich.var('reactions', reactions) - mrich.var('compounds', compounds) - - # Create the object - self = cls.__new__(cls) - self.__init__( - products=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - compounds=compounds, - ) - - return self - - ### PROPERTIES - - @property - def products(self) -> 'IngredientSet': - """Product :class:`.IngredientSet`""" - return self._products - - @property - def compounds(self) -> 'IngredientSet': - """Product :class:`.IngredientSet`""" - return self._compounds - - @compounds.setter - def compounds(self, a: 'IngredientSet'): - """Set the compounds""" - self._compounds = a - self.__flag_modification() - - @property - def poses(self) -> 'PoseSet': - """Product poses""" - if self._poses is None: - self._poses = self.combined_compounds.poses - self._poses._name = f'poses of {self}' - return self._poses - - @property - def product_compounds(self) -> 'CompoundSet': - """Product compounds""" - if self._product_compounds is None: - self._product_compounds = self.products.compounds - self._product_compounds._name = f'products of {self}' - return self._product_compounds - - @property - def combined_compound_ids(self) -> set[int]: - """Combined :class:`.Compound` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" - return set(self.product_compounds.ids) | set(self.compounds.ids) - - @property - def combined_compounds(self) -> 'CompoundSet': - """Combined product and no-chem compounds""" - if self._combined_compounds is None: - from .cset import CompoundSet - - self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) - self._combined_compounds._name = f'combined compounds of {self}' - return self._combined_compounds - - @property - def interactions(self) -> 'InteractionSet': - """Product pose interactions""" - if self._interactions is None: - self._interactions = self.poses.interactions - return self._interactions - - @property - def product(self) -> 'Ingredient': - """Return single product (if there's only one)""" - assert len(self.products) == 1 - return self.products[0] - - @products.setter - def products(self, a: 'IngredientSet'): - """Set the products""" - self._products = a - self.__flag_modification() - - @property - def reactants(self): - """Reactant :class:`.IngredientSet`""" - return self._reactants - - @reactants.setter - def reactants(self, a: 'IngredientSet'): - """Set the reactants""" - self._reactants = a - self.__flag_modification() - - @property - def intermediates(self) -> 'IngredientSet': - """Intermediates :class:`.IngredientSet`""" - return self._intermediates - - @intermediates.setter - def intermediates(self, a: 'IngredientSet'): - """Set the intermediates""" - self._intermediates = a - # self.__flag_modification() - - @property - def reactions(self) -> 'ReactionSet': - """Intermediates :class:`.IngredientSet`""" - return self._reactions - - @reactions.setter - def reactions(self, a: 'ReactionSet'): - """Set the reactions""" - self._reactions = a - self.__flag_modification() - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.get_price() + self.compounds.get_price() - - @property - def num_products(self) -> int: - """Return the number of products""" - return len(self.products) - - @property - def num_compounds(self) -> int: - """Return the number of compounds""" - return len(self.combined_compound_ids) - - @property - def num_reactions(self): - """Return the number of reactions""" - return len(self.reactions) - - @property - def num_reaction_types(self): - """Return the number of reactions""" - return self.reactions.num_types - - @property - def num_reactants(self): - """Return the number of reactants""" - return len(self.reactants) - - @property - def num_intermediates(self): - """Return the number of intermediates""" - return len(self.intermediates) - - @property - def hash(self) -> str: - """Return the unique hash string""" - return self._hash - - @property - def score(self): - """Return the Recipe score""" - return self._score - - @property - def type(self) -> str: - """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" - - if self.empty: - return 'EMPTY' - - chem = bool(self.reactions) - nochem = bool(self.compounds) - - if chem and nochem: - return 'MIXED' - - if chem and not nochem: - return 'CHEM' - - if nochem and not chem: - return 'NOCHEM' - - @property - def empty(self) -> bool: - """Is this Recipe empty?""" - - if self.reactants: - return False - - if self.products: - return False - - if self.intermediates: - return False - - if self.reactions: - return False - - if self.compounds: - return False - - return True - - ### METHODS - - def get_price(self, supplier: str | None = None) -> 'Price': - """get the reactants price. See :meth:`.IngredientSet.get_price` - - :param supplier: restrict quotes to this supplier - - """ - return self.reactants.get_price(supplier=supplier) - - def draw(self, color_mapper=None, node_size=300, graph_only=False): - """draw graph of the reaction network - - :param color_mapper: (Default value = None) - :param node_size: (Default value = 300) - :param graph_only: (Default value = False) - - """ - - import networkx as nx - - color_mapper = color_mapper or {} - colors = {} - sizes = {} - - graph = nx.DiGraph() - - for reaction in self.reactions: - for reactant in reaction.reactants: - key = str(reactant) - ingredient = self.get_ingredient(id=reactant.id) - - graph.add_node( - key, - id=reactant.id, - smiles=reactant.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = self.get_ingredient(id=reactant.id).amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for product in self.products: - key = str(product.compound) - ingredient = self.get_ingredient(id=product.id) - - graph.add_node( - key, - id=product.id, - smiles=product.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = product.amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for reaction in self.reactions: - for reactant in reaction.reactants: - graph.add_edge( - str(reactant), - str(reaction.product), - id=reaction.id, - type=reaction.type, - product_yield=reaction.product_yield, - ) - - # rescale sizes - if not graph_only: - s_min = min(sizes.values()) - sizes = [s / s_min * node_size for s in sizes.values()] - - if graph_only: - return graph - else: - # return nx.draw(graph, pos, with_labels=True, font_weight='bold') - # pos = nx.spring_layout(graph, iterations=200, k=30) - pos = nx.spring_layout(graph) - return nx.draw( - graph, - pos=pos, - with_labels=True, - font_weight='bold', - node_color=list(colors.values()), - node_size=sizes, - ) - - def sankey(self, title: str | None = None) -> 'graph_objects.Figure': - """draw a plotly Sankey diagram - - :param title: (Default value = None) - - """ - - graph = self.draw(graph_only=True) - - import plotly.graph_objects as go - - nodes = {} - - for edge in graph.edges: - c = edge[0] - if c not in nodes: - nodes[c] = len(nodes) - - c = edge[1] - if c not in nodes: - nodes[c] = len(nodes) - - source = [nodes[a] for a, b in graph.edges] - target = [nodes[b] for a, b in graph.edges] - value = [1 for l in graph.edges] - - labels = list(nodes.keys()) - - hoverkeys = None - - customdata = [] - for key in nodes.keys(): - n = graph.nodes[key] - - if not hoverkeys: - hoverkeys = list(n.keys()) - - if not n: - mrich.error(f'problem w/ node {key=}') - compound_id = int(key[1:]) - customdata.append((compound_id, None)) - - else: - d = tuple(v if v is not None else 'N/A' for v in n.values()) - customdata.append(d) - - hoverkeys_edges = None - - customdata_edges = [] - - for s, t in graph.edges.keys(): - edge = graph.edges[s, t] - - if not hoverkeys_edges: - hoverkeys_edges = list(edge.keys()) - - if not n: - mrich.error(f'problem w/ edge {s=} {t=}') - customdata_edges.append((None, None, None)) - - else: - d = tuple(v if v is not None else 'N/A' for v in edge.values()) - customdata_edges.append(d) - - hoverlines = [] - for i, key in enumerate(hoverkeys): - hoverlines.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' - - hoverlines_edges = [] - for i, key in enumerate(hoverkeys_edges): - hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate_edges = ( - 'Reaction ' + '
'.join(hoverlines_edges) + '' - ) - - fig = go.Figure( - data=[ - go.Sankey( - node=dict( - # pad = 15, - # thickness = 20, - # line = dict(color = "black", width = 0.5), - label=labels, - # color = "blue" - customdata=customdata, - # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", - # "Long name C1", "Long name C2"], - # hovertemplate='Compound %{label}

smiles=%{customdata}', - hovertemplate=hovertemplate, - ), - link=dict( - customdata=customdata_edges, - hovertemplate=hovertemplate_edges, - source=source, - target=target, - value=value, - ), - ) - ] - ) - - if not title: - try: - title = f'Recipe
price={self.price}' - except AssertionError: - title = 'Recipe' - - fig.update_layout(title=title) - - return fig - - def summary(self, price: bool = True) -> None: - """Print a summary of this recipe - - :param price: print the price (Default value = True) - - """ - - mrich.h1(str(self)) - - if price: - price = self.price - if price: - mrich.var('\nprice', price.amount, price.currency) - # mrich.var('lead-time', self.lead_time, 'working days)) - - if self.products: - mrich.h3(f'{len(self.products)} products') - - if len(self.products) < 100: - for product in self.products: - mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') - - if self.intermediates: - mrich.h3(f'{len(self.intermediates)} intermediates') - - if len(self.intermediates) < 100: - for intermediate in self.intermediates: - mrich.var( - str(intermediate.compound), - f'{intermediate.amount:.2f}', - 'mg', - ) - - if self.reactants: - mrich.h3(f'{len(self.reactants)} reactants') - - if len(self.reactants) < 100: - for reactant in self.reactants: - mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') - - if self.reactions: - mrich.h3(f'{len(self.reactions)} reactions') - - if len(self.reactions) < 100: - for reaction in self.reactions: - mrich.var(str(reaction), reaction.reaction_str, reaction.type) - - if hasattr(self, '_compounds') and self.compounds: - mrich.h3(f'{len(self.compounds)} compounds') - - if len(self.compounds) < 100: - for compound in self.compounds: - mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') - - def get_ingredient(self, id) -> 'Ingredient': - """Get an ingredient by its compound ID - - :param id: compound ID - - """ - matches = [r for r in self.reactants if r.id == id] - if not matches: - matches = [r for r in self.intermediates if r.id == id] - if not matches: - matches = [r for r in self.products if r.id == id] - - assert len(matches) == 1 - return matches[0] - - def add_to_all_reactants(self, amount: float = 20) -> None: - """Increment all reactants by this amount - - :param amount: amount in ``mg`` (Default value = 20) - - """ - self.reactants.df['amount'] += amount - - def write_json( - self, - file: 'str | Path', - *, - extra: dict | None = None, - indent: str = '\t', - **kwargs, - ) -> None: - """Serialise this recipe object and write it to disk - - :param file: write to this path - :param extra: extra data to serialise - :param indent: indentation whitespace (Default value = '\t') - - """ - import json - from pathlib import Path - - file = Path(file).resolve() - - assert file.parent.exists(), f'Directory does not exist: {file.parent}' - - data = self.get_dict(serialise_price=True, **kwargs) - - if extra: - data.update(extra) - - mrich.writing(file) - json.dump(data, open(file, 'w'), indent=indent) - - def get_dict( - self, - *, - price: bool = True, - reactant_supplier: bool = True, - compound_supplier: bool = True, - database: bool = True, - timestamp: bool = True, - compound_ids_only: bool = False, - products: bool = True, - serialise_price: bool = False, - ): - """Serialise this recipe object - - Store - ===== - - - Path to database - - Timestamp - - Reactants (& their quotes, amounts) - - Intermediates (& their quotes) - - Products (& their poses/scores/fingerprints) - - Reactions - - Total Price - - Lead time - - :param price: include the price (Default value = True) - :param reactant_supplier: include the supplier (Default value = True) - :param database: include the database (Default value = True) - :param timestamp: add a timestamp (Default value = True) - :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) - :param products: include products (Default value = True) - :param serialise_price: serialise :class:`.Price` object (Default value = False) - - """ - - from datetime import datetime - - data = {} - - # Database - if database: - data['database'] = str(self.db.path.resolve()) - if timestamp: - data['timestamp'] = str(datetime.now()) - - # Recipe properties - try: - if price and serialise_price: - data['price'] = self.price.get_dict() - elif price: - data['price'] = self.price - except AssertionError as e: - mrich.warning(f'Could not get price: {e}') - data['price'] = None - - if reactant_supplier: - data['reactant_supplier'] = self.reactants.supplier - - if compound_supplier: - data['compound_supplier'] = self.compounds.supplier - - # IngredientSets - if compound_ids_only: - data['reactant_ids'] = self.reactants.compound_ids - data['intermediate_ids'] = self.intermediates.compound_ids - if products: - data['products_ids'] = self.products.compound_ids - data['compound_ids'] = self.compounds.compound_ids - - else: - data['reactants'] = self.reactants.df.to_dict(orient='list') - data['intermediates'] = self.intermediates.df.to_dict(orient='list') - if products: - data['products'] = self.products.df.to_dict(orient='list') - data['compounds'] = self.compounds.df.to_dict(orient='list') - - # ReactionSet - data['reaction_ids'] = self.reactions.ids - - return data - - def get_routes(self, return_ids: bool = False) -> 'RouteSet': - """Get routes""" - return self.products.get_routes( - permitted_reactions=self.reactions, return_ids=return_ids - ) - - def register_missing_routes( - self, missing_only: bool = True, supplier: str = 'Enamine' - ) -> None: - """Calculate missing routes to products of this Recipe""" - - return products.compounds.register_missing_routes( - missing_only=missing_only, supplier=supplier - ) - - if missing_only: - from .cset import CompoundSet - - records = self.db.select_where( - table='route', - key=f'route_product IN {products.str_ids}', - query='route_product', - multiple=True, - ) - existing = set(i for (i,) in records) - missing = set(products.ids) - existing - products = CompoundSet(self.db, missing) - - mrich.var('#products', len(products)) - - for i, c in mrich.track(enumerate(products), total=len(products)): - try: - reactions = c.reactions - except Exception as e: - mrich.error(f"Error getting {c}'s reactions", e) - continue - - for reaction in reactions: - try: - recipes = reaction.get_recipes(supplier=supplier) - except Exception as e: - mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) - continue - - for recipe in recipes: - route = self.db.register_route(recipe=recipe) - - mrich.print(f'registered {route=}') - - self.db.prune_duplicate_routes() - - def write_CAR_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'DataFrame | None': - """Prepares CSVs for use with CAR. - - .. attention:: - - This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: file to write to - :param return_df: return the dataframe (Default value = False) - - """ - - from pathlib import Path - - from pandas import DataFrame - - # solve each product's reaction - - file = str(Path(file).resolve()) - - rows = [] - - routes = self.get_routes() - - for sub_recipe in routes: - product = sub_recipe.product - - row = { - 'target-names': str(product.compound), - 'no-steps': 0, - 'concentration-required-mM': None, - 'amount-required-uL': None, - 'batch-tag': None, - } - - for i, reaction in enumerate(sub_recipe.reactions): - i = i + 1 - - row['no-steps'] += 1 - - match len(reaction.reactants): - case 1: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = None - case 2: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = reaction.reactants[1].smiles - case _: - # mrich.warning(f"More than two reactants for {reaction=}") - for j, r in enumerate(reaction.reactants): - row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles - - row[f'reaction-product-smiles-{i}'] = reaction.product.smiles - row[f'reaction-name-{i}'] = reaction.type - row[f'reaction-recipe-{i}'] = None - row[f'reaction-groupby-column-{i}'] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - if len(df[df.duplicated()]): - mrich.warning('Removing duplicates from CAR DataFrame') - df = df.drop_duplicates() - - df = df.convert_dtypes() - - for n_steps in set(df['no-steps']): - subset = df[df['no-steps'] == n_steps] - this_file = file.replace('.csv', f'_{n_steps}steps.csv') - mrich.writing(this_file) - subset.to_csv(this_file, index=False) - - mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_reactant_csv( - self, - file: 'str | Path', - reaction_type_counts: bool = True, - return_df: bool = False, - ) -> 'DataFrame | None': - """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use - - Reactant - ======== - - - ID - - SMILES - - Inchikey - - Quote - ===== - - - Supplier - - Catalogue - - Entry - - Lead-time - - Quoted amount - - Quote currency - - Quote price - - Quote purity - - Downstream - ========== - - - num_reaction_dependencies - - num_product_dependencies - - reaction_dependencies - - product_dependencies - - """ - # - remove_with - - # from rich import print - - data = [] - - ### Get lookup data - - route_ids = self.get_routes(return_ids=True) - - sql = f""" - SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} - """ - product_lookup = {} - for reactant_id, product_id in self.db.execute(sql): - product_lookup.setdefault(reactant_id, set()) - product_lookup[reactant_id].add(product_id) - - sql = f""" - WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - ), - - reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id - WHERE component_type = 1 - AND component_ref IN {self.reactions.str_ids} - ) - - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id - """ - reaction_lookup = {} - for reactant_id, reaction_id, reaction_type in self.db.execute(sql): - reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) - reaction_lookup[reactant_id]['ids'].add(reaction_id) - reaction_lookup[reactant_id]['types'].add(reaction_type) - reaction_lookup[reactant_id].setdefault('counts', {}) - reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) - reaction_lookup[reactant_id]['counts'][reaction_type] += 1 - - smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) - - inchikey_lookup = self.db.get_compound_id_inchikey_dict( - self.reactants.compounds - ) - - ### Reactant Dataframe - - df = self.reactants.df - - df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) - df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) - df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) - - ### Quote DataFrame - - qdf = self.db.get_quote_df(self.reactants.quote_ids) - - qdf = qdf.rename( - columns={ - 'id': 'quote_id', - 'smiles': 'quoted_smiles', - 'purity': 'quoted_purity', - 'date': 'quote_date', - 'lead_time': 'quote_lead_time_days', - 'price': 'quote_price', - 'currency': 'quote_currency', - 'catalogue': 'quote_catalogue', - 'supplier': 'quote_supplier', - 'entry': 'quote_entry', - 'amount': 'quoted_amount_mg', - } - ) - qdf = qdf.drop(columns=['compound']) - - ### Downstream info - - try: - df['downstream_product_ids'] = df['compound_id'].apply( - lambda x: product_lookup.get(x, set()) - ) - - df['downstream_reaction_ids'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['ids'] - ) - df['downstream_reaction_types'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['types'] - ) - except KeyError as e: - mrich.error(f'Reactant C{e} is missing downstream reaction') - mrich.error( - 'Are all routes enumerated? Try running calculate_missing_routes()' - ) - return None - - df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) - df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) - df['num_downstream_products'] = df['downstream_product_ids'].apply(len) - - ### Join and reformat - - df = df.merge(qdf, on='quote_id', how='left') - - df = df.rename( - columns={ - 'amount': 'required_amount_mg', - } - ) - - cols = [ - 'compound_id', - 'smiles', - 'inchikey', - 'required_amount_mg', - 'quoted_amount_mg', - 'quote_id', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_price', - 'quote_currency', - 'quote_lead_time_days', - 'quoted_purity', - 'quoted_smiles', - 'quote_date', - 'num_downstream_products', - 'num_downstream_reaction_types', - 'num_downstream_reactions', - ] - - if reaction_type_counts: - for i, row in df.iterrows(): - counts = reaction_lookup[row['compound_id']]['counts'] - - for reaction_type, count in counts.items(): - key = f'num_downstream ({reaction_type})' - df.loc[i, key] = count - if key not in cols: - cols.append(key) - - cols += [ - 'downstream_product_ids', - 'downstream_reaction_types', - 'downstream_reaction_ids', - ] - - df = df[[c for c in cols if c in df.columns]] - - ### Add estimated quotes - - unquoted = df[df['quote_id'].isna()] - - if len(unquoted): - for i, row in unquoted.iterrows(): - compound = self.db.get_compound(id=row['compound_id']) - ingredient = compound.as_ingredient( - amount=row['required_amount_mg'], get_quote=False - ) - - quote = ingredient.quote - - df.loc[i, 'quoted_amount_mg'] = quote.amount - df.loc[i, 'quote_supplier'] = quote.supplier - df.loc[i, 'quote_catalogue'] = quote.catalogue - df.loc[i, 'quote_entry'] = quote.entry - df.loc[i, 'quote_price'] = quote.price.amount - df.loc[i, 'quote_currency'] = quote.price.currency - df.loc[i, 'quote_lead_time_days'] = quote.lead_time - df.loc[i, 'quoted_purity'] = quote.purity - df.loc[i, 'quoted_smiles'] = quote.smiles - df.loc[i, 'quote_date'] = quote.date - - ### N.B. scaffold series no longer output - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_product_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'pd.DataFrame | None': - """Detailed CSV output including product information for selection and synthesis""" - - from pandas import DataFrame - - # from rich import print - from .pset import PoseSet - from .rset import ReactionSet - - data = [] - - routes = self.get_routes() - - pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - for product in mrich.track( - self.products, prefix='Constructing product DataFrame' - ): - d = dict( - hippo_id=product.compound_id, - smiles=product.smiles, - inchikey=product.inchikey, - required_amount_mg=product.amount, - ) - - upstream_routes = [] - upstream_reactions = [] - - for route in routes: - if product in route.products: - upstream_routes.append(route) - - for reaction in route.reactions: - upstream_reactions.append(reaction) - - upstream_reactions = ReactionSet( - self.db, set(reaction.id for reaction in upstream_reactions) - ) - - if not upstream_routes: - mrich.error('No upstream routes for', product) - continue - - if not upstream_reactions: - mrich.error('No upstream reactions for', product) - continue - - def get_scaffold_series() -> tuple[list[int], bool]: - """Get scaffold series value""" - - if scaffolds := product.scaffolds: - return scaffolds.ids, False - - else: - return [product.id], True - - poses = pose_map.get(product.id, set()) - - d['num_poses'] = len(poses) - d['poses'] = poses - d['tags'] = product.tags - d['num_routes'] = len(upstream_routes) - d['num_reaction_steps'] = set( - len(route.reactions) for route in upstream_routes - ) - d['reaction_dependencies'] = upstream_reactions.ids - d['reactant_dependencies'] = set( - sum([route.reactants.ids for route in upstream_routes], []) - ) - d['route_ids'] = [route.id for route in upstream_routes] - d['chemistry_types'] = ', '.join(upstream_reactions.types) - series, is_scaffold = get_scaffold_series() - d['is_scaffold'] = is_scaffold - d['scaffold_series'] = series - - inspirations = inspiration_map.get(product.id, None) - - if not inspirations and not is_scaffold: - scaffold = product.scaffolds[0] - inspirations = inspiration_map.get(scaffold.id, None) - - if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: - inspirations = scaffold.metadata['inspiration_pose_ids'] - - if ( - not inspirations - and is_scaffold - and 'inspiration_pose_ids' in product.metadata - ): - inspirations = product.metadata['inspiration_pose_ids'] - - if inspirations: - inspirations = PoseSet(self.db, inspirations) - d['inspirations'] = ', '.join(n for n in inspirations.names) - else: - d['inspirations'] = '' - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_chemistry_csv( - self, file: 'str | Path', return_df: bool = True - ) -> 'pd.DataFrame | None': - """Detailed CSV output synthetis information for chemistry types in this set""" - - from pandas import DataFrame - - from .cset import CompoundSet - - data = [] - - # get compounds - - scaffolds = CompoundSet(self.db) - - for product in self.products: - if scaffolds := product.scaffolds: - scaffolds += scaffolds - else: - scaffolds.add(product.compound) - - routes = self.get_routes() - - route_types = {} - - for compound in scaffolds: - elabs = ( - self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') - or [] - ) - - d = dict( - scaffold_id=compound.id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elabs), - is_scaffold=True, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.warning(f'No routes to scaffold={compound}') - continue - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - product = reaction.product - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = product.smiles - d[f'{key}_product_id'] = product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - missing_scaffolds = {} - - for compound in self.products.compounds: - if compound in scaffolds: - continue - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - scaffolds = compound.scaffolds - - for scaffold in scaffolds: - if scaffold.id not in route_types: - group = missing_scaffolds.setdefault(scaffold.id, []) - group.append(compound.id) - continue - - else: - for route in upstream_routes: - chem_types = tuple([r.type for r in route.reactions]) - - if chem_types not in route_types[base.id]: - mrich.success(scaffold) - mrich.success(chem_types) - raise ValueError( - 'Scaffold has route not present in dataframe' - ) - - for scaffold_id, elab_ids in missing_scaffolds.items(): - compound = self.db.get_compound(id=sorted(elab_ids)[0]) - - d = dict( - scaffold_id=scaffold_id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elab_ids), - is_scaffold=False, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.error(f'No routes to elab {compound}') - raise ValueError(f'No routes to elab {compound}') - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([r.type for r in route.reactions])) - - for k, reaction in enumerate(route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - product = reaction.product - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = product.smiles - d[f'{key}_product_id'] = product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def to_syndirella( - self, - out_key: 'str | Path', - poses: 'PoseSet', - *, - separate: bool = False, - ) -> 'DataFrame': - """Generate inputs for running syndirella elaboration""" - - import shutil - from pathlib import Path - - out_key = Path('.') / out_key - out_dir = out_key.parent - out_key = out_key.name - - mrich.var('out_key', out_key) - mrich.var('out_dir', out_dir) - - if not out_dir.exists(): - mrich.writing(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - - template_dir = out_dir / 'templates' - if not template_dir.exists(): - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) - - """ - - Need to create dataframe with columns: - - compound_id - - pose_id - - smiles - - reaction_name_step1 - - reactant_step1 - - reactant2_step1 - - product_step1 - ... - - hit1 - - hit2 - ... - - template - - compound_set - - """ - - pose_compounds = poses.compounds - assert set(self.products.compound_ids) == set(pose_compounds.ids), ( - 'supplied poses have different compounds to Recipe products' - ) - assert len(poses) == len(self.products), ( - 'some duplicate compounds in supplied poses' - ) - - df = poses.get_df( - inchikey=False, - alias=False, - name=False, - compound_id=True, - reference_id=True, - inspiration_aliases=True, - ) - - df = df.reset_index() - df = df.rename(columns={'id': 'pose_id'}) - df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') - df = df.set_index(['compound_id', 'pose_id']) - - ## CHECKS - - no_refs = df[df['reference_id'].isna()] - - if len(no_refs): - mrich.error(len(no_refs), 'poses without reference!') - ids = set(no_refs.index.get_level_values('pose_id')) - mrich.print(ids) - - no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) - - if no_insps: - mrich.error(len(no_insps), 'poses without inspirations!') - return None - - ## TEMPLATES - - references = poses.references - ref_lookup = self.db.get_pose_id_alias_dict(references) - df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) - - for ref_pose in references: - assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' - - template = template_dir / ref_pose.apo_path.name - - if not template.exists(): - mrich.writing(template) - shutil.copy(ref_pose.apo_path, template) - - ## INSPIRATIONS - - for i, row in df.iterrows(): - for j, alias in enumerate(row['inspiration_aliases']): - df.loc[i, f'hit{j + 1}'] = alias - - inspirations = poses.inspirations - - sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' - - inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col='name', - ) - - ## ADD ROUTE INFO - - routes = self.get_routes() - - for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): - product = sub_recipe.product - - product_id = product.compound_id - - matches = df.xs(product_id, level='compound_id') - - if len(matches) > 1: - mrich.warning('Multiple rows for compound', product_id) - - for i, row in matches.iterrows(): - key = (product_id, i) - - for j, reaction in enumerate(sub_recipe.reactions): - j = j + 1 - - match len(reaction.reactants): - case 1: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = None - case 2: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - case 3: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ - 2 - ].smiles - case _: - raise NotImplementedError('Too many reactants') - - df.loc[key, f'product_step{j}'] = reaction.product.smiles - df.loc[key, f'reaction_name_step{j}'] = reaction.type - - break - - ## REMOVE UNECESSARY COLS - - df = df.drop(columns=['reference_id', 'inspiration_aliases']) - - ## REORDER COLUMNS - - cols = [ - 'smiles', - 'reaction_name_step1', - 'reactant_step1', - 'reactant2_step1', - 'reactant3_step1', - 'product_step11', - 'hit1', - 'hit2', - 'hit3', - 'hit4', - 'hit5', - 'hit6', - 'hit7', - 'hit8', - 'hit9', - 'template', - 'compound_set', - ] - - if not any([c not in cols for c in df.columns]): - df = df[[c for c in cols if c in df.columns]] - - if not separate: - out_path = out_dir / f'{out_key}_syndirella_input.csv' - mrich.writing(out_path) - df.to_csv(out_path) - return df - - for idx, row in df.iterrows(): - out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' - mrich.writing(out_path) - single_df = row.to_frame().T - single_df = single_df.dropna(axis=1, how='all') - single_df.to_csv(out_path, index=False) - - return df - - def copy(self) -> 'Recipe': - """Copy this recipe""" - - if hasattr(self, 'compounds'): - compounds = self.compounds.copy() - else: - compounds = None - - return Recipe( - self.db, - products=self.products.copy(), - reactants=self.reactants.copy(), - intermediates=self.intermediates.copy(), - reactions=self.reactions.copy(), - compounds=compounds, - # supplier=self.supplier - ) - - def __flag_modification(self) -> None: - """Flag this recipe as modified""" - self._product_interactions = None - self._score = None - self._product_compounds = None - self._product_poses = None - - def check_integrity(self, debug: bool = False) -> bool: - """Verify integrity of this recipe""" - - # no duplicate ingredients - - if debug: - mrich.debug('Checking integrity:', self) - mrich.debug('Checking for duplicate compounds') - - if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): - mrich.error("Reactant compound ID's are not unique") - return False - if len(self.intermediates.compound_ids) != len( - set(self.intermediates.compound_ids) - ): - mrich.error("Intermediate compound ID's are not unique") - return False - if len(self.products.compound_ids) != len(set(self.products.compound_ids)): - mrich.error("Product compound ID's are not unique") - return False - - # all references should exist - - if debug: - mrich.debug('Checking for missing references') - - if self.db.count_where( - table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' - ) < len(self.reactions): - mrich.error('Not all Reactions in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' - ) < len(self.products): - mrich.error('Not all product Compounds in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' - ) < len(self.reactants): - mrich.error('Not all reactant Compounds in Database') - return False - - if self.db.count_where( - table='compound', - key=f'compound_id IN {self.intermediates.compounds.str_ids}', - ) < len(self.intermediates): - mrich.error('Not all intermediate Compounds in Database') - return False - - reaction_intermediates = self.reactions.intermediates - reaction_products = self.reactions.products - reaction_reactants = self.reactions.reactants - - if debug: - mrich.debug('Checking for missing reactions') - - # all products should have a reaction - for product in self.products: - if product not in reaction_products: - mrich.error(f'Product: {product} does not have associated reaction') - return False - - # intermediates - for intermediate in self.intermediates: - if intermediate not in reaction_intermediates: - mrich.error( - f'Intermediate: {intermediate} is not in self.reactions.intermediates' - ) - return False - - # reactants - for reactant in self.reactants: - if reactant not in reaction_reactants: - mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') - return False - - # all reactions should have enough reactant - - if debug: - mrich.debug('Checking reactant quantities') - - for reaction in self.reactions: - product_ingredient = self.products(compound_id=reaction.product_id) - - if product_ingredient is None: - product_ingredient = self.intermediates(compound_id=reaction.product_id) - - if debug and reaction.product_yield < 1.0: - mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') - - for reactant in reaction.reactants: - reactant_ingredient = self.intermediates(compound_id=reactant.id) - - if reactant_ingredient is None: - reactant_ingredient = self.reactants(compound_id=reactant.id) - - required_amount = product_ingredient.amount / reaction.product_yield - - if reactant_ingredient.amount < required_amount: - mrich.error( - f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' - ) - return False - - if debug: - mrich.success(self, 'OK') - - return True - - def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): - """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" - self.compounds.add(ingredient) - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - - if self.score: - s = f'(score={self.score:.3f})' - else: - s = '' - - if self.hash: - return f'Recipe_{self.hash}{s}' - - return f'Recipe{s}' - - def __longstr(self) -> str: - """Unformatted string representation""" - - if self.empty: - return 'Empty Recipe()' - - if self.reactions: - if self.intermediates: - s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' - else: - s = f'{self.reactants} --> {self.products} via {self.reactions}' - - if self.score: - s += f', score={self.score:.3f}' - - if self.hash: - return f'Recipe_{self.hash}({s})' - - return f'Recipe({s})' - - else: - s = f'{self.compounds}' - - if self.hash: - return f'Recipe_{self.hash}({s})' - - return f'Recipe(#compounds={self.num_compounds} [no-chem])' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self.__longstr()}' - - def __add__(self, other: 'Recipe'): - """Add another :class:`.Recipe` to this one""" - result = self.copy() - result.reactants += other.reactants - result.intermediates += other.intermediates - result.reactions += other.reactions - result.products += other.products - if hasattr(other, 'compounds'): - result.compounds += other.compounds - return result - - -class Route(Recipe): - """A recipe with a single product, that is stored in the database""" - - def __init__( - self, - db, - *, - route_id: int, - product: 'IngredientSet', - reactants: 'IngredientSet', - intermediates: 'IngredientSet', - reactions: 'ReactionSet', - ) -> None: - """Route initialisation""" - - from .cset import IngredientSet - from .rset import ReactionSet - - # check typing - assert isinstance(product, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(reactions, ReactionSet) - - assert len(product) == 1 - assert isinstance(route_id, int) - assert route_id - - self._id = route_id - self._products = product - self._product_id = product.ids[0] - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - self._db = db - - ### FACTORIES - - @classmethod - def from_json( - cls, db: 'Database', path: 'str | Path', data: dict = None - ) -> 'Route': - """Load a serialised route from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - import json - - from .cset import IngredientSet - from .rset import ReactionSet - - if data is None: - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._db = db - self._id = data['id'] - - self._product_id = data['product_id'] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id], db=db - ) # IngredientSet - - self._reactants = IngredientSet.from_json( - db=db, - path=None, - data=data['reactants']['data'], - supplier=data['reactants']['supplier'], - ) - self._intermediates = IngredientSet.from_json( - db=db, - path=None, - data=data['intermediates']['data'], - supplier=data['intermediates']['supplier'], - ) - self._reactions = ReactionSet( - db=db, indices=data['reactions']['indices'] - ) # ReactionSet - - return self - - ### PROPERTIES - - @property - def product(self) -> 'Ingredient': - """Product ingredient""" - return self._products[0] - - @property - def product_compound(self) -> 'Compound': - """Product compound""" - return self.product.compound - - @property - def id(self) -> int: - """Route ID""" - return self._id - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.price - - ### METHODS - - def get_dict(self) -> dict: - """Serialisable dictionary""" - data = {} - - data['id'] = self.id - data['product_id'] = self.product.id - data['reactants'] = self.reactants.get_dict() - data['intermediates'] = self.intermediates.get_dict() - data['reactions'] = self.reactions.get_dict() - - return data - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'Route #{self.id}: {self.product_compound}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - -class RouteSet: - """A set of Route objects""" - - def __init__(self, db: 'Database', routes: 'list[Route]') -> None: - """RouteSet initialisation""" - - data = {} - for route in routes: - # assert isinstance(route, Route) - data[route.id] = route - - self._data = data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - ### FACTORIES - - @classmethod - def from_ids(cls, db: 'Database', ids: list | set, progress: bool = True): - """Generate a routeset from a set of :class:`.Route` IDs - - :param db: database to link - :param ids: :class:`.Route` database IDs - :param progress: show progress bar - """ - - if progress: - ids = mrich.track(ids, prefix='Getting routes') - - routes = [db.get_route(id=route_id) for route_id in ids] - - self = cls.__new__(cls) - return RouteSet(db, routes) - - @classmethod - def from_product_ids(cls, db: 'Database', ids: list | set, progress: bool = True): - """Generate a routeset from a set of product :class:`.Compound` IDs - - :param db: database to link - :param ids: :class:`.Compound` database IDs - """ - - str_ids = str(tuple(ids)).replace(',)', ')') - - records = db.select_where( - table='route', - query='route_id', - key=f'route_product IN {str_ids}', - multiple=True, - ) - - route_ids = [i for (i,) in records] - - return cls.from_ids(db, route_ids, progress=progress) - - @classmethod - def from_json( - cls, db: 'Database', path: 'str | Path', data: dict = None - ) -> 'RouteSet': - """Load a serialised routeset from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - self = cls.__new__(cls) - - if data is None: - import json - - data = json.load(open(path)) - - new_data = {} - for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): - route_id = d['id'] - new_data[route_id] = Route.from_json(db=db, path=None, data=d) - - self._data = new_data - self._db = db - self._cluster_map = None - self._permitted_clusters = None - self._current_cluster = None - - return self - - ### PROPERTIES - - @property - def data(self) -> 'dict[int, Route]': - """Get internal data dictionary""" - return self._data - - @property - def db(self): - """Get associated database""" - return self._db - - @property - def routes(self) -> 'list[Route]': - """Get route objects""" - return self.data.values() - - @property - def product_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the products""" - ids = self.db.select_where( - table='route', - query='DISTINCT route_product', - key=f'route_id IN {self.str_ids}', - multiple=True, - ) - return [i for (i,) in ids] - - @property - def reactant_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the reactants""" - sql = f""" - SELECT DISTINCT component_ref FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON component_route = route_id - WHERE component_type = 2 - AND route_id IN {self.str_ids} - """ - - c = self.db.execute(sql) - return [i for (i,) in c] - - @property - def products(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route products""" - from .cset import CompoundSet - - return CompoundSet(self.db, self.product_ids) - - @property - def reactants(self) -> 'CompoundSet': - """Return a :class:`.CompoundSet` of all the route reactants""" - from .cset import CompoundSet - - return CompoundSet(self.db, self.reactant_ids) - - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Route` ID's""" - return str(tuple(self.ids)).replace(',)', ')') - - @property - def ids(self) -> list[int]: - """Return the :class:`.Route` IDs""" - return self.data.keys() - - @property - def cluster_map(self) -> dict[tuple, set]: - """Create a dictionary grouping routes by their scaffold/base cluster. - - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. - """ - - if self._cluster_map is None: - # get route mapping - pairs = self.db.select_where( - query='route_product, route_id', - key=f'route_id IN {self.str_ids}', - table='route', - multiple=True, - ) - - route_map = {route_product: route_id for route_product, route_id in pairs} - - # group compounds by cluster - compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) - - # create the map - self._cluster_map = {} - for cluster, compounds in compound_clusters.items(): - self._cluster_map[cluster] = [] - for compound in compounds: - route_id = route_map.get(compound, None) - if not route_id: - continue - self._cluster_map[cluster].append(route_id) - - if not self._cluster_map[cluster]: - del self._cluster_map[cluster] - - return self._cluster_map - - ### METHODS - - def copy(self) -> 'RouteSet': - """Copy this RouteSet""" - return RouteSet(self.db, self.data.values()) - - def set_db_pointers(self, db: 'Database') -> None: - """ - - :param db: - - """ - self._db = db - for route in self.data.values(): - route._db = db - - # def clear_db_pointers(self): - # """ """ - # self._db = None - # for route in self.data.values(): - # route._db = None - - def get_dict(self): - """Get serialisable dictionary""" - - data = dict(db=str(self.db), routes={}) - - # populate with routes - for route_id, route in self.data.items(): - data['routes'][route_id] = route.get_dict() - - return data - - def prune_unavailable(self, suppliers: list[str]): - """Remove routes that don't have all reactants available from given suppliers""" - - suppliers_str = str(tuple(suppliers)).replace(',)', ')') - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] - FROM {self.db.SQL_SCHEMA_PREFIX}quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) - - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - AND route_id IN {self.str_ids} - """ - - route_ids = self.db.execute(sql).fetchall() - - route_ids = [i for (i,) in route_ids] - - mrich.var('#routes before pruning', len(self)) - mrich.var('#routes after pruning', len(route_ids)) - - return RouteSet.from_ids(self.db, route_ids) - - def pop_id(self) -> int: - """Pop the last route from the set and return it's id""" - route_id, route = self.data.popitem() - return route_id - - def pop(self) -> 'Route': - """Pop the last route from the set and return it's object""" - route_id, route = self.data.popitem() - return route - - def balanced_pop( - self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> 'Route': - """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" - - if not self._data: - mrich.print('RouteSet depleted') - return None - - if not self.cluster_map: - # mrich.warning("RouteSet.cluster_map depleted but _data isn't...") - return self.pop() - - # store the permitted clusters (or all clusters) list as property - - if self._permitted_clusters is None: - if permitted_clusters: - permitted_clusters = set( - (cluster,) if isinstance(cluster, int) else cluster - for cluster in permitted_clusters - ) - - self._permitted_clusters = [] - for cluster in permitted_clusters: - if cluster not in self.cluster_map: - mrich.warning( - cluster, 'in permitted_clusters but not cluster_map' - ) - else: - self._permitted_clusters.append(cluster) - - else: - self._permitted_clusters = list(self.cluster_map.keys()) - - if self._current_cluster is None: - self._current_cluster = self._permitted_clusters[0] - - ### pop a Route - - if debug: - mrich.debug(f'Would pop Route from {self._current_cluster=}') - - cluster = self._current_cluster - - # pop the last route id from the given cluster - - try: - route_id = self.cluster_map[cluster].pop() - except IndexError: - mrich.print(self._permitted_clusters) - mrich.print(self.cluster_map) - raise - except AttributeError: - mrich.print(cluster) - mrich.print(self.cluster_map) - raise - except KeyError: - mrich.print('cluster', cluster) - mrich.print('self._permitted_clusters', self._permitted_clusters) - mrich.print('self.cluster_map.keys()', self.cluster_map.keys()) - raise - - # clean up empty clusters - - if debug: - mrich.debug('Popped route', route_id) - - # get the Route object - - if route_id in self._data: - route = self._data[route_id] - del self._data[route_id] - else: - # if debug: - mrich.debug('Route not present') - return self.balanced_pop() - - ### increment cluster - - # def increment_cluster(cluster): - n = len(self._permitted_clusters) - if n > 1: - for i, cluster in enumerate(self._permitted_clusters): - if cluster == self._current_cluster: - if i == n - 1: - self._current_cluster = self._permitted_clusters[0] - else: - self._current_cluster = self._permitted_clusters[i + 1] - break - else: - raise IndexError('This should never be reached...') - - # increment_cluster() - - if not self.cluster_map[cluster]: - del self.cluster_map[cluster] - if not self.cluster_map: - mrich.debug('RouteSet.cluster_map depleted') - self._permitted_clusters = [ - c for c in self._permitted_clusters if c != cluster - ] - # if debug: - mrich.debug('Depleted cluster', cluster) - - if not self._permitted_clusters: - mrich.debug('Depleted all permitted clusters', cluster) - mrich.debug('Removing cluster restriction', cluster) - self._permitted_clusters = list(self.cluster_map.keys()) - self._current_cluster = None - - if debug: - mrich.debug('#Routes in set', len(self._data)) - - return route - - def shuffle(self): - """Randomly shuffle the routes in this set""" - import random - - items = list(self.data.items()) - random.shuffle(items) - self._data = dict(items) - - ### shuffle the cluster map as well - - for cluster, routes in self.cluster_map.items(): - random.shuffle(routes) - self.cluster_map[cluster] = routes - - ### DUNDERS - - def __len__(self) -> int: - """Number of routes in this set""" - return len(self.data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Route × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' - - def __iter__(self): - """Iterate over routes in this set""" - return iter(self.data.values()) - - def __getitem__(self, key): - """Get a specific route in this set""" - return list(self.data.values())[key] - - -class RecipeSet: - """A set of recipes stored on disk""" - - def __init__( - self, db: 'Database', directory: 'str | Path', pattern: str = '*.json' - ): - """RecipeSet initialisation""" - - from json import JSONDecodeError - from pathlib import Path - - self._db = db - self._json_directory = Path(directory) - self._json_pattern = pattern - - self._json_paths = {} - for path in self._json_directory.glob(self._json_pattern): - self._json_paths[ - path.name.removeprefix('Recipe_').removesuffix('.json') - ] = path.resolve() - - mrich.reading(f'{directory}/{pattern}') - - self._recipes = {} - for key, path in mrich.track( - self._json_paths.items(), prefix='Loading recipes' - ): - try: - recipe = Recipe.from_json( - db=self.db, - path=path, - allow_db_mismatch=True, - debug=False, - db_mismatch_warning=False, - ) - except JSONDecodeError: - mrich.error(f'Bad JSON in {path}') - continue - recipe._hash = key - self._recipes[key] = recipe - - mrich.success('Loaded', len(self), 'Recipes') - - ### FACTORIES - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Associated database""" - return self._db - - ### METHODS - - def get_values( - self, - key: str, - progress: bool = False, - serialise_price: bool = False, - ): - """Get values of member recipes associated with attribute ``key`` - - :param key: attribute to query/calculate - :param progress: show a progress bar - :param serialise_price: serialise price objects - - """ - - values = [] - recipes = self._recipes.values() - - if progress: - recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') - - for recipe in recipes: - value = getattr(recipe, key) - if serialise_price and key == 'price': - value = value.amount - values.append(value) - - return values - - def get_df(self, **kwargs) -> 'pandas.DataFrame': - """Get dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`""" - - data = [] - - for recipe in self: - d = recipe.get_dict( - # reactant_supplier=False, - database=False, - timestamp=False, - **kwargs, - # timestamp=False, - ) - - data.append(d) - - from pandas import DataFrame - - return DataFrame(data) - - def items(self) -> 'list[tuple[str, Recipe]]': - """Get data dictionary items""" - return self._recipes.items() - - def keys(self) -> list[str]: - """Get data dictionary keys (recipe hashes)""" - return self._recipes.keys() - - ### DUNDERS - - def __len__(self) -> int: - """Number of recipes in this set""" - return len(self._recipes) - - def __getitem__( - self, - key: int | str, - ) -> Recipe: - """Get a :class:`.Recipe` in this set by it's index or key/hash""" - - match key: - case int(): - return list(self._recipes.values())[key] - - case str(): - return self._recipes[key] - - case _: - mrich.error( - f'Unsupported type for RecipeSet.__getitem__(): {key=} {type(key)}' - ) - - return None - - def __iter__(self): - """Iterate over recipes""" - return iter(self._recipes.values()) - - def __contains__(self, key: str): - """Is this hash contained in the set""" - assert isinstance(key, str) - return key in self._recipes - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Recipe × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/hippo/designdb/route.py b/hippo/designdb/route.py deleted file mode 100644 index 1406c08..0000000 --- a/hippo/designdb/route.py +++ /dev/null @@ -1,218 +0,0 @@ -import json - -import mcol -import mrich -from designdb.models import Component, Reaction, Route - -from .recipe import Recipe - - -# name conflict with route model. Trying to get rid of this entirely -class RouteObj(Recipe): - """A recipe with a single product, that is stored in the database""" - - def __init__( - self, - *, - route_id: int, - product: 'IngredientSet', - reactants: 'IngredientSet', - intermediates: 'IngredientSet', - reactions: 'ReactionSet', - ) -> None: - """Route initialisation""" - - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet - - # check typing - assert isinstance(product, IngredientSet) - assert isinstance(reactants, IngredientSet) - assert isinstance(intermediates, IngredientSet) - assert isinstance(reactions, ReactionSet) - - assert len(product) == 1 - assert isinstance(route_id, int) - assert route_id - - self._id = route_id - self._products = product - self._product_id = product.ids[0] - self._reactants = reactants - self._intermediates = intermediates - self._reactions = reactions - - ### FACTORIES - - @classmethod - def from_json(cls, path: 'str | Path', data: dict = None) -> 'Route': - """Load a serialised route from a JSON file - - :param db: database to link - :param path: path to JSON - :param data: serialised data (Default value = None) - - """ - - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet - - if data is None: - data = json.load(open(path)) - - self = cls.__new__(cls) - - self._id = data['id'] - - self._product_id = data['product_id'] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id] - ) # IngredientSet - - self._reactants = IngredientSet.from_json( - path=None, - data=data['reactants']['data'], - supplier=data['reactants']['supplier'], - ) - self._intermediates = IngredientSet.from_json( - path=None, - data=data['intermediates']['data'], - supplier=data['intermediates']['supplier'], - ) - self._reactions = ReactionSet( - Reaction.objects.filter(pk__in=data['reactions']['indices']) - ) # ReactionSet - - return self - - @classmethod - def get_route( - cls, - *, - id: int, - debug: bool = False, - ) -> 'RouteObj': - """Fetch a :class:`.Route` object stored in the :class:`.Database`. - - :param id: the ID of the :class:`.Route` to be retrieved - :param debug: increase verbosity for debugging, defaults to False - :returns: :class:`.Route` object - - """ - - # avoiding circular dependencies - from designdb.sets.compound import CompoundSet, IngredientSet - from designdb.sets.reaction import ReactionSet - - # multiples?? - route = Route.objects.get(pk=id) - - if debug: - mrich.var('product_id', route.product_compound) - - qs = Component.objects.filter(route=route).order_by('id') - - reaction_ids = [] - reactant_ids = [] - reactant_amounts = [] - intermediate_ids = [] - intermediate_amounts = [] - - # for ref, c_type, amount in triples: - for k in qs: - ref = k.component_ref - c_type = k.component_type - amount = k.component_amount - match c_type: - case 1: - reaction_ids.append(ref) - case 2: - reactant_ids.append(ref) - reactant_amounts.append(amount) - case 3: - intermediate_ids.append(ref) - intermediate_amounts.append(amount) - case _: - raise ValueError(f'Unknown component type {c_type}') - - if debug: - mrich.var('pairs', qs) - - products = CompoundSet([route.pk]) - reactants = CompoundSet(reactant_ids) - intermediates = CompoundSet(intermediate_ids) - - products = IngredientSet.from_compounds(compounds=products, amount=1) - reactants = IngredientSet.from_compounds( - compounds=reactants, amount=reactant_amounts - ) - intermediates = IngredientSet.from_compounds( - compounds=intermediates, amount=intermediate_amounts - ) - - reactions = ReactionSet(reaction_ids) - - recipe = RouteObj( - route_id=id, - product=products, - reactants=reactants, - intermediates=intermediates, - reactions=reactions, - ) - - if debug: - mrich.var('recipe', recipe) - - return recipe - - ### PROPERTIES - - @property - def product(self) -> 'Ingredient': - """Product ingredient""" - return self._products[0] - - @property - def product_compound(self) -> 'Compound': - """Product compound""" - return self.product.compound - - @property - def id(self) -> int: - """Route ID""" - return self._id - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.price - - ### METHODS - - def get_dict(self) -> dict: - """Serialisable dictionary""" - data = {} - - data['id'] = self.id - data['product_id'] = self.product.id - data['reactants'] = self.reactants.get_dict() - data['intermediates'] = self.intermediates.get_dict() - data['reactions'] = self.reactions.get_dict() - - return data - - ### DUNDERS - - def __str__(self) -> str: - """Unformatted string representation""" - return f'Route #{self.id}: {self.product_compound}' - - def __repr__(self) -> str: - """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich Formatted string representation""" - return f'[bold underline]{self}' diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index 7531e9a..61382cf 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -3,7 +3,7 @@ import mrich import rdkit -from designdb.models import Compound, CompoundTag +from designdb.models import CompoundModel, CompoundTagModel from designdb.utils import ( inchikey_from_smiles, registration_hash_tautomer_insensitive, @@ -54,7 +54,7 @@ def create( # mol: Chem.rdchem.Mol, smiles: str, # inchikey: str, - ) -> tuple[Compound, bool]: + ) -> tuple[CompoundModel, bool]: # designdb expects smils as input, so this is the entrypoint # for insertion @@ -66,7 +66,7 @@ def create( h = registration_hash_tautomer_insensitive(sp) - compound, created = Compound.objects.get_or_create( + compound, created = CompoundModel.objects.get_or_create( compound_hash=h, defaults={ # 'compound_mol': mol, @@ -100,7 +100,7 @@ def create( # if not compound: # mrich.error( - # 'Compound exists in database but could not be found by inchikey' + # 'CompoundModel exists in database but could not be found by inchikey' # ) # mrich.var('smiles', smiles) # mrich.var('inchikey', inchikey) @@ -117,7 +117,7 @@ def create( # def create_from_smiles( # cls, # smiles: str, - # ) -> tuple[Compound, bool]: + # ) -> tuple[CompoundModel, bool]: # mol = Chem.MolFromSmiles(smiles, sanitize=True) # compound, created = cls.create(mol=mol) # return compound, created @@ -140,7 +140,7 @@ def create_from_smiles_list( @classmethod - def get_by_smiles(cls, smiles: str) -> Compound | None: + def get_by_smiles(cls, smiles: str) -> CompoundModel | None: mol = Chem.MolFromSmiles(smiles, sanitize=True) try: sp = superparent(mol) @@ -149,7 +149,7 @@ def get_by_smiles(cls, smiles: str) -> Compound | None: h = registration_hash_tautomer_insensitive(sp) - return Compound.objects.get(compound_hash=h) + return CompoundModel.objects.get(compound_hash=h) @@ -158,9 +158,9 @@ class CompoundTagService: def tags_from_list(tag_list: list[str]): assert tag_list is not None, '"None" passed as tag_list' - CompoundTag.objects.bulk_create( - [CompoundTag(compound_tag_name=k.strip()) for k in tag_list if k.strip()], + CompoundTagModel.objects.bulk_create( + [CompoundTagModel(compound_tag_name=k.strip()) for k in tag_list if k.strip()], ignore_conflicts=True, ) - tags = CompoundTag.objects.filter(compound_tag_name__in=tag_list) + tags = CompoundTagModel.objects.filter(compound_tag_name__in=tag_list) return tags diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index b031dff..65b5fba 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -8,10 +8,16 @@ import mrich import pandas as pd from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry -from designdb.ingredient import Ingredient -from designdb.models import Compound, Pose, Reactant, Reaction, Scaffold, Target -from designdb.recipe import Recipe -from designdb.route import RouteObj +from designdb.components.compound import Ingredient +from designdb.components.recipe import Recipe, Route +from designdb.models import ( + CompoundModel, + PoseModel, + ReactantModel, + ReactionModel, + ScaffoldModel, + TargetModel, +) from designdb.services.compound import CompoundService, CompoundTagService from designdb.services.pose import PoseService, PoseTagService from designdb.services.reaction import ReactionService @@ -296,7 +302,7 @@ def ingest_filesystem( cls, *, root_path: Path, - target: Target, + target: TargetModel, skip_records: list[str], compound_tag_list: list[str], metadata_file: Path | str, @@ -394,7 +400,7 @@ def ingest_sdf( name_col: str, inspiration_col: str | None = None, inspirations: list[int], - inspiration_map: dict[str, Pose], + inspiration_map: dict[str, PoseModel], reference: int | None, reference_col: str, skip_equal, @@ -516,7 +522,7 @@ def ingest_sdf( result.poses_created += 1 pose.tags.add(*pose_tags) - pose.inspirations.add(*Pose.objects.filter(pk__in=pose_inspirations)) + pose.inspirations.add(*PoseModel.objects.filter(pk__in=pose_inspirations)) scorer.add_scores_from_record(pose=pose, record=r) # re-enable trigger and populate matview @@ -579,8 +585,8 @@ def ingest_syndirella_routes( intermediates = IngredientSet() products = IngredientSet() - # new models include Reaction, Reactant and - # Component. Should use these instead? + # new models include ReactionModel, ReactantModel and + # ComponentModel. Should use these instead? try: for k, reaction_struct in enumerate(route): @@ -601,7 +607,7 @@ def ingest_syndirella_routes( mrich.print(i, j, k, reaction_type, product) - reaction, _ = Reaction.objects.get_or_create( + reaction, _ = ReactionModel.objects.get_or_create( reaction_type=reaction_type, product_compound=product, ) @@ -610,7 +616,7 @@ def ingest_syndirella_routes( print('reactant smiles', reaction_struct['reactantSmiles']) for reactant_s in reaction_struct['reactantSmiles']: reactant_comp, _ = CompoundService.create(smiles=reactant_s) - reactant, _ = Reactant.objects.get_or_create( + reactant, _ = ReactantModel.objects.get_or_create( compound=reactant_comp, reaction=reaction, ) @@ -665,7 +671,7 @@ def ingest_syndirella_elabs( cls, *, df: pd.DataFrame, - target: Target, + target: TargetModel, reject_flags: list[str], pose_tag_list: list[str], product_tag_list: list[str], @@ -673,8 +679,8 @@ def ingest_syndirella_elabs( max_distance_score: float, require_intra_geometry_pass: bool, register_reactions: bool, - scaffold_route: RouteObj | None = None, - scaffold_compound: Compound | None = None, + scaffold_route: Route | None = None, + scaffold_compound: CompoundModel | None = None, ) -> pd.DataFrame: # work out number of reaction steps @@ -739,7 +745,7 @@ def ingest_syndirella_elabs( (inspiration_set,) = inspiration_sets - inspirations = Pose.objects.filter( + inspirations = PoseModel.objects.filter( pose_alias__in=inspiration_set, target=target, ) @@ -760,7 +766,7 @@ def ingest_syndirella_elabs( # reference = self.poses[base_name] # TODO: error handling - reference = Pose.objects.get( + reference = PoseModel.objects.get( pose_alias=base_name, target=target, ) @@ -865,7 +871,7 @@ def ingest_syndirella_elabs( # get associated IDs compound_inchikey_id_dict = { k.compound_inchikey: k.pk - for k in Compound.objects.filter(compound_smiles__in=unique_smiles) + for k in CompoundModel.objects.filter(compound_smiles__in=unique_smiles) } df[compound_id_col] = df[inchikey_col].apply( lambda x: compound_inchikey_id_dict.get(x) @@ -927,7 +933,7 @@ def ingest_syndirella_elabs( # tag product compounds: product_ids = list(df[f'{num_steps}_product_compound_id'].dropna().unique()) - products = Compound.objects.filter(pk__in=product_ids) + products = CompoundModel.objects.filter(pk__in=product_ids) product_tags = CompoundTagService.tags_from_list(product_tag_list) for compound in products: compound.tags.add(*product_tags) @@ -969,9 +975,9 @@ def ingest_syndirella_elabs( # comp service? for superstructure_id in superstructure_ids: - base = Compound.objects.get(pk=scaffold_id) - superstructure = Compound.objects.get(pk=int(superstructure_id)) - Scaffold.objects.get_or_create( + base = CompoundModel.objects.get(pk=scaffold_id) + superstructure = CompoundModel.objects.get(pk=int(superstructure_id)) + ScaffoldModel.objects.get_or_create( base_compound=base, superstructure_compound=superstructure, ) @@ -1047,18 +1053,18 @@ def ingest_syndirella_elabs( mrich.warning('No valid poses') return None - poses = Pose.objects.filter(pk__in=pose_ids) + poses = PoseModel.objects.filter(pk__in=pose_ids) mrich.success('Registered', poses.count(), 'new poses') # query relevant poses (also previously registered) paths = poses.values_list('path', flat=True) # what the hell is this?? - records = Pose.objects.filter( + records = PoseModel.objects.filter( path__in=paths, ) for pose in records: - # pose.inspirations.add(*Pose.objects.filter(pk__in=inspiration.ids)) + # pose.inspirations.add(*PoseModel.objects.filter(pk__in=inspiration.ids)) pose.inspirations.add(*inspirations.queryset) # if pose_tags: diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index 0e3e382..27f146d 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -8,7 +8,7 @@ import pandas as pd import rdkit # from rdkit.Chem import inchi -from designdb.models import Compound, Pose, PoseTag, Target +from designdb.models import CompoundModel, PoseModel, PoseTagModel, TargetModel from designdb.utils import normalize_string_list from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS from django.db.models import Q @@ -37,8 +37,8 @@ class PoseService: def create( cls, *, - compound: Compound, - target: Target, + compound: CompoundModel, + target: TargetModel, mol: Chem.rdchem.Mol, alias: str, path: str, @@ -49,7 +49,7 @@ def create( ): try: - pose = Pose.objects.get( + pose = PoseModel.objects.get( target=target, compound=compound, pose_alias=alias, @@ -59,8 +59,8 @@ def create( pose.metadata = metadata pose.save() created = False - except Pose.DoesNotExist: - pose = Pose( + except PoseModel.DoesNotExist: + pose = PoseModel( compound=compound, target=target, pose_alias=alias, @@ -90,9 +90,9 @@ def create_from_record( path: str, reference: int | None = None, ): - target = Target.objects.get(pk=target_id) - compound = Compound.objects.get(pk=compound_id) - pose, created = Pose.objects.get_or_create( + target = TargetModel.objects.get(pk=target_id) + compound = CompoundModel.objects.get(pk=compound_id) + pose, created = PoseModel.objects.get_or_create( compound=compound, target=target, pose_path=path, @@ -102,7 +102,7 @@ def create_from_record( # this is parsing input, maybe in ingestion? @staticmethod - def get_inspirations(*args, target: Target | None = None): + def get_inspirations(*args, target: TargetModel | None = None): parsed = [] for el in args: if isinstance(el, str): @@ -126,7 +126,7 @@ def get_inspirations(*args, target: Target | None = None): # assume string alias aliases.append(val) - qs = Pose.objects.filter( + qs = PoseModel.objects.filter( Q(pk__in=pks) | Q(pose_alias__in=aliases, target=target) ) @@ -139,13 +139,13 @@ def get_reference(reference, target) -> int: # should I check if exist here as well? except ValueError: try: - reference = Pose.objects.get( + reference = PoseModel.objects.get( pose_alias=reference, target=target, ).pk - except Pose.DoesNotExist as exp: - logger.error('Pose %s does not exist', reference) - raise Pose.DoesNotExist from exp + except PoseModel.DoesNotExist as exp: + logger.error('PoseModel %s does not exist', reference) + raise PoseModel.DoesNotExist from exp return reference @@ -170,11 +170,11 @@ def __init__(self, metadata_file: Path | str, other_tags: list[str] | None = Non def tags_from_list(tag_list: list[str]): assert tag_list is not None, '"None" passed as tag_list' - PoseTag.objects.bulk_create( - [PoseTag(pose_tag_name=k.strip()) for k in tag_list if k.strip()], + PoseTagModel.objects.bulk_create( + [PoseTagModel(pose_tag_name=k.strip()) for k in tag_list if k.strip()], ignore_conflicts=True, ) - tags = PoseTag.objects.filter(pose_tag_name__in=tag_list) + tags = PoseTagModel.objects.filter(pose_tag_name__in=tag_list) return tags # might be a good idea to break meta and tags apart @@ -183,7 +183,7 @@ def tags_and_meta( *, code: str, longcode: str, - ) -> tuple[list[PoseTag], dict[str, str]]: + ) -> tuple[list[PoseTagModel], dict[str, str]]: meta_row = self._df[self._df['Code'] == code] if not len(meta_row): meta_row = self._df[self._df['Long code'] == longcode] diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py index 81cb61b..37abf33 100644 --- a/hippo/designdb/services/reaction.py +++ b/hippo/designdb/services/reaction.py @@ -3,7 +3,7 @@ import mrich # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi -from designdb.models import Compound, Reactant, Reaction +from designdb.models import CompoundModel, ReactantModel, ReactionModel logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ def create_from_lists( non_duplicates = {} # not entirely sure how the original query was meant to work - qs = Reactant.objects.filter(compound__pk__in=product_ids) + qs = ReactantModel.objects.filter(compound__pk__in=product_ids) existing = {} for r in qs: reaction_type = r.reaction.reaction_type @@ -72,10 +72,10 @@ def create_from_lists( return None for reaction_type, product_id in non_duplicates.keys(): - compound = Compound.objects.get(pk=product_id) + compound = CompoundModel.objects.get(pk=product_id) # if I understand the original procedure correctly, it # should have already weeded out the duplicates - reaction, _ = Reaction.objects.get_or_create( + reaction, _ = ReactionModel.objects.get_or_create( reaction_type=reaction_type, product_compound=compound, reaction_product_yield=1.0, @@ -90,17 +90,17 @@ def create_from_lists( payload.append((reaction_id, reactant_id)) for reaction_id, reactant_id in payload: - reaction = Reaction.objects.get(pk=reaction_id) - compound = Compound.objects.get(pk=reactant_id) - reaction, _ = Reactant.objects.get_or_create( + reaction = ReactionModel.objects.get(pk=reaction_id) + compound = CompoundModel.objects.get(pk=reactant_id) + reaction, _ = ReactantModel.objects.get_or_create( reaction=reaction, compound=compound, reactant_amount=1.0, ) # delete orphaned reactions, srsly?? - Reaction.objects.filter( - pk__in=Reactant.objects.filter( + ReactionModel.objects.filter( + pk__in=ReactantModel.objects.filter( compound__isnull=True, ).values('reaction'), ).delete() diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py index 52d74fe..17afdd3 100644 --- a/hippo/designdb/services/route.py +++ b/hippo/designdb/services/route.py @@ -1,8 +1,8 @@ # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi -from designdb.models import Component, Route -from designdb.recipe import Recipe +from designdb.components.recipe import Recipe +from designdb.models import ComponentModel, RouteModel class RouteService: @@ -11,9 +11,9 @@ def create_from_recipe( cls, *, recipe: Recipe, - ) -> tuple[Route, bool]: + ) -> tuple[RouteModel, bool]: - route, created = Route.objects.get_or_create( + route, created = RouteModel.objects.get_or_create( product_compound=recipe.product.compound ) @@ -24,7 +24,7 @@ def create_from_recipe( components = [] components.extend( [ - Component(route=route, component_type=1, component_ref=ref.pk) + ComponentModel(route=route, component_type=1, component_ref=ref.pk) for ref in recipe.reactions ], ) @@ -40,7 +40,7 @@ def create_from_recipe( components.extend( [ - Component( + ComponentModel( route=route, component_type=1, component_ref=ref, @@ -58,7 +58,7 @@ def create_from_recipe( components.extend( [ - Component( + ComponentModel( route=route, component_type=1, component_ref=ref, @@ -68,7 +68,7 @@ def create_from_recipe( ], ) - Component.objects.bulk_create(components, ignore_conflicts=True) + ComponentModel.objects.bulk_create(components, ignore_conflicts=True) return route, created diff --git a/hippo/designdb/services/score.py b/hippo/designdb/services/score.py index fbadeb7..7eff26b 100644 --- a/hippo/designdb/services/score.py +++ b/hippo/designdb/services/score.py @@ -3,7 +3,7 @@ # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi -from designdb.models import Pose, ScoreValue, ScoringMethod +from designdb.models import PoseModel, ScoreValueModel, ScoringMethodModel # from .validation.compound import ValidationError, validate_compound_data @@ -33,7 +33,7 @@ def __init__(self, scoring_method_list: list[str] | None = None): # if self._scoring_method_list: # for m in self._scoring_method_list: - # sm, _ = ScoringMethod.objects.get_or_create( + # sm, _ = ScoringMethodModel.objects.get_or_create( # method_name=m, # ) # self._score_map[sm.method_name] = sm @@ -41,7 +41,7 @@ def __init__(self, scoring_method_list: list[str] | None = None): def add_scores_from_record( self, *, - pose: Pose, + pose: PoseModel, record: dict[str, str | float], ): @@ -53,11 +53,11 @@ def add_scores_from_record( method = self.scoring_methods[method_name] except KeyError: # there's so many more fields, should I really be creating them? - method, _ = ScoringMethod.objects.get_or_create( + method, _ = ScoringMethodModel.objects.get_or_create( method_name=method_name, ) - score = ScoreValue( + score = ScoreValueModel( pose=pose, compound=pose.compound, scoring_method=method, @@ -70,5 +70,5 @@ def add_scores_from_record( # pass @property - def scoring_methods(self) -> dict[str, ScoringMethod]: + def scoring_methods(self) -> dict[str, ScoringMethodModel]: return self._scoring_method_cache diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index 6933275..44323f0 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -5,16 +5,16 @@ import mcol import mrich import pandas as pd -from designdb.ingredient import Ingredient +from designdb.components.compound import Ingredient +from designdb.components.price import Price from designdb.models import ( - CataloguePrice, - Compound, - CompoundTag, - CompoundTagJunction, - Reactant, - Reaction, + CataloguePriceModel, + CompoundModel, + CompoundTagJunctionModel, + CompoundTagModel, + ReactantModel, + ReactionModel, ) -from designdb.price import Price from django.db.models import Exists, OuterRef, Q from pandas import DataFrame, concat, isna from rdkit import Chem @@ -32,7 +32,7 @@ class CompoundSet: Use as an iterable ================== - Iterate through :class:`.Compound` objects in the set: + Iterate through :class:`.CompoundModel` objects in the set: :: @@ -44,7 +44,7 @@ class CompoundSet: Check membership ================ - To determine if a :class:`.Compound` is present in the set: + To determine if a :class:`.CompoundModel` is present in the set: :: @@ -87,11 +87,11 @@ def __init__( if queryset: if isinstance(queryset, list): - self._queryset = Compound.objects.filter(pk__in=queryset) + self._queryset = CompoundModel.objects.filter(pk__in=queryset) else: self._queryset = queryset else: - self._queryset = Compound.objects.none() + self._queryset = CompoundModel.objects.none() if sort: self._queryset = self._queryset.order_by('pk') @@ -111,7 +111,7 @@ def __iter__(self): def __getitem__( self, key: int | slice, - ) -> 'Compound | CompoundSet': + ) -> 'CompoundModel | CompoundSet': """Get compounds or subsets thereof from this set :param key: integer index or slice of indices @@ -121,46 +121,46 @@ def __getitem__( case int(): index = self.indices[key] try: - return Compound.objects.get(id=index) - except Compound.DoesNotExist: - raise Compound.DoesNotExist from exc + return CompoundModel.objects.get(id=index) + except CompoundModel.DoesNotExist: + raise CompoundModel.DoesNotExist from exc case slice(): - return CompoundSet(Compound.objects.filter(pk__in=key)) + return CompoundSet(CompoundModel.objects.filter(pk__in=key)) case _: raise NotImplementedError def __sub__( self, - other: 'Compound | CompoundSet | IngredientSet', + other: 'CompoundModel | CompoundSet | IngredientSet', ) -> 'CompoundSet': - """Subtract a :class:`.Compound` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + """Subtract a :class:`.CompoundModel` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" match other: case CompoundSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) ), sort=False, ) case int(): return CompoundSet( - Compound.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + CompoundModel.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), sort=False, ) def __add__( self, - other: 'Compound | CompoundSet | IngredientSet | int', + other: 'CompoundModel | CompoundSet | IngredientSet | int', ) -> 'CompoundSet': - """Add a :class:`.Compound` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + """Add a :class:`.CompoundModel` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" match other: - case Compound(): + case CompoundModel(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other._queryset) ), sort=False, @@ -168,7 +168,7 @@ def __add__( case int(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other._queryset) ), sort=False, @@ -176,7 +176,7 @@ def __add__( case CompoundSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other._queryset) ), sort=False, @@ -184,7 +184,7 @@ def __add__( case IngredientSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other._queryset) ), sort=False, @@ -199,7 +199,7 @@ def __and__(self, other: 'CompoundSet'): match other: case CompoundSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) & Q(pk__in=other.queryset) ), sort=False, @@ -214,7 +214,7 @@ def __or__(self, other: 'CompoundSet'): match other: case CompoundSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other.queryset) ), sort=False, @@ -229,7 +229,7 @@ def __xor__(self, other: 'CompoundSet'): match other: case CompoundSet(): return CompoundSet( - Compound.objects.filter( + CompoundModel.objects.filter( Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) ), @@ -259,10 +259,10 @@ def __rich__(self) -> str: """Representation for mrich""" return f'[bold underline]{self}' - def __contains__(self, other: Compound | int): + def __contains__(self, other: CompoundModel | int): """Check if compound or ingredient is a member of this set""" match other: - case Compound(): + case CompoundModel(): ik = other.pk case int(): pk = other @@ -280,7 +280,7 @@ def get_by_tag( self._queryset = self._queryset.annotate( has_tag=Exists( - CompoundTagJunction.objects.filter( + CompoundTagJunctionModel.objects.filter( pose=OuterRef('pk'), pose_tag__pose_tag_name=tag, ), @@ -302,18 +302,18 @@ def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': if value: q = Q(compound_metadata__key=value) - qs = Compound.objects.filter(q) + qs = CompoundModel.objects.filter(q) return CompoundSet(qs) def get_by_scaffold( self, - scaffold: Compound | int, + scaffold: CompoundModel | int, none: str = 'error', ) -> 'CompoundSet': """Get all compounds that elaborate the given scaffold compound - :param scaffold: :class:`.Compound` object or ID to search by + :param scaffold: :class:`.CompoundModel` object or ID to search by """ @@ -344,8 +344,8 @@ def get_all_possible_reactants( """ - qs = Compound.objects.filter( - pk__in=Reactant.objects.filter( + qs = CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( reaction__in=self._queryset, ), ) @@ -356,8 +356,8 @@ def get_all_possible_reactants( while frontier: new = ( set( - Compound.objects.filter( - pk__in=Reactant.objects.filter( + CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( reaction__in=self._queryset, ), ).values_list('pk', flat=True) @@ -368,7 +368,7 @@ def get_all_possible_reactants( seen |= new frontier = new - return CompoundSet(Compound.objects.filter(pk__in=seen)) + return CompoundSet(CompoundModel.objects.filter(pk__in=seen)) def get_all_possible_reactions( self, @@ -379,8 +379,8 @@ def get_all_possible_reactions( :param debug: Increased verbosity for debugging (Default value = False) """ - qs = Compound.objects.filter( - pk__in=Reactant.objects.filter( + qs = CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( reaction__in=self._queryset, ), ) @@ -391,8 +391,8 @@ def get_all_possible_reactions( while frontier: new = ( set( - Compound.objects.filter( - pk__in=Reactant.objects.filter( + CompoundModel.objects.filter( + pk__in=ReactantModel.objects.filter( reaction__in=self._queryset, ), ).values_list('pk', flat=True) @@ -403,7 +403,7 @@ def get_all_possible_reactions( seen |= new frontier = new - return Reaction.objects.filter(product__compound__in=seen) + return ReactionModel.objects.filter(product__compound__in=seen) def get_risk_diversity(self, debug: bool = False) -> float: """Calculate the average spread of risk (#atoms added) for each scaffold in this set @@ -456,7 +456,7 @@ def count_by_tag( """ return self._queryset.annotate( has_tag=Exists( - CompoundTag.objects.filter( + CompoundTagModel.objects.filter( compound=OuterRef('pk'), compound_tag__compound_tag_name=tag, ), @@ -755,7 +755,7 @@ def get_recipes( """ # avoiding circular imports - from designdb.recipe import Recipe + from designdb.components.recipe import Recipe return Recipe.from_compounds( self, @@ -847,7 +847,7 @@ def get_routes( for (route_id,) in mrich.track(records, prefix='Getting routes') ] - from .recipe import RouteSet + from .route import RouteSet return RouteSet(self.db, routes) @@ -861,7 +861,7 @@ def shuffled(self) -> 'CompoundSet': copy.shuffle() return copy - def pop(self) -> Compound: + def pop(self) -> CompoundModel: """Pop the last compound in this set""" c_id = self.pop_id() return self.db.get_compound(id=c_id) @@ -1272,7 +1272,7 @@ def write_CAR_csv( """ # avoiding circular imports - from designdb.recipe import Recipe + from designdb.components.recipe import Recipe file = str(Path(file).resolve()) @@ -1458,8 +1458,6 @@ def register_missing_routes( """Calculate missing routes to compounds in this set""" if missing_only: - from .cset import CompoundSet - records = self.db.select_where( table='route', key=f'route_product IN {self.str_ids}', @@ -1585,7 +1583,7 @@ def num_poses(self) -> int: @property def poses(self) -> 'PoseSet': """Get the poses associated to this set of compounds""" - from .pset import PoseSet + from .pose import PoseSet ids = self.db.select_where( query='pose_id', @@ -1604,7 +1602,7 @@ def poses(self) -> 'PoseSet': @property def best_placed_poses(self) -> 'PoseSet': """Get the best placed pose for each compound in this set""" - from .pset import PoseSet + from .pose import PoseSet query = self.db.select_where( table='pose', @@ -1617,7 +1615,7 @@ def best_placed_poses(self) -> 'PoseSet': @property def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" return str(tuple(self.ids)).replace(',)', ')') @property @@ -1760,7 +1758,7 @@ def scaffolds(self) -> 'CompoundSet': @property def scaffold_ids(self) -> list[int]: - """Return a list of :class:`.Compound` ID's for scaffolds of this set""" + """Return a list of :class:`.CompoundModel` ID's for scaffolds of this set""" scaffold_ids = self.db.execute( f""" SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold @@ -1796,8 +1794,6 @@ def elabs(self) -> 'CompoundSet': return None ids = [q for (q,) in ids] - from .cset import CompoundSet - return CompoundSet(self.db, ids) @property @@ -1865,7 +1861,7 @@ def _db_changed(self) -> bool: @property def reaction_ids(self) -> list[int]: - """Returns a list of :class:`.Reaction` IDs that result in members of this set""" + """Returns a list of :class:`.ReactionModel` IDs that result in members of this set""" records = self.db.select_where( table='reaction', query='reaction_id', @@ -1878,7 +1874,7 @@ def reaction_ids(self) -> list[int]: class IngredientSet: - """An :class:`.Ingredient` is a :class:`.Compound` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. + """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. .. attention:: @@ -1893,7 +1889,7 @@ class IngredientSet: ingredient = ingredient_set[0] # first ingredient - To get the ingredient for a specific :class:`.Compound` ID: + To get the ingredient for a specific :class:`.CompoundModel` ID: :: @@ -2018,10 +2014,10 @@ def __getattr__(self, key: str): """For missing attributes try getting from associated :class:`.CompoundSet`""" return getattr(self.compounds, key) - def __contains__(self, other: Compound | Ingredient | int): + def __contains__(self, other: CompoundModel | Ingredient | int): """Check if compound or ingredient is a member of this set""" match other: - case Compound(): + case CompoundModel(): id = other.id case Ingredient(): id = other.compound_id @@ -2111,7 +2107,7 @@ def from_compounds( """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) - :param ids: Compound IDs (Default value = None) + :param ids: CompoundModel IDs (Default value = None) :param db: HIPPO Database (Default value = None) :param amount: Amount(s) in ``mg`` (Default value = 1) :param supplier: supplier to use for all quoting, (Default value = ``None``) @@ -2154,7 +2150,7 @@ def get_price( mrich.debug('quote_ids', quote_ids) if quote_ids: - qs = CataloguePrice.objects.filter(pk__in=quote_ids) + qs = CataloguePriceModel.objects.filter(pk__in=quote_ids) if supplier: qs = qs.filter(quote_supplier=supplier) @@ -2234,7 +2230,7 @@ def add( """Add an :class:`.Ingredient` to this set :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify other parameters, (Default value = None) - :param compound_id: :class:`.Compound` ID (Default value = None) + :param compound_id: :class:`.CompoundModel` ID (Default value = None) :param amount: amount in ``mg`` (Default value = None) :param quote_id: :class:`.Quote` ID (Default value = None) :param supplier: supplier name string or list (Default value = None) @@ -2345,7 +2341,7 @@ def _get_ingredient( q_id = None return Ingredient( - compound=Compound.objects.get(pk=series['compound_id']), + compound=CompoundModel.objects.get(pk=series['compound_id']), amount=series['amount'], quote=q_id, supplier=series['supplier'], @@ -2392,7 +2388,7 @@ def set_amounts( # """ # ).fetchall() - qs = CataloguePrice.objects.filter( + qs = CataloguePriceModel.objects.filter( compound__pk__in=self.compound_ids, quote_amount__gte=amount, ) @@ -2464,7 +2460,7 @@ def supplier(self, s): def smiles(self) -> list[str]: """SMILES for all ingredients""" compound_ids = list(self.df['compound_id']) - return Compound.objects.filter( + return CompoundModel.objects.filter( pk__in=compound_ids, ).values_list('compound_smiles', flat=True) @@ -2472,18 +2468,18 @@ def smiles(self) -> list[str]: def inchikeys(self) -> list[str]: """InChI-keys for all ingredients""" compound_ids = list(self.df['compound_id']) - return Compound.objects.filter( + return CompoundModel.objects.filter( pk__in=compound_ids, ).values_list('compound_inchikeys', flat=True) @property def compound_ids(self) -> list[int]: - """Compound IDs for all ingredients""" + """CompoundModel IDs for all ingredients""" return list(self.df['compound_id'].values) @property def ids(self) -> list[int]: - """Compound IDs for all ingredients""" + """CompoundModel IDs for all ingredients""" return self.compound_ids @property @@ -2495,7 +2491,7 @@ def id_amount_pairs(self) -> list[tuple]: @property def str_compound_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Compound` IDs""" + """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" return str(tuple(self.df['compound_id'].values)).replace(',)', ')') @property diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index 7b2f6f7..abd1c9c 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -2,11 +2,11 @@ import mcol import mrich -from designdb.models import Interaction +from designdb.models import InteractionModel class InteractionTable: - """Class representing all :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. + """Class representing all :class:`.InteractionModel` objects in the 'interaction' table of the :class:`.Database`. .. attention:: @@ -70,11 +70,11 @@ def __rich__(self) -> str: class InteractionSet: - """Class representing a subset of the :class:`.Interaction` objects in the 'interaction' table of the :class:`.Database`. + """Class representing a subset of the :class:`.InteractionModel` objects in the 'interaction' table of the :class:`.Database`. .. attention:: - :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.Pose.interactions`, or :meth:`.PoseSet.interactions` methods. + :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.PoseModel.interactions`, or :meth:`.PoseSet.interactions` methods. """ @@ -93,22 +93,22 @@ def __init__( self._indices = sorted(list(set(indices))) self._df = None - self._qs = Interaction.objects.filter(pk__in=indices) + self._qs = InteractionModel.objects.filter(pk__in=indices) ### FACTORIES @classmethod def from_pose( cls, - pose: 'Pose | PoseSet', + pose: 'PoseModel | PoseSet', table: str = 'interaction', db: 'Database | None' = None, ) -> 'InteractionSet': """Construct a :class:`.InteractionSet` from one or more poses. - :param pose: a :class:`.Pose` or :class:`.PoseSet` object + :param pose: a :class:`.PoseModel` or :class:`.PoseSet` object :param table: Database table name - :param db: Use this instead of Pose's Database + :param db: Use this instead of PoseModel's Database :returns: an :class:`.InteractionSet` """ @@ -118,7 +118,7 @@ def from_pose( ### get the ID's - from .pset import PoseSet + from .pose import PoseSet if isinstance(pose, PoseSet): # check if all poses have fingerprints @@ -161,7 +161,7 @@ def all( """ # bit of a round-trip - ids = Interaction.objects.values_list('pk', flat=True) + ids = InteractionModel.objects.values_list('pk', flat=True) self = cls.__new__(cls) self.__init__(ids) @@ -173,22 +173,22 @@ def from_residue( db: 'Database', residue_number: int, chain: None | str = None, - target: 'Target | int' = 1, + target: 'TargetModel | int' = 1, ) -> 'InteractionSet': """Get the set of interactions for a given residue number (and chain) :param db: HIPPO :class:`.Database` :param residue_number: the residue number :param chain: the chain name / letter, defaults to any chain - :param target: the protein :class:`.Target` object or ID, defaults to first target in database + :param target: the protein :class:`.TargetModel` object or ID, defaults to first target in database :returns: a :class:`.InteractionSet` object """ - from .target import Target + from designdb.models import TargetModel self = cls.__new__(cls) - if isinstance(target, Target): + if isinstance(target, TargetModel): target = target.id sql = f""" @@ -245,12 +245,12 @@ def table(self) -> str: @property def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Interaction` IDs""" + """Return an SQL formatted tuple string of the :class:`.InteractionModel` IDs""" return str(tuple(self.ids)).replace(',)', ')') @property def feature_ids(self) -> list[int]: - """Return a list of :class:`.Feature` ID's""" + """Return a list of :class:`.FeatureModel` ID's""" records = self.db.select_where( query='DISTINCT interaction_feature', table=self.table, @@ -261,7 +261,7 @@ def feature_ids(self) -> list[int]: @property def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" + """Classic HIPPO fingerprint dictionary, mapping protein :class:`.FeatureModel` ID's to the number of corresponding ligand features (from any :class:`.PoseModel`)""" return self.get_classic_fingerprint() @property @@ -381,7 +381,7 @@ def type_residue_number_chain_triples(self) -> list[tuple]: @property def num_features(self) -> int: - """Count the funmber of protein :class:`.Feature`s with which interactions are formed""" + """Count the funmber of protein :class:`.FeatureModel`s with which interactions are formed""" (count,) = self.db.execute( f""" @@ -394,7 +394,7 @@ def num_features(self) -> int: @property def avg_num_interactions_per_feature(self) -> float: - """Average number of interactions formed with each protein :class:`.Feature`""" + """Average number of interactions formed with each protein :class:`.FeatureModel`""" (count,) = self.db.execute( f""" @@ -413,7 +413,7 @@ def avg_num_interactions_per_feature(self) -> float: @property def per_feature_count_hirsch(self) -> float: - """A measure for how evenly protein :class:`.Feature`s are being interacted with""" + """A measure for how evenly protein :class:`.FeatureModel`s are being interacted with""" counts = self.db.execute( f""" @@ -456,7 +456,7 @@ def summary( mrich.var(s, f'{interaction.distance:.1f}', 'Å') def get_classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.Feature` ID's to the number of corresponding ligand features (from any :class:`.Pose`)""" + """Classic HIPPO fingerprint dictionary, mapping protein :class:`.FeatureModel` ID's to the number of corresponding ligand features (from any :class:`.PoseModel`)""" pairs = self.db.execute( f""" @@ -722,7 +722,7 @@ def __iter__(self): self.db.get_interaction(id=i, table=self.table) for i in self.indices ) - def __getitem__(self, key) -> 'Interaction | InteractionSet': + def __getitem__(self, key) -> 'InteractionModel | InteractionSet': """Get interaction or subsets thereof from this set""" match key: case int(): diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 3f396ec..4edd1a7 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -17,15 +17,15 @@ import networkx as nx import pandas as pd from designdb.models import ( - Compound, - Inspiration, - Interaction, - Pose, - PoseTag, - PoseTagJunction, - Subsite, - SubsiteTag, - Target, + CompoundModel, + InspirationModel, + InteractionModel, + PoseModel, + PoseTagJunctionModel, + PoseTagModel, + SubsiteModel, + SubsiteTagModel, + TargetModel, ) from designdb.sets.interaction import InteractionSet from designdb.utils import ScoreSubquery, normalize_string_list @@ -83,7 +83,7 @@ class PoseSet: Use as an iterable ================== - Iterate through :class:`.Pose` objects in the set: + Iterate through :class:`.PoseModel` objects in the set: :: @@ -95,7 +95,7 @@ class PoseSet: Check membership ================ - To determine if a :class:`.Pose` is present in the set: + To determine if a :class:`.PoseModel` is present in the set: :: @@ -133,7 +133,7 @@ def __init__( if queryset: self._queryset = queryset else: - self._queryset = Pose.objects.none() + self._queryset = PoseModel.objects.none() self._name = name if sort: @@ -174,7 +174,7 @@ def __iter__(self): def __getitem__( self, key: int | slice, - ) -> 'Pose | PoseSet': + ) -> 'PoseModel | PoseSet': """Get poses or subsets thereof from this set :param key: integer index or slice of indices @@ -183,15 +183,15 @@ def __getitem__( match key: case int(): try: - pose = Pose.objects.get(pk=key) - except Pose.DoesNotExist as exc: + pose = PoseModel.objects.get(pk=key) + except PoseModel.DoesNotExist as exc: mrich.error(f'list index out of range: {key=} for {self}') - raise Pose.DoesNotExist from exc + raise PoseModel.DoesNotExist from exc return pose case slice(): - return PoseSet(Pose.objects.filter(pk__in=key)) + return PoseSet(PoseModel.objects.filter(pk__in=key)) case _: raise NotImplementedError @@ -203,14 +203,14 @@ def __add__( """Add a :class:`.PoseSet` to this set""" if isinstance(other, PoseSet): return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other.queryset) ), sort=False, ) - elif isinstance(other, Pose): + elif isinstance(other, PoseModel): return PoseSet( - Pose.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), + PoseModel.objects.filter(Q(pk__in=self._queryset) | Q(pk=other.pk)), sort=False, ) else: @@ -224,14 +224,14 @@ def __sub__( match other: case PoseSet(): return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) ), sort=False, ) case int(): return PoseSet( - Pose.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + PoseModel.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), sort=False, ) @@ -241,7 +241,7 @@ def __and__(self, other: 'PoseSet'): match other: case PoseSet(): return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( Q(pk__in=self._queryset) & Q(pk__in=other.queryset) ), sort=False, @@ -256,7 +256,7 @@ def __or__(self, other: 'PoseSet'): match other: case PoseSet(): return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other.queryset) ), sort=False, @@ -271,7 +271,7 @@ def __xor__(self, other: 'PoseSet'): match other: case PoseSet(): return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( Q(Q(pk__in=self._queryset) | Q(pk__in=other.queryset)) & ~Q(Q(pk__in=self._queryset) & Q(pk__in=other.queryset)) ), @@ -288,29 +288,29 @@ def __call__( target: int = None, subsite: int = None, ) -> 'PoseSet': - """Filter poses by a given tag, Subsite ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" + """Filter poses by a given tag, SubsiteModel ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" if tag: return self.get_by_tag(tag) elif target: - return self.get_by_target(target=Target.objects.get(pk=target)) + return self.get_by_target(target=TargetModel.objects.get(pk=target)) elif subsite: - return self.get_by_subsite(subsite=Subsite.objects.get(pk=subsite)) + return self.get_by_subsite(subsite=SubsiteModel.objects.get(pk=subsite)) else: raise NotImplementedError @classmethod def get_by_references(cls, poseset: 'PoseSet') -> 'PoseSet': return PoseSet( - Pose.objects.filter(pk__in=poseset._queryset.values('pose_reference')) + PoseModel.objects.filter(pk__in=poseset._queryset.values('pose_reference')) ) # there's a method get_by_inspiration @classmethod def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': return PoseSet( - Pose.objects.filter( - pk__in=Inspiration.objects.filter( + PoseModel.objects.filter( + pk__in=InspirationModel.objects.filter( derivative_pose__in=self._queryset, ).values( 'original_pose', @@ -333,7 +333,7 @@ def get_by_tag( """ self._queryset = self._queryset.annotate( has_tag=Exists( - PoseTagJunction.objects.filter( + PoseTagJunctionModel.objects.filter( pose=OuterRef('pk'), pose_tag__pose_tag_name=tag, ), @@ -374,16 +374,16 @@ def get_by_metadata( self._queryset.filter(pose_metadata__contains=f'"{key}: {value}"'), ) - def get_by_inspiration(self, inspiration: Pose, inverse: bool = False): + def get_by_inspiration(self, inspiration: PoseModel, inverse: bool = False): """Get all child poses with with this inspiration. - :param inspiration: inspiration :class:`.Pose` ID or object + :param inspiration: inspiration :class:`.PoseModel` ID or object :param inverse: invert the selection (Default value = False) """ # not entirely sure which way the filtering should go qs = ( - Inspiration.objects.filter( + InspirationModel.objects.filter( derivative_pose=inspiration, ).values('original_pose'), ) @@ -425,9 +425,9 @@ def get_df( :param inchikey: include InChIKey column (Default value = True) :param alias: include alias column (Default value = True) :param name: include name column (Default value = True) - :param compound_id: include :class:`.Compound` ID column (Default value = False) - :param reference_id: include reference :class:`.Pose` ID column (Default value = False) - :param target_id: include reference :class:`.Target` ID column (Default value = False) + :param compound_id: include :class:`.CompoundModel` ID column (Default value = False) + :param reference_id: include reference :class:`.PoseModel` ID column (Default value = False) + :param target_id: include reference :class:`.TargetModel` ID column (Default value = False) :param path: include path column (Default value = False) :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) :param energy_score: include energy_score column (Default value = False) @@ -435,9 +435,9 @@ def get_df( :param inspiration_score: include inspiration_score column (Default value = False) :param metadata: include metadata in output (Default value = False) :param expand_metadata: create separate column for each metadata key (Default value = True) - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column - :param derivative_ids: include derivative :class:`.Pose` ID column + :param inspiration_ids: include inspiration :class:`.PoseModel` ID column + :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column + :param derivative_ids: include derivative :class:`.PoseModel` ID column :param tags: include tags column :param subsites: include subsites column """ @@ -476,7 +476,7 @@ def get_df( 'reference_alias', 'reference_alias', Subquery( - Pose.objects.filter( + PoseModel.objects.filter( pk=OuterRef('pose_reference'), ).values('pose_alias')[0:1] ), @@ -600,7 +600,7 @@ def get_by_reference( ) -> 'PoseSet | None': """Get poses with a certain reference id - :param ref_id: reference :class:`.Pose` ID + :param ref_id: reference :class:`.PoseModel` ID """ qs = self._queryset.filter(pose_reference=ref_id) @@ -613,17 +613,17 @@ def get_by_reference( def get_by_compound( self, *, - compound: 'int | Compound | CompoundSet', + compound: 'int | CompoundModel | CompoundSet', ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Compound`. + """Select a subset of this :class:`.PoseSet` by the associated :class:`.CompoundModel`. - :param compound: :class:`.Compound` object or ID + :param compound: :class:`.CompoundModel` object or ID :returns: a :class:`.PoseSet` of the selection """ if isinstance(compound, int): return PoseSet(self._queryset.filter(compound__id=compound)) - elif isinstance(compound, Compound): + elif isinstance(compound, CompoundModel): return PoseSet(self._queryset.filter(compound=compound)) else: # possible crash point: assuming CompoundSet but not @@ -634,11 +634,11 @@ def get_by_compound( def get_by_target( self, *, - target: Target, + target: TargetModel, ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Target`. + """Select a subset of this :class:`.PoseSet` by the associated :class:`.TargetModel`. - :param id: :class:`.Target` ID + :param id: :class:`.TargetModel` ID :returns: a :class:`.PoseSet` of the selection """ @@ -649,16 +649,16 @@ def get_by_target( def get_by_subsite( self, *, - subsite: Subsite, + subsite: SubsiteModel, ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.Subsite`. + """Select a subset of this :class:`.PoseSet` by the associated :class:`.SubsiteModel`. - :param id: :class:`.Subsite` ID + :param id: :class:`.SubsiteModel` ID :returns: a :class:`.PoseSet` of the selection """ qs = self._queryset.filter( - id__in=SubsiteTag.objects.filter( + id__in=SubsiteTagModel.objects.filter( subsite=subsite, ).values('pose'), ) @@ -737,18 +737,18 @@ def add_tag( assert isinstance(tag, str) - pose_tag = PoseTag(pose_tag_name=tag) + pose_tag = PoseTagModel(pose_tag_name=tag) pose_tag.save() - PoseTagJunction.objects.bulk_create( - [PoseTagJunction(pose=pose, pose_tag=pose_tag) for pose in self._queryset], + PoseTagJunctionModel.objects.bulk_create( + [PoseTagJunctionModel(pose=pose, pose_tag=pose_tag) for pose in self._queryset], ignore_conflicts=True, ) mrich.print(f'Tagged {self} w/ "{tag}"') # refetch in case was evaluated - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) # NB! I'm now realizing this is potentially a huge # problem. with every evaluation and refretch some attributes @@ -775,7 +775,7 @@ def append_to_metadata( mrich.error(f'Could not append to metadata {key=}. Not a list?') pose.save() - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: """Create and assign subsite entries from a metadata field @@ -793,11 +793,11 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: # I'm still not entirely clear can you really have # posesets from different target, if not, and it really # seems that not, this should be a single subsite - subsite, _ = Subsite.get_or_create(target=pose.target, subsite_name=key) - subsite_tag = SubsiteTag(subsite=subsite, pose=pose) + subsite, _ = SubsiteModel.get_or_create(target=pose.target, subsite_name=key) + subsite_tag = SubsiteTagModel(subsite=subsite, pose=pose) subsite_tag.save() - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) # TODO: implement scores # def calculate_inspiration_scores( @@ -866,7 +866,7 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: def split_by_reference(self) -> 'dict[int,PoseSet]': """Split this :class:`.PoseSet` into subsets grouped by reference ID - :returns: a dictionary with reference :class:`.Pose` IDs as keys and :class:`.PoseSet` subsets as values + :returns: a dictionary with reference :class:`.PoseModel` IDs as keys and :class:`.PoseSet` subsets as values """ sets = {} @@ -897,17 +897,17 @@ def split_by_inspirations( if single_set: return PoseSet( - Pose.objects.filter( + PoseModel.objects.filter( pk__in=[id for s in sets.values() for id in s.ids], sort=False, ) ) - self._queryset = Pose.objects.filter(pk__in=self._queryset.values('pk')) + self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) return { - PoseSet(Pose.objects.filter(pk__in=insp_ids)): PoseSet( - Pose.objects.filter(pk__in=pose_ids) + PoseSet(PoseModel.objects.filter(pk__in=insp_ids)): PoseSet( + PoseModel.objects.filter(pk__in=pose_ids) ) for insp_ids, pose_ids in sets.items() } @@ -926,8 +926,8 @@ def write_sdf( :param out_path: filepath of the output :param name_col: pose property to use as the name column, can be ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') - :param inspiration_ids: include inspiration :class:`.Pose` ID column - :param inspiration_aliases: include inspiration :class:`.Pose` alias column + :param inspiration_ids: include inspiration :class:`.PoseModel` ID column + :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column :param fragalysis_inspirations: create inspirations column "ref_mols" """ @@ -1045,7 +1045,7 @@ def to_fragalysis( if not poses: poses = self - values = Inspiration.objects.filter( + values = InspirationModel.objects.filter( derivative_pose__in=self._queryset, ).values( 'derivative_pose', @@ -1056,7 +1056,7 @@ def to_fragalysis( logger.warning('no inspirations, quitting') return - poses = PoseSet(Pose.objects.filter(pk__in=values)) + poses = PoseSet(PoseModel.objects.filter(pk__in=values)) mrich.debug(len(poses), 'remaining after skipping null inspirations') @@ -1071,7 +1071,7 @@ def to_fragalysis( # TODO: this should not go through the df # Scope issue - this code expect access to all poses in the db - self._queryset = Pose.objects.all() + self._queryset = PoseModel.objects.all() pose_df = poses.get_df( mol=True, @@ -1150,8 +1150,8 @@ def fix_subsites(subsite_list: list[str]): pose_df.rename( inplace=True, columns={ - 'id': 'HIPPO Pose ID', - 'compound_id': 'HIPPO Compound ID', + 'id': 'HIPPO PoseModel ID', + 'compound_id': 'HIPPO CompoundModel ID', 'mol': mol_col, # "smiles": "original SMILES", # "compound_id": "compound inchikey", @@ -1159,8 +1159,8 @@ def fix_subsites(subsite_list: list[str]): ) extras = { - 'HIPPO Pose ID': 'HIPPO Pose ID', - 'HIPPO Compound ID': 'HIPPO Compound ID', + 'HIPPO PoseModel ID': 'HIPPO PoseModel ID', + 'HIPPO CompoundModel ID': 'HIPPO CompoundModel ID', 'smiles': 'smiles', 'ref_pdb': 'protein reference', 'ref_mols': 'fragment inspirations', @@ -1366,7 +1366,7 @@ def to_pymol(self, prefix: str | None = None) -> None: from pathlib import Path for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): - ref_pose = Pose.objects.get(id=ref_id) + ref_pose = PoseModel.objects.get(id=ref_id) ref_name = ref_pose.pose_alias or ref_id # create the subdirectory @@ -1550,7 +1550,7 @@ def to_syndirella( templates = df['template'].unique() # records = self._queryset.filter(pose_alias__in=templates) - records = Pose.objects.filter( + records = PoseModel.objects.filter( target__in=self.targets, pose_alias__in=templates, ) @@ -1567,7 +1567,7 @@ def to_syndirella( print('all inspirations', all_inspirations) # records = self._queryset.filter(pose_alias__in=all_inspirations) # isn't this overwriting the one few lines above?? - records = Pose.objects.filter( + records = PoseModel.objects.filter( target__in=self.targets, pose_alias__in=all_inspirations ) @@ -1609,8 +1609,8 @@ def interactive( ): """Interactive widget to navigate compounds in the table - :param print_name: print the :class:`.Pose` name (Default value = True) - :param method: pass the name of a :class:`.Pose` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) + :param print_name: print the :class:`.PoseModel` name (Default value = True) + :param method: pass the name of a :class:`.PoseModel` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) :param function: pass a callable which will be called as `function(pose)` """ @@ -1633,7 +1633,7 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description='Pose:', + description='PoseModel:', disabled=False, ), ) @@ -1654,7 +1654,7 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description='Pose:', + description='PoseModel:', disabled=False, ), ) @@ -1665,7 +1665,7 @@ def widget(i): min=0, max=len(self) - 1, step=1, - description=f'Pose (/{len(self)}):', + description=f'PoseModel (/{len(self)}):', disabled=False, ) @@ -1674,7 +1674,7 @@ def widget(i): h = Checkbox(description='Tags', value=False) i = Checkbox(description='Subsites', value=False) d = Checkbox(description='2D (Comp.)', value=False) - e = Checkbox(description='2D (Pose)', value=False) + e = Checkbox(description='2D (PoseModel)', value=False) f = Checkbox(description='3D', value=True) g = Checkbox(description='Metadata', value=False) @@ -1793,7 +1793,7 @@ def grid(self) -> None: def get_interaction_overlaps(self, return_pairs: bool = False) -> int: """Count the number of member pose pairs which share at least one but not all interactions""" - records = Interaction.objects.filter( + records = InteractionModel.objects.filter( pose__in=self._queryset, ).values( 'pose', @@ -1829,7 +1829,7 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: pairs.add((pose_j, pose_k)) if return_pairs: - return [PoseSet(Pose.objects.filter(pk__in[a, b])) for a, b in pairs] + return [PoseSet(PoseModel.objects.filter(pk__in[a, b])) for a, b in pairs] return count @@ -1847,7 +1847,7 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': """ records = self.db.execute(sql).fetchall() - records = Interaction.objects.filter( + records = InteractionModel.objects.filter( pose__in=self._queryset, ).values( 'pose', @@ -1895,7 +1895,7 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': # create the PoseSets psets = { - i: PoseSet(Pose.objects.filter(pk__in=ids), name=f'Cluster {i}') + i: PoseSet(PoseModel.objects.filter(pk__in=ids), name=f'Cluster {i}') for i, ids in enumerate(clusters.values()) } @@ -1925,7 +1925,7 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': # unclustered unclustered = set(i for i in self.ids if i not in all_ids) psets[None] = PoseSet( - Pose.objects.filter(pk__in=unclustered), name='Unclustered' + PoseModel.objects.filter(pk__in=unclustered), name='Unclustered' ) return psets @@ -1933,7 +1933,7 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': ### PROPERTIES @property - def queryset(self) -> QuerySet[Pose]: + def queryset(self) -> QuerySet[PoseModel]: """Returns the ids of poses in this set""" return self._queryset @@ -1970,7 +1970,7 @@ def inchikeys(self) -> list[str]: @property def id_name_dict(self) -> dict: """Return a dictionary mapping pose ID's to their name""" - return {p.pk: p.pose_alias for p in Pose.objects.all()} + return {p.pk: p.pose_alias for p in PoseModel.objects.all()} @property def smiles(self) -> list[str]: @@ -2026,14 +2026,14 @@ def references(self) -> 'PoseSet': @property def reference_ids(self) -> set[int]: - """Return a set of :class:`.Pose` ID's of the all the distinct references in this :class:`.PoseSet`""" + """Return a set of :class:`.PoseModel` ID's of the all the distinct references in this :class:`.PoseSet`""" return self.get_by_references(self).values_list('pk', flat=True) @property def inspiration_sets(self) -> list[set[int]]: - """Return a list of unique sets of inspiration :class:`.Pose` IDs""" + """Return a list of unique sets of inspiration :class:`.PoseModel` IDs""" - pairs = Inspiration.objects.filter(derivative_pose__in=self._queryset) + pairs = InspirationModel.objects.filter(derivative_pose__in=self._queryset) data = {} for p in pairs: if p.derivative_pose not in data: @@ -2055,7 +2055,7 @@ def num_inspiration_sets(self) -> int: def num_inspirations(self) -> int: """Return the number of unique inspirations for poses in this set""" # fmt: off - return Inspiration.objects.filter( + return InspirationModel.objects.filter( derivative_pose__in=self._queryset, ).values( 'original_pose', @@ -2069,26 +2069,26 @@ def inspirations(self) -> int: # @property # def str_ids(self) -> str: - # """Return an SQL formatted tuple string of the :class:`.Pose` IDs""" + # """Return an SQL formatted tuple string of the :class:`.PoseModel` IDs""" # return str(tuple(self.ids)).replace(',)', ')') @property - def targets(self) -> QuerySet[Target]: - """Returns the :class:`.Target` objects of poses in this set""" - return Target.objects.filter(pk__in=self._queryset.values('target')) + def targets(self) -> QuerySet[TargetModel]: + """Returns the :class:`.TargetModel` objects of poses in this set""" + return TargetModel.objects.filter(pk__in=self._queryset.values('target')) @property def target_names(self) -> list[str]: - """Returns the :class:`.Target` objects of poses in this set""" + """Returns the :class:`.TargetModel` objects of poses in this set""" return self.targets.values_list('target_name', flat=True) @property def target_ids(self) -> list[int]: - """Returns the :class:`.Target` objects ID's of poses in this set""" + """Returns the :class:`.TargetModel` objects ID's of poses in this set""" return self.targets.values_list('id', flat=True) @property - def best_placed_pose(self) -> Pose: + def best_placed_pose(self) -> PoseModel: """Returns the pose with the best distance_score in this subset""" return self._queryset.get(pk=self.best_placed_pose_id) @@ -2110,7 +2110,7 @@ def best_placed_pose_id(self) -> int: @property def interactions(self) -> 'InteractionSet': - """Get a :class:`.InteractionSet` for this :class:`.Pose`""" + """Get a :class:`.InteractionSet` for this :class:`.PoseModel`""" if self._interactions is None: self._interactions = InteractionSet.from_pose(self) return self._interactions @@ -2133,7 +2133,7 @@ def fraction_fingerprinted(self) -> float: @property def num_subsites(self) -> int: """Count the number of subsites that poses in this set come into contact with""" - return Subsite.objects.filter(pose__in=self._queryset).distinct().count() + return SubsiteModel.objects.filter(pose__in=self._queryset).distinct().count() @property def subsite_balance(self) -> float: @@ -2158,8 +2158,8 @@ def subsite_balance(self) -> float: @property def subsite_ids(self) -> set[int]: """Return a list of subsite id's of member poses""" - return Subsite.objects.filter( - pk__in=SubsiteTag.objects.filter( + return SubsiteModel.objects.filter( + pk__in=SubsiteTagModel.objects.filter( pose__in=self._queryset, ).values(subsite), ).values_list('pk', flat=True) @@ -2201,8 +2201,8 @@ def avg_distance_score(self) -> float: def derivatives(self) -> 'PoseSet': """Get the :class:`.PoseSet` of derivatives""" return PoseSet( - Pose.objects.filter( - pk__in=Inspiration.objects.filter( + PoseModel.objects.filter( + pk__in=InspirationModel.objects.filter( original_pose__in=self._queryset, ).values( 'derivative_pose', @@ -2233,10 +2233,10 @@ def _delete(self, *, force: bool = False) -> None: try: with transaction.atomic(): - Inspiration.objects.filter(original_pose__in=self._queryset).delete() - Inspiration.objects.filter(derivative_pose__in=self._queryset).delete() - SubsiteTag.objects.filter(pose__in=self._queryset).delete() - Interaction.objects.filter(pose__in=self._queryset).delete() + InspirationModel.objects.filter(original_pose__in=self._queryset).delete() + InspirationModel.objects.filter(derivative_pose__in=self._queryset).delete() + SubsiteTagModel.objects.filter(pose__in=self._queryset).delete() + InteractionModel.objects.filter(pose__in=self._queryset).delete() self._queryset.delete() except IntegrityError as exc: mrich.error(exc) diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index 4d6e957..c0cb129 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -1,9 +1,9 @@ -"""Classes for working with sets of :class:`.Reaction` objects""" +"""Classes for working with sets of :class:`.ReactionModel` objects""" import mcol import mrich import pandas as pd -from designdb.models import Compound, Reactant, Reaction +from designdb.models import CompoundModel, ReactantModel, ReactionModel from designdb.sets.compound import CompoundSet from django.db.models import Q from IPython.display import display @@ -20,7 +20,7 @@ class ReactionSet: Use as an iterable ================== - Iterate through :class:`.Reaction` objects in the set: + Iterate through :class:`.ReactionModel` objects in the set: :: @@ -32,7 +32,7 @@ class ReactionSet: Check membership ================ - To determine if a :class:`.Reaction` is present in the set: + To determine if a :class:`.ReactionModel` is present in the set: :: @@ -68,11 +68,11 @@ def __init__( if queryset: if isinstance(queryset, list): - self._queryset = Reaction.objects.filter(pk__in=queryset) + self._queryset = ReactionModel.objects.filter(pk__in=queryset) else: self._queryset = queryset else: - self._queryset = Reaction.objects.none() + self._queryset = ReactionModel.objects.none() self._name = name if sort: @@ -99,29 +99,29 @@ def __rich__(self) -> str: return f'[bold underline]{self}' def __len__(self) -> int: - """Number of member :class:`.Reaction` objects""" + """Number of member :class:`.ReactionModel` objects""" return self._queryset.count() def __iter__(self): - """Iterate through member :class:`.Reaction` objects""" + """Iterate through member :class:`.ReactionModel` objects""" return iter(self._queryset) - def __getitem__(self, key) -> 'Reaction | ReactionSet': - """Get member :class:`.Reaction` object by single, slice or list/set/tuple of ID""" + def __getitem__(self, key) -> 'ReactionModel | ReactionSet': + """Get member :class:`.ReactionModel` object by single, slice or list/set/tuple of ID""" match key: case int(): try: - # reaction = Reaction.objects.get(pk=key) + # reaction = ReactionModel.objects.get(pk=key) reaction = self._queryset[key] - except Reaction.DoesNotExist as exc: + except ReactionModel.DoesNotExist as exc: mrich.error(f'list index out of range: {key=} for {self}') - raise Reaction.DoesNotExist from exc + raise ReactionModel.DoesNotExist from exc return reaction case slice(): - return ReactionSet(Reaction.objects.filter(pk__in=key)) + return ReactionSet(ReactionModel.objects.filter(pk__in=key)) case _: mrich.error( @@ -134,7 +134,7 @@ def __add__(self, other: 'ReactionSet') -> 'ReactionSet': """Add a :class:`.ReactionSet` to this one""" if other: return ReactionSet( - Reaction.objects.filter( + ReactionModel.objects.filter( Q(pk__in=self._queryset) | Q(pk__in=other.queryset) ), sort=False, @@ -148,7 +148,7 @@ def __sub__( match other: case ReactionSet(): return ReactionSet( - Reaction.objects.filter( + ReactionModel.objects.filter( Q(pk__in=self._queryset) & ~Q(pk__in=other.queryset) ), sort=False, @@ -156,14 +156,14 @@ def __sub__( ### METHODS - def add(self, r: Reaction) -> None: - """Add a :class:`.Reaction` to this set + def add(self, r: ReactionModel) -> None: + """Add a :class:`.ReactionModel` to this set - :param r: :class:`.Reaction` to be added + :param r: :class:`.ReactionModel` to be added """ - assert isinstance(r, Reaction) - self._queryset = Reaction.objects.filter( + assert isinstance(r, ReactionModel) + self._queryset = ReactionModel.objects.filter( pk__in=list(self._queryset.values_list('pk', flat=True)) + [r.pk], ) @@ -183,7 +183,7 @@ def interactive(self): c = Checkbox(description='Summary', value=False) d = Checkbox(description='Draw', value=True) e = Checkbox(description='Check chemistry', value=False) - f = Checkbox(description='Reactant Quotes', value=False) + f = Checkbox(description='ReactantModel Quotes', value=False) ui1 = GridBox( [b, c, d], layout=Layout(grid_template_columns='repeat(5, 100px)') @@ -247,11 +247,11 @@ def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: :param smiles: Include smiles column (Default value = True) :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - :param kwargs: keyword arguments are passed on to :meth:`.Reaction.get_dict: + :param kwargs: keyword arguments are passed on to :meth:`.ReactionModel.get_dict: """ - mrich.debug('Using slower Reaction.dict rather than direct SQL query...') + mrich.debug('Using slower ReactionModel.dict rather than direct SQL query...') data = [] for r in mrich.track(self, prefix='ReactionSet --> DataFrame'): @@ -273,7 +273,7 @@ def get_recipes( """ # avoiding circular imports - from designdb.recipe import Recipe + from designdb.components.recipe import Recipe return Recipe.from_reactions(reactions=self, amounts=1, **kwargs) @@ -315,7 +315,7 @@ def num_types(self) -> int: def products(self) -> CompoundSet: """Get all product compounds that can be synthesised with these reactions (no intermediates)""" - qs = Compound.objects.filter( + qs = CompoundModel.objects.filter( pk__in=self._queryset.values('product_compound'), ).exclude( pk__in=self.intermediates.queryset.values('pk'), @@ -330,9 +330,9 @@ def intermediates(self) -> CompoundSet: """Get all intermediate compounds that can be synthesised with these reactions""" # NB! not 100% sure about this queryset - qs = Compound.objects.filter( + qs = CompoundModel.objects.filter( Q( - pk__in=Reactant.objects.values('compound'), + pk__in=ReactantModel.objects.values('compound'), ) & Q(pk__in=self._queryset.values('product_compound')), ) @@ -346,7 +346,7 @@ def intermediates(self) -> CompoundSet: def reactants(self) -> 'CompoundSet': """Get all reactant compounds that are used by these reactions""" - qs = Reactant.objects.filter( + qs = ReactantModel.objects.filter( reaction__in=self._queryset, ).values('compound') cset = CompoundSet(qs) diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index c1f3b54..4389870 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -2,7 +2,7 @@ import mcol import mrich -from designdb.models import Component, Route +from designdb.models import ComponentModel, RouteModel from designdb.sets.compound import CompoundSet @@ -26,10 +26,10 @@ def __init__(self, routes: 'list[Route]') -> None: @classmethod def from_ids(cls, ids: list | set, progress: bool = True): - """Generate a routeset from a set of :class:`.Route` IDs + """Generate a routeset from a set of :class:`.RouteModel` IDs :param db: database to link - :param ids: :class:`.Route` database IDs + :param ids: :class:`.RouteModel` database IDs :param progress: show progress bar """ @@ -39,19 +39,19 @@ def from_ids(cls, ids: list | set, progress: bool = True): # avoiding circular reference # avoiding name conflict - from designdb.route import RouteObj + from designdb.components.recipe import Route - routes = [RouteObj.get_route(id=r) for r in ids] + routes = [Route.get_route(id=r) for r in ids] # self = cls.__new__(cls) return RouteSet(routes) @classmethod def from_product_ids(cls, ids: list | set, progress: bool = True): - """Generate a routeset from a set of product :class:`.Compound` IDs + """Generate a routeset from a set of product :class:`.CompoundModel` IDs :param db: database to link - :param ids: :class:`.Compound` database IDs + :param ids: :class:`.CompoundModel` database IDs """ # str_ids = str(tuple(ids)).replace(',)', ')') @@ -62,7 +62,7 @@ def from_product_ids(cls, ids: list | set, progress: bool = True): # key=f'route_product IN {str_ids}', # multiple=True, # ) - records = Route.objects.filter( + records = RouteModel.objects.filter( product_compound__pk__in=ids, ) @@ -88,7 +88,7 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': new_data = {} for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): route_id = d['id'] - new_data[route_id] = Route.from_json(db=db, path=None, data=d) + new_data[route_id] = RouteModel.from_json(db=db, path=None, data=d) self._data = new_data self._cluster_map = None @@ -100,7 +100,7 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': ### PROPERTIES @property - def data(self) -> 'dict[int, Route]': + def data(self) -> 'dict[int, RouteModel]': """Get internal data dictionary""" return self._data @@ -116,14 +116,14 @@ def routes(self) -> 'list[Route]': @property def product_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the products""" - return Route.objects.values_list('product_compound__id', flat=True).distinct() + """Get the :class:`.CompoundModel` ID's of the products""" + return RouteModel.objects.values_list('product_compound__id', flat=True).distinct() @property def reactant_ids(self) -> list[int]: - """Get the :class:`.Compound` ID's of the reactants""" + """Get the :class:`.CompoundModel` ID's of the reactants""" - return Component.objects.filter( + return ComponentModel.objects.filter( route__in=self.ids, component_type=2, ).values_list('component_ref', flat=True) @@ -140,19 +140,19 @@ def reactants(self) -> 'CompoundSet': @property def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.Route` ID's""" + """Return an SQL formatted tuple string of the :class:`.RouteModel` ID's""" return str(tuple(self.ids)).replace(',)', ')') @property def ids(self) -> list[int]: - """Return the :class:`.Route` IDs""" + """Return the :class:`.RouteModel` IDs""" return self.data.keys() @property def cluster_map(self) -> dict[tuple, set]: """Create a dictionary grouping routes by their scaffold/base cluster. - :returns: A dictionary mapping a tuple of scaffold :class:`.Compound` IDs to a set of :class:`.Route` ID's to their superstructures. + :returns: A dictionary mapping a tuple of scaffold :class:`.CompoundModel` IDs to a set of :class:`.RouteModel` ID's to their superstructures. """ if self._cluster_map is None: @@ -265,14 +265,14 @@ def pop_id(self) -> int: route_id, route = self.data.popitem() return route_id - def pop(self) -> 'Route': + def pop(self) -> 'RouteModel': """Pop the last route from the set and return it's object""" route_id, route = self.data.popitem() return route def balanced_pop( self, permitted_clusters: set[tuple] | None = None, debug: bool = False - ) -> 'Route': + ) -> 'RouteModel': """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" if not self._data: @@ -307,10 +307,10 @@ def balanced_pop( if self._current_cluster is None: self._current_cluster = self._permitted_clusters[0] - ### pop a Route + ### pop a RouteModel if debug: - mrich.debug(f'Would pop Route from {self._current_cluster=}') + mrich.debug(f'Would pop RouteModel from {self._current_cluster=}') cluster = self._current_cluster @@ -337,14 +337,14 @@ def balanced_pop( if debug: mrich.debug('Popped route', route_id) - # get the Route object + # get the RouteModel object if route_id in self._data: route = self._data[route_id] del self._data[route_id] else: # if debug: - mrich.debug('Route not present') + mrich.debug('RouteModel not present') return self.balanced_pop() ### increment cluster @@ -407,7 +407,7 @@ def __len__(self) -> int: def __str__(self) -> str: """Unformatted string representation""" - return f'{{Route × {len(self)}}}' + return f'{{RouteModel × {len(self)}}}' def __repr__(self) -> str: """ANSI Formatted string representation""" diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py index c6ace54..e06204f 100644 --- a/hippo/designdb/utils.py +++ b/hippo/designdb/utils.py @@ -211,14 +211,14 @@ def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: 'Pose', b: 'Pose') -> float: - """Calculate minimum distance between two :class:`.Pose` objects""" +def pose_gap(a: 'PoseModel', b: 'PoseModel') -> float: + """Calculate minimum distance between two :class:`.PoseModel` objects""" from molparse.rdkit import mol_to_AtomGroup from numpy.linalg import norm # avoiding circular imports - from .models import Pose, ScoreValue + from .models import PoseModel, ScoreValueModel min_dist = None @@ -294,8 +294,8 @@ def warn(key, msg): class ScoreSubquery(Subquery): def __init__(self, scoring_method): # avoiding circular imports - from .models import Pose, ScoreValue - query = ScoreValue.objects.filter( + from .models import PoseModel, ScoreValueModel + query = ScoreValueModel.objects.filter( pose=OuterRef('pk'), compound=OuterRef('compound'), scoring_method__method_name=scoring_method, diff --git a/hippo/designdb/utils_frag.py b/hippo/designdb/utils_frag.py index de75c23..08add9b 100644 --- a/hippo/designdb/utils_frag.py +++ b/hippo/designdb/utils_frag.py @@ -23,14 +23,14 @@ 'Crystalforms short tag', 'Centroid res', 'Experiment code', - 'Pose', + 'PoseModel', ] META_IGNORE_COLS = [ 'Code', 'Long code', - 'Compound code', + 'CompoundModel code', 'Smiles', 'Downloaded', 'Main status', @@ -97,7 +97,7 @@ class LongcodeRecord: def parse_observation_longcode(longcode: str) -> LongcodeRecord: """Parse a Fragalysis longcode and try to extract the following information: - - Target name (target) + - TargetModel name (target) - Crystal/dataset code (crystal) - Chain letter (chain) - Residue number (residue_number) diff --git a/hippo/designdb/utils_xca.py b/hippo/designdb/utils_xca.py index 4bb8a56..3264658 100644 --- a/hippo/designdb/utils_xca.py +++ b/hippo/designdb/utils_xca.py @@ -6,7 +6,7 @@ def parse_observation_longcode(longcode: str) -> dict[str]: """Parse an XChemAlign longcode and try to extract the following information: - - Target name (target) + - TargetModel name (target) - Crystal/dataset code (crystal) - Chain letter (chain) - Residue number (residue_number) From ce4e5bedad2abaf33d4ea235574999ec1a53ee7f Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 19 May 2026 14:13:50 +0100 Subject: [PATCH 137/163] fix: Recipe.from_compound working --- Dockerfile | 8 +++----- docker-compose.yaml | 5 ++++- hippo/designdb/animal.py | 7 ++++--- hippo/designdb/sets/compound.py | 18 ++++++++++++++++++ hippo/designdb/sets/reaction.py | 4 ++++ 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index 11cbdff..ed65ce1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ ARG PYTHON_VERSION=3.12 WORKDIR "/home/code/HIPPO" +# WORKDIR "/home/code/HIPPO/data" +# WORKDIR "/home/code/HIPPO/hippo" USER 0 @@ -32,14 +34,10 @@ ENV PATH="/home/code/HIPPO/.venv/bin:$PATH" ENV PYTHONPATH="/home/code/HIPPO/.venv/lib/python${PYTHON_VERSION}/site-packages:$PYTHONPATH" -# copy files from host -# COPY . ./ - # patch rich RUN python -c "import mrich; mrich.patch_rich_jupyter_margins()" - # notebooks RUN chown ${NB_USER} "/home/code" && sudo apt update && sudo apt install screen -y @@ -49,4 +47,4 @@ RUN uv pip install --upgrade numpy==2.4.4 USER ${NB_USER} -WORKDIR "/home/code/HIPPO" +# WORKDIR "/home/code/HIPPO" diff --git a/docker-compose.yaml b/docker-compose.yaml index d7c31f3..3284b62 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -57,7 +57,10 @@ services: env_file: - .env ports: - - "8888:8888" + - "8888:8888" + volumes: + - ./hippo:/home/code/HIPPO/hippo + - ./data:/home/code/HIPPO/data networks: - app_network # depends_on: diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index f9bcb39..af7e8e5 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -11,6 +11,7 @@ from .models import CompoundModel, PoseModel, TargetModel from .services.ingestion import IngestionBatchResult, IngestionService +from .sets.compound import CompoundSet from .sets.pose import PoseSet from .utils import make_warn_once_per_key @@ -78,9 +79,9 @@ def poses(self): return PoseSet(PoseModel.objects.filter(target=self._target)) @property - def compounds(self): - """Return compound instances for this target""" - return CompoundModel.compound_filter.all() + def compounds(self) -> CompoundSet: + """Return all compounds in the database""" + return CompoundSet(CompoundModel.compound_filter.all()) @property def num_poses(self) -> int: diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index 44323f0..f7cdcca 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -21,6 +21,8 @@ # from rdkit.Chem import inchi from rdkit.Chem import Mol +from ..utils import registration_hash_tautomer_insensitive, superparent + class CompoundSet: """Object representing a subset of the 'compound' table in the :class:`.Database`. @@ -334,6 +336,22 @@ def get_by_scaffold( return None return CompoundSet(self.db, ids) + def get_by_smiles(self, smiles: str) -> CompoundModel: + """Get a compound in this set by SMILES, using tautomer-insensitive matching. + + :param smiles: SMILES string to search for + :raises ValueError: if SMILES standardisation fails + :raises CompoundModel.DoesNotExist: if no match found in this set + """ + mol = Chem.MolFromSmiles(smiles, sanitize=True) + try: + sp = superparent(mol) + except Exception as e: + raise ValueError(f"SuperParent failed: {e}") from e + + h = registration_hash_tautomer_insensitive(sp) + return self._queryset.get(compound_hash=h) + def get_all_possible_reactants( self, debug: bool = False, diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index c0cb129..5cce081 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -263,6 +263,10 @@ def copy(self) -> 'ReactionSet': """Return a copy of this set""" return ReactionSet(self._queryset.all(), sort=False, name=self.name) + def reverse(self) -> None: + """Reverse the ordering of this set in-place""" + self._queryset = self._queryset.reverse() + def get_recipes( self, amounts: float | list[float] = 1.0, **kwargs ): From f5f5bc475fd7b8637de0f8c3817d2decb7c140b2 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 20 May 2026 07:16:04 +0100 Subject: [PATCH 138/163] fix: 2084, add missing files --- hippo/designdb/components/__init__.py | 0 hippo/designdb/components/compound.py | 1107 +++++++++++ hippo/designdb/components/price.py | 249 +++ hippo/designdb/components/reaction.py | 184 ++ hippo/designdb/components/recipe.py | 2553 +++++++++++++++++++++++++ hippo/designdb/services/ingredient.py | 76 + hippo/designdb/services/recipe.py | 226 +++ hippo/ta_auth_connector.py | 168 ++ 8 files changed, 4563 insertions(+) create mode 100644 hippo/designdb/components/__init__.py create mode 100644 hippo/designdb/components/compound.py create mode 100644 hippo/designdb/components/price.py create mode 100644 hippo/designdb/components/reaction.py create mode 100644 hippo/designdb/components/recipe.py create mode 100644 hippo/designdb/services/ingredient.py create mode 100644 hippo/designdb/services/recipe.py create mode 100644 hippo/ta_auth_connector.py diff --git a/hippo/designdb/components/__init__.py b/hippo/designdb/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py new file mode 100644 index 0000000..12847e5 --- /dev/null +++ b/hippo/designdb/components/compound.py @@ -0,0 +1,1107 @@ +"""Compound and Ingredient components.""" + +import logging +from pathlib import Path + +import mcol +import mrich +import pandas as pd +from designdb.models import ( + CataloguePriceCompoundJunctionModel, + CataloguePriceModel, + CompoundModel, + CompoundTagJunctionModel, + CompoundTagModel, + InspirationModel, + PoseModel, + ReactantModel, + ReactionModel, + ScaffoldModel, +) +from django.db.models import Exists, OuterRef, Q +from molparse.atomtypes import formula_to_atomtype_dict +from molparse.rdkit import draw_highlighted_mol, draw_mcs +from molparse.rdkit.classify import classify_mol +from rdkit import Chem +from rdkit.Chem import Descriptors, MolFromSmarts, rdRGroupDecomposition +from rdkit.Chem.rdMolDescriptors import CalcMolFormula, CalcNumRings +from rdkit.Chem.Scaffolds import MurckoScaffold + +from .price import Price + + +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, instance: CompoundModel): + """Compound initialisation""" + + self._instance = instance + + # caches + self._scaffolds = None + self._elabs = None + self._tags = None + self._mol = None + self._num_heavy_atoms = None + self._num_rings = None + self._formula = None + self._molecular_weight = None + + ### FACTORIES + + @classmethod + def from_id(cls, id: int) -> "Compound": + """Create a :class:`.Compound` from its database ID""" + return cls(CompoundModel.objects.get(pk=id)) + + ### PROPERTIES + + @property + def id(self) -> int: + """Returns the compound's database ID""" + return self._instance.pk + + @property + def inchikey(self) -> str: + """Returns the compound's InChiKey""" + return self._instance.compound_inchikey + + @property + def name(self) -> str: + """Returns the compound's alias, or InChiKey if no alias is set""" + if self.alias: + return self.alias + return self.inchikey + + @property + def smiles(self) -> str: + """Returns the compound's (flattened) SMILES""" + return self._instance.compound_smiles + + @property + def alias(self) -> str: + """Returns the compound's alias""" + return self._instance.compound_alias + + @alias.setter + def alias(self, alias: str) -> None: + """Set the compound's alias""" + self.set_alias(alias) + + @property + def mol(self) -> Chem.Mol | None: + """Returns the compound's RDKit Molecule""" + if self._mol is None: + mol_text = self._instance.compound_mol + if mol_text: + self._mol = Chem.MolFromMolBlock(mol_text) + return self._mol + + @property + def num_heavy_atoms(self) -> int | None: + """Get the number of heavy atoms""" + if self._num_heavy_atoms is None and self.mol is not None: + self._num_heavy_atoms = self.mol.GetNumHeavyAtoms() + return self._num_heavy_atoms + + @property + def molecular_weight(self) -> float | None: + """Get the molecular weight""" + if self._molecular_weight is None and self.mol is not None: + self._molecular_weight = Descriptors.ExactMolWt(self.mol) + return self._molecular_weight + + @property + def num_rings(self) -> int | None: + """Get the number of rings""" + if self._num_rings is None and self.mol is not None: + self._num_rings = CalcNumRings(self.mol) + return self._num_rings + + @property + def formula(self) -> str | None: + """Get the chemical formula""" + if self._formula is None and self.mol is not None: + self._formula = CalcMolFormula(self.mol) + return self._formula + + @property + def atomtype_dict(self) -> dict[str, int]: + """Get a dictionary with atomtypes as keys and corresponding quantities/counts as values.""" + + 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: + scaffold = Compound(next(iter(self.scaffolds._queryset))) + return self.num_heavy_atoms - scaffold.num_heavy_atoms + case _: + mrich.warning(f"{self} has multiple scaffolds") + n_e = self.num_heavy_atoms + return [n_e - Compound(c).num_heavy_atoms for c in self.scaffolds._queryset] + + @property + def metadata(self) -> dict | None: + """Returns the compound's metadata dict""" + return self._instance.compound_metadata + + @property + def tags(self) -> list[str]: + """Returns the compound's tags""" + if self._tags is None: + 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) -> "PoseModel": + """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 PoseModel.objects.filter(compound=self._instance).count() + + @property + def num_reactions(self) -> int: + """Returns the number of associated reactions (product)""" + return ReactionModel.objects.filter(product_compound=self._instance).count() + + @property + def num_reactant(self) -> int: + """Returns the number of associated reactions (reactant)""" + return ReactantModel.objects.filter(compound=self._instance).count() + + @property + def scaffolds(self) -> "CompoundSet | None": + """Returns the scaffold compounds for this elaboration""" + if self._scaffolds is None: + ids = self.get_scaffold_ids() + if not ids: + return None + from designdb.sets.compound import CompoundSet + self._scaffolds = CompoundSet(ids, name=f"scaffolds of {self}") + 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) + return 0 + + @property + def elabs(self) -> "CompoundSet | None": + """Returns the elaborations of this scaffold compound""" + if self._elabs is None: + ids = self.get_superstructure_ids() + if not ids: + return None + from designdb.sets.compound import CompoundSet + self._elabs = CompoundSet(ids, name=f"elaborations of {self}") + return self._elabs + + @property + def reactions(self) -> "ReactionSet": + """Returns the reactions resulting in this compound""" + return self.get_reactions() + + @property + def reaction(self) -> "ReactionModel | None": + """Returns the reaction resulting in this compound (warns if multiple)""" + reactions = self.reactions + match len(reactions): + case 0: + mrich.warning(f"{self} has no reactions") + return None + case 1: + pass + case _: + mrich.warning(f"{self} has multiple reactions, returning first") + 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 ScaffoldModel.objects.filter(base_compound=self._instance).exists() + + @property + def is_elab(self) -> bool: + """Is this Compound based on any other compound?""" + return ScaffoldModel.objects.filter(superstructure_compound=self._instance).exists() + + @property + def is_product(self) -> bool: + """Is this Compound a product of at least one reaction?""" + return ReactionModel.objects.filter(product_compound=self._instance).exists() + + @property + def table(self) -> str: + """Returns the name of the database table""" + return self._table + + ### METHODS + + def add_stock( + self, + amount: float, + *, + purity: float | None = None, + entry: str | None = None, + location: str | None = None, + return_quote: bool = True, + ) -> int | CataloguePriceModel: + """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 entry: Catalogue entry identifier, defaults to ``None`` + :param location: String describing where this stock is located, defaults to ``None`` + :param return_quote: If ``True`` a :class:`.CataloguePriceModel` object is returned instead of its ID, defaults to ``True`` + :returns: The inserted :class:`.CataloguePriceModel` object or ID + """ + + assert amount + + # Find existing in-stock entries for this compound + existing_qs = CataloguePriceModel.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunctionModel.objects.filter( + compound=self._instance, + catalogue_price=OuterRef("pk"), + ) + ) + ).filter(has_compound=True, supplier="Stock") + + # Delete entries matching this entry/purity/location + to_delete = existing_qs.filter( + supplier_id=entry or "", + purity=purity, + vendor=location or "", + ) + deleted_count = to_delete.count() + if deleted_count: + CataloguePriceCompoundJunctionModel.objects.filter( + compound=self._instance, + catalogue_price__in=to_delete, + ).delete() + mrich.warning(f"Removed {deleted_count} existing In-Stock entries") + + not_deleted = existing_qs.exclude( + supplier_id=entry or "", + purity=purity, + vendor=location or "", + ).count() + if not_deleted: + mrich.warning( + f"Did not remove {not_deleted} existing In-Stock entries with differing entry/purity/location" + ) + + # Create new price entry and link to compound + quote = CataloguePriceModel.objects.create( + supplier="Stock", + supplier_id=entry or "", + vendor=location or "", + amount=amount, + price=0, + currency=None, + lead_time=0, + purity=purity, + catalogue_compound=None, + ) + CataloguePriceCompoundJunctionModel.objects.create( + compound=self._instance, + catalogue_price=quote, + ) + + if return_quote: + return quote + return quote.pk + + def get_tags(self) -> list[str]: + """Get the tags assigned to this compound""" + return list(self._instance.tags.values_list("compound_tag_name", flat=True)) + + def add_tag(self, tag: str) -> None: + """Add a tag to this compound""" + + assert isinstance(tag, str) + tag_obj, _ = CompoundTagModel.objects.get_or_create(compound_tag_name=tag) + CompoundTagJunctionModel.objects.get_or_create( + compound=self._instance, compound_tag=tag_obj + ) + self._tags = None # invalidate cache + + 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, + ): + """Get all quotes associated to this compound. See :meth:`.Ingredient.get_quotes`""" + return Ingredient.get_quotes( + compound=self._instance, + min_amount=min_amount, + supplier=supplier, + max_lead_time=max_lead_time, + none=none, + pick_cheapest=pick_cheapest, + df=df, + ) + + def get_reactions( + self, + as_reactant: bool = False, + permitted_reactions: "ReactionSet" = None, + none: str = "error", + ) -> "ReactionSet": + """Get the associated :class:`.ReactionModel` objects. + + :param as_reactant: Search for reactions using this compound as a reactant, defaults to ``False`` + :param permitted_reactions: Filter results to this :class:`.ReactionSet` + :param none: Unused, kept for API compatibility + """ + + from designdb.sets.reaction import ReactionSet + + if as_reactant: + reaction_ids = list( + ReactantModel.objects.filter(compound=self._instance).values_list( + "reaction_id", flat=True + ) + ) + else: + reaction_ids = list( + ReactionModel.objects.filter( + product_compound=self._instance + ).values_list("pk", flat=True) + ) + + if permitted_reactions: + reaction_ids = [i for i in reaction_ids if i in permitted_reactions] + + rset = ReactionSet(reaction_ids) + if not as_reactant and not permitted_reactions: + rset._name = f"reactions resulting in {str(self)}" + + return rset + + def get_poses(self) -> "PoseSet": + """Get the associated :class:`.PoseModel` objects.""" + from designdb.sets.pose import PoseSet + + qs = PoseModel.objects.filter(compound=self._instance) + return PoseSet(qs, name=f"{self}'s poses") + + def get_dict( + self, + *, + mol: bool = True, + alias: bool = True, + inchikey: bool = True, + metadata: bool = True, + poses: bool = True, + 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 IDs of associated :class:`.PoseModel` objects, defaults to ``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 + """ + + data: dict = {"id": self.id, "smiles": self.smiles} + + if alias: + data["alias"] = self.alias + if inchikey: + data["inchikey"] = self.inchikey + if num_reactant: + data["num_reactant"] = self.num_reactant + if num_reactions: + data["num_reactions"] = self.num_reactions + + if mol: + data["mol"] = self.mol + + if scaffolds: + data["scaffolds"] = self.scaffolds.ids if self.scaffolds else None + + if elabs: + data["elabs"] = self.elabs.ids if self.elabs else None + + if tags: + data["tags"] = self.tags + + if poses: + pose_set = self.poses + if pose_set: + data["poses"] = pose_set.ids + data["targets"] = pose_set.target_names + + if metadata and (metadict := self.metadata): + for key, value in metadict.items(): + data[key] = value + + return data + + 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 designdb.sets.compound import CompoundSet + + from .recipe import Recipe + + return Recipe.from_compounds( + CompoundSet([self._instance.pk]), + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + quoted_only=quoted_only, + supplier=supplier, + **kwargs, + ) + + def get_scaffold_ids(self) -> list[int] | None: + """Get a list of :class:`.Compound` IDs that this object is a superstructure of""" + ids = list( + ScaffoldModel.objects.filter( + superstructure_compound=self._instance + ).values_list("base_compound_id", flat=True) + ) + return ids or None + + def get_superstructure_ids(self) -> list[int] | None: + """Get a list of :class:`.Compound` IDs that this object is a substructure of""" + ids = list( + ScaffoldModel.objects.filter( + base_compound=self._instance + ).values_list("superstructure_compound_id", flat=True) + ) + return ids or None + + def add_scaffold(self, scaffold: "Compound | CompoundModel | int", commit: bool = True) -> None: + """Add a scaffold :class:`.Compound` this molecule is derived from. + + :param scaffold: The scaffold :class:`.Compound`, :class:`.CompoundModel`, or its ID. + :param commit: Unused, kept for API compatibility + """ + + if isinstance(scaffold, int): + scaffold_model = CompoundModel.objects.get(pk=scaffold) + elif isinstance(scaffold, CompoundModel): + scaffold_model = scaffold + else: + assert scaffold._table == "compound" + scaffold_model = scaffold._model + + ScaffoldModel.objects.get_or_create( + base_compound=scaffold_model, + superstructure_compound=self._instance, + ) + self._scaffolds = None # invalidate cache + + def set_alias(self, alias: str, commit: bool = True) -> None: + """Set this :class:`.Compound`'s alias. + + :param alias: The alias + :param commit: Unused, kept for API compatibility + """ + + assert isinstance(alias, str) + self._instance.compound_alias = alias + self._instance.save(update_fields=["compound_alias", "updated_on"]) + + 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` with an associated amount and quote. + + :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`` + """ + + return Ingredient.from_compound( + compound=self._instance, + amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + get_quote=get_quote, + quote_none=quote_none, + ) + + 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 drawings by their common substructure, defaults to ``False`` + """ + + if scaffolds and (scaffolds := self.scaffolds): + + data = {} + for scaffold in scaffolds: + data[scaffold.compound_smiles] = f"C{scaffold.pk} (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 vs {self.id=}, self referential?") + display(self.mol) + else: + display(self.mol) + + def draw_elabs(self) -> None: + """Draw elaborations""" + + + elabs = self.elabs + + display(self) + display(elabs) + + if not elabs: + mrich.error(self, "has no elaborations") + return self.draw() + + params = rdRGroupDecomposition.RGroupDecompositionParameters() + params.removeAllHydrogenRGroups = False + params.removeAllHydrogenRGroupsAndLabels = True + params.removeHydrogensPostMatch = True + + rgd = rdRGroupDecomposition.RGroupDecomposition( + MolFromSmarts(self.smiles), params + ) + for c in elabs._queryset: + mol_text = c.compound_mol + if mol_text: + mol = Chem.MolFromMolBlock(mol_text) + if mol: + rgd.Add(mol) + rgd.Process() + + rgroup_table = rgd.GetRGroupsAsColumns() + core = rgroup_table["Core"][0] + attachment_points = set() + for rgroup in rgroup_table["Core"]: + for atom in rgroup.GetAtoms(): + if atom.GetAtomicNum() == 0: + attachment_points.add(atom.GetIdx()) + + drawing = draw_highlighted_mol( + core, [(i, (0.5, 1, 0.5)) for i in attachment_points] + ) + display(drawing) + + 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 + """ + + + return classify_mol(self.mol, draw=draw) + + def murcko_scaffold(self, generic: bool = False) -> Chem.Mol: + """Get the rdkit MurckoScaffold for this compound""" + + + scaffold = MurckoScaffold.GetScaffoldForMol(self.mol) + if generic: + scaffold = MurckoScaffold.MakeScaffoldGeneric(scaffold) + return scaffold + + 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() + + def place( + self, + *, + animal: "HIPPO", + reference: "PoseModel", + inspirations: list["PoseModel"] | 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, + ) -> "PoseModel | None": + """Generate a new pose for this compound using Fragmenstein. + + :param animal: The :class:`.HIPPO` instance used to register the pose + :param reference: Choose the :class:`.PoseModel` to use as the reference protein conformation + :param inspirations: Choose the (virtual) hits to define the ligand reference, defaults to the ``reference``'s inspirations + :param max_ddG: Maximum ``ddG`` value permitted, defaults to ``0.0`` + :param max_RMSD: Maximum ``RMSD`` value permitted, 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 Wictor + + tags = tags or [] + metadata = metadata or {} + + inspirations = inspirations or reference.inspirations.all() + target = reference.target.target_name + + inspiration_mols = [] + for insp in inspirations: + mol_text = insp.compound.compound_mol + if mol_text: + mol = Chem.MolFromMolBlock(mol_text) + if mol: + inspiration_mols.append(mol) + + protein_pdb_block = reference.protein_system.pdb_block_with_alt_sites + + victor = Wictor(hits=inspiration_mols, pdb_block=protein_pdb_block) + victor.work_path = output_dir + victor.enable_stdout(logging.CRITICAL) + victor.place(self.smiles, long_name=self.name) + + 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 + + pose = 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: + PoseModel.objects.filter(compound=self._instance).exclude(pk=pose.pk).delete() + mrich.success(f"Successfully posed {self} (and deleted old poses)") + else: + mrich.success(f"Successfully posed {self}") + + return pose + + def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSet | None": + """Get the fragment inspirations for this compound's poses. + + Since inspirations map :class:`.PoseModel` objects to each other, this requires + poses to be registered for this compound. + + :returns: a :class:`.PoseSet` object + """ + + from designdb.sets.pose import PoseSet + + poses_qs = PoseModel.objects.filter(compound=self._instance) + insp_qs = InspirationModel.objects.filter(derivative_pose__in=poses_qs) + + if not insp_qs.exists() and none in ("warning", "warn"): + mrich.warning("Could not determine inspirations for", self) + return None + + derivative_ids = set(insp_qs.values_list("derivative_pose_id", flat=True)) + original_ids = set(insp_qs.values_list("original_pose_id", flat=True)) + + if debug: + mrich.debug(f"Inspirations derived from {derivative_ids}") + + inspirations = PoseSet( + PoseModel.objects.filter(pk__in=original_ids), + name=f"Inspirations for {self}", + ) + + return inspirations + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f"C{self.id}" + + 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}"' + + def __eq__(self, other) -> bool: + """Compare compounds""" + assert isinstance(other, Compound) + return self.id == other.id + + + +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, + compound: CompoundModel, # or CatalogueCompoundModel? + amount: float, + quote: CataloguePriceModel, + max_lead_time: float | None = None, + supplier: str | None = None, + ): + """Ingredient initialisation""" + + self._compound = compound + self._quote = quote + self._amount = amount + self._max_lead_time = max_lead_time + self._supplier = supplier + + def __str__(self) -> str: + """Plain string representation""" + return f'{self.amount:.2f}mg of C{self._compound.id}' + + 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)}' + + def __eq__(self, other) -> bool: + """Equality operator""" + + if self.compound != other.compound: + return False + + return self.amount == other.amount + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.Compound`""" + return getattr(self.compound, key) + + @classmethod + def from_compound( + cls, + compound: CompoundModel, + 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 + + quote = cls.get_quotes( + compound=compound, + pick_cheapest=True, + min_amount=amount, + max_lead_time=max_lead_time, + supplier=supplier, + none=quote_none, + ) + + else: + quote = None + + return Ingredient( + compound=compound, + amount=amount, + quote=quote, + supplier=supplier, + max_lead_time=max_lead_time, + ) + + @classmethod + def get_quotes( + cls, + compound: CompoundModel, + 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, + ): + """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 + + """ + + qs = CataloguePriceModel.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + catalogue_price=OuterRef('pk'), + ), + ), + ).filter( + has_compound=True, + ) + + if supplier: + if isinstance(supplier, str): + qs = qs.filter(supplier=supplier) + else: + qs = qs.filter(supplier__in=supplier) + + if not qs.exists(): + return None + + if max_lead_time: + qs = qs.filter(lead_time__lte=max_lead_time) + + if min_amount: + qs = qs.filter(amount__gte=min_amount) + + if not qs.exists(): + mrich.debug( + f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + ) + + if pick_cheapest: + return qs.order_by('price').first() + + if df: + return pd.DataFrame(qs.values()).drop(columns='compound') + + return qs + + ### METHODS + + def get_cheapest_quote_id( + self, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + ) -> 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. + """ + + query = Q(compound=self.compound) + + if supplier: + query &= Q(quote_supplier=supplier) + + if min_amount: + query &= Q(quote_amount__gte=min_amount) + + if max_lead_time: + query &= Q(quote_lead_time__lte=max_lead_time) + + return CataloguePriceModel.objects.filter(query).order_by('quote_price').first() + + ### PROPERTIES + + @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(self) -> int: + """Returns the ID of the associated :class:`.Quote`""" + return self._quote + + @property + def price(self) -> Price: + """Returns the price from the associated quote, or a null Price if unavailable.""" + if self._quote is None: + return Price.null() + return Price(self._quote.price, self._quote.currency) + + @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 = self.get_cheapest_quote_id( + min_amount=a, + max_lead_time=self._max_lead_time, + supplier=self._supplier, + none='quiet', + ) + + self._quote = quote + + self._amount = a + + @property + def compound(self) -> CompoundModel: + """Returns the associated :class:`.Compound`""" + + # if not self._compound: + # self._compound = self.db.get_compound(id=self.compound_id) + return self._compound + + @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 diff --git a/hippo/designdb/components/price.py b/hippo/designdb/components/price.py new file mode 100644 index 0000000..88f74b2 --- /dev/null +++ b/hippo/designdb/components/price.py @@ -0,0 +1,249 @@ +"""Class for working with prices""" + +import mcol + +CURRENCIES = { + 'USD': '$', + 'EUR': '€', + 'GBP': '£', +} + + +class Price: + """Class to represent a certain amount of currency. Supported currencies: + + :: + + CURRENCIES = { + 'USD':'$', + 'EUR':'€', + 'GBP':'£', + } + + """ + + def __init__(self, amount: float | None, currency: str | None): + """Price initialisation""" + + if currency not in CURRENCIES: + assert currency is None, f'Unrecognised {currency=}' + assert not amount, f"Null Price can't have {amount=}" + amount = None + + if amount is not None: + amount = float(amount) + + self._amount = amount + self._currency = currency + + ### FACTORIES + + @classmethod + def null(cls) -> 'Price': + """Zero in any currency""" + self = cls.__new__(cls) + self.__init__(None, None) + return self + + @classmethod + def from_dict( + cls, + d: dict, + ) -> 'Price': + """Create a :class:`.Price` object from a dictionary: + + :: + + dict(amount: float, currency: str) + + :param d: dictionary in the above format: + + """ + self = cls.__new__(cls) + self.__init__(d['amount'], d['currency']) + return self + + ### PROPERTIES + + @property + def symbol(self) -> str: + """Currency symbol""" + return CURRENCIES[self.currency] + + @property + def currency(self) -> str: + """Currency string""" + return self._currency + + @property + def amount(self) -> float: + """Amount""" + return self._amount + + @property + def is_null(self) -> bool: + """Is this :meth:`.Price.null` or zero?""" + return self.amount is None + + ### METHODS + + def get_dict(self) -> dict: + """Dictionary in the format: + + :: + + dict(amount: float, currency: str) + + """ + return dict(amount=self.amount, currency=self.currency) + + def copy(self) -> 'Price': + """Return a copy of this :class:`.Price`""" + return Price(amount=self.amount, currency=self.currency) + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + if self.currency is None: + return 'Null Price' + + return f'{self.symbol}{self.amount:.2f} {self.currency}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + def __add__(self, other: 'Price') -> 'Price': + """Add two :class:`.Price` objects + + :param other: :class:`.Price` object + :returns: :class:`.Price` object + + """ + + if other is None: + return self + + if other.is_null: + return self + + if self.is_null: + return other + + if self.currency != other.currency: + raise NotImplementedError( + f'Adding two different currencies: {self.currency} != {other.currency}' + ) + return Price(self.amount + other.amount, self.currency) + + def __truediv__(self, other: 'Price | float | int') -> 'Price | float': + """Divide this :class:`.Price` by another object + + :param other: :class:`.Price` or float or int + :returns: :class:`.Price` object or float + + """ + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return self + return Price(amount=self.amount / other, currency=self.currency) + + elif isinstance(other, Price): + assert self.currency == other.currency + assert not other.is_null + return self.amount / other.amount + + raise TypeError(f'Division not supported between Price and {type(other)}') + + def __mul__(self, other: 'Price | float | int') -> 'Price | float': + """Multiply this :class:`.Price` by another object + + :param other: :class:`.Price` or float or int + :returns: :class:`.Price` object or float + + """ + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return self + return Price(amount=self.amount * other, currency=self.currency) + + raise TypeError(f'Multiplication not supported between Price and {type(other)}') + + def __eq__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return other == 0 + return self.amount == other + + if self.is_null and other.is_null: + return True + + if self.is_null and not other.is_null: + return False + + if not self.is_null and other.is_null: + return False + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount == other.amount + + def __lt__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return False + return self.amount > other + + if self.is_null and other.is_null: + return False + + if self.is_null and not other.is_null: + return True + + if not self.is_null and other.is_null: + return False + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount < other.amount + + def __gt__(self, other: 'Price') -> bool: + """Compare two :class:`.Price` objects""" + + if isinstance(other, int) or isinstance(other, float): + if self.is_null: + return False + return self.amount < other + + if self.is_null and other.is_null: + return False + + if self.is_null and not other.is_null: + return False + + if not self.is_null and other.is_null: + return True + + assert self.currency == other.currency, ( + f'Comparing different currencies: {self.currency} != {other.currency}' + ) + return self.amount > other.amount + + def __hash__(self) -> int: + """Allow for Prices to be hashed for comparison""" + if self.is_null: + return hash('NULL') + return hash(f'{self.currency} {self.amount}') diff --git a/hippo/designdb/components/reaction.py b/hippo/designdb/components/reaction.py new file mode 100644 index 0000000..b918f83 --- /dev/null +++ b/hippo/designdb/components/reaction.py @@ -0,0 +1,184 @@ +"""Reaction component.""" + +import mcol +import mrich +from designdb.models import ( + CataloguePriceCompoundJunctionModel, + CompoundModel, + ReactantModel, + ReactionModel, +) + +from .compound import Compound + +DEFAULT_REACTANT_AMOUNT = 1.0 +DEFAULT_PRODUCT_YIELD = 1.0 + + +class Reaction: + """A :class:`.Reaction` wraps a :class:`.ReactionModel` and represents a + synthetic step from reactant :class:`.Compound` objects to a product.""" + + def __init__(self, instance: ReactionModel) -> None: + self._instance = instance + + ### PROPERTIES + + @property + def id(self) -> int: + """Returns the :class:`.Reaction` ID""" + return self._instance.pk + + @property + def type(self) -> str: + """Returns the reaction type string""" + return self._instance.reaction_type + + @property + def product_yield(self) -> float: + """Returns the reaction product yield (fraction)""" + return self._instance.reaction_product_yield + + @property + def product(self) -> 'Compound': + """Returns the product as a :class:`.Compound` component""" + return Compound(self._instance.product_compound) + + @property + def reactants(self) -> 'list[Compound]': + """Returns the reactant :class:`.Compound` components""" + return [Compound(r.compound) for r in self._instance.reactants.all()] + + @property + def reactant_ids(self) -> list[int]: + """Returns the reactant :class:`.CompoundModel` PKs""" + return list( + self._instance.reactants.values_list('compound_id', flat=True) + ) + + @property + def product_smiles(self) -> str: + """Returns the product compound SMILES""" + return self._instance.product_compound.compound_smiles + + @property + def reaction_str(self) -> str: + """Returns a human-readable reaction string""" + s = ' + '.join(str(r) for r in self.reactants) + return f'{s} -> {self.product}' + + @property + def plain_repr(self) -> str: + """Unformatted long string representation""" + return f'{self}: {self.reaction_str} via {self.type}' + + ### METHODS + + def get_reactant_amount_pairs( + self, compound_object: bool = True + ) -> list[tuple]: + """Returns pairs of (compound, amount) for each reactant. + + :param compound_object: return :class:`.Compound` objects instead of IDs + """ + pairs = [ + (cid, amount if amount is not None else DEFAULT_REACTANT_AMOUNT) + for cid, amount in self._instance.reactants.values_list( + 'compound_id', 'reactant_amount' + ) + ] + if not pairs: + return [] + if compound_object: + return [ + (Compound(CompoundModel.objects.get(pk=cid)), amount) + for cid, amount in pairs + ] + return pairs + + def check_reactant_availability( + self, + supplier: str | None = None, + debug: bool = False, + ) -> bool: + """Check that every reactant either has a catalogue price or can be synthesised. + + :param supplier: restrict price check to this supplier + :param debug: increase verbosity + """ + for reactant_model in self._instance.reactants.all(): + compound = reactant_model.compound + + if debug: + mrich.var('reactant', compound.pk) + + if supplier: + has_quote = CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + catalogue_price__supplier=supplier, + ).exists() + else: + has_quote = CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + ).exists() + + has_reaction = ReactionModel.objects.filter( + product_compound=compound + ).exists() + + if debug: + mrich.debug(f'{has_quote=}, {has_reaction=}') + + if not has_quote and not has_reaction: + if debug: + mrich.warning(f'No quote or reaction for reactant pk={compound.pk}') + return False + + return True + + def get_recipes( + self, + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = False, + permitted_reactions: 'ReactionSet | None' = None, + supplier: str | None = None, + ) -> 'Recipe | list[Recipe]': + """Get a :class:`.Recipe` for this reaction. + + :param amount: amount in mg + """ + from .recipe import Recipe # local to break circular import + return Recipe.from_reaction( + self._instance, + amount=amount, + debug=debug, + pick_cheapest=pick_cheapest, + permitted_reactions=permitted_reactions, + supplier=supplier, + ) + + ### DUNDERS + + def __str__(self) -> str: + return f'R{self.id}' + + def __repr__(self) -> str: + return f'{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + return f'[bold underline]{self.plain_repr}' + + def __eq__(self, other: 'int | Reaction | ReactionModel') -> bool: + match other: + case int(): + return self.id == other + case Reaction(): + return self._instance == other._instance + case ReactionModel(): + return self._instance == other + case _: + raise NotImplementedError + + def __hash__(self) -> int: + return self.id diff --git a/hippo/designdb/components/recipe.py b/hippo/designdb/components/recipe.py new file mode 100644 index 0000000..cc23a38 --- /dev/null +++ b/hippo/designdb/components/recipe.py @@ -0,0 +1,2553 @@ +"""Classes for working with Recipes (reaction networks)""" + +from itertools import product + +import mcol +import mrich +from designdb.models import ComponentModel, CompoundModel, ReactionModel, RouteModel +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet + +from .compound import Compound +from .reaction import DEFAULT_PRODUCT_YIELD, Reaction + + +class Recipe: + """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" + + def __init__( + self, + *, + products: 'IngredientSet | None' = None, + reactants: 'IngredientSet | None' = None, + intermediates: 'IngredientSet | None' = None, + reactions: 'ReactionSet | None' = None, + compounds: 'IngredientSet | None' = None, + ) -> None: + """Recipe initialisation""" + + if products is None: + products = IngredientSet() + + if reactants is None: + reactants = IngredientSet() + + if intermediates is None: + intermediates = IngredientSet() + + if compounds is None: + compounds = IngredientSet() + + if reactions is None: + reactions = ReactionSet() + + # check typing + assert isinstance(products, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(compounds, IngredientSet) + assert isinstance(reactions, ReactionSet) + + self._products = products + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + self._compounds = compounds + self._hash = None + + self._score = None + + # caches + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None + + ### FACTORIES + + @classmethod + def from_reaction( + cls, + reaction, + amount=1, + *, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] = None, + reaction_reactant_cache: dict[int, bool] = None, + inner: bool = False, + get_ingredient_quotes: bool = True, + ) -> 'Recipe | list[Recipe]': + """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream dependencies + + :param reaction: reaction to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param quoted_only: bool: only allow reactants with quotes (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') + :param inner: used to indicate that this is a recursive call (Default value = False) + :param get_ingredient_quotes: get quotes for ingredients in this recipe + + """ + + assert isinstance(reaction, ReactionModel) + reaction_component = Reaction(reaction) + + if debug: + mrich.debug( + f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' + ) + mrich.debug(f'{reaction.product_compound.pk=}') + mrich.debug(f'{reaction_component.reactant_ids=}') + + if permitted_reactions: + assert reaction in permitted_reactions + # raise NotImplementedError + + recipe = cls.__new__(cls) + recipe.__init__( + products=IngredientSet( + [ + Compound(reaction.product_compound).as_ingredient( + amount=amount, get_quote=get_ingredient_quotes + ) + ], + ), + reactants=IngredientSet([], supplier=supplier), + intermediates=IngredientSet([]), + reactions=ReactionSet([reaction.id], sort=False), + ) + + recipes = [recipe] + + if quoted_only or supplier: + if debug: + mrich.debug(f'Checking reactant_availability: {reaction=}') + if reaction_checking_cache and reaction.id in reaction_checking_cache: + ok = reaction_checking_cache[reaction.id] + print('reaction_checking_cache used') + else: + ok = reaction_component.check_reactant_availability(supplier=supplier) + if reaction_checking_cache is not None: + reaction_checking_cache[reaction.id] = ok + if not ok: + if unavailable_reaction == 'error': + mrich.error(f'Reactants not available for {reaction=}') + if pick_cheapest: + return None + else: + return [] + + def get_reactant_amount_pairs(reaction_model: ReactionModel) -> list[tuple[int, float]]: + """Get pairs of reactant ID and float amounts""" + if reaction_reactant_cache and reaction_model.id in reaction_reactant_cache: + print('reaction_reactant_cache used') + return reaction_reactant_cache[reaction_model.id] + else: + pairs = Reaction(reaction_model).get_reactant_amount_pairs(compound_object=False) + if reaction_reactant_cache is not None: + reaction_reactant_cache[reaction_model.id] = pairs + return pairs + + if debug: + mrich.debug(f'get_reactant_amount_pairs({reaction.id})') + pairs = get_reactant_amount_pairs(reaction) + + for reactant, reactant_amount in pairs: + # reactant = db.get_compound(id=reactant) + reactant = Compound(CompoundModel.objects.get(pk=reactant)) + + if debug: + mrich.debug(f'{reactant.id=}, {reactant_amount=}') + + # scale amount + reactant_amount *= amount + reactant_amount /= reaction.reaction_product_yield or DEFAULT_PRODUCT_YIELD + + inner_reactions = reactant.get_reactions( + none='quiet', permitted_reactions=permitted_reactions + ) + + if inner_reactions: + if debug: + if len(inner_reactions) == 1: + mrich.debug('ReactantModel has ONE inner reaction') + else: + mrich.warning(f'{reactant=} has MULTIPLE inner reactions') + + new_recipes = [] + + inner_recipes = [] + for reaction in inner_reactions: + reaction_recipes = Recipe.from_reaction( + reaction=reaction, + amount=reactant_amount, + debug=debug, + pick_cheapest=False, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + inner=True, + ) + inner_recipes += reaction_recipes + + for recipe in recipes: + for inner_recipe in inner_recipes: + combined_recipe = recipe.copy() + + combined_recipe.reactants += inner_recipe.reactants + combined_recipe.intermediates += inner_recipe.intermediates + combined_recipe.reactions += inner_recipe.reactions + combined_recipe.intermediates.add( + reactant.as_ingredient(reactant_amount, supplier=supplier) + ) + + new_recipes.append(combined_recipe) + + recipes = new_recipes + + else: + ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) + for recipe in recipes: + recipe.reactants.add(ingredient) + + # reverse ReactionSet's + if not inner: + for recipe in recipes: + recipe.reactions.reverse() + + if pick_cheapest: + if debug: + mrich.debug('Picking cheapest') + priced = [r for r in recipes if r.get_price(supplier=supplier)] + # priced = [r for r in recipes if r.price] + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return recipes + sorted_recipes = sorted( + priced, key=lambda r: r.get_price(supplier=supplier) + ) + + if debug: + for recipe in recipes: + mrich.debug(f'{recipe}, {recipe.price}') + + return sorted_recipes[0] + # return sorted(priced, key=lambda r: r.price)[0] + + return recipes + + @classmethod + def from_reactions( + cls, + reactions: 'ReactionSet', + amount: float = 1, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + final_products_only: bool = True, + return_products: bool = False, + supplier: str | None = None, + use_routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'Recipe | list[Recipe] | CompoundSet': + """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies + + :param reactions: reactions to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param final_products_only: don't get routes to intermediates (Default value = True) + :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) + + """ + + from designdb.sets.compound import CompoundSet + from designdb.sets.reaction import ReactionSet + + assert isinstance(reactions, ReactionSet) + + if debug: + mrich.debug('Recipe.from_reactions()') + mrich.var('reactions', reactions) + mrich.var('amount', amount) + mrich.var('final_products_only', final_products_only) + mrich.var('permitted_reactions', permitted_reactions) + + # get all the products + products = reactions.products + + if debug: + mrich.var('products', products) + + # return products + + if final_products_only: + if debug: + mrich.var('products.str_ids', products.str_ids) + + # raise NotImplementedError + ids = reactions.db.execute( + f""" + SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound + WHERE reactant_compound IS NULL + AND compound_id IN {products.str_ids} + """ + ).fetchall() + + ids = [i for (i,) in ids] + + products = CompoundSet(db, ids) + if debug: + mrich.var('final products', products) + + # return ids + + if return_products: + return products + + recipe = Recipe.from_compounds( + compounds=products, + amount=amount, + permitted_reactions=reactions, + pick_cheapest=pick_cheapest, + supplier=supplier, + use_routes=use_routes, + **kwargs, + ) + + return recipe + + @classmethod + def from_compounds( + cls, + compounds: 'CompoundSet', + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions=None, + quoted_only: bool = False, + supplier: None | str = None, + solve_combinations: bool = True, + pick_first: bool = False, + warn_multiple_solutions: bool = True, + pick_cheapest_inner_routes: bool = False, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, + use_routes: bool = False, + **kwargs, + ): + """Create recipe(s) to synthesis products in the :class:`.CompoundSet` + + :param compounds: set of compounds to find routes for + :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) + :param pick_first: return the first solution without comparison (Default value = False) + :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) + :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) + :param reaction: reaction to create recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: bool: increase verbosity for debugging (Default value = False) + :param pick_cheapest: bool: choose the cheapest solution (Default value = True) + :param permitted_reactions: once consider reactions in this set (Default value = None) + :param quoted_only: bool: only allow reactants with quotes (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') + + """ + + # from .sets.compound import CompoundSet + + # assert isinstance(compounds, CompoundSet) + compounds = [compounds] + + n_comps = len(compounds) + + assert n_comps + + if not hasattr(amount, '__iter__'): + amount = [amount] * n_comps + + + if use_routes and supplier: + raise NotImplementedError + + options = [] + + ok = 0 + mrich.var('#compounds', n_comps) + + for comp, a in mrich.track( + zip(compounds, amount, strict=False), + prefix='Solving individual compound recipes...', + total=n_comps, + ): + comp_options = [] + + if use_routes: + route_qs = RouteModel.objects.filter(product_compound__id=comp.id) + if not route_qs.exists(): + mrich.error('No routes to', comp) + continue + + comp_options = [] + for route in route_qs: + comp_options.append(route) + + else: + # this assuming i'm not going to use wrapper class + for reaction in Compound(comp).reactions: + if permitted_reactions and reaction not in permitted_reactions: + continue + + sol = Recipe.from_reaction( + reaction=reaction, + amount=a, + pick_cheapest=pick_cheapest_inner_routes, + debug=debug, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + **kwargs, + ) + + if pick_cheapest_inner_routes: + if sol: + comp_options.append(sol) + else: + assert isinstance(sol, list) + comp_options += sol + + if not comp_options: + mrich.error( + f'No solutions for compound={comp} ({Compound(comp).reactions.ids=})' + ) + continue + + if pick_cheapest and len(comp_options) > 1: + if warn_multiple_solutions: + mrich.warning( + 'Multiple solutions for', comp, '(', len(comp_options), ')' + ) + if debug: + mrich.debug('Picking cheapest...') + priced = [r for r in comp_options if r.price] + comp_options = sorted(priced, key=lambda r: r.price)[:1] + + if warn_multiple_solutions and len(comp_options) > 1: + mrich.warning(f'Multiple solutions for compound={comp}') + if debug: + mrich.debug(f'{comp_options=}') + else: + if n_comps <= 200: + mrich.success(f'Found solution for compound={comp}') + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', n_comps) + + options.append(comp_options) + + assert all(options) + + mrich.print('Solving recipe combinations...') + combinations = list(product(*options)) + + if not solve_combinations: + return combinations + + solutions = [] + + if n_comps > 1: + generator = mrich.track( + combinations, prefix='Combining recipes...', total=len(combinations) + ) + else: + generator = combinations + + ok = 0 + for combo in generator: + if debug: + mrich.debug(f'Combination of {len(combo)} recipes') + + if not combo: + continue + + solution = combo[0] + + for i, recipe in enumerate(combo[1:]): + if debug: + mrich.debug(i + 1) + solution += recipe + + solutions.append(solution) + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', len(combinations)) + + if not solutions: + mrich.error('No solutions') + return None + + if pick_first: + return solutions[0] + + if pick_cheapest: + mrich.debug('Calculating prices...') + priced = [r for r in solutions if r.price] + mrich.print('Picking cheapest from', len(priced), 'options') + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return solutions + return sorted(priced, key=lambda r: r.price)[0] + + return solutions + + @classmethod + def from_reactants( + cls, + reactants: 'CompoundSet | IngredientSet', + amount: float = 1, + debug: bool = False, + return_products: bool = False, + supplier: str | None = None, + pick_cheapest: bool = False, + use_routes: bool = False, + **kwargs, + ) -> 'list[Recipe] | Recipe | CompoundSet': + """Find the maximal recipe from a given set of reactants + + :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored + :param amount: amount of each product needed (Default value = 1) + :param debug: increase verbosity (Default value = False) + :param return_products: return products instead of recipe (Default value = False) + :param kwargs: passed to :meth:`.Recipe.from_reactions` + + """ + + from designdb.sets.compound import IngredientSet + + if isinstance(reactants, IngredientSet): + reactant_ids = reactants.compound_ids + else: + reactant_ids = reactants.ids + + db = reactants.db + + all_reactants = set(reactant_ids) + + possible_reactions = [] + + # recursively search for possible reactions + for i in range(300): + if debug: + mrich.debug(i) + + # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) + reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) + + if not reaction_ids: + break + + if debug: + mrich.debug(f'Adding {len(reaction_ids)} reactions') + + possible_reactions += reaction_ids + + if debug: + mrich.var('reaction_ids', reaction_ids) + + product_ids = db.get_possible_reaction_product_ids( + reaction_ids=reaction_ids + ) + + if debug: + mrich.var('product_ids', product_ids) + + n_prev = len(all_reactants) + + all_reactants |= set(product_ids) + + if n_prev == len(all_reactants): + break + + else: + raise NotImplementedError('Maximum recursion depth exceeded') + + possible_reactions = list(set(possible_reactions)) + + if debug: + mrich.var('all possible reactions', possible_reactions) + + from designdb.sets.reaction import ReactionSet + + rset = ReactionSet(db, possible_reactions, sort=False) + + recipe = cls.from_reactions( + rset, + amount=amount, + permitted_reactions=rset, + debug=debug, + return_products=return_products, + supplier=supplier, + use_routes=use_routes, + **kwargs, + ) + + return recipe + + @classmethod + def from_json( + cls, + db: 'Database', + path: 'str | Path', + debug: bool = True, + allow_db_mismatch: bool = False, + clear_quotes: bool = False, + data: dict = None, + db_mismatch_warning: bool = True, + ): + """Load a serialised recipe from a JSON file + + :param db: database to link + :param path: path to JSON + :param debug: increase verbosity (Default value = True) + :param allow_db_mismatch: allow a database mismatch (Default value = False) + :param clear_quotes: ignore reactant quotes (Default value = False) + :param data: serialised data (Default value = None) + + """ + + # imports + import json + + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + # load JSON + if not data: + if debug: + mrich.reading(path) + data = json.load(open(path)) + + # check metadata + if str(db.path.resolve()) != data['database']: + if db_mismatch_warning: + mrich.var('session', str(db.path.resolve())) + mrich.var('in file', data['database']) + if allow_db_mismatch: + if db_mismatch_warning: + mrich.warning('Database path mismatch') + else: + mrich.error( + 'Database path mismatch, set allow_db_mismatch=True to ignore' + ) + return None + + if debug: + mrich.print(f'Recipe was generated at: {data["timestamp"]}') + price = data['price'] + + # IngredientSets + products = IngredientSet.from_ingredient_dicts(db, data['products']) + intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) + reactants = IngredientSet.from_ingredient_dicts( + db, data['reactants'], supplier=data['reactant_supplier'] + ) + + if 'compounds' in data: + compounds = IngredientSet.from_ingredient_dicts( + db, data['compounds'], supplier=data['compound_supplier'] + ) + else: + compounds = IngredientSet(db) + + if clear_quotes: + reactants.df['quote_id'] = None + reactants.df['quoted_amount'] = None + compounds.df['quote_id'] = None + compounds.df['quoted_amount'] = None + + # ReactionSet + reactions = ReactionSet(db, data['reaction_ids'], sort=False) + + if debug: + mrich.var('reactants', reactants) + mrich.var('intermediates', intermediates) + mrich.var('products', products) + mrich.var('reactions', reactions) + mrich.var('compounds', compounds) + + # Create the object + self = cls.__new__(cls) + self.__init__( + products=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + compounds=compounds, + ) + + return self + + ### PROPERTIES + + @property + def products(self) -> 'IngredientSet': + """Product :class:`.IngredientSet`""" + return self._products + + @property + def compounds(self) -> 'IngredientSet': + """Product :class:`.IngredientSet`""" + return self._compounds + + @compounds.setter + def compounds(self, a: 'IngredientSet'): + """Set the compounds""" + self._compounds = a + self.__flag_modification() + + @property + def poses(self) -> 'PoseSet': + """Product poses""" + if self._poses is None: + self._poses = self.combined_compounds.poses + self._poses._name = f'poses of {self}' + return self._poses + + @property + def product_compounds(self) -> 'CompoundSet': + """Product compounds""" + if self._product_compounds is None: + self._product_compounds = self.products.compounds + self._product_compounds._name = f'products of {self}' + return self._product_compounds + + @property + def combined_compound_ids(self) -> set[int]: + """Combined :class:`.CompoundModel` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" + return set(self.product_compounds.ids) | set(self.compounds.ids) + + @property + def combined_compounds(self) -> 'CompoundSet': + """Combined product and no-chem compounds""" + if self._combined_compounds is None: + from designdb.sets.compound import CompoundSet + + self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) + self._combined_compounds._name = f'combined compounds of {self}' + return self._combined_compounds + + @property + def interactions(self) -> 'InteractionSet': + """Product pose interactions""" + if self._interactions is None: + self._interactions = self.poses.interactions + return self._interactions + + @property + def product(self) -> 'Ingredient': + """Return single product (if there's only one)""" + assert len(self.products) == 1 + return self.products[0] + + @products.setter + def products(self, a: 'IngredientSet'): + """Set the products""" + self._products = a + self.__flag_modification() + + @property + def reactants(self): + """ReactantModel :class:`.IngredientSet`""" + return self._reactants + + @reactants.setter + def reactants(self, a: 'IngredientSet'): + """Set the reactants""" + self._reactants = a + self.__flag_modification() + + @property + def intermediates(self) -> 'IngredientSet': + """Intermediates :class:`.IngredientSet`""" + return self._intermediates + + @intermediates.setter + def intermediates(self, a: 'IngredientSet'): + """Set the intermediates""" + self._intermediates = a + # self.__flag_modification() + + @property + def reactions(self) -> 'ReactionSet': + """Intermediates :class:`.IngredientSet`""" + return self._reactions + + @reactions.setter + def reactions(self, a: 'ReactionSet'): + """Set the reactions""" + self._reactions = a + self.__flag_modification() + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.get_price() + self.compounds.get_price() + + @property + def num_products(self) -> int: + """Return the number of products""" + return len(self.products) + + @property + def num_compounds(self) -> int: + """Return the number of compounds""" + return len(self.combined_compound_ids) + + @property + def num_reactions(self): + """Return the number of reactions""" + return len(self.reactions) + + @property + def num_reaction_types(self): + """Return the number of reactions""" + return self.reactions.num_types + + @property + def num_reactants(self): + """Return the number of reactants""" + return len(self.reactants) + + @property + def num_intermediates(self): + """Return the number of intermediates""" + return len(self.intermediates) + + @property + def hash(self) -> str: + """Return the unique hash string""" + return self._hash + + @property + def score(self): + """Return the Recipe score""" + return self._score + + @property + def type(self) -> str: + """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" + + if self.empty: + return 'EMPTY' + + chem = bool(self.reactions) + nochem = bool(self.compounds) + + if chem and nochem: + return 'MIXED' + + if chem and not nochem: + return 'CHEM' + + if nochem and not chem: + return 'NOCHEM' + + @property + def empty(self) -> bool: + """Is this Recipe empty?""" + + if self.reactants: + return False + + if self.products: + return False + + if self.intermediates: + return False + + if self.reactions: + return False + + if self.compounds: + return False + + return True + + ### METHODS + + def get_price(self, supplier: str | None = None) -> 'Price': + """get the reactants price. See :meth:`.IngredientSet.get_price` + + :param supplier: restrict quotes to this supplier + + """ + return self.reactants.get_price(supplier=supplier) + + def draw(self, color_mapper=None, node_size=300, graph_only=False): + """draw graph of the reaction network + + :param color_mapper: (Default value = None) + :param node_size: (Default value = 300) + :param graph_only: (Default value = False) + + """ + + import networkx as nx + + color_mapper = color_mapper or {} + colors = {} + sizes = {} + + graph = nx.DiGraph() + + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + key = str(reactant) + ingredient = self.get_ingredient(id=reactant.id) + + graph.add_node( + key, + id=reactant.id, + smiles=reactant.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + + if not graph_only: + sizes[key] = self.get_ingredient(id=reactant.id).amount + if key in color_mapper: + colors[key] = color_mapper[key] + else: + colors[key] = (0.7, 0.7, 0.7) + + for product in self.products: + key = str(product.compound) + ingredient = self.get_ingredient(id=product.id) + + graph.add_node( + key, + id=product.id, + smiles=product.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, + ) + + if not graph_only: + sizes[key] = product.amount + if key in color_mapper: + colors[key] = color_mapper[key] + else: + colors[key] = (0.7, 0.7, 0.7) + + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + graph.add_edge( + str(reactant), + str(reaction.product), + id=reaction.id, + type=reaction.type, + product_yield=reaction.product_yield, + ) + + # rescale sizes + if not graph_only: + s_min = min(sizes.values()) + sizes = [s / s_min * node_size for s in sizes.values()] + + if graph_only: + return graph + else: + # return nx.draw(graph, pos, with_labels=True, font_weight='bold') + # pos = nx.spring_layout(graph, iterations=200, k=30) + pos = nx.spring_layout(graph) + return nx.draw( + graph, + pos=pos, + with_labels=True, + font_weight='bold', + node_color=list(colors.values()), + node_size=sizes, + ) + + def sankey(self, title: str | None = None) -> 'graph_objects.Figure': + """draw a plotly Sankey diagram + + :param title: (Default value = None) + + """ + + graph = self.draw(graph_only=True) + + import plotly.graph_objects as go + + nodes = {} + + for edge in graph.edges: + c = edge[0] + if c not in nodes: + nodes[c] = len(nodes) + + c = edge[1] + if c not in nodes: + nodes[c] = len(nodes) + + source = [nodes[a] for a, b in graph.edges] + target = [nodes[b] for a, b in graph.edges] + value = [1 for l in graph.edges] + + labels = list(nodes.keys()) + + hoverkeys = None + + customdata = [] + for key in nodes.keys(): + n = graph.nodes[key] + + if not hoverkeys: + hoverkeys = list(n.keys()) + + if not n: + mrich.error(f'problem w/ node {key=}') + compound_id = int(key[1:]) + customdata.append((compound_id, None)) + + else: + d = tuple(v if v is not None else 'N/A' for v in n.values()) + customdata.append(d) + + hoverkeys_edges = None + + customdata_edges = [] + + for s, t in graph.edges.keys(): + edge = graph.edges[s, t] + + if not hoverkeys_edges: + hoverkeys_edges = list(edge.keys()) + + if not n: + mrich.error(f'problem w/ edge {s=} {t=}') + customdata_edges.append((None, None, None)) + + else: + d = tuple(v if v is not None else 'N/A' for v in edge.values()) + customdata_edges.append(d) + + hoverlines = [] + for i, key in enumerate(hoverkeys): + hoverlines.append(f'{key}=%{{customdata[{i}]}}') + hovertemplate = 'CompoundModel ' + '
'.join(hoverlines) + '' + + hoverlines_edges = [] + for i, key in enumerate(hoverkeys_edges): + hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') + hovertemplate_edges = ( + 'ReactionModel ' + '
'.join(hoverlines_edges) + '' + ) + + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + # pad = 15, + # thickness = 20, + # line = dict(color = "black", width = 0.5), + label=labels, + # color = "blue" + customdata=customdata, + # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", + # "Long name C1", "Long name C2"], + # hovertemplate='CompoundModel %{label}

smiles=%{customdata}', + hovertemplate=hovertemplate, + ), + link=dict( + customdata=customdata_edges, + hovertemplate=hovertemplate_edges, + source=source, + target=target, + value=value, + ), + ) + ] + ) + + if not title: + try: + title = f'Recipe
price={self.price}' + except AssertionError: + title = 'Recipe' + + fig.update_layout(title=title) + + return fig + + def summary(self, price: bool = True) -> None: + """Print a summary of this recipe + + :param price: print the price (Default value = True) + + """ + + mrich.h1(str(self)) + + if price: + price = self.price + if price: + mrich.var('\nprice', price.amount, price.currency) + # mrich.var('lead-time', self.lead_time, 'working days)) + + if self.products: + mrich.h3(f'{len(self.products)} products') + + if len(self.products) < 100: + for product in self.products: + mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') + + if self.intermediates: + mrich.h3(f'{len(self.intermediates)} intermediates') + + if len(self.intermediates) < 100: + for intermediate in self.intermediates: + mrich.var( + str(intermediate.compound), + f'{intermediate.amount:.2f}', + 'mg', + ) + + if self.reactants: + mrich.h3(f'{len(self.reactants)} reactants') + + if len(self.reactants) < 100: + for reactant in self.reactants: + mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') + + if self.reactions: + mrich.h3(f'{len(self.reactions)} reactions') + + if len(self.reactions) < 100: + for reaction in (Reaction(r) for r in self.reactions): + mrich.var(str(reaction), reaction.reaction_str, reaction.type) + + if hasattr(self, '_compounds') and self.compounds: + mrich.h3(f'{len(self.compounds)} compounds') + + if len(self.compounds) < 100: + for compound in self.compounds: + mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') + + def get_ingredient(self, id) -> 'Ingredient': + """Get an ingredient by its compound ID + + :param id: compound ID + + """ + matches = [r for r in self.reactants if r.id == id] + if not matches: + matches = [r for r in self.intermediates if r.id == id] + if not matches: + matches = [r for r in self.products if r.id == id] + + assert len(matches) == 1 + return matches[0] + + def add_to_all_reactants(self, amount: float = 20) -> None: + """Increment all reactants by this amount + + :param amount: amount in ``mg`` (Default value = 20) + + """ + self.reactants.df['amount'] += amount + + def write_json( + self, + file: 'str | Path', + *, + extra: dict | None = None, + indent: str = '\t', + **kwargs, + ) -> None: + """Serialise this recipe object and write it to disk + + :param file: write to this path + :param extra: extra data to serialise + :param indent: indentation whitespace (Default value = '\t') + + """ + import json + from pathlib import Path + + file = Path(file).resolve() + + assert file.parent.exists(), f'Directory does not exist: {file.parent}' + + data = self.get_dict(serialise_price=True, **kwargs) + + if extra: + data.update(extra) + + mrich.writing(file) + json.dump(data, open(file, 'w'), indent=indent) + + def get_dict( + self, + *, + price: bool = True, + reactant_supplier: bool = True, + compound_supplier: bool = True, + database: bool = True, + timestamp: bool = True, + compound_ids_only: bool = False, + products: bool = True, + serialise_price: bool = False, + ): + """Serialise this recipe object + + Store + ===== + + - Path to database + - Timestamp + - Reactants (& their quotes, amounts) + - Intermediates (& their quotes) + - Products (& their poses/scores/fingerprints) + - Reactions + - Total Price + - Lead time + + :param price: include the price (Default value = True) + :param reactant_supplier: include the supplier (Default value = True) + :param database: include the database (Default value = True) + :param timestamp: add a timestamp (Default value = True) + :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) + :param products: include products (Default value = True) + :param serialise_price: serialise :class:`.Price` object (Default value = False) + + """ + + from datetime import datetime + + data = {} + + # Database + if database: + data['database'] = str(self.db.path.resolve()) + if timestamp: + data['timestamp'] = str(datetime.now()) + + # Recipe properties + try: + if price and serialise_price: + data['price'] = self.price.get_dict() + elif price: + data['price'] = self.price + except AssertionError as e: + mrich.warning(f'Could not get price: {e}') + data['price'] = None + + if reactant_supplier: + data['reactant_supplier'] = self.reactants.supplier + + if compound_supplier: + data['compound_supplier'] = self.compounds.supplier + + # IngredientSets + if compound_ids_only: + data['reactant_ids'] = self.reactants.compound_ids + data['intermediate_ids'] = self.intermediates.compound_ids + if products: + data['products_ids'] = self.products.compound_ids + data['compound_ids'] = self.compounds.compound_ids + + else: + data['reactants'] = self.reactants.df.to_dict(orient='list') + data['intermediates'] = self.intermediates.df.to_dict(orient='list') + if products: + data['products'] = self.products.df.to_dict(orient='list') + data['compounds'] = self.compounds.df.to_dict(orient='list') + + # ReactionSet + data['reaction_ids'] = self.reactions.ids + + return data + + def get_routes(self, return_ids: bool = False) -> 'RouteSet': + """Get routes""" + return self.products.get_routes( + permitted_reactions=self.reactions, return_ids=return_ids + ) + + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate missing routes to products of this Recipe""" + + return products.compounds.register_missing_routes( + missing_only=missing_only, supplier=supplier + ) + + if missing_only: + from designdb.sets.compound import CompoundSet + + records = self.db.select_where( + table='route', + key=f'route_product IN {products.str_ids}', + query='route_product', + multiple=True, + ) + existing = set(i for (i,) in records) + missing = set(products.ids) - existing + products = CompoundSet(self.db, missing) + + mrich.var('#products', len(products)) + + for i, c in mrich.track(enumerate(products), total=len(products)): + try: + reactions = c.reactions + except Exception as e: + mrich.error(f"Error getting {c}'s reactions", e) + continue + + for reaction in reactions: + try: + recipes = reaction.get_recipes(supplier=supplier) + except Exception as e: + mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) + continue + + for recipe in recipes: + route = self.db.register_route(recipe=recipe) + + mrich.print(f'registered {route=}') + + self.db.prune_duplicate_routes() + + def write_CAR_csv( + self, file: 'str | Path', return_df: bool = False + ) -> 'DataFrame | None': + """Prepares CSVs for use with CAR. + + .. attention:: + + This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead + + Columns: + + * target-name + * no-steps + * concentration = None + * amount-required + * batch-tag + + per reaction + + * reactant-1-1 + * reactant-2-1 + * reaction-product-smiles-1 + * reaction-name-1 + * reaction-recipe-1 + * reaction-groupby-column-1 + + :param file: file to write to + :param return_df: return the dataframe (Default value = False) + + """ + + from pathlib import Path + + from pandas import DataFrame + + # solve each product's reaction + + file = str(Path(file).resolve()) + + rows = [] + + routes = self.get_routes() + + for sub_recipe in routes: + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction in enumerate(Reaction(r) for r in sub_recipe.reactions): + i = i + 1 + + row['no-steps'] += 1 + + match len(reaction.reactants): + case 1: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reaction.reactants[0].smiles + row[f'reactant-2-{i}'] = reaction.reactants[1].smiles + case _: + # mrich.warning(f"More than two reactants for {reaction=}") + for j, r in enumerate(reaction.reactants): + row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles + + row[f'reaction-product-smiles-{i}'] = reaction.product_smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + # row[f'reaction-id-{i}'] = int(reaction.id) + + rows.append(row) + + df = DataFrame(rows) + + if len(df[df.duplicated()]): + mrich.warning('Removing duplicates from CAR DataFrame') + df = df.drop_duplicates() + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + return df + + def write_reactant_csv( + self, + file: 'str | Path', + reaction_type_counts: bool = True, + return_df: bool = False, + ) -> 'DataFrame | None': + """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use + + ReactantModel + ======== + + - ID + - SMILES + - Inchikey + + Quote + ===== + + - Supplier + - Catalogue + - Entry + - Lead-time + - Quoted amount + - Quote currency + - Quote price + - Quote purity + + Downstream + ========== + + - num_reaction_dependencies + - num_product_dependencies + - reaction_dependencies + - product_dependencies + + """ + # - remove_with + + # from rich import print + + data = [] + + ### Get lookup data + + route_ids = self.get_routes(return_ids=True) + + sql = f""" + SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route + WHERE component_type = 2 + AND component_ref IN {self.reactants.compounds.str_ids} + AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} + """ + product_lookup = {} + for reactant_id, product_id in self.db.execute(sql): + product_lookup.setdefault(reactant_id, set()) + product_lookup[reactant_id].add(product_id) + + sql = f""" + WITH reactants AS ( + SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component + WHERE component_type = 2 + AND component_ref IN {self.reactants.compounds.str_ids} + ), + + reactions AS ( + SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id + WHERE component_type = 1 + AND component_ref IN {self.reactions.str_ids} + ) + + SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id + """ + reaction_lookup = {} + for reactant_id, reaction_id, reaction_type in self.db.execute(sql): + reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) + reaction_lookup[reactant_id]['ids'].add(reaction_id) + reaction_lookup[reactant_id]['types'].add(reaction_type) + reaction_lookup[reactant_id].setdefault('counts', {}) + reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) + reaction_lookup[reactant_id]['counts'][reaction_type] += 1 + + smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) + + inchikey_lookup = self.db.get_compound_id_inchikey_dict( + self.reactants.compounds + ) + + ### ReactantModel Dataframe + + df = self.reactants.df + + df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) + df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) + df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) + + ### Quote DataFrame + + qdf = self.db.get_quote_df(self.reactants.quote_ids) + + qdf = qdf.rename( + columns={ + 'id': 'quote_id', + 'smiles': 'quoted_smiles', + 'purity': 'quoted_purity', + 'date': 'quote_date', + 'lead_time': 'quote_lead_time_days', + 'price': 'quote_price', + 'currency': 'quote_currency', + 'catalogue': 'quote_catalogue', + 'supplier': 'quote_supplier', + 'entry': 'quote_entry', + 'amount': 'quoted_amount_mg', + } + ) + qdf = qdf.drop(columns=['compound']) + + ### Downstream info + + try: + df['downstream_product_ids'] = df['compound_id'].apply( + lambda x: product_lookup.get(x, set()) + ) + + df['downstream_reaction_ids'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['ids'] + ) + df['downstream_reaction_types'] = df['compound_id'].apply( + lambda x: reaction_lookup[x]['types'] + ) + except KeyError as e: + mrich.error(f'ReactantModel C{e} is missing downstream reaction') + mrich.error( + 'Are all routes enumerated? Try running calculate_missing_routes()' + ) + return None + + df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) + df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) + df['num_downstream_products'] = df['downstream_product_ids'].apply(len) + + ### Join and reformat + + df = df.merge(qdf, on='quote_id', how='left') + + df = df.rename( + columns={ + 'amount': 'required_amount_mg', + } + ) + + cols = [ + 'compound_id', + 'smiles', + 'inchikey', + 'required_amount_mg', + 'quoted_amount_mg', + 'quote_id', + 'quote_supplier', + 'quote_catalogue', + 'quote_entry', + 'quote_price', + 'quote_currency', + 'quote_lead_time_days', + 'quoted_purity', + 'quoted_smiles', + 'quote_date', + 'num_downstream_products', + 'num_downstream_reaction_types', + 'num_downstream_reactions', + ] + + if reaction_type_counts: + for i, row in df.iterrows(): + counts = reaction_lookup[row['compound_id']]['counts'] + + for reaction_type, count in counts.items(): + key = f'num_downstream ({reaction_type})' + df.loc[i, key] = count + if key not in cols: + cols.append(key) + + cols += [ + 'downstream_product_ids', + 'downstream_reaction_types', + 'downstream_reaction_ids', + ] + + df = df[[c for c in cols if c in df.columns]] + + ### Add estimated quotes + + unquoted = df[df['quote_id'].isna()] + + if len(unquoted): + for i, row in unquoted.iterrows(): + compound = self.db.get_compound(id=row['compound_id']) + ingredient = compound.as_ingredient( + amount=row['required_amount_mg'], get_quote=False + ) + + quote = ingredient.quote + + df.loc[i, 'quoted_amount_mg'] = quote.amount + df.loc[i, 'quote_supplier'] = quote.supplier + df.loc[i, 'quote_catalogue'] = quote.catalogue + df.loc[i, 'quote_entry'] = quote.entry + df.loc[i, 'quote_price'] = quote.price.amount + df.loc[i, 'quote_currency'] = quote.price.currency + df.loc[i, 'quote_lead_time_days'] = quote.lead_time + df.loc[i, 'quoted_purity'] = quote.purity + df.loc[i, 'quoted_smiles'] = quote.smiles + df.loc[i, 'quote_date'] = quote.date + + ### N.B. scaffold series no longer output + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def write_product_csv( + self, file: 'str | Path', return_df: bool = False + ) -> 'pd.DataFrame | None': + """Detailed CSV output including product information for selection and synthesis""" + + # from rich import print + from designdb.sets.pose import PoseSet + from designdb.sets.reaction import ReactionSet + from pandas import DataFrame + + data = [] + + routes = self.get_routes() + + pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) + + inspiration_map = self.db.get_compound_id_inspiration_ids_dict() + + for product in mrich.track( + self.products, prefix='Constructing product DataFrame' + ): + d = dict( + hippo_id=product.compound_id, + smiles=product.smiles, + inchikey=product.inchikey, + required_amount_mg=product.amount, + ) + + upstream_routes = [] + upstream_reactions = [] + + for route in routes: + if product in route.products: + upstream_routes.append(route) + + for reaction in route.reactions: + upstream_reactions.append(reaction) + + upstream_reactions = ReactionSet( + self.db, set(reaction.id for reaction in upstream_reactions) + ) + + if not upstream_routes: + mrich.error('No upstream routes for', product) + continue + + if not upstream_reactions: + mrich.error('No upstream reactions for', product) + continue + + def get_scaffold_series() -> tuple[list[int], bool]: + """Get scaffold series value""" + + if scaffolds := product.scaffolds: + return scaffolds.ids, False + + else: + return [product.id], True + + poses = pose_map.get(product.id, set()) + + d['num_poses'] = len(poses) + d['poses'] = poses + d['tags'] = product.tags + d['num_routes'] = len(upstream_routes) + d['num_reaction_steps'] = set( + len(route.reactions) for route in upstream_routes + ) + d['reaction_dependencies'] = upstream_reactions.ids + d['reactant_dependencies'] = set( + sum([route.reactants.ids for route in upstream_routes], []) + ) + d['route_ids'] = [route.id for route in upstream_routes] + d['chemistry_types'] = ', '.join(upstream_reactions.types) + series, is_scaffold = get_scaffold_series() + d['is_scaffold'] = is_scaffold + d['scaffold_series'] = series + + inspirations = inspiration_map.get(product.id, None) + + if not inspirations and not is_scaffold: + scaffold = product.scaffolds[0] + inspirations = inspiration_map.get(scaffold.id, None) + + if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: + inspirations = scaffold.metadata['inspiration_pose_ids'] + + if ( + not inspirations + and is_scaffold + and 'inspiration_pose_ids' in product.metadata + ): + inspirations = product.metadata['inspiration_pose_ids'] + + if inspirations: + inspirations = PoseSet(self.db, inspirations) + d['inspirations'] = ', '.join(n for n in inspirations.names) + else: + d['inspirations'] = '' + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def write_chemistry_csv( + self, file: 'str | Path', return_df: bool = True + ) -> 'pd.DataFrame | None': + """Detailed CSV output synthetis information for chemistry types in this set""" + + from designdb.sets.compound import CompoundSet + from pandas import DataFrame + + data = [] + + # get compounds + + scaffolds = CompoundSet(self.db) + + for product in self.products: + if scaffolds := product.scaffolds: + scaffolds += scaffolds + else: + scaffolds.add(product.compound) + + routes = self.get_routes() + + route_types = {} + + for compound in scaffolds: + elabs = ( + self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') + or [] + ) + + d = dict( + scaffold_id=compound.id, + product_id=compound.id, + smiles=compound.smiles, + inchikey=compound.inchikey, + num_elaborations=len(elabs), + is_scaffold=True, + ) + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + if not upstream_routes: + mrich.warning(f'No routes to scaffold={compound}') + continue + + d['num_routes'] = len(upstream_routes) + + for j, route in enumerate(upstream_routes): + d[f'route_{j + 1}_num_steps'] = len(route.reactions) + + group = route_types.setdefault(compound.id, set()) + group.add(tuple([Reaction(r).type for r in route.reactions])) + + for k, reaction in enumerate(Reaction(r) for r in route.reactions): + key = f'route_{j + 1}_reaction_{k + 1}' + + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = reaction.product_smiles + d[f'{key}_product_id'] = reaction.product.id + d[f'{key}_product_yield'] = reaction.product_yield + + for i, reactant in enumerate(reaction.reactants): + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id + + data.append(d) + + missing_scaffolds = {} + + for compound in self.products.compounds: + if compound in scaffolds: + continue + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + scaffolds = compound.scaffolds + + for scaffold in scaffolds: + if scaffold.id not in route_types: + group = missing_scaffolds.setdefault(scaffold.id, []) + group.append(compound.id) + continue + + else: + for route in upstream_routes: + chem_types = tuple([Reaction(r).type for r in route.reactions]) + + if chem_types not in route_types[base.id]: + mrich.success(scaffold) + mrich.success(chem_types) + raise ValueError( + 'ScaffoldModel has route not present in dataframe' + ) + + for scaffold_id, elab_ids in missing_scaffolds.items(): + compound = self.db.get_compound(id=sorted(elab_ids)[0]) + + d = dict( + scaffold_id=scaffold_id, + product_id=compound.id, + smiles=compound.smiles, + inchikey=compound.inchikey, + num_elaborations=len(elab_ids), + is_scaffold=False, + ) + + upstream_routes = [] + for route in routes: + if compound in route.products: + upstream_routes.append(route) + + if not upstream_routes: + mrich.error(f'No routes to elab {compound}') + raise ValueError(f'No routes to elab {compound}') + + d['num_routes'] = len(upstream_routes) + + for j, route in enumerate(upstream_routes): + d[f'route_{j + 1}_num_steps'] = len(route.reactions) + + group = route_types.setdefault(compound.id, set()) + group.add(tuple([Reaction(r).type for r in route.reactions])) + + for k, reaction in enumerate(Reaction(r) for r in route.reactions): + key = f'route_{j + 1}_reaction_{k + 1}' + + d[f'{key}_type'] = reaction.type + d[f'{key}_product_smiles'] = reaction.product_smiles + d[f'{key}_product_id'] = reaction.product.id + d[f'{key}_product_yield'] = reaction.product_yield + + for i, reactant in enumerate(reaction.reactants): + d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles + d[f'{key}_reactant_{i + 1}_id'] = reactant.id + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + + return None + + def to_syndirella( + self, + out_key: 'str | Path', + poses: 'PoseSet', + *, + separate: bool = False, + ) -> 'DataFrame': + """Generate inputs for running syndirella elaboration""" + + import shutil + from pathlib import Path + + out_key = Path('.') / out_key + out_dir = out_key.parent + out_key = out_key.name + + mrich.var('out_key', out_key) + mrich.var('out_dir', out_dir) + + if not out_dir.exists(): + mrich.writing(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + template_dir = out_dir / 'templates' + if not template_dir.exists(): + mrich.writing(template_dir) + template_dir.mkdir(parents=True, exist_ok=True) + + """ + + Need to create dataframe with columns: + - compound_id + - pose_id + - smiles + - reaction_name_step1 + - reactant_step1 + - reactant2_step1 + - product_step1 + ... + - hit1 + - hit2 + ... + - template + - compound_set + + """ + + pose_compounds = poses.compounds + assert set(self.products.compound_ids) == set(pose_compounds.ids), ( + 'supplied poses have different compounds to Recipe products' + ) + assert len(poses) == len(self.products), ( + 'some duplicate compounds in supplied poses' + ) + + df = poses.get_df( + inchikey=False, + alias=False, + name=False, + compound_id=True, + reference_id=True, + inspiration_aliases=True, + ) + + df = df.reset_index() + df = df.rename(columns={'id': 'pose_id'}) + df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') + df = df.set_index(['compound_id', 'pose_id']) + + ## CHECKS + + no_refs = df[df['reference_id'].isna()] + + if len(no_refs): + mrich.error(len(no_refs), 'poses without reference!') + ids = set(no_refs.index.get_level_values('pose_id')) + mrich.print(ids) + + no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) + + if no_insps: + mrich.error(len(no_insps), 'poses without inspirations!') + return None + + ## TEMPLATES + + references = poses.references + ref_lookup = self.db.get_pose_id_alias_dict(references) + df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) + + for ref_pose in references: + assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' + + template = template_dir / ref_pose.apo_path.name + + if not template.exists(): + mrich.writing(template) + shutil.copy(ref_pose.apo_path, template) + + ## INSPIRATIONS + + for i, row in df.iterrows(): + for j, alias in enumerate(row['inspiration_aliases']): + df.loc[i, f'hit{j + 1}'] = alias + + inspirations = poses.inspirations + + sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' + + inspirations.write_sdf( + sdf_name, + tags=False, + metadata=False, + name_col='name', + ) + + ## ADD ROUTE INFO + + routes = self.get_routes() + + for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): + product = sub_recipe.product + + product_id = product.compound_id + + matches = df.xs(product_id, level='compound_id') + + if len(matches) > 1: + mrich.warning('Multiple rows for compound', product_id) + + for i, row in matches.iterrows(): + key = (product_id, i) + + for j, reaction in enumerate(Reaction(r) for r in sub_recipe.reactions): + j = j + 1 + + match len(reaction.reactants): + case 1: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = None + case 2: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ + 1 + ].smiles + case 3: + df.loc[key, f'reactant_step{j}'] = reaction.reactants[ + 0 + ].smiles + df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ + 1 + ].smiles + df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ + 2 + ].smiles + case _: + raise NotImplementedError('Too many reactants') + + df.loc[key, f'product_step{j}'] = reaction.product_smiles + df.loc[key, f'reaction_name_step{j}'] = reaction.type + + break + + ## REMOVE UNECESSARY COLS + + df = df.drop(columns=['reference_id', 'inspiration_aliases']) + + ## REORDER COLUMNS + + cols = [ + 'smiles', + 'reaction_name_step1', + 'reactant_step1', + 'reactant2_step1', + 'reactant3_step1', + 'product_step11', + 'hit1', + 'hit2', + 'hit3', + 'hit4', + 'hit5', + 'hit6', + 'hit7', + 'hit8', + 'hit9', + 'template', + 'compound_set', + ] + + if not any([c not in cols for c in df.columns]): + df = df[[c for c in cols if c in df.columns]] + + if not separate: + out_path = out_dir / f'{out_key}_syndirella_input.csv' + mrich.writing(out_path) + df.to_csv(out_path) + return df + + for idx, row in df.iterrows(): + out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' + mrich.writing(out_path) + single_df = row.to_frame().T + single_df = single_df.dropna(axis=1, how='all') + single_df.to_csv(out_path, index=False) + + return df + + def copy(self) -> 'Recipe': + """Copy this recipe""" + + if hasattr(self, 'compounds'): + compounds = self.compounds.copy() + else: + compounds = None + + return Recipe( + self.db, + products=self.products.copy(), + reactants=self.reactants.copy(), + intermediates=self.intermediates.copy(), + reactions=self.reactions.copy(), + compounds=compounds, + # supplier=self.supplier + ) + + def __flag_modification(self) -> None: + """Flag this recipe as modified""" + self._product_interactions = None + self._score = None + self._product_compounds = None + self._product_poses = None + + def check_integrity(self, debug: bool = False) -> bool: + """Verify integrity of this recipe""" + + # no duplicate ingredients + + if debug: + mrich.debug('Checking integrity:', self) + mrich.debug('Checking for duplicate compounds') + + if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): + mrich.error("ReactantModel compound ID's are not unique") + return False + if len(self.intermediates.compound_ids) != len( + set(self.intermediates.compound_ids) + ): + mrich.error("Intermediate compound ID's are not unique") + return False + if len(self.products.compound_ids) != len(set(self.products.compound_ids)): + mrich.error("Product compound ID's are not unique") + return False + + # all references should exist + + if debug: + mrich.debug('Checking for missing references') + + if self.db.count_where( + table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' + ) < len(self.reactions): + mrich.error('Not all Reactions in Database') + return False + + if self.db.count_where( + table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' + ) < len(self.products): + mrich.error('Not all product Compounds in Database') + return False + + if self.db.count_where( + table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' + ) < len(self.reactants): + mrich.error('Not all reactant Compounds in Database') + return False + + if self.db.count_where( + table='compound', + key=f'compound_id IN {self.intermediates.compounds.str_ids}', + ) < len(self.intermediates): + mrich.error('Not all intermediate Compounds in Database') + return False + + reaction_intermediates = self.reactions.intermediates + reaction_products = self.reactions.products + reaction_reactants = self.reactions.reactants + + if debug: + mrich.debug('Checking for missing reactions') + + # all products should have a reaction + for product in self.products: + if product not in reaction_products: + mrich.error(f'Product: {product} does not have associated reaction') + return False + + # intermediates + for intermediate in self.intermediates: + if intermediate not in reaction_intermediates: + mrich.error( + f'Intermediate: {intermediate} is not in self.reactions.intermediates' + ) + return False + + # reactants + for reactant in self.reactants: + if reactant not in reaction_reactants: + mrich.error(f'ReactantModel: {reactant} is not in self.reactions.reactants') + return False + + # all reactions should have enough reactant + + if debug: + mrich.debug('Checking reactant quantities') + + for reaction in (Reaction(r) for r in self.reactions): + product_ingredient = self.products(compound_id=reaction.product.id) + + if product_ingredient is None: + product_ingredient = self.intermediates(compound_id=reaction.product.id) + + if debug and reaction.product_yield < 1.0: + mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') + + for reactant in reaction.reactants: + reactant_ingredient = self.intermediates(compound_id=reactant.id) + + if reactant_ingredient is None: + reactant_ingredient = self.reactants(compound_id=reactant.id) + + required_amount = product_ingredient.amount / reaction.product_yield + + if reactant_ingredient.amount < required_amount: + mrich.error( + f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' + ) + return False + + if debug: + mrich.success(self, 'OK') + + return True + + def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): + """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" + self.compounds.add(ingredient) + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + + if self.score: + s = f'(score={self.score:.3f})' + else: + s = '' + + if self.hash: + return f'Recipe_{self.hash}{s}' + + return f'Recipe{s}' + + def __longstr(self) -> str: + """Unformatted string representation""" + + if self.empty: + return 'Empty Recipe()' + + if self.reactions: + if self.intermediates: + s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' + else: + s = f'{self.reactants} --> {self.products} via {self.reactions}' + + if self.score: + s += f', score={self.score:.3f}' + + if self.hash: + return f'Recipe_{self.hash}({s})' + + return f'Recipe({s})' + + else: + s = f'{self.compounds}' + + if self.hash: + return f'Recipe_{self.hash}({s})' + + return f'Recipe(#compounds={self.num_compounds} [no-chem])' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self.__longstr()}' + + def __add__(self, other: 'Recipe'): + """Add another :class:`.Recipe` to this one""" + result = self.copy() + result.reactants += other.reactants + result.intermediates += other.intermediates + result.reactions += other.reactions + result.products += other.products + if hasattr(other, 'compounds'): + result.compounds += other.compounds + return result + + + + +# name conflict with route model. Trying to get rid of this entirely +class Route(Recipe): + """A recipe with a single product, that is stored in the database""" + + def __init__( + self, + *, + route_id: int, + product: 'IngredientSet', + reactants: 'IngredientSet', + intermediates: 'IngredientSet', + reactions: 'ReactionSet', + ) -> None: + """RouteModel initialisation""" + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + # check typing + assert isinstance(product, IngredientSet) + assert isinstance(reactants, IngredientSet) + assert isinstance(intermediates, IngredientSet) + assert isinstance(reactions, ReactionSet) + + assert len(product) == 1 + assert isinstance(route_id, int) + assert route_id + + self._id = route_id + self._products = product + self._product_id = product.ids[0] + self._reactants = reactants + self._intermediates = intermediates + self._reactions = reactions + + ### FACTORIES + + @classmethod + def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteModel': + """Load a serialised route from a JSON file + + :param db: database to link + :param path: path to JSON + :param data: serialised data (Default value = None) + + """ + + # avoiding circular imports + from designdb.sets.compound import IngredientSet + from designdb.sets.reaction import ReactionSet + + if data is None: + data = json.load(open(path)) + + self = cls.__new__(cls) + + self._id = data['id'] + + self._product_id = data['product_id'] + self._products = IngredientSet.from_compounds( + compounds=None, ids=[self._product_id] + ) # IngredientSet + + self._reactants = IngredientSet.from_json( + path=None, + data=data['reactants']['data'], + supplier=data['reactants']['supplier'], + ) + self._intermediates = IngredientSet.from_json( + path=None, + data=data['intermediates']['data'], + supplier=data['intermediates']['supplier'], + ) + self._reactions = ReactionSet( + ReactionModel.objects.filter(pk__in=data['reactions']['indices']) + ) # ReactionSet + + return self + + @classmethod + def get_route( + cls, + *, + id: int, + debug: bool = False, + ) -> 'Route': + """Fetch a :class:`.RouteModel` object stored in the :class:`.Database`. + + :param id: the ID of the :class:`.RouteModel` to be retrieved + :param debug: increase verbosity for debugging, defaults to False + :returns: :class:`.RouteModel` object + + """ + + # avoiding circular dependencies + from designdb.sets.compound import CompoundSet, IngredientSet + from designdb.sets.reaction import ReactionSet + + # multiples?? + route = RouteModel.objects.get(pk=id) + + if debug: + mrich.var('product_id', route.product_compound) + + qs = ComponentModel.objects.filter(route=route).order_by('id') + + reaction_ids = [] + reactant_ids = [] + reactant_amounts = [] + intermediate_ids = [] + intermediate_amounts = [] + + # for ref, c_type, amount in triples: + for k in qs: + ref = k.component_ref + c_type = k.component_type + amount = k.component_amount + match c_type: + case 1: + reaction_ids.append(ref) + case 2: + reactant_ids.append(ref) + reactant_amounts.append(amount) + case 3: + intermediate_ids.append(ref) + intermediate_amounts.append(amount) + case _: + raise ValueError(f'Unknown component type {c_type}') + + if debug: + mrich.var('pairs', qs) + + products = CompoundSet([route.pk]) + reactants = CompoundSet(reactant_ids) + intermediates = CompoundSet(intermediate_ids) + + products = IngredientSet.from_compounds(compounds=products, amount=1) + reactants = IngredientSet.from_compounds( + compounds=reactants, amount=reactant_amounts + ) + intermediates = IngredientSet.from_compounds( + compounds=intermediates, amount=intermediate_amounts + ) + + reactions = ReactionSet(reaction_ids) + + recipe = Route( + route_id=id, + product=products, + reactants=reactants, + intermediates=intermediates, + reactions=reactions, + ) + + if debug: + mrich.var('recipe', recipe) + + return recipe + + ### PROPERTIES + + @property + def product(self) -> 'Ingredient': + """Product ingredient""" + return self._products[0] + + @property + def product_compound(self) -> 'CompoundModel': + """Product compound""" + return self.product.compound + + @property + def id(self) -> int: + """RouteModel ID""" + return self._id + + @property + def price(self) -> 'Price': + """Get the price of the reactants""" + return self.reactants.price + + ### METHODS + + def get_dict(self) -> dict: + """Serialisable dictionary""" + data = {} + + data['id'] = self.id + data['product_id'] = self.product.id + data['reactants'] = self.reactants.get_dict() + data['intermediates'] = self.intermediates.get_dict() + data['reactions'] = self.reactions.get_dict() + + return data + + ### DUNDERS + + def __str__(self) -> str: + """Unformatted string representation""" + return f'RouteModel #{self.id}: {self.product_compound}' + + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' diff --git a/hippo/designdb/services/ingredient.py b/hippo/designdb/services/ingredient.py new file mode 100644 index 0000000..bcc1c67 --- /dev/null +++ b/hippo/designdb/services/ingredient.py @@ -0,0 +1,76 @@ +import mrich +import pandas as pd +from designdb.models import CataloguePriceCompoundJunctionModel, CataloguePriceModel, CompoundModel +from django.db.models import Exists, OuterRef, Q + + +class IngredientService: + + @staticmethod + def get_quotes( + compound: CompoundModel, + 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, + ): + qs = CataloguePriceModel.objects.annotate( + has_compound=Exists( + CataloguePriceCompoundJunctionModel.objects.filter( + compound=compound, + catalogue_price=OuterRef('pk'), + ), + ), + ).filter( + has_compound=True, + ) + + if supplier: + if isinstance(supplier, str): + qs = qs.filter(supplier=supplier) + else: + qs = qs.filter(supplier__in=supplier) + + if not qs.exists(): + return None + + if max_lead_time: + qs = qs.filter(lead_time__lte=max_lead_time) + + if min_amount: + qs = qs.filter(amount__gte=min_amount) + + if not qs.exists(): + mrich.debug( + f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + ) + + if pick_cheapest: + return qs.order_by('price').first() + + if df: + return pd.DataFrame(qs.values()).drop(columns='compound') + + return qs + + @staticmethod + def get_cheapest_quote_id( + compound: CompoundModel, + min_amount: float | None = None, + supplier: str | None = None, + max_lead_time: float | None = None, + ) -> int | None: + query = Q(compound=compound) + + if supplier: + query &= Q(quote_supplier=supplier) + + if min_amount: + query &= Q(quote_amount__gte=min_amount) + + if max_lead_time: + query &= Q(quote_lead_time__lte=max_lead_time) + + return CataloguePriceModel.objects.filter(query).order_by('quote_price').first() diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py new file mode 100644 index 0000000..78005d6 --- /dev/null +++ b/hippo/designdb/services/recipe.py @@ -0,0 +1,226 @@ +import mrich +from designdb.models import CompoundModel, ReactionModel +from designdb.sets.compound import IngredientSet +from designdb.sets.reaction import ReactionSet + + +class RecipeService: + + @staticmethod + def from_reaction( + reaction, + amount=1, + *, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] = None, + reaction_reactant_cache: dict[int, bool] = None, + inner: bool = False, + get_ingredient_quotes: bool = True, + ) -> 'Recipe | list[Recipe]': + """Create a Recipe from a ReactionModel and its upstream dependencies.""" + + from designdb.components.recipe import Recipe + + assert isinstance(reaction, ReactionModel) + + if debug: + mrich.debug( + f'RecipeService.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' + ) + mrich.debug(f'{reaction.product.id=}') + mrich.debug(f'{reaction.reactants.ids=}') + + if permitted_reactions: + assert reaction in permitted_reactions + + recipe = Recipe( + products=IngredientSet( + [ + reaction.product.as_ingredient( + amount=amount, get_quote=get_ingredient_quotes + ) + ], + ), + reactants=IngredientSet([], supplier=supplier), + intermediates=IngredientSet([]), + reactions=ReactionSet([reaction.id], sort=False), + ) + + recipes = [recipe] + + if quoted_only or supplier: + if debug: + mrich.debug(f'Checking reactant_availability: {reaction=}') + if reaction_checking_cache and reaction.id in reaction_checking_cache: + ok = reaction_checking_cache[reaction.id] + print('reaction_checking_cache used') + else: + ok = reaction.check_reactant_availability(supplier=supplier) + # print('cache not used') + if reaction_checking_cache is not None: + reaction_checking_cache[reaction.id] = ok + if not ok: + if unavailable_reaction == 'error': + mrich.error(f'Reactants not available for {reaction=}') + if pick_cheapest: + return None + else: + return [] + + def get_reactant_amount_pairs(reaction: 'ReactionModel') -> list[tuple[int, float]]: + """Get pairs of reactant ID and float amounts""" + if reaction_reactant_cache and reaction.id in reaction_reactant_cache: + print('reaction_reactant_cache used') + return reaction_reactant_cache[reaction.id] + else: + pairs = reaction.get_reactant_amount_pairs(compound_object=False) + if reaction_reactant_cache is not None: + reaction_reactant_cache[reaction.id] = pairs + return pairs + + if debug: + mrich.debug(f'get_reactant_amount_pairs({reaction.id})') + pairs = get_reactant_amount_pairs(reaction) + + for reactant, reactant_amount in pairs: + reactant = CompoundModel.objects.get(pk=reactant) + + if debug: + mrich.debug(f'{reactant.id=}, {reactant_amount=}') + + # scale amount + reactant_amount *= amount + reactant_amount /= reaction.product_yield + + inner_reactions = reactant.get_reactions( + none='quiet', permitted_reactions=permitted_reactions + ) + + if inner_reactions: + if debug: + if len(inner_reactions) == 1: + mrich.debug('ReactantModel has ONE inner reaction') + else: + mrich.warning(f'{reactant=} has MULTIPLE inner reactions') + + new_recipes = [] + + inner_recipes = [] + for reaction in inner_reactions: + reaction_recipes = RecipeService.from_reaction( + reaction=reaction, + amount=reactant_amount, + debug=debug, + pick_cheapest=False, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + inner=True, + ) + inner_recipes += reaction_recipes + + for recipe in recipes: + for inner_recipe in inner_recipes: + combined_recipe = recipe.copy() + + combined_recipe.reactants += inner_recipe.reactants + combined_recipe.intermediates += inner_recipe.intermediates + combined_recipe.reactions += inner_recipe.reactions + combined_recipe.intermediates.add( + reactant.as_ingredient(reactant_amount, supplier=supplier) + ) + + new_recipes.append(combined_recipe) + + recipes = new_recipes + + else: + ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) + for recipe in recipes: + recipe.reactants.add(ingredient) + + # reverse ReactionSet's + if not inner: + for recipe in recipes: + recipe.reactions.reverse() + + if pick_cheapest: + if debug: + mrich.debug('Picking cheapest') + priced = [r for r in recipes if r.get_price(supplier=supplier)] + # priced = [r for r in recipes if r.price] + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return recipes + sorted_recipes = sorted( + priced, key=lambda r: r.get_price(supplier=supplier) + ) + + if debug: + for recipe in recipes: + mrich.debug(f'{recipe}, {recipe.price}') + + return sorted_recipes[0] + + return recipes + + @staticmethod + def from_reactions( + reactions: 'ReactionSet', + amount: float = 1, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + final_products_only: bool = True, + return_products: bool = False, + supplier: str | None = None, + use_routes: bool = False, + debug: bool = False, + **kwargs, + ) -> 'Recipe | list[Recipe]': + """Create a Recipe from a ReactionSet and its upstream dependencies.""" + + from designdb.components.recipe import Recipe + from designdb.sets.compound import CompoundSet + + assert isinstance(reactions, ReactionSet) + + if debug: + mrich.debug('RecipeService.from_reactions()') + mrich.var('reactions', reactions) + mrich.var('amount', amount) + mrich.var('final_products_only', final_products_only) + mrich.var('permitted_reactions', permitted_reactions) + + # get all the products + products = reactions.products + + if debug: + mrich.var('products', products) + + if final_products_only: + if debug: + mrich.var('products.str_ids', products.str_ids) + + # TODO: port to Django ORM — reactions.db.execute() is the old API + raise NotImplementedError( + 'final_products_only branch not yet ported to Django ORM' + ) + + recipe = Recipe.from_compounds( + compounds=products, + amount=amount, + permitted_reactions=reactions, + pick_cheapest=pick_cheapest, + supplier=supplier, + use_routes=use_routes, + **kwargs, + ) + + return recipe diff --git a/hippo/ta_auth_connector.py b/hippo/ta_auth_connector.py new file mode 100644 index 0000000..a059529 --- /dev/null +++ b/hippo/ta_auth_connector.py @@ -0,0 +1,168 @@ +"""A module that provides simplified request access to the TA Authenticator service. +Provides the following functions, that access the authenticator Pod: - + +- get_auth_version() +- get_auth_ping() +- get_auth_target_access(username) +""" + +# downloaded from https://github.com/xchem/fragalysis-target-access-authenticator-python-client +# incompatible versions because this is stuck on 3.12 and ta-auth requires 3.13 + +import logging +import os +from dataclasses import dataclass +from urllib.parse import quote + +import requests + +# Service location (e.g. "http://auth.ta-authenticator.svc") and request query key +_TA_AUTH_SERVICE: str = os.environ.get("TA_AUTH_SERVICE", "") +_TA_AUTH_QUERY_KEY: str = os.environ.get("TA_AUTH_QUERY_KEY", "") + +_URL_TIMEOUT: int = 3 +_QUERY_HEADERS: dict[str, str] = {'X-TAAQueryKey': _TA_AUTH_QUERY_KEY} + +logger: logging.Logger = logging.getLogger(__name__) + + +@dataclass +class TasAuthVersionGetResponse: + """The TA authenticator version response (including the base URL).""" + + version: str + kind: str + name: str + location: str = _TA_AUTH_SERVICE + + +@dataclass +class TasAuthPingGetResponse: + """The TA authenticator ping response.""" + + ping: str + + +def get_auth_version() -> TasAuthVersionGetResponse: + """Returns the version reported by the TA authentication service.""" + if not _TA_AUTH_SERVICE: + return TasAuthVersionGetResponse( + version='', kind='AUTH_SERVICE_NOT_DEFINED', name='' + ) + if not _TA_AUTH_QUERY_KEY: + return TasAuthVersionGetResponse( + version='', kind='SERVICE_QUERY_KEY_NOT_DEFINED', name='' + ) + + url: str = f'{_TA_AUTH_SERVICE}/version/' + resp: requests.Response | None = None + try: + resp = requests.get(url, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as r_ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, r_ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='Null response' + ) + elif resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(status not 200)' + ) + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(empty response)' + ) + elif 'version' not in resp.json(): + logger.warning('TA:GET:%s (no version property)', url) + return TasAuthVersionGetResponse( + version='', kind='ERROR_INTERNAL', name='(no version property)' + ) + + logger.info('TA:GET:%s [%s]', url, resp.json()) + + return TasAuthVersionGetResponse( + version=resp.json()['version'], + kind=resp.json()['kind'], + name=resp.json()['name'], + ) + + +def get_auth_ping() -> TasAuthPingGetResponse: + """Returns the ping reported by the TA authentication service.""" + if not _TA_AUTH_SERVICE: + return TasAuthPingGetResponse(ping='AUTH_SERVICE_NOT_DEFINED') + + url: str = f'{_TA_AUTH_SERVICE}/ping/' + resp: requests.Response | None = None + try: + resp = requests.get(url, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as r_ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, r_ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return TasAuthPingGetResponse('PING response was null') + elif resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return TasAuthPingGetResponse('PING response status not 200') + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return TasAuthPingGetResponse('PING response was empty') + elif 'ping' not in resp.json(): + logger.warning('TA:GET:%s (no ping property)', url) + return TasAuthPingGetResponse('PING response has no ping property') + + ping: str = resp.json()['ping'] + logger.info('TA:GET:%s [%s]', url, ping) + + return TasAuthPingGetResponse(ping=ping) + + +def get_auth_target_access(username: str) -> set[str]: + """Returns the set of target access strings a user is entitled to + as reported by the TA authentication service.""" + assert username + + empty_target_access: set[str] = set() + + if not _TA_AUTH_QUERY_KEY: + logger.debug('Skipping query - query key is not set (TA_AUTH_QUERY_KEY)') + return empty_target_access + + url: str = f'{_TA_AUTH_SERVICE}/target-access/{quote(username)}' + resp: requests.Response | None = None + try: + resp = requests.get(url, headers=_QUERY_HEADERS, timeout=_URL_TIMEOUT) + except requests.exceptions.RequestException as ex: # pylint: disable=broad-except + logger.error('TA:GET:%s RequestException (%s)', url, ex) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.error('TA:GET:%s Exception (%s)', url, ex) + + if resp is None: + logger.warning('TA:GET:%s (no response)', url) + return empty_target_access + if resp.status_code not in (200,): + logger.warning('TA:GET:%s [%s] (status not 200)', url, resp.status_code) + return empty_target_access + elif 'application/json' not in resp.headers.get('Content-Type', ''): + logger.warning('TA:GET:%s (empty response)', url) + return empty_target_access + elif 'count' not in resp.json(): + logger.warning('TA:GET:%s (no count)', url) + return empty_target_access + elif 'target_access' not in resp.json(): + logger.warning('TA:GET:%s (no target_access)', url) + return empty_target_access + + target_access: set[str] = set(resp.json()['target_access']) + logger.info('TA:GET:%s (got %d for %s)', url, len(target_access), username) + + return target_access From b4db36e543fe73c8b10b20e8bdbb2a891222c2df Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 20 May 2026 08:21:46 +0100 Subject: [PATCH 139/163] fix: some linting errors --- hippo/designdb/animal.py | 70 ++++-- hippo/designdb/chem.py | 3 +- hippo/designdb/components/compound.py | 348 +++++++++++++++----------- hippo/designdb/components/reaction.py | 21 +- hippo/designdb/components/recipe.py | 141 +++++++---- hippo/designdb/managers.py | 20 +- hippo/designdb/models.py | 8 +- hippo/designdb/services/compound.py | 33 +-- hippo/designdb/services/ingestion.py | 23 +- hippo/designdb/services/ingredient.py | 4 +- hippo/designdb/services/recipe.py | 10 +- hippo/designdb/sets/compound.py | 151 +++++++---- hippo/designdb/sets/interaction.py | 58 +++-- hippo/designdb/sets/pose.py | 141 +++++++---- hippo/designdb/sets/reaction.py | 26 +- hippo/designdb/sets/route.py | 13 +- hippo/designdb/utils.py | 12 +- hippo/ta_auth_connector.py | 4 +- pyproject.toml | 6 +- 19 files changed, 678 insertions(+), 414 deletions(-) diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index af7e8e5..13db01c 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -222,23 +222,41 @@ def load_sdf( :param target: Name of the protein :class:`.TargetModel` :param path: Path to the SDF - :param reference: Optional single reference :class:`.PoseModel` to use as the protein conformation for all poses, defaults to ``None`` - :param reference_col: Column that contains reference :class:`.PoseModel` aliases or ID's - :param compound_tags: List of string Tags to assign to all created compounds, defaults to ``None`` - :param pose_tags: List of string Tags to assign to all created poses, defaults to ``None`` - :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, defaults to ``"ROMol"`` - :param name_col: Name of the column containing the ligand name/alias, defaults to ``"ID"`` - :param inspirations: Optional single set of inspirations :class:`.PoseSet` object or list of IDs to assign as inspirations to all inserted poses, defaults to ``None`` - :param inspiration_col: Name of the column containing the list of inspiration :class:`.PoseModel` names or ID's, defaults to ``"ref_mols"`` - :param inspiration_map: Optional dictionary or callable mapping between inspiration strings found in ``inspiration_col`` and :class:`.PoseModel` ids - :param energy_score_col: Name of the column containing the list of energy scores ``"energy_score"`` - :param distance_score_col: Name of the column containing the list of distance scores, defaults to ``"distance_score"`` - :param convert_floats: Try to convert all values to ``float``, defaults to ``True`` - :param skip_equal_dict: Skip rows where ``any(row[key] == value for key, value in skip_equal_dict.items())``, defaults to ``None`` - :param skip_not_equal_dict: Skip rows where ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, defaults to ``None`` + :param reference: Optional single reference :class:`.PoseModel` to use as + the protein conformation for all poses, defaults to ``None`` + :param reference_col: Column that contains reference :class:`.PoseModel` aliases + or ID's + :param compound_tags: List of string Tags to assign to all created compounds, + defaults to ``None`` + :param pose_tags: List of string Tags to assign to all created poses, + defaults to ``None`` + :param mol_col: Name of the column containing the ``rdkit.ROMol`` ligands, + defaults to ``"ROMol"`` + :param name_col: Name of the column containing the ligand name/alias, + defaults to ``"ID"`` + :param inspirations: Optional single set of inspirations :class:`.PoseSet` + object or list of IDs to assign as inspirations to all inserted poses, + defaults to ``None`` + :param inspiration_col: Name of the column containing the list of inspiration + :class:`.PoseModel` names or ID's, defaults to ``"ref_mols"`` + :param inspiration_map: Optional dictionary or callable mapping between + inspiration strings found in ``inspiration_col`` and :class:`.PoseModel` ids + :param energy_score_col: Name of the column containing the list of energy + scores ``"energy_score"`` + :param distance_score_col: Name of the column containing the list of distance + scores, defaults to ``"distance_score"`` + :param convert_floats: Try to convert all values to ``float``, + defaults to ``True`` + :param skip_equal_dict: Skip rows where + ``any(row[key] == value for key, value in skip_equal_dict.items())``, + defaults to ``None`` + :param skip_not_equal_dict: Skip rows where + ``any(row[key] != value for key, value in skip_not_equal_dict.items())``, + defaults to ``None`` All non-name columns are added to the PoseModel metadata. - N.B. separate .mol files are not created. The molecule binary will only be stored in the .sqlite file and fake paths are added to the database. + N.B. separate .mol files are not created. The molecule binary will only be + stored in the .sqlite file and fake paths are added to the database. """ # TODO: original code reads sdf into data frame. I don't see # much point for this in this function. get rid of it at some @@ -362,13 +380,21 @@ def add_syndirella_elabs( :param df_path: Path to the pickled DataFrame :param max_energy_score: Filter out poses with `∆∆G` above this value :param max_distance_score: Filter out poses with `comRMSD` above this value - :param require_intra_geometry_pass: Filter out poses with falsy `intra_geometry_pass` values - :param reject_flags: Filter out rows flagged with strings from this list (default = ["one_of_multiple_products", "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) - :param scaffold_route: Supply a known single-step route to the scaffold product to use if scaffold placements are missing - :param scaffold_compound: Supply a :class:`.CompoundModel` for the scaffold product to use if scaffold placements are missing - :param dry_run: Don't insert new records into the database (for debugging/testing) - :param pose_tags: Add these tags to all inserted poses, defaults to ["syndirella_product", "syndirella_placed"] - :param product_tags: Add these tags to all inserted product compounds, defaults to ["syndirella_product"] + :param require_intra_geometry_pass: Filter out poses with falsy + `intra_geometry_pass` values + :param reject_flags: Filter out rows flagged with strings from this list + (default = ["one_of_multiple_products", + "selectivity_issue_contains_reaction_atoms_of_both_reactants"]) + :param scaffold_route: Supply a known single-step route to the scaffold product + to use if scaffold placements are missing + :param scaffold_compound: Supply a :class:`.CompoundModel` for the scaffold + product to use if scaffold placements are missing + :param dry_run: Don't insert new records into the database + (for debugging/testing) + :param pose_tags: Add these tags to all inserted poses, defaults to + ["syndirella_product", "syndirella_placed"] + :param product_tags: Add these tags to all inserted product compounds, + defaults to ["syndirella_product"] :returns: annotated DataFrame """ diff --git a/hippo/designdb/chem.py b/hippo/designdb/chem.py index ca9d406..bd29d23 100644 --- a/hippo/designdb/chem.py +++ b/hippo/designdb/chem.py @@ -143,7 +143,8 @@ def check_reaction_types(types: list[str]) -> None: """ - Prints a warning if any of the reaction type strings in ``types`` are not in ``SUPPORTED_CHEMISTRY`` + Prints a warning if any of the reaction type strings in ``types`` are not in + ``SUPPORTED_CHEMISTRY`` :param types: A list of reaction type strings to check """ diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index 12847e5..3199839 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -31,15 +31,22 @@ 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`). + """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`. + :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" + _table = 'compound' def __init__(self, instance: CompoundModel): """Compound initialisation""" @@ -59,7 +66,7 @@ def __init__(self, instance: CompoundModel): ### FACTORIES @classmethod - def from_id(cls, id: int) -> "Compound": + def from_id(cls, id: int) -> 'Compound': """Create a :class:`.Compound` from its database ID""" return cls(CompoundModel.objects.get(pk=id)) @@ -136,7 +143,8 @@ def formula(self) -> str | None: @property def atomtype_dict(self) -> dict[str, int]: - """Get a dictionary with atomtypes as keys and corresponding quantities/counts as values.""" + """Get a dictionary with atomtypes as keys and corresponding + quantities/counts as values.""" return formula_to_atomtype_dict(self.formula) @@ -145,15 +153,17 @@ 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") + mrich.error(f'{self} has no scaffold') return None case 1: scaffold = Compound(next(iter(self.scaffolds._queryset))) return self.num_heavy_atoms - scaffold.num_heavy_atoms case _: - mrich.warning(f"{self} has multiple scaffolds") + mrich.warning(f'{self} has multiple scaffolds') n_e = self.num_heavy_atoms - return [n_e - Compound(c).num_heavy_atoms for c in self.scaffolds._queryset] + return [ + n_e - Compound(c).num_heavy_atoms for c in self.scaffolds._queryset + ] @property def metadata(self) -> dict | None: @@ -168,12 +178,12 @@ def tags(self) -> list[str]: return self._tags @property - def poses(self) -> "PoseSet": + def poses(self) -> 'PoseSet': """Returns the compound's poses""" return self.get_poses() @property - def best_placed_pose(self) -> "PoseModel": + def best_placed_pose(self) -> 'PoseModel': """Returns the compound's pose with the lowest distance score""" return self.poses.best_placed_pose @@ -193,14 +203,15 @@ def num_reactant(self) -> int: return ReactantModel.objects.filter(compound=self._instance).count() @property - def scaffolds(self) -> "CompoundSet | None": + def scaffolds(self) -> 'CompoundSet | None': """Returns the scaffold compounds for this elaboration""" if self._scaffolds is None: ids = self.get_scaffold_ids() if not ids: return None from designdb.sets.compound import CompoundSet - self._scaffolds = CompoundSet(ids, name=f"scaffolds of {self}") + + self._scaffolds = CompoundSet(ids, name=f'scaffolds of {self}') return self._scaffolds @property @@ -211,33 +222,34 @@ def num_scaffolds(self) -> int: return 0 @property - def elabs(self) -> "CompoundSet | None": + def elabs(self) -> 'CompoundSet | None': """Returns the elaborations of this scaffold compound""" if self._elabs is None: ids = self.get_superstructure_ids() if not ids: return None from designdb.sets.compound import CompoundSet - self._elabs = CompoundSet(ids, name=f"elaborations of {self}") + + self._elabs = CompoundSet(ids, name=f'elaborations of {self}') return self._elabs @property - def reactions(self) -> "ReactionSet": + def reactions(self) -> 'ReactionSet': """Returns the reactions resulting in this compound""" return self.get_reactions() @property - def reaction(self) -> "ReactionModel | None": + def reaction(self) -> 'ReactionModel | None': """Returns the reaction resulting in this compound (warns if multiple)""" reactions = self.reactions match len(reactions): case 0: - mrich.warning(f"{self} has no reactions") + mrich.warning(f'{self} has no reactions') return None case 1: pass case _: - mrich.warning(f"{self} has multiple reactions, returning first") + mrich.warning(f'{self} has multiple reactions, returning first') return reactions[0] @property @@ -253,7 +265,9 @@ def is_scaffold(self) -> bool: @property def is_elab(self) -> bool: """Is this Compound based on any other compound?""" - return ScaffoldModel.objects.filter(superstructure_compound=self._instance).exists() + return ScaffoldModel.objects.filter( + superstructure_compound=self._instance + ).exists() @property def is_product(self) -> bool: @@ -281,8 +295,10 @@ def add_stock( :param amount: Amount in ``mg`` :param purity: Purity fraction ``0 < purity <= 1``, defaults to ``None`` :param entry: Catalogue entry identifier, defaults to ``None`` - :param location: String describing where this stock is located, defaults to ``None`` - :param return_quote: If ``True`` a :class:`.CataloguePriceModel` object is returned instead of its ID, defaults to ``True`` + :param location: String describing where this stock is located, defaults to + ``None`` + :param return_quote: If ``True`` a :class:`.CataloguePriceModel` object is + returned instead of its ID, defaults to ``True`` :returns: The inserted :class:`.CataloguePriceModel` object or ID """ @@ -293,16 +309,16 @@ def add_stock( has_compound=Exists( CataloguePriceCompoundJunctionModel.objects.filter( compound=self._instance, - catalogue_price=OuterRef("pk"), + catalogue_price=OuterRef('pk'), ) ) - ).filter(has_compound=True, supplier="Stock") + ).filter(has_compound=True, supplier='Stock') # Delete entries matching this entry/purity/location to_delete = existing_qs.filter( - supplier_id=entry or "", + supplier_id=entry or '', purity=purity, - vendor=location or "", + vendor=location or '', ) deleted_count = to_delete.count() if deleted_count: @@ -310,23 +326,24 @@ def add_stock( compound=self._instance, catalogue_price__in=to_delete, ).delete() - mrich.warning(f"Removed {deleted_count} existing In-Stock entries") + mrich.warning(f'Removed {deleted_count} existing In-Stock entries') not_deleted = existing_qs.exclude( - supplier_id=entry or "", + supplier_id=entry or '', purity=purity, - vendor=location or "", + vendor=location or '', ).count() if not_deleted: mrich.warning( - f"Did not remove {not_deleted} existing In-Stock entries with differing entry/purity/location" + f'Did not remove {not_deleted} existing In-Stock entries with' + f' differing entry/purity/location' ) # Create new price entry and link to compound quote = CataloguePriceModel.objects.create( - supplier="Stock", - supplier_id=entry or "", - vendor=location or "", + supplier='Stock', + supplier_id=entry or '', + vendor=location or '', amount=amount, price=0, currency=None, @@ -345,7 +362,7 @@ def add_stock( def get_tags(self) -> list[str]: """Get the tags assigned to this compound""" - return list(self._instance.tags.values_list("compound_tag_name", flat=True)) + return list(self._instance.tags.values_list('compound_tag_name', flat=True)) def add_tag(self, tag: str) -> None: """Add a tag to this compound""" @@ -362,11 +379,12 @@ def get_quotes( min_amount: float | None = None, supplier: str | None = None, max_lead_time: float | None = None, - none: str = "quiet", + none: str = 'quiet', pick_cheapest: bool = False, df: bool = False, ): - """Get all quotes associated to this compound. See :meth:`.Ingredient.get_quotes`""" + """Get all quotes associated to this compound. + See :meth:`.Ingredient.get_quotes`""" return Ingredient.get_quotes( compound=self._instance, min_amount=min_amount, @@ -380,12 +398,13 @@ def get_quotes( def get_reactions( self, as_reactant: bool = False, - permitted_reactions: "ReactionSet" = None, - none: str = "error", - ) -> "ReactionSet": + permitted_reactions: 'ReactionSet' = None, + none: str = 'error', + ) -> 'ReactionSet': """Get the associated :class:`.ReactionModel` objects. - :param as_reactant: Search for reactions using this compound as a reactant, defaults to ``False`` + :param as_reactant: Search for reactions using this compound as a reactant, + defaults to ``False`` :param permitted_reactions: Filter results to this :class:`.ReactionSet` :param none: Unused, kept for API compatibility """ @@ -395,14 +414,14 @@ def get_reactions( if as_reactant: reaction_ids = list( ReactantModel.objects.filter(compound=self._instance).values_list( - "reaction_id", flat=True + 'reaction_id', flat=True ) ) else: reaction_ids = list( ReactionModel.objects.filter( product_compound=self._instance - ).values_list("pk", flat=True) + ).values_list('pk', flat=True) ) if permitted_reactions: @@ -410,11 +429,11 @@ def get_reactions( rset = ReactionSet(reaction_ids) if not as_reactant and not permitted_reactions: - rset._name = f"reactions resulting in {str(self)}" + rset._name = f'reactions resulting in {str(self)}' return rset - def get_poses(self) -> "PoseSet": + def get_poses(self) -> 'PoseSet': """Get the associated :class:`.PoseModel` objects.""" from designdb.sets.pose import PoseSet @@ -439,7 +458,8 @@ def get_dict( :param mol: Include a ``rdkit.Chem.Mol object``, defaults to ``True`` :param metadata: Include metadata, defaults to ``True`` - :param poses: Include IDs of associated :class:`.PoseModel` objects, defaults to ``True`` + :param poses: Include IDs of associated :class:`.PoseModel` objects, + defaults to ``True`` :param num_reactant: include num_reactant column :param num_reactions: include num_reactions column :param scaffolds: include scaffolds column @@ -448,34 +468,34 @@ def get_dict( :returns: A dictionary """ - data: dict = {"id": self.id, "smiles": self.smiles} + data: dict = {'id': self.id, 'smiles': self.smiles} if alias: - data["alias"] = self.alias + data['alias'] = self.alias if inchikey: - data["inchikey"] = self.inchikey + data['inchikey'] = self.inchikey if num_reactant: - data["num_reactant"] = self.num_reactant + data['num_reactant'] = self.num_reactant if num_reactions: - data["num_reactions"] = self.num_reactions + data['num_reactions'] = self.num_reactions if mol: - data["mol"] = self.mol + data['mol'] = self.mol if scaffolds: - data["scaffolds"] = self.scaffolds.ids if self.scaffolds else None + data['scaffolds'] = self.scaffolds.ids if self.scaffolds else None if elabs: - data["elabs"] = self.elabs.ids if self.elabs else None + data['elabs'] = self.elabs.ids if self.elabs else None if tags: - data["tags"] = self.tags + data['tags'] = self.tags if poses: pose_set = self.poses if pose_set: - data["poses"] = pose_set.ids - data["targets"] = pose_set.target_names + data['poses'] = pose_set.ids + data['targets'] = pose_set.target_names if metadata and (metadict := self.metadata): for key, value in metadict.items(): @@ -493,7 +513,8 @@ def get_recipes( supplier: None | str = None, **kwargs, ): - """Get :class:`.Recipe` objects that result in this compound. See :meth:`.Recipe.from_compounds`""" + """Get :class:`.Recipe` objects that result in this compound. + See :meth:`.Recipe.from_compounds`""" from designdb.sets.compound import CompoundSet from .recipe import Recipe @@ -509,27 +530,31 @@ def get_recipes( ) def get_scaffold_ids(self) -> list[int] | None: - """Get a list of :class:`.Compound` IDs that this object is a superstructure of""" + """Get a list of :class:`.Compound` IDs that this object is a superstructure + of""" ids = list( ScaffoldModel.objects.filter( superstructure_compound=self._instance - ).values_list("base_compound_id", flat=True) + ).values_list('base_compound_id', flat=True) ) return ids or None def get_superstructure_ids(self) -> list[int] | None: """Get a list of :class:`.Compound` IDs that this object is a substructure of""" ids = list( - ScaffoldModel.objects.filter( - base_compound=self._instance - ).values_list("superstructure_compound_id", flat=True) + ScaffoldModel.objects.filter(base_compound=self._instance).values_list( + 'superstructure_compound_id', flat=True + ) ) return ids or None - def add_scaffold(self, scaffold: "Compound | CompoundModel | int", commit: bool = True) -> None: + def add_scaffold( + self, scaffold: 'Compound | CompoundModel | int', commit: bool = True + ) -> None: """Add a scaffold :class:`.Compound` this molecule is derived from. - :param scaffold: The scaffold :class:`.Compound`, :class:`.CompoundModel`, or its ID. + :param scaffold: The scaffold :class:`.Compound`, :class:`.CompoundModel`, + or its ID. :param commit: Unused, kept for API compatibility """ @@ -538,7 +563,7 @@ def add_scaffold(self, scaffold: "Compound | CompoundModel | int", commit: bool elif isinstance(scaffold, CompoundModel): scaffold_model = scaffold else: - assert scaffold._table == "compound" + assert scaffold._table == 'compound' scaffold_model = scaffold._model ScaffoldModel.objects.get_or_create( @@ -556,7 +581,7 @@ def set_alias(self, alias: str, commit: bool = True) -> None: assert isinstance(alias, str) self._instance.compound_alias = alias - self._instance.save(update_fields=["compound_alias", "updated_on"]) + self._instance.save(update_fields=['compound_alias', 'updated_on']) def as_ingredient( self, @@ -564,13 +589,16 @@ def as_ingredient( max_lead_time: float = None, supplier: str = None, get_quote: bool = True, - quote_none: str = "quiet", - ) -> "Ingredient": - """Convert this compound into an :class:`.Ingredient` with an associated amount and quote. + quote_none: str = 'quiet', + ) -> 'Ingredient': + """Convert this compound into an :class:`.Ingredient` with an associated + amount and quote. :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`` + :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`` """ return Ingredient.from_compound( @@ -589,14 +617,14 @@ def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None This method is only intended for use within a Jupyter Notebook. - :param align_substructure: Align the two drawings by their common substructure, defaults to ``False`` + :param align_substructure: Align the two drawings by their common + substructure, defaults to ``False`` """ if scaffolds and (scaffolds := self.scaffolds): - data = {} for scaffold in scaffolds: - data[scaffold.compound_smiles] = f"C{scaffold.pk} (scaffold)" + data[scaffold.compound_smiles] = f'C{scaffold.pk} (scaffold)' data[self.smiles] = str(self) if len(data) > 1: @@ -608,7 +636,9 @@ def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None ) display(drawing) else: - mrich.error(f"Problem drawing scaffold vs {self.id=}, self referential?") + mrich.error( + f'Problem drawing scaffold vs {self.id=}, self referential?' + ) display(self.mol) else: display(self.mol) @@ -616,14 +646,13 @@ def draw(self, scaffolds: bool = True, align_substructure: bool = False) -> None def draw_elabs(self) -> None: """Draw elaborations""" - elabs = self.elabs display(self) display(elabs) if not elabs: - mrich.error(self, "has no elaborations") + mrich.error(self, 'has no elaborations') return self.draw() params = rdRGroupDecomposition.RGroupDecompositionParameters() @@ -643,9 +672,9 @@ def draw_elabs(self) -> None: rgd.Process() rgroup_table = rgd.GetRGroupsAsColumns() - core = rgroup_table["Core"][0] + core = rgroup_table['Core'][0] attachment_points = set() - for rgroup in rgroup_table["Core"]: + for rgroup in rgroup_table['Core']: for atom in rgroup.GetAtoms(): if atom.GetAtomicNum() == 0: attachment_points.add(atom.GetIdx()) @@ -659,22 +688,23 @@ 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 + :returns: A list of tuples containing a descriptor (``str``) and count + (``int``) pair """ - return classify_mol(self.mol, draw=draw) def murcko_scaffold(self, generic: bool = False) -> Chem.Mol: """Get the rdkit MurckoScaffold for this compound""" - scaffold = MurckoScaffold.GetScaffoldForMol(self.mol) if generic: scaffold = MurckoScaffold.MakeScaffoldGeneric(scaffold) return scaffold - def summary(self, metadata: bool = True, draw: bool = True, tags: bool = True) -> None: + 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`` @@ -682,29 +712,29 @@ def summary(self, metadata: bool = True, draw: bool = True, tags: bool = 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) + 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) + mrich.var('tags', self.tags) poses = self.poses - mrich.var("#poses", len(poses)) + mrich.var('#poses', len(poses)) if poses: - mrich.var("targets", poses.targets) + mrich.var('targets', poses.targets) if metadata: - mrich.var("metadata", str(self.metadata)) + mrich.var('metadata', str(self.metadata)) if draw: self.draw() @@ -712,26 +742,30 @@ def summary(self, metadata: bool = True, draw: bool = True, tags: bool = True) - def place( self, *, - animal: "HIPPO", - reference: "PoseModel", - inspirations: list["PoseModel"] | None = None, + animal: 'HIPPO', + reference: 'PoseModel', + inspirations: list['PoseModel'] | None = None, max_ddG: float = 0.0, max_RMSD: float = 2.0, - output_dir: str = "wictor_place", + output_dir: str = 'wictor_place', tags: list[str] = None, metadata: dict = None, overwrite: bool = False, - ) -> "PoseModel | None": + ) -> 'PoseModel | None': """Generate a new pose for this compound using Fragmenstein. :param animal: The :class:`.HIPPO` instance used to register the pose - :param reference: Choose the :class:`.PoseModel` to use as the reference protein conformation - :param inspirations: Choose the (virtual) hits to define the ligand reference, defaults to the ``reference``'s inspirations + :param reference: Choose the :class:`.PoseModel` to use as the reference + protein conformation + :param inspirations: Choose the (virtual) hits to define the ligand reference, + defaults to the ``reference``'s inspirations :param max_ddG: Maximum ``ddG`` value permitted, defaults to ``0.0`` :param max_RMSD: Maximum ``RMSD`` value permitted, defaults to ``2.0`` - :param output_dir: Output directory for Fragmenstein files, defaults to ``wictor_place`` + :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 metadata: A dictionary of metadata to assign to this compound, + defaults to ``{}`` :param overwrite: Delete old poses, defaults to ``False`` """ @@ -758,21 +792,21 @@ def place( victor.enable_stdout(logging.CRITICAL) victor.place(self.smiles, long_name=self.name) - metadata["ddG"] = ( - victor.energy_score["bound"]["total_score"] - - victor.energy_score["unbound"]["total_score"] + metadata['ddG'] = ( + victor.energy_score['bound']['total_score'] + - victor.energy_score['unbound']['total_score'] ) - metadata["RMSD"] = victor.mrmsd.mrmsd + metadata['RMSD'] = victor.mrmsd.mrmsd - if metadata["ddG"] > max_ddG: + if metadata['ddG'] > max_ddG: return None - if metadata["RMSD"] > max_RMSD: + if metadata['RMSD'] > max_RMSD: return None pose = animal.register_pose( compound=self, target=target, - path=Path(victor.work_path) / self.name / f"{self.name}.minimised.mol", + path=Path(victor.work_path) / self.name / f'{self.name}.minimised.mol', inspirations=inspirations, reference=reference, tags=tags, @@ -780,14 +814,18 @@ def place( ) if overwrite: - PoseModel.objects.filter(compound=self._instance).exclude(pk=pose.pk).delete() - mrich.success(f"Successfully posed {self} (and deleted old poses)") + PoseModel.objects.filter(compound=self._instance).exclude( + pk=pose.pk + ).delete() + mrich.success(f'Successfully posed {self} (and deleted old poses)') else: - mrich.success(f"Successfully posed {self}") + mrich.success(f'Successfully posed {self}') return pose - def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSet | None": + def get_inspirations( + self, debug: bool = True, none: str = 'warning' + ) -> 'PoseSet | None': """Get the fragment inspirations for this compound's poses. Since inspirations map :class:`.PoseModel` objects to each other, this requires @@ -801,19 +839,19 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe poses_qs = PoseModel.objects.filter(compound=self._instance) insp_qs = InspirationModel.objects.filter(derivative_pose__in=poses_qs) - if not insp_qs.exists() and none in ("warning", "warn"): - mrich.warning("Could not determine inspirations for", self) + if not insp_qs.exists() and none in ('warning', 'warn'): + mrich.warning('Could not determine inspirations for', self) return None - derivative_ids = set(insp_qs.values_list("derivative_pose_id", flat=True)) - original_ids = set(insp_qs.values_list("original_pose_id", flat=True)) + derivative_ids = set(insp_qs.values_list('derivative_pose_id', flat=True)) + original_ids = set(insp_qs.values_list('original_pose_id', flat=True)) if debug: - mrich.debug(f"Inspirations derived from {derivative_ids}") + mrich.debug(f'Inspirations derived from {derivative_ids}') inspirations = PoseSet( PoseModel.objects.filter(pk__in=original_ids), - name=f"Inspirations for {self}", + name=f'Inspirations for {self}', ) return inspirations @@ -822,11 +860,14 @@ def get_inspirations(self, debug: bool = True, none: str = "warning") -> "PoseSe def __str__(self) -> str: """Unformatted string representation""" - return f"C{self.id}" + return f'C{self.id}' def __repr__(self) -> str: """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self} "{self.name}"{mcol.unbold}{mcol.ununderline}' + return ( + f'{mcol.bold}{mcol.underline}{self} "{self.name}"' + f'{mcol.unbold}{mcol.ununderline}' + ) def __rich__(self) -> str: """Representation for mrich""" @@ -838,9 +879,9 @@ def __eq__(self, other) -> bool: return self.id == other.id - class Ingredient: - """An ingredient is a :class:`.Compound` with a fixed quanitity and an attached quote. + """An ingredient is a :class:`.Compound` with a fixed quanitity and an attached + quote. .. image:: ../images/ingredient.png :width: 450 @@ -848,7 +889,8 @@ class Ingredient: .. attention:: - :class:`.Ingredient` objects should not be created directly. Instead use :meth:`.Compound.as_ingredient`. + :class:`.Ingredient` objects should not be created directly. Instead use + :meth:`.Compound.as_ingredient`. """ _table = 'ingredient' @@ -903,11 +945,14 @@ def from_compound( 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. + """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`` + :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: @@ -955,13 +1000,19 @@ def get_quotes( ): """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 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 + :returns: List of :class:`.Quote` objects, ``DataFrame``, or single + :class:`.Quote`. See ``pick_cheapest`` and ``df`` parameters """ @@ -993,7 +1044,8 @@ def get_quotes( if not qs.exists(): mrich.debug( - f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + f'No quote available for C{compound.pk} with amount >=' + f' {min_amount} mg. Estimating price...' ) if pick_cheapest: @@ -1015,10 +1067,14 @@ def get_cheapest_quote_id( """ 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. + :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. """ query = Q(compound=self.compound) @@ -1058,7 +1114,8 @@ def quote(self) -> int: @property def price(self) -> Price: - """Returns the price from the associated quote, or a null Price if unavailable.""" + """Returns the price from the associated quote, or a null Price if + unavailable.""" if self._quote is None: return Price.null() return Price(self._quote.price, self._quote.currency) @@ -1098,7 +1155,8 @@ def compound(self) -> CompoundModel: @property def compound_price_amount_str(self) -> str: - """String representation including :class:`.Compound`, :class:`.Price`, and amount.""" + """String representation including :class:`.Compound`, :class:`.Price`, + and amount.""" return f'{self} ({self.amount})' @property diff --git a/hippo/designdb/components/reaction.py b/hippo/designdb/components/reaction.py index b918f83..580570c 100644 --- a/hippo/designdb/components/reaction.py +++ b/hippo/designdb/components/reaction.py @@ -2,12 +2,7 @@ import mcol import mrich -from designdb.models import ( - CataloguePriceCompoundJunctionModel, - CompoundModel, - ReactantModel, - ReactionModel, -) +from designdb.models import CataloguePriceCompoundJunctionModel, CompoundModel, ReactionModel from .compound import Compound @@ -52,9 +47,7 @@ def reactants(self) -> 'list[Compound]': @property def reactant_ids(self) -> list[int]: """Returns the reactant :class:`.CompoundModel` PKs""" - return list( - self._instance.reactants.values_list('compound_id', flat=True) - ) + return list(self._instance.reactants.values_list('compound_id', flat=True)) @property def product_smiles(self) -> str: @@ -74,9 +67,7 @@ def plain_repr(self) -> str: ### METHODS - def get_reactant_amount_pairs( - self, compound_object: bool = True - ) -> list[tuple]: + def get_reactant_amount_pairs(self, compound_object: bool = True) -> list[tuple]: """Returns pairs of (compound, amount) for each reactant. :param compound_object: return :class:`.Compound` objects instead of IDs @@ -149,6 +140,7 @@ def get_recipes( :param amount: amount in mg """ from .recipe import Recipe # local to break circular import + return Recipe.from_reaction( self._instance, amount=amount, @@ -164,7 +156,10 @@ def __str__(self) -> str: return f'R{self.id}' def __repr__(self) -> str: - return f'{mcol.bold}{mcol.underline}{self.plain_repr}{mcol.unbold}{mcol.ununderline}' + return ( + f'{mcol.bold}{mcol.underline}{self.plain_repr}' + f'{mcol.unbold}{mcol.ununderline}' + ) def __rich__(self) -> str: return f'[bold underline]{self.plain_repr}' diff --git a/hippo/designdb/components/recipe.py b/hippo/designdb/components/recipe.py index cc23a38..63051b0 100644 --- a/hippo/designdb/components/recipe.py +++ b/hippo/designdb/components/recipe.py @@ -13,7 +13,8 @@ class Recipe: - """A Recipe stores data corresponding to a specific synthetic recipe involving several products, reactants, intermediates, and reactions.""" + """A Recipe stores data corresponding to a specific synthetic recipe involving + several products, reactants, intermediates, and reactions.""" def __init__( self, @@ -82,17 +83,23 @@ def from_reaction( inner: bool = False, get_ingredient_quotes: bool = True, ) -> 'Recipe | list[Recipe]': - """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream dependencies + """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream + dependencies :param reaction: reaction to create recipe from :param amount: amount in ``mg`` (Default value = 1) :param debug: bool: increase verbosity for debugging (Default value = False) :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') - :param inner: used to indicate that this is a recursive call (Default value = False) + :param permitted_reactions: once consider reactions in this set + (Default value = None) + :param quoted_only: bool: only allow reactants with quotes + (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier + (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has + unavailable reactants (Default value = 'error') + :param inner: used to indicate that this is a recursive call + (Default value = False) :param get_ingredient_quotes: get quotes for ingredients in this recipe """ @@ -145,13 +152,17 @@ def from_reaction( else: return [] - def get_reactant_amount_pairs(reaction_model: ReactionModel) -> list[tuple[int, float]]: + def get_reactant_amount_pairs( + reaction_model: ReactionModel, + ) -> list[tuple[int, float]]: """Get pairs of reactant ID and float amounts""" if reaction_reactant_cache and reaction_model.id in reaction_reactant_cache: print('reaction_reactant_cache used') return reaction_reactant_cache[reaction_model.id] else: - pairs = Reaction(reaction_model).get_reactant_amount_pairs(compound_object=False) + pairs = Reaction(reaction_model).get_reactant_amount_pairs( + compound_object=False + ) if reaction_reactant_cache is not None: reaction_reactant_cache[reaction_model.id] = pairs return pairs @@ -260,15 +271,19 @@ def from_reactions( debug: bool = False, **kwargs, ) -> 'Recipe | list[Recipe] | CompoundSet': - """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream dependencies + """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream + dependencies :param reactions: reactions to create recipe from :param amount: amount in ``mg`` (Default value = 1) :param debug: bool: increase verbosity for debugging (Default value = False) :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param final_products_only: don't get routes to intermediates (Default value = True) - :param return_products: return the :class:`.CompoundSet` of products instead (Default value = False) + :param permitted_reactions: once consider reactions in this set + (Default value = None) + :param final_products_only: don't get routes to intermediates + (Default value = True) + :param return_products: return the :class:`.CompoundSet` of products instead + (Default value = False) """ @@ -300,7 +315,8 @@ def from_reactions( ids = reactions.db.execute( f""" SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant ON compound_id = reactant_compound + LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant + ON compound_id = reactant_compound WHERE reactant_compound IS NULL AND compound_id IN {products.str_ids} """ @@ -352,18 +368,26 @@ def from_compounds( """Create recipe(s) to synthesis products in the :class:`.CompoundSet` :param compounds: set of compounds to find routes for - :param solve_combinations: bool: combinatorially combine all individual routes (Default value = True) - :param pick_first: return the first solution without comparison (Default value = False) - :param warn_multiple_solutions: warn if a compound has multiple routes (Default value = True) - :param pick_cheapest_inner_routes: for each compound choose the cheapest route (Default value = False) + :param solve_combinations: bool: combinatorially combine all individual routes + (Default value = True) + :param pick_first: return the first solution without comparison + (Default value = False) + :param warn_multiple_solutions: warn if a compound has multiple routes + (Default value = True) + :param pick_cheapest_inner_routes: for each compound choose the cheapest route + (Default value = False) :param reaction: reaction to create recipe from :param amount: amount in ``mg`` (Default value = 1) :param debug: bool: increase verbosity for debugging (Default value = False) :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set (Default value = None) - :param quoted_only: bool: only allow reactants with quotes (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has unavailable reactants (Default value = 'error') + :param permitted_reactions: once consider reactions in this set + (Default value = None) + :param quoted_only: bool: only allow reactants with quotes + (Default value = False) + :param supplier: None | str: optionally restrict quotes to only this supplier + (Default value = None) + :param unavailable_reaction: define the behaviour for when a reaction has + unavailable reactants (Default value = 'error') """ @@ -379,7 +403,6 @@ def from_compounds( if not hasattr(amount, '__iter__'): amount = [amount] * n_comps - if use_routes and supplier: raise NotImplementedError @@ -434,7 +457,8 @@ def from_compounds( if not comp_options: mrich.error( - f'No solutions for compound={comp} ({Compound(comp).reactions.ids=})' + f'No solutions for compound={comp} ' + f'({Compound(comp).reactions.ids=})' ) continue @@ -530,10 +554,12 @@ def from_reactants( ) -> 'list[Recipe] | Recipe | CompoundSet': """Find the maximal recipe from a given set of reactants - :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the reactants. Ingredient amounts are ignored + :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the + reactants. Ingredient amounts are ignored :param amount: amount of each product needed (Default value = 1) :param debug: increase verbosity (Default value = False) - :param return_products: return products instead of recipe (Default value = False) + :param return_products: return products instead of recipe + (Default value = False) :param kwargs: passed to :meth:`.Recipe.from_reactions` """ @@ -739,7 +765,8 @@ def product_compounds(self) -> 'CompoundSet': @property def combined_compound_ids(self) -> set[int]: - """Combined :class:`.CompoundModel` IDs from :meth:`.Recipe.product_compounds` and :meth:`.Recipe.compounds`""" + """Combined :class:`.CompoundModel` IDs from :meth:`.Recipe.product_compounds` + and :meth:`.Recipe.compounds`""" return set(self.product_compounds.ids) | set(self.compounds.ids) @property @@ -1073,9 +1100,10 @@ def sankey(self, title: str | None = None) -> 'graph_objects.Figure': label=labels, # color = "blue" customdata=customdata, - # customdata = ["Long name A1", "Long name A2", "Long name B1", "Long name B2", - # "Long name C1", "Long name C2"], - # hovertemplate='CompoundModel %{label}

smiles=%{customdata}', + # customdata = ["Long name A1", "Long name A2", "Long name B1", + # "Long name B2", "Long name C1", "Long name C2"], + # hovertemplate='CompoundModel %{label}

' + # 'smiles=%{customdata}', hovertemplate=hovertemplate, ), link=dict( @@ -1236,7 +1264,8 @@ def get_dict( :param reactant_supplier: include the supplier (Default value = True) :param database: include the database (Default value = True) :param timestamp: add a timestamp (Default value = True) - :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) (Default value = False) + :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) + (Default value = False) :param products: include products (Default value = True) :param serialise_price: serialise :class:`.Price` object (Default value = False) @@ -1346,7 +1375,8 @@ def write_CAR_csv( .. attention:: - This method requires a populated `route` table. For a workaround use :meth:`.CompoundSet.write_CAR_csv` instead + This method requires a populated `route` table. For a workaround use + :meth:`.CompoundSet.write_CAR_csv` instead Columns: @@ -1443,7 +1473,8 @@ def write_reactant_csv( reaction_type_counts: bool = True, return_df: bool = False, ) -> 'DataFrame | None': - """Detailed CSV output including reactant information for purchasing and information on the downstream synthetic use + """Detailed CSV output including reactant information for purchasing and + information on the downstream synthetic use ReactantModel ======== @@ -1497,20 +1528,26 @@ def write_reactant_csv( sql = f""" WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id FROM {self.db.SQL_SCHEMA_PREFIX}component + SELECT component_ref AS reactant_id, component_route AS route_id + FROM {self.db.SQL_SCHEMA_PREFIX}component WHERE component_type = 2 AND component_ref IN {self.reactants.compounds.str_ids} ), reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction ON component_ref = reaction_id + SELECT component_ref AS reaction_id, component_route AS route_id, + reaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}component + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction + ON component_ref = reaction_id WHERE component_type = 1 AND component_ref IN {self.reactions.str_ids} ) - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type FROM {self.db.SQL_SCHEMA_PREFIX}reactants - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions ON reactants.route_id = reactions.route_id + SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type + FROM {self.db.SQL_SCHEMA_PREFIX}reactants + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions + ON reactants.route_id = reactions.route_id """ reaction_lookup = {} for reactant_id, reaction_id, reaction_type in self.db.execute(sql): @@ -1666,7 +1703,8 @@ def write_reactant_csv( def write_product_csv( self, file: 'str | Path', return_df: bool = False ) -> 'pd.DataFrame | None': - """Detailed CSV output including product information for selection and synthesis""" + """Detailed CSV output including product information for selection and + synthesis""" # from rich import print from designdb.sets.pose import PoseSet @@ -2230,14 +2268,17 @@ def check_integrity(self, debug: bool = False) -> bool: for intermediate in self.intermediates: if intermediate not in reaction_intermediates: mrich.error( - f'Intermediate: {intermediate} is not in self.reactions.intermediates' + f'Intermediate: {intermediate} is not in ' + f'self.reactions.intermediates' ) return False # reactants for reactant in self.reactants: if reactant not in reaction_reactants: - mrich.error(f'ReactantModel: {reactant} is not in self.reactions.reactants') + mrich.error( + f'ReactantModel: {reactant} is not in self.reactions.reactants' + ) return False # all reactions should have enough reactant @@ -2264,7 +2305,8 @@ def check_integrity(self, debug: bool = False) -> bool: if reactant_ingredient.amount < required_amount: mrich.error( - f'Not enough of {reactant_ingredient.compound}: {reactant_ingredient.amount} < {required_amount}' + f'Not enough of {reactant_ingredient.compound}: ' + f'{reactant_ingredient.amount} < {required_amount}' ) return False @@ -2274,7 +2316,8 @@ def check_integrity(self, debug: bool = False) -> bool: return True def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): - """Add an :class:`.Ingredient` object for direct purchase (no associated reactions)""" + """Add an :class:`.Ingredient` object for direct purchase (no associated + reactions)""" self.compounds.add(ingredient) ### DUNDERS @@ -2300,7 +2343,10 @@ def __longstr(self) -> str: if self.reactions: if self.intermediates: - s = f'{self.reactants} --> {self.intermediates} --> {self.products} via {self.reactions}' + s = ( + f'{self.reactants} --> {self.intermediates} --> ' + f'{self.products} via {self.reactions}' + ) else: s = f'{self.reactants} --> {self.products} via {self.reactions}' @@ -2322,7 +2368,10 @@ def __longstr(self) -> str: def __repr__(self) -> str: """ANSI Formatted string representation""" - return f'{mcol.bold}{mcol.underline}{self.__longstr()}{mcol.unbold}{mcol.ununderline}' + return ( + f'{mcol.bold}{mcol.underline}{self.__longstr()}' + f'{mcol.unbold}{mcol.ununderline}' + ) def __rich__(self) -> str: """Rich Formatted string representation""" @@ -2340,8 +2389,6 @@ def __add__(self, other: 'Recipe'): return result - - # name conflict with route model. Trying to get rid of this entirely class Route(Recipe): """A recipe with a single product, that is stored in the database""" diff --git a/hippo/designdb/managers.py b/hippo/designdb/managers.py index c055a90..97fa890 100644 --- a/hippo/designdb/managers.py +++ b/hippo/designdb/managers.py @@ -1,15 +1,5 @@ from django.apps import apps -from django.db.models import ( - BooleanField, - Case, - F, - Func, - Manager, - OuterRef, - QuerySet, - Subquery, - When, -) +from django.db.models import Manager, QuerySet from rdkit import Chem from .utils import registration_hash_tautomer_insensitive, superparent @@ -17,24 +7,22 @@ class CompoundQueryset(QuerySet): def filter_qs(self): - CompoundModel = apps.get_model("designdb", "CompoundModel") + CompoundModel = apps.get_model('designdb', 'CompoundModel') qs = CompoundModel.objects.all() return qs - def get_by_smiles(self, smiles): mol = Chem.MolFromSmiles(smiles, sanitize=True) try: sp = superparent(mol) except Exception as e: - raise ValueError(f"SuperParent failed: {e}") from e + raise ValueError(f'SuperParent failed: {e}') from e h = registration_hash_tautomer_insensitive(sp) return self.filter_qs().get(compound_hash=h) - class CompoundManager(Manager): def get_queryset(self): return CompoundQueryset(self.model, using=self._db) @@ -45,7 +33,7 @@ def get_by_smiles(self, smiles): try: sp = superparent(mol) except Exception as e: - raise ValueError(f"SuperParent failed: {e}") from e + raise ValueError(f'SuperParent failed: {e}') from e h = registration_hash_tautomer_insensitive(sp) diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index fa15214..e688b00 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -6,7 +6,6 @@ from django.db import models from django.db.models import Q from django.utils import timezone -from pandas._libs.hashtable import objects_are_equal from rdkit import Chem from .managers import CompoundManager @@ -62,7 +61,8 @@ def get_prep_value(self, value): return Chem.MolToMolBlock(value) raise TypeError( - f'RDKitMolField only accepts RDKit Mol or MolBlock string, got {type(value)}' + 'RDKitMolField only accepts RDKit Mol or MolBlock string, ' + f'got {type(value)}' ) def deconstruct(self): @@ -73,11 +73,11 @@ def deconstruct(self): if settings.MANAGE_MODELS: # sqlite3, rdkit field types not available # shouldn't this be binary as well? - from django.db.models import BinaryField as BfpField + pass # from .models import RDKitMolField as MolField else: - from django_rdkit.models import BfpField, MolField + from django_rdkit.models import MolField class BaseModel(models.Model): diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index 61382cf..7a6baf2 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -4,17 +4,9 @@ import mrich import rdkit from designdb.models import CompoundModel, CompoundTagModel -from designdb.utils import ( - inchikey_from_smiles, - registration_hash_tautomer_insensitive, - sanitise_smiles, - superparent, -) +from designdb.utils import registration_hash_tautomer_insensitive, sanitise_smiles, superparent # from mypackage.services.compound import CompoundService from rdkit import Chem -from rdkit.Chem import RegistrationHash -from rdkit.Chem import inchi as rdkit_inchi -from rdkit.Chem.MolStandardize import rdMolStandardize # from rdkit.Chem import inchi @@ -36,10 +28,6 @@ logger = logging.getLogger(__name__) - - - - class CompoundBatchResult: def __init__(self): self.created = [] @@ -62,7 +50,7 @@ def create( try: sp = superparent(mol) except Exception as e: - raise ValueError(f"SuperParent failed: {e}") from e + raise ValueError(f'SuperParent failed: {e}') from e h = registration_hash_tautomer_insensitive(sp) @@ -77,9 +65,7 @@ def create( }, ) if not created and logger.level == logging.DEBUG: - mrich.warning( - f'Skipping compound {h}, duplicate of {compound.pk}' - ) + mrich.warning(f'Skipping compound {h}, duplicate of {compound.pk}') # there's a following block in the original code # I don't understand what it is trying to achieve @@ -100,7 +86,8 @@ def create( # if not compound: # mrich.error( - # 'CompoundModel exists in database but could not be found by inchikey' + # 'CompoundModel exists in database but could not be found ' + # 'by inchikey' # ) # mrich.var('smiles', smiles) # mrich.var('inchikey', inchikey) @@ -138,28 +125,30 @@ def create_from_smiles_list( return result - @classmethod def get_by_smiles(cls, smiles: str) -> CompoundModel | None: mol = Chem.MolFromSmiles(smiles, sanitize=True) try: sp = superparent(mol) except Exception as e: - raise ValueError(f"SuperParent failed: {e}") from e + raise ValueError(f'SuperParent failed: {e}') from e h = registration_hash_tautomer_insensitive(sp) return CompoundModel.objects.get(compound_hash=h) - class CompoundTagService: @staticmethod def tags_from_list(tag_list: list[str]): assert tag_list is not None, '"None" passed as tag_list' CompoundTagModel.objects.bulk_create( - [CompoundTagModel(compound_tag_name=k.strip()) for k in tag_list if k.strip()], + [ + CompoundTagModel(compound_tag_name=k.strip()) + for k in tag_list + if k.strip() + ], ignore_conflicts=True, ) tags = CompoundTagModel.objects.filter(compound_tag_name__in=tag_list) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 65b5fba..df8868a 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -449,7 +449,10 @@ def ingest_sdf( # temp hack: disable a trigger that runs on every score # insertion and later enable it cursor = connection.cursor() - cursor.execute("ALTER TABLE designdb.score_values DISABLE TRIGGER trg_score_values_refresh_pivoted_mv;") + cursor.execute( + 'ALTER TABLE designdb.score_values ' + 'DISABLE TRIGGER trg_score_values_refresh_pivoted_mv;' + ) for r in records: result.attempts += 1 @@ -526,8 +529,14 @@ def ingest_sdf( scorer.add_scores_from_record(pose=pose, record=r) # re-enable trigger and populate matview - cursor.execute("ALTER TABLE designdb.score_values ENABLE TRIGGER trg_score_values_refresh_pivoted_mv;") - cursor.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY designdb.scores_per_pose_pivoted_mv;") + cursor.execute( + 'ALTER TABLE designdb.score_values ' + 'ENABLE TRIGGER trg_score_values_refresh_pivoted_mv;' + ) + cursor.execute( + 'REFRESH MATERIALIZED VIEW CONCURRENTLY ' + 'designdb.scores_per_pose_pivoted_mv;' + ) return result @@ -643,7 +652,9 @@ def ingest_syndirella_routes( mrich.warning('Skipping unsupported chemistry:', reaction_type) continue # except Exception: - # mrich.error('Uncaught error with row', i, 'route', j, 'reaction', k) + # mrich.error( + # 'Uncaught error with row', i, 'route', j, 'reaction', k + # ) # continue products.add(Ingredient.from_compound(product, amount=1)) @@ -976,7 +987,9 @@ def ingest_syndirella_elabs( # comp service? for superstructure_id in superstructure_ids: base = CompoundModel.objects.get(pk=scaffold_id) - superstructure = CompoundModel.objects.get(pk=int(superstructure_id)) + superstructure = CompoundModel.objects.get( + pk=int(superstructure_id) + ) ScaffoldModel.objects.get_or_create( base_compound=base, superstructure_compound=superstructure, diff --git a/hippo/designdb/services/ingredient.py b/hippo/designdb/services/ingredient.py index bcc1c67..034cf26 100644 --- a/hippo/designdb/services/ingredient.py +++ b/hippo/designdb/services/ingredient.py @@ -5,7 +5,6 @@ class IngredientService: - @staticmethod def get_quotes( compound: CompoundModel, @@ -44,7 +43,8 @@ def get_quotes( if not qs.exists(): mrich.debug( - f'No quote available for C{compound.pk} with amount >= {min_amount} mg. Estimating price...' + f'No quote available for C{compound.pk} with amount >= ' + f'{min_amount} mg. Estimating price...' ) if pick_cheapest: diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 78005d6..2435b03 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -1,11 +1,11 @@ import mrich +from designdb.components.recipe import Recipe from designdb.models import CompoundModel, ReactionModel from designdb.sets.compound import IngredientSet from designdb.sets.reaction import ReactionSet class RecipeService: - @staticmethod def from_reaction( reaction, @@ -30,7 +30,8 @@ def from_reaction( if debug: mrich.debug( - f'RecipeService.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' + f'RecipeService.from_reaction(R{reaction.id}, ' + f'{amount=}, {pick_cheapest=})' ) mrich.debug(f'{reaction.product.id=}') mrich.debug(f'{reaction.reactants.ids=}') @@ -72,7 +73,9 @@ def from_reaction( else: return [] - def get_reactant_amount_pairs(reaction: 'ReactionModel') -> list[tuple[int, float]]: + def get_reactant_amount_pairs( + reaction: 'ReactionModel', + ) -> list[tuple[int, float]]: """Get pairs of reactant ID and float amounts""" if reaction_reactant_cache and reaction.id in reaction_reactant_cache: print('reaction_reactant_cache used') @@ -187,7 +190,6 @@ def from_reactions( """Create a Recipe from a ReactionSet and its upstream dependencies.""" from designdb.components.recipe import Recipe - from designdb.sets.compound import CompoundSet assert isinstance(reactions, ReactionSet) diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index f7cdcca..ef849b9 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -29,7 +29,9 @@ class CompoundSet: .. attention:: - :class:`.CompoundSet` objects should not be created directly. Instead use the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :class:`.CompoundSet` objects should not be created directly. Instead use + the :meth:`.HIPPO.compounds` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. Use as an iterable ================== @@ -137,7 +139,9 @@ def __sub__( self, other: 'CompoundModel | CompoundSet | IngredientSet', ) -> 'CompoundSet': - """Subtract a :class:`.CompoundModel` object or ID from this set, or subtract multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + """Subtract a :class:`.CompoundModel` object or ID from this set, or subtract + multiple at once when ``other`` is a :class:`.CompoundSet` or + :class:`.IngredientSet`""" match other: case CompoundSet(): @@ -149,7 +153,9 @@ def __sub__( ) case int(): return CompoundSet( - CompoundModel.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + CompoundModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk=other.pk) + ), sort=False, ) @@ -157,7 +163,8 @@ def __add__( self, other: 'CompoundModel | CompoundSet | IngredientSet | int', ) -> 'CompoundSet': - """Add a :class:`.CompoundModel` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + """Add a :class:`.CompoundModel` object or ID to this set, or add multiple at + once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" match other: case CompoundModel(): @@ -226,7 +233,8 @@ def __or__(self, other: 'CompoundSet'): raise NotImplementedError def __xor__(self, other: 'CompoundSet'): - """Exclusive OR set operation, returns all compounds in either set but not both""" + """Exclusive OR set operation, returns all compounds in either set but + not both""" match other: case CompoundSet(): @@ -294,7 +302,8 @@ def get_by_tag( return CompoundSet(self._queryset.filter(has_tag=True)) def get_by_metadata(self, key: str, value: str | None = None) -> 'CompoundSet': - """Get all child compounds with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + """Get all child compounds with by their metadata. If no value is passed, then + simply containing the key in the metadata dictionary is sufficient :param key: metadata key :param value: metadata value (Default value = None) @@ -326,7 +335,10 @@ def get_by_scaffold( values = self.db.select_where( query='scaffold_superstructure', table='scaffold', - key=f'scaffold_base = {scaffold} AND scaffold_superstructure IN {self.str_ids}', + key=( + f'scaffold_base = {scaffold}' + f' AND scaffold_superstructure IN {self.str_ids}' + ), multiple=True, none=none, ) @@ -347,7 +359,7 @@ def get_by_smiles(self, smiles: str) -> CompoundModel: try: sp = superparent(mol) except Exception as e: - raise ValueError(f"SuperParent failed: {e}") from e + raise ValueError(f'SuperParent failed: {e}') from e h = registration_hash_tautomer_insensitive(sp) return self._queryset.get(compound_hash=h) @@ -356,7 +368,8 @@ def get_all_possible_reactants( self, debug: bool = False, ) -> 'CompoundSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. + """Recursively searches for all the reactants that could possible be needed to + synthesise these compounds. :param debug: Increased verbosity for debugging (Default value = False) @@ -392,7 +405,8 @@ def get_all_possible_reactions( self, debug: bool = False, ) -> 'ReactionSet': - """Recursively searches for all the reactants that could possible be needed to synthesise these compounds. + """Recursively searches for all the reactants that could possible be needed to + synthesise these compounds. :param debug: Increased verbosity for debugging (Default value = False) @@ -424,9 +438,11 @@ def get_all_possible_reactions( return ReactionModel.objects.filter(product__compound__in=seen) def get_risk_diversity(self, debug: bool = False) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set + """Calculate the average spread of risk (#atoms added) for each scaffold in + this set - :returns: average of the standard deviations of number of atoms added for each scaffold + :returns: average of the standard deviations of number of atoms added for each + scaffold """ @@ -434,10 +450,14 @@ def get_risk_diversity(self, debug: bool = False) -> float: f""" WITH nums AS ( SELECT scaffold_base AS base, scaffold_superstructure AS elab, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) AS diff + {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) + - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) + AS diff FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 ON scaffold_base = c1.compound_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 ON scaffold_superstructure = c2.compound_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 + ON scaffold_base = c1.compound_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 + ON scaffold_superstructure = c2.compound_id WHERE scaffold_superstructure IN {self.str_ids} ), @@ -556,7 +576,8 @@ def summary(self, return_df: bool = False) -> None: # compounds with poses sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) FROM {self.db.SQL_SCHEMA_PREFIX}tag + SELECT tag_name, COUNT(DISTINCT pose_compound) + FROM {self.db.SQL_SCHEMA_PREFIX}tag INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose ON tag_pose = pose_id WHERE pose_compound IN {self.str_ids} @@ -794,7 +815,8 @@ def get_routes( ) -> 'RouteSet': """Get a RoutSet to products in this set. - :param permitted_reactions: optionally restrict reactions to those in this :class:`.ReactionSet` + :param permitted_reactions: optionally restrict reactions to those in this + :class:`.ReactionSet` """ @@ -920,11 +942,14 @@ def get_df( :param alias: include alias column (Default value = True) :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) + :param expand_metadata: create separate column for each metadata key + (Default value = True) :param poses: include poses in output (Default value = False) :param num_reactant: include num_poses column - :param num_reactant: include num_reactant column (number of reactions where compound is a reactant) - :param num_reactions: include num_reactions column (number of reactions where compound is a product) + :param num_reactant: include num_reactant column (number of reactions where + compound is a reactant) + :param num_reactions: include num_reactions column (number of reactions where + compound is a product) :param tags: include tags column :param scaffolds: include scaffolds column :param elabs: include elabs column @@ -1103,7 +1128,8 @@ def get_unquoted( return self - quoted def get_dict(self) -> dict: - """Get a dictionary object with all serialisable data needed to reconstruct this set""" + """Get a dictionary object with all serialisable data needed to reconstruct + this set""" return dict(db=str(self.db.path.resolve()), indices=self.indices) def write_smiles_csv( @@ -1333,7 +1359,8 @@ def write_CAR_csv( row[f'reactant-2-{i}'] = reaction.reactants[1].smiles case _: raise NotImplementedError( - f'Unsupported number of reactants for {reaction=}: {len(reaction.reactants)}' + f'Unsupported number of reactants for' + f' {reaction=}: {len(reaction.reactants)}' ) row[f'reaction-product-smiles-{i}'] = reaction.product.smiles @@ -1655,7 +1682,8 @@ def formula(self) -> str: @property def atomtype_dict(self) -> dict[str, int]: - """Get a dictionary with atomtypes as keys and corresponding quantities/counts as values""" + """Get a dictionary with atomtypes as keys and corresponding + quantities/counts as values""" from molparse.atomtypes import combine_atomtype_dicts atomtype_dicts = [c.atomtype_dict for c in self] @@ -1669,12 +1697,16 @@ def num_atoms_added(self) -> list[int]: """ + nha = self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms'] sql = f""" WITH nums AS ( SELECT A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + {nha}(A.compound_mol) + - {nha}(B.compound_mol) + AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, + {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} ) @@ -1695,15 +1727,20 @@ def num_atoms_added(self) -> list[int]: def avg_num_atoms_added(self) -> float: """Calculate the average number of atoms added w.r.t the scaffold - :returns: average number of atoms added values for compounds which have a scaffold + :returns: average number of atoms added values for compounds which have a + scaffold """ + nha = self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms'] sql = f""" WITH nums AS ( SELECT A.compound_id AS comp_id, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(A.compound_mol) - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(B.compound_mol) AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, {self.db.SQL_SCHEMA_PREFIX}compound B + {nha}(A.compound_mol) + - {nha}(B.compound_mol) + AS diff + FROM {self.db.SQL_SCHEMA_PREFIX}compound A, + {self.db.SQL_SCHEMA_PREFIX}compound B WHERE A.compound_base = B.compound_id AND A.compound_id IN {self.str_ids} ) @@ -1720,9 +1757,11 @@ def avg_num_atoms_added(self) -> float: @property def risk_diversity(self) -> float: - """Calculate the average spread of risk (#atoms added) for each scaffold in this set + """Calculate the average spread of risk (#atoms added) for each scaffold in + this set - :returns: average of the standard deviations of number of atoms added for each scaffold + :returns: average of the standard deviations of number of atoms added for each + scaffold """ @@ -1730,7 +1769,8 @@ def risk_diversity(self) -> float: @property def elaboration_balance(self) -> float: - """Measure of how evenly elaborations are distributed across scaffolds in this set""" + """Measure of how evenly elaborations are distributed across scaffolds in + this set""" sql = f""" SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold @@ -1750,7 +1790,8 @@ def elaboration_balance(self) -> float: @property def num_scaffolds_elaborated(self) -> int: - """Count the number of scaffold compounds that have at least one elaboration in this set + """Count the number of scaffold compounds that have at least one elaboration in + this set :returns: number of scaffold compounds @@ -1758,7 +1799,8 @@ def num_scaffolds_elaborated(self) -> int: (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + SELECT COUNT(DISTINCT scaffold_base) + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() @@ -1790,7 +1832,8 @@ def num_scaffolds(self) -> int: """Return a count of scaffolds of this set""" (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_base) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + SELECT COUNT(DISTINCT scaffold_base) + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_superstructure IN {self.str_ids} """ ).fetchone() @@ -1798,12 +1841,16 @@ def num_scaffolds(self) -> int: @property def elabs(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration of an existing scaffold""" + """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration + of an existing scaffold""" ids = self.db.select_where( query='scaffold_superstructure', table='scaffold', - key=f'scaffold_superstructure IS NOT NULL and scaffold_base IN {self.str_ids}', + key=( + f'scaffold_superstructure IS NOT NULL' + f' and scaffold_base IN {self.str_ids}' + ), multiple=True, none='quiet', ) @@ -1819,7 +1866,8 @@ def num_elabs(self) -> int: """Return a count of elaborations of this set""" (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT scaffold_superstructure) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold + SELECT COUNT(DISTINCT scaffold_superstructure) + FROM {self.db.SQL_SCHEMA_PREFIX}scaffold WHERE scaffold_base IN {self.str_ids} """ ).fetchone() @@ -1879,7 +1927,8 @@ def _db_changed(self) -> bool: @property def reaction_ids(self) -> list[int]: - """Returns a list of :class:`.ReactionModel` IDs that result in members of this set""" + """Returns a list of :class:`.ReactionModel` IDs that result in members of + this set""" records = self.db.select_where( table='reaction', query='reaction_id', @@ -1892,11 +1941,15 @@ def reaction_ids(self) -> list[int]: class IngredientSet: - """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and an attached quote, the :class:`.IngredientSet` is a object representing multiple ingredients. + """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and + an attached quote, the :class:`.IngredientSet` is a object representing multiple + ingredients. .. attention:: - :class:`.IngredientSet` objects should not be created directly. Instead they are returned by several methods when working with :doc:`quoting` and :doc:`rgen`. + :class:`.IngredientSet` objects should not be created directly. Instead they + are returned by several methods when working with :doc:`quoting` and + :doc:`rgen`. Selecting ingredients in the set ================================ @@ -2124,7 +2177,8 @@ def from_compounds( ) -> 'IngredientSet': """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs - :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide ``ids`` and ``db`` (Default value = None) + :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide + ``ids`` and ``db`` (Default value = None) :param ids: CompoundModel IDs (Default value = None) :param db: HIPPO Database (Default value = None) :param amount: Amount(s) in ``mg`` (Default value = 1) @@ -2247,13 +2301,16 @@ def add( ) -> None: """Add an :class:`.Ingredient` to this set - :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify other parameters, (Default value = None) + :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify + other parameters, (Default value = None) :param compound_id: :class:`.CompoundModel` ID (Default value = None) :param amount: amount in ``mg`` (Default value = None) :param quote_id: :class:`.Quote` ID (Default value = None) :param supplier: supplier name string or list (Default value = None) - :param max_lead_time: maximum lead-time for quoting (in days) (Default value = None) - :param quoted_amount: amount of associated :class:`.Quote` (Default value = None) + :param max_lead_time: maximum lead-time for quoting (in days) + (Default value = None) + :param quoted_amount: amount of associated :class:`.Quote` + (Default value = None) :param debug: increase verbosity for debugging (Default value = False) """ @@ -2395,7 +2452,8 @@ def set_amounts( # pairs = self.db.execute( # f""" # WITH matching_quotes AS ( - # SELECT quote_id, quote_compound, MIN(quote_price) FROM {self.db.SQL_SCHEMA_PREFIX}quote + # SELECT quote_id, quote_compound, MIN(quote_price) + # FROM {self.db.SQL_SCHEMA_PREFIX}quote # WHERE quote_compound IN {self.str_compound_ids} # AND quote_amount >= {amount} # GROUP BY quote_compound @@ -2418,7 +2476,8 @@ def set_amounts( def get_dict(self, data_orient: str = 'list') -> dict: """Get serialisable dictionary - :param data_orient: passed to ``pandas.DataFrame.to_dict`` (Default value = 'list') + :param data_orient: passed to ``pandas.DataFrame.to_dict`` + (Default value = 'list') """ return dict( diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index abd1c9c..d19b29f 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -6,11 +6,13 @@ class InteractionTable: - """Class representing all :class:`.InteractionModel` objects in the 'interaction' table of the :class:`.Database`. + """Class representing all :class:`.InteractionModel` objects in the 'interaction' + table of the :class:`.Database`. .. attention:: - :class:`.InteractionTable` objects should not be created directly. Instead use the :meth:`.HIPPO.interactions` property. + :class:`.InteractionTable` objects should not be created directly. Instead + use the :meth:`.HIPPO.interactions` property. """ @@ -70,11 +72,14 @@ def __rich__(self) -> str: class InteractionSet: - """Class representing a subset of the :class:`.InteractionModel` objects in the 'interaction' table of the :class:`.Database`. + """Class representing a subset of the :class:`.InteractionModel` objects in the + 'interaction' table of the :class:`.Database`. .. attention:: - :class:`.InteractionSet` objects should not be created directly. Instead use :meth:`.PoseModel.interactions`, or :meth:`.PoseSet.interactions` methods. + :class:`.InteractionSet` objects should not be created directly. Instead + use :meth:`.PoseModel.interactions`, or :meth:`.PoseSet.interactions` + methods. """ @@ -180,7 +185,8 @@ def from_residue( :param db: HIPPO :class:`.Database` :param residue_number: the residue number :param chain: the chain name / letter, defaults to any chain - :param target: the protein :class:`.TargetModel` object or ID, defaults to first target in database + :param target: the protein :class:`.TargetModel` object or ID, defaults to + first target in database :returns: a :class:`.InteractionSet` object """ @@ -261,7 +267,9 @@ def feature_ids(self) -> list[int]: @property def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.FeatureModel` ID's to the number of corresponding ligand features (from any :class:`.PoseModel`)""" + """Classic HIPPO fingerprint dictionary, mapping protein + :class:`.FeatureModel` ID's to the number of corresponding ligand features + (from any :class:`.PoseModel`)""" return self.get_classic_fingerprint() @property @@ -288,7 +296,8 @@ def residue_number_chain_pairs(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT DISTINCT feature_residue_number, feature_chain_name + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} @@ -301,7 +310,8 @@ def avg_num_residues_per_pose(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} @@ -347,7 +357,9 @@ def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: """Get a list of ``(residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_pose, interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT DISTINCT interaction_pose, interaction_type, + feature_residue_number, feature_chain_name + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} @@ -371,7 +383,8 @@ def type_residue_number_chain_triples(self) -> list[tuple]: """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" sql = f""" - SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON feature_id = interaction_feature WHERE interaction_id IN {self.str_ids} @@ -381,11 +394,13 @@ def type_residue_number_chain_triples(self) -> list[tuple]: @property def num_features(self) -> int: - """Count the funmber of protein :class:`.FeatureModel`s with which interactions are formed""" + """Count the funmber of protein :class:`.FeatureModel`s with which interactions + are formed""" (count,) = self.db.execute( f""" - SELECT COUNT(DISTINCT interaction_feature) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT COUNT(DISTINCT interaction_feature) + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} """ ).fetchone() @@ -394,13 +409,15 @@ def num_features(self) -> int: @property def avg_num_interactions_per_feature(self) -> float: - """Average number of interactions formed with each protein :class:`.FeatureModel`""" + """Average number of interactions formed with each protein + :class:`.FeatureModel`""" (count,) = self.db.execute( f""" WITH counts AS ( - SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT interaction_feature, COUNT(1) AS count + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature ) @@ -413,11 +430,13 @@ def avg_num_interactions_per_feature(self) -> float: @property def per_feature_count_hirsch(self) -> float: - """A measure for how evenly protein :class:`.FeatureModel`s are being interacted with""" + """A measure for how evenly protein :class:`.FeatureModel`s are being + interacted with""" counts = self.db.execute( f""" - SELECT interaction_feature, COUNT(1) AS count FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT interaction_feature, COUNT(1) AS count + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature """ @@ -456,11 +475,14 @@ def summary( mrich.var(s, f'{interaction.distance:.1f}', 'Å') def get_classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein :class:`.FeatureModel` ID's to the number of corresponding ligand features (from any :class:`.PoseModel`)""" + """Classic HIPPO fingerprint dictionary, mapping protein + :class:`.FeatureModel` ID's to the number of corresponding ligand features + (from any :class:`.PoseModel`)""" pairs = self.db.execute( f""" - SELECT interaction_feature, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} + SELECT interaction_feature, COUNT(1) + FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} WHERE interaction_id IN {self.str_ids} GROUP BY interaction_feature """ diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 4edd1a7..339a7bf 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -78,7 +78,9 @@ class PoseSet: .. attention:: - :class:`.PoseSet` objects should not be created directly. Instead use the :meth:`.HIPPO.poses` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :class:`.PoseSet` objects should not be created directly. Instead use the + :meth:`.HIPPO.poses` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. Use as an iterable ================== @@ -231,7 +233,9 @@ def __sub__( ) case int(): return PoseSet( - PoseModel.objects.filter(Q(pk__in=self._queryset) & ~Q(pk=other.pk)), + PoseModel.objects.filter( + Q(pk__in=self._queryset) & ~Q(pk=other.pk) + ), sort=False, ) @@ -288,7 +292,9 @@ def __call__( target: int = None, subsite: int = None, ) -> 'PoseSet': - """Filter poses by a given tag, SubsiteModel ID, or target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd :meth:`.PoseSet.get_by_subsite`""" + """Filter poses by a given tag, SubsiteModel ID, or target ID. See + :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd + :meth:`.PoseSet.get_by_subsite`""" if tag: return self.get_by_tag(tag) @@ -328,7 +334,8 @@ def get_by_tag( """Get all child poses with a certain tag :param tag: tag to filter by - :param inverse: return all poses *not* tagged with ``tag`` (Default value = False) + :param inverse: return all poses *not* tagged with ``tag`` + (Default value = False) """ self._queryset = self._queryset.annotate( @@ -347,10 +354,12 @@ def get_by_tag( def get_by_metadata( self, key: str, value: str | None = None, debug: bool = False ) -> 'PoseSet': - """Get all child poses with by their metadata. If no value is passed, then simply containing the key in the metadata dictionary is sufficient + """Get all child poses with by their metadata. If no value is passed, then + simply containing the key in the metadata dictionary is sufficient :param key: metadata key to search for - :param value: metadata value, if ``None`` return poses with the metadata key regardless of value (Default value = None) + :param value: metadata value, if ``None`` return poses with the metadata key + regardless of value (Default value = None) """ results = self.db.select_where( @@ -425,16 +434,21 @@ def get_df( :param inchikey: include InChIKey column (Default value = True) :param alias: include alias column (Default value = True) :param name: include name column (Default value = True) - :param compound_id: include :class:`.CompoundModel` ID column (Default value = False) - :param reference_id: include reference :class:`.PoseModel` ID column (Default value = False) - :param target_id: include reference :class:`.TargetModel` ID column (Default value = False) + :param compound_id: include :class:`.CompoundModel` ID column + (Default value = False) + :param reference_id: include reference :class:`.PoseModel` ID column + (Default value = False) + :param target_id: include reference :class:`.TargetModel` ID column + (Default value = False) :param path: include path column (Default value = False) :param mol: include ``rdkit.Chem.Mol`` in output (Default value = False) :param energy_score: include energy_score column (Default value = False) :param distance_score: include distance_score column (Default value = False) - :param inspiration_score: include inspiration_score column (Default value = False) + :param inspiration_score: include inspiration_score column + (Default value = False) :param metadata: include metadata in output (Default value = False) - :param expand_metadata: create separate column for each metadata key (Default value = True) + :param expand_metadata: create separate column for each metadata key + (Default value = True) :param inspiration_ids: include inspiration :class:`.PoseModel` ID column :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column :param derivative_ids: include derivative :class:`.PoseModel` ID column @@ -530,7 +544,8 @@ def get_df( 'subsites__subsite_name', filter=Q(subsites__isnull=False), ), - # JsonGroupArray('subsites__subsite_name', filter=Q(subsites__isnull=False),), + # JsonGroupArray('subsites__subsite_name', + # filter=Q(subsites__isnull=False),), ), } @@ -615,7 +630,8 @@ def get_by_compound( *, compound: 'int | CompoundModel | CompoundSet', ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.CompoundModel`. + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.CompoundModel`. :param compound: :class:`.CompoundModel` object or ID :returns: a :class:`.PoseSet` of the selection @@ -636,7 +652,8 @@ def get_by_target( *, target: TargetModel, ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.TargetModel`. + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.TargetModel`. :param id: :class:`.TargetModel` ID :returns: a :class:`.PoseSet` of the selection @@ -651,7 +668,8 @@ def get_by_subsite( *, subsite: SubsiteModel, ) -> 'PoseSet | None': - """Select a subset of this :class:`.PoseSet` by the associated :class:`.SubsiteModel`. + """Select a subset of this :class:`.PoseSet` by the associated + :class:`.SubsiteModel`. :param id: :class:`.SubsiteModel` ID :returns: a :class:`.PoseSet` of the selection @@ -695,7 +713,9 @@ def get_by_subsite( # operator='=', # inverse: bool = False, # ): - # """Filter this :class:`.PoseSet` by selecting members where ``function(pose)`` is truthy or pass a key, value, and optional operator to search by database values + # """Filter this :class:`.PoseSet` by selecting members where + # ``function(pose)`` is truthy or pass a key, value, and optional operator + # to search by database values # :param function: callable object # :param key: database field for 'pose' table ('pose_' prefix not needed) @@ -741,7 +761,10 @@ def add_tag( pose_tag.save() PoseTagJunctionModel.objects.bulk_create( - [PoseTagJunctionModel(pose=pose, pose_tag=pose_tag) for pose in self._queryset], + [ + PoseTagJunctionModel(pose=pose, pose_tag=pose_tag) + for pose in self._queryset + ], ignore_conflicts=True, ) @@ -760,7 +783,8 @@ def append_to_metadata( key, value, ) -> None: - """Append a specific item to list-like values associated with a given key for all member's metadata dictionaries + """Append a specific item to list-like values associated with a given key for + all member's metadata dictionaries :param key: the :class:`.Metadata` key to match :param value: the value to append to the list @@ -793,7 +817,9 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: # I'm still not entirely clear can you really have # posesets from different target, if not, and it really # seems that not, this should be a single subsite - subsite, _ = SubsiteModel.get_or_create(target=pose.target, subsite_name=key) + subsite, _ = SubsiteModel.get_or_create( + target=pose.target, subsite_name=key + ) subsite_tag = SubsiteTagModel(subsite=subsite, pose=pose) subsite_tag.save() @@ -810,7 +836,8 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: # :param alpha: Tversky alpha parameter # :param beta: Tversky beta parameter - # :param score_type: Score type to add to database, choose from "combo", "shape", "colour" + # :param score_type: Score type to add to database, choose from + # "combo", "shape", "colour" # :returns: Pandas DataFrame with molecules and scores # """ @@ -853,7 +880,9 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: # tuples = df[f'mocassin_{score_type}({alpha},{beta})'].items() - # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" + # sql = f"""UPDATE {self.db.SQL_SCHEMA_PREFIX}pose + # SET pose_inspiration_score = {self.db.SQL_STRING_PLACEHOLDER} + # WHERE pose_id = {self.db.SQL_STRING_PLACEHOLDER}""" # mrich.debug('Updating pose_inspiration_score values') # self.db.executemany(sql, [(b, a) for a, b in tuples]) @@ -866,7 +895,8 @@ def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: def split_by_reference(self) -> 'dict[int,PoseSet]': """Split this :class:`.PoseSet` into subsets grouped by reference ID - :returns: a dictionary with reference :class:`.PoseModel` IDs as keys and :class:`.PoseSet` subsets as values + :returns: a dictionary with reference :class:`.PoseModel` IDs as keys and + :class:`.PoseSet` subsets as values """ sets = {} @@ -880,8 +910,10 @@ def split_by_inspirations( ) -> 'dict[PoseSet,PoseSet] | PoseSet': """Split this :class:`.PoseSet` into subsets grouped by inspirations - :param single_set: Return a single :class:`.PoseSet` with members sorted by inspirations (Default value = False) - :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and :class:`.PoseSet` derivative subsets as values + :param single_set: Return a single :class:`.PoseSet` with members sorted by + inspirations (Default value = False) + :returns: a dictionary with tuples of inspiration :class:`.PoseSet` as keys and + :class:`.PoseSet` derivative subsets as values """ @@ -925,7 +957,8 @@ def write_sdf( """Write an SDF :param out_path: filepath of the output - :param name_col: pose property to use as the name column, can be ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') + :param name_col: pose property to use as the name column, can be + ``["name", "alias", "inchikey", "id"]`` (Default value = 'name') :param inspiration_ids: include inspiration :class:`.PoseModel` ID column :param inspiration_aliases: include inspiration :class:`.PoseModel` alias column :param fragalysis_inspirations: create inspirations column "ref_mols" @@ -1004,16 +1037,23 @@ def to_fragalysis( :param ref_url: reference URL for the method :param submitter_name: name of the person submitting the compounds :param submitter_email: email of the person submitting the compounds - :param submitter_institution: institution name of the person submitting the compounds + :param submitter_institution: institution name of the person submitting the + compounds :param metadata: include metadata in the output? (Default value = True) :param skipmetadata: exclude metadata keys from output - :param sort_by: if set will sort the SDF by this column/field (Default value = None) + :param sort_by: if set will sort the SDF by this column/field + (Default value = None) :param sort_reverse: reverse the sorting (Default value = False) - :param generate_pdbs: generate accompanying protein-ligand complex PDBs (Default value = False) - :param ingredients: get procurement and amount information from this :class:`.IngredientSet` (Default value = None) + :param generate_pdbs: generate accompanying protein-ligand complex PDBs + (Default value = False) + :param ingredients: get procurement and amount information from this + :class:`.IngredientSet` (Default value = None) :param tags: include a column for tags in the output (Default value = True) - :param subsites: include a column for subsites in the output (Default value = True) - :param extra_cols: extra_cols should be a dictionary with a key for each column name, and list values where the first element is the field description, and all subsequent elements are values for each pose. + :param subsites: include a column for subsites in the output + (Default value = True) + :param extra_cols: extra_cols should be a dictionary with a key for each column + name, and list values where the first element is the field description, and + all subsequent elements are values for each pose. """ @@ -1192,7 +1232,8 @@ def fix_subsites(subsite_list: list[str]): # for i, row in pose_df.iterrows(): - # compound_id = self.db.get_compound_id(inchikey=row["compound inchikey"]) + # compound_id = self.db.get_compound_id( + # inchikey=row["compound inchikey"]) # ingredient = ingredients(compound_id=compound_id) @@ -1351,7 +1392,8 @@ def fix_subsites(subsite_list: list[str]): return pose_df def to_pymol(self, prefix: str | None = None) -> None: - """Group the poses by reference protein and inspirations and output relevant PDBs and SDFs. + """Group the poses by reference protein and inspirations and output relevant + PDBs and SDFs. :param prefix: prefix to give all output subdirectories (Default value = None) @@ -1610,7 +1652,9 @@ def interactive( """Interactive widget to navigate compounds in the table :param print_name: print the :class:`.PoseModel` name (Default value = True) - :param method: pass the name of a :class:`.PoseModel` method to interactively display. Keyword arguments to interactive() will be passed through (Default value = None) + :param method: pass the name of a :class:`.PoseModel` method to interactively + display. Keyword arguments to interactive() will be passed through + (Default value = None) :param function: pass a callable which will be called as `function(pose)` """ @@ -1769,7 +1813,8 @@ def grid(self) -> None: # """Print a table counting poses by subsite""" # sql = f""" - # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) FROM {self.db.SQL_SCHEMA_PREFIX}subsite + # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) + # FROM {self.db.SQL_SCHEMA_PREFIX}subsite # INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag # ON subsite_id = subsite_tag_ref # WHERE subsite_tag_pose IN {self.str_ids} @@ -1791,7 +1836,8 @@ def grid(self) -> None: # return df def get_interaction_overlaps(self, return_pairs: bool = False) -> int: - """Count the number of member pose pairs which share at least one but not all interactions""" + """Count the number of member pose pairs which share at least one but not all + interactions""" records = InteractionModel.objects.filter( pose__in=self._queryset, @@ -1839,7 +1885,8 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': # get interaction records sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, feature_residue_number, interaction_type + SELECT DISTINCT interaction_pose, feature_residue_name, + feature_residue_number, interaction_type FROM {self.db.SQL_SCHEMA_PREFIX}interaction INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature ON interaction_feature = feature_id @@ -2020,13 +2067,15 @@ def df(self) -> pd.DataFrame: @property def references(self) -> 'PoseSet': - """Return a :class:`.PoseSet` of the all the distinct references in this :class:`.PoseSet`""" + """Return a :class:`.PoseSet` of the all the distinct references in this + :class:`.PoseSet`""" # TODO: call through proper factory method return self.get_by_references(self) @property def reference_ids(self) -> set[int]: - """Return a set of :class:`.PoseModel` ID's of the all the distinct references in this :class:`.PoseSet`""" + """Return a set of :class:`.PoseModel` ID's of the all the distinct references + in this :class:`.PoseSet`""" return self.get_by_references(self).values_list('pk', flat=True) @property @@ -2101,7 +2150,8 @@ def best_placed_pose_id(self) -> int: # query = 'pose_id, MIN(pose_distance_score)' # query = self.db.select_where( - # table='pose', query=query, key=f'pose_id in {self.str_ids}', multiple=False + # table='pose', query=query, + # key=f'pose_id in {self.str_ids}', multiple=False # ) # return query[0] @@ -2137,7 +2187,8 @@ def num_subsites(self) -> int: @property def subsite_balance(self) -> float: - """Measure of how evenly subsite counts are distributed across poses in this set""" + """Measure of how evenly subsite counts are distributed across poses in this + set""" # TODO: subsites not implemented yet # from numpy import std @@ -2233,8 +2284,12 @@ def _delete(self, *, force: bool = False) -> None: try: with transaction.atomic(): - InspirationModel.objects.filter(original_pose__in=self._queryset).delete() - InspirationModel.objects.filter(derivative_pose__in=self._queryset).delete() + InspirationModel.objects.filter( + original_pose__in=self._queryset + ).delete() + InspirationModel.objects.filter( + derivative_pose__in=self._queryset + ).delete() SubsiteTagModel.objects.filter(pose__in=self._queryset).delete() InteractionModel.objects.filter(pose__in=self._queryset).delete() self._queryset.delete() diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index 5cce081..48f3573 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -15,7 +15,9 @@ class ReactionSet: .. attention:: - :class:`.ReactionSet` objects should not be created directly. Instead use the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and :doc:`insert_elaborations`. + :class:`.ReactionSet` objects should not be created directly. Instead use + the :meth:`.HIPPO.reactions` property. See :doc:`getting_started` and + :doc:`insert_elaborations`. Use as an iterable ================== @@ -107,7 +109,8 @@ def __iter__(self): return iter(self._queryset) def __getitem__(self, key) -> 'ReactionModel | ReactionSet': - """Get member :class:`.ReactionModel` object by single, slice or list/set/tuple of ID""" + """Get member :class:`.ReactionModel` object by single, slice or + list/set/tuple of ID""" match key: case int(): @@ -125,7 +128,8 @@ def __getitem__(self, key) -> 'ReactionModel | ReactionSet': case _: mrich.error( - f'Unsupported type for ReactionSet.__getitem__(): {key=} {type(key)}' + f'Unsupported type for ReactionSet.__getitem__():' + f' {key=} {type(key)}' ) return None @@ -247,7 +251,8 @@ def get_df(self, smiles=True, mols=True, **kwargs) -> pd.DataFrame: :param smiles: Include smiles column (Default value = True) :param mols: Include `rdkit.Chem.Mol` column (Default value = True) - :param kwargs: keyword arguments are passed on to :meth:`.ReactionModel.get_dict: + :param kwargs: keyword arguments are passed on to + :meth:`.ReactionModel.get_dict:` """ @@ -267,12 +272,11 @@ def reverse(self) -> None: """Reverse the ordering of this set in-place""" self._queryset = self._queryset.reverse() - def get_recipes( - self, amounts: float | list[float] = 1.0, **kwargs - ): + def get_recipes(self, amounts: float | list[float] = 1.0, **kwargs): """Get the :class:`.Recipe` object(s) from this set of recipes - :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) + :param amounts: float or list/generator of product amounts in mg, + (Default value = 1.0) :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: """ @@ -317,7 +321,8 @@ def num_types(self) -> int: @property def products(self) -> CompoundSet: - """Get all product compounds that can be synthesised with these reactions (no intermediates)""" + """Get all product compounds that can be synthesised with these reactions + (no intermediates)""" qs = CompoundModel.objects.filter( pk__in=self._queryset.values('product_compound'), @@ -331,7 +336,8 @@ def products(self) -> CompoundSet: @property def intermediates(self) -> CompoundSet: - """Get all intermediate compounds that can be synthesised with these reactions""" + """Get all intermediate compounds that can be synthesised with these + reactions""" # NB! not 100% sure about this queryset qs = CompoundModel.objects.filter( diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index 4389870..f2fd14d 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -117,7 +117,9 @@ def routes(self) -> 'list[Route]': @property def product_ids(self) -> list[int]: """Get the :class:`.CompoundModel` ID's of the products""" - return RouteModel.objects.values_list('product_compound__id', flat=True).distinct() + return RouteModel.objects.values_list( + 'product_compound__id', flat=True + ).distinct() @property def reactant_ids(self) -> list[int]: @@ -152,7 +154,8 @@ def ids(self) -> list[int]: def cluster_map(self) -> dict[tuple, set]: """Create a dictionary grouping routes by their scaffold/base cluster. - :returns: A dictionary mapping a tuple of scaffold :class:`.CompoundModel` IDs to a set of :class:`.RouteModel` ID's to their superstructures. + :returns: A dictionary mapping a tuple of scaffold :class:`.CompoundModel` IDs + to a set of :class:`.RouteModel` ID's to their superstructures. """ if self._cluster_map is None: @@ -240,7 +243,8 @@ def prune_unavailable(self, suppliers: list[str]): WHEN count_valid IS NULL THEN 1 END) AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component ON component_route = route_id + INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component + ON component_route = route_id LEFT JOIN possible_reactants ON quote_compound = component_ref WHERE component_type = 2 GROUP BY route_id @@ -273,7 +277,8 @@ def pop(self) -> 'RouteModel': def balanced_pop( self, permitted_clusters: set[tuple] | None = None, debug: bool = False ) -> 'RouteModel': - """Pop a route from this set, while maintaining the balance of scaffold clusters populations""" + """Pop a route from this set, while maintaining the balance of scaffold + clusters populations""" if not self._data: mrich.print('RouteSet depleted') diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py index e06204f..beea82e 100644 --- a/hippo/designdb/utils.py +++ b/hippo/designdb/utils.py @@ -14,10 +14,11 @@ from molparse.rdkit import mol_from_smiles from rdkit import Chem from rdkit.Chem import AddHs, MolFromSmiles, MolToSmiles, RegistrationHash, RemoveHs -from rdkit.Chem import inchi as rdkit_inchi from rdkit.Chem.inchi import MolToInchiKey from rdkit.Chem.MolStandardize import rdMolStandardize +from .models import PoseModel + def strip_sql(sql) -> str: """Reduce unecessary whitespace in SQL""" @@ -211,15 +212,13 @@ def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: 'PoseModel', b: 'PoseModel') -> float: +def pose_gap(a: PoseModel, b: PoseModel) -> float: """Calculate minimum distance between two :class:`.PoseModel` objects""" from molparse.rdkit import mol_to_AtomGroup from numpy.linalg import norm # avoiding circular imports - from .models import PoseModel, ScoreValueModel - min_dist = None @@ -294,7 +293,8 @@ def warn(key, msg): class ScoreSubquery(Subquery): def __init__(self, scoring_method): # avoiding circular imports - from .models import PoseModel, ScoreValueModel + from .models import ScoreValueModel + query = ScoreValueModel.objects.filter( pose=OuterRef('pk'), compound=OuterRef('compound'), @@ -355,7 +355,7 @@ def superparent(mol: Chem.Mol) -> Chem.Mol: def registration_hash_tautomer_insensitive(mol: Chem.Mol) -> str: layers = RegistrationHash.GetMolLayers( mol, - escape="", + escape='', enable_tautomer_hash_v2=True, ) return RegistrationHash.GetMolHash( diff --git a/hippo/ta_auth_connector.py b/hippo/ta_auth_connector.py index a059529..f369f37 100644 --- a/hippo/ta_auth_connector.py +++ b/hippo/ta_auth_connector.py @@ -17,8 +17,8 @@ import requests # Service location (e.g. "http://auth.ta-authenticator.svc") and request query key -_TA_AUTH_SERVICE: str = os.environ.get("TA_AUTH_SERVICE", "") -_TA_AUTH_QUERY_KEY: str = os.environ.get("TA_AUTH_QUERY_KEY", "") +_TA_AUTH_SERVICE: str = os.environ.get('TA_AUTH_SERVICE', '') +_TA_AUTH_QUERY_KEY: str = os.environ.get('TA_AUTH_QUERY_KEY', '') _URL_TIMEOUT: int = 3 _QUERY_HEADERS: dict[str, str] = {'X-TAAQueryKey': _TA_AUTH_QUERY_KEY} diff --git a/pyproject.toml b/pyproject.toml index 9062dfc..02a5743 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ exclude = [ "tests", "migrations", "hippo", + "hippo_legacy", ] force-exclude = true @@ -118,16 +119,13 @@ exclude = [ "migrations", "tests", "hippo", + "hippo_legacy", ] [tool.isort] profile = "hug" src_paths = ["src", "tests"] -# [tool.hatch.build] -# include = [ -# "hippo/*.py", -# ] [tool.uv.sources] django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } From 86c6af29ca3b49d2d1fbd9bf06e9c44915481fbd Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 20 May 2026 10:16:25 +0100 Subject: [PATCH 140/163] fix: circular import --- hippo/designdb/utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py index beea82e..91ce289 100644 --- a/hippo/designdb/utils.py +++ b/hippo/designdb/utils.py @@ -17,8 +17,6 @@ from rdkit.Chem.inchi import MolToInchiKey from rdkit.Chem.MolStandardize import rdMolStandardize -from .models import PoseModel - def strip_sql(sql) -> str: """Reduce unecessary whitespace in SQL""" @@ -212,7 +210,7 @@ def sanitise_mol(m: Chem.rdchem.Mol) -> Chem.rdchem.Mol: return MolFromMolBlock(MolToMolBlock(m)) -def pose_gap(a: PoseModel, b: PoseModel) -> float: +def pose_gap(a: 'PoseModel', b: 'PoseModel') -> float: """Calculate minimum distance between two :class:`.PoseModel` objects""" from molparse.rdkit import mol_to_AtomGroup From bcdcb80dd9770f5091060d4b4c57cb07883bc23d Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 22 May 2026 15:29:30 +0100 Subject: [PATCH 141/163] stashing --- hippo/designdb/animal.py | 8 + hippo/designdb/chem.py | 397 --------------------------- hippo/designdb/services/ingestion.py | 14 +- hippo/designdb/services/pose.py | 18 ++ 4 files changed, 39 insertions(+), 398 deletions(-) delete mode 100644 hippo/designdb/chem.py diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 13db01c..80d3c3f 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -95,6 +95,8 @@ def add_hits( aligned_directory: str | Path, tags: list | None = None, skip: list | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, # debug: bool = False, # load_pose_mols: bool = False, ) -> pd.DataFrame: @@ -184,6 +186,8 @@ def __str__(self) -> str: skip_records=skip, compound_tag_list=tags, metadata_file=metadata_csv, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, ) except Exception as exc: logger.error(exc, exc_info=True) @@ -217,6 +221,8 @@ def load_sdf( convert_floats: bool = True, skip_equal_dict: dict | None = None, skip_not_equal_dict: dict | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, ) -> None: """Add posed virtual hits from an SDF into the database. @@ -309,6 +315,8 @@ def load_sdf( convert_floats=convert_floats, field_warning=warn, inspiration_map=inspiration_map, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, ) except Exception as exc: logger.error(exc, exc_info=True) diff --git a/hippo/designdb/chem.py b/hippo/designdb/chem.py deleted file mode 100644 index bd29d23..0000000 --- a/hippo/designdb/chem.py +++ /dev/null @@ -1,397 +0,0 @@ -"""functions for validating chemistry""" - -import mrich -from designdb.models import CompoundModel - -""" - -Checks -====== - -- Num heavy atoms difference -- Formula checks -- Num rings difference - -""" - -SUPPORTED_CHEMISTRY = { - 'Amidation': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': 2}, - }, - }, - 'Ester_amidation': { - 'heavy_atoms_diff': '>=3', - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': '>=1', '*': '*'}, - }, - }, - 'Williamson_ether_synthesis': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, # any halogen - }, - }, - 'N-Boc_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 2, 'C': 5, 'H': 8}, - }, - }, - 'TBS_alcohol_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'C': 6, 'Si': 1, 'H': 14}, - }, - }, - 'Sp3-sp2_Suzuki_coupling': { - # "heavy_atoms_diff": 10, - 'heavy_atoms_diff': '>=4', - 'rings_diff': '>=0', - 'atomtype': { - # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen - 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen - }, - }, - 'Sp2-sp2_Suzuki_coupling': { - 'heavy_atoms_diff': '>=4', - 'rings_diff': '>=0', - 'atomtype': { - 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen - }, - }, - 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'Buchwald-Hartwig_amination': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'Nucleophilic_substitution_with_amine': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, - }, - }, - 'N-nucleophilic_aromatic_substitution': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'Ha': 1, 'H': 1}, # any halogen - }, - }, - 'Reductive_amination': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1}, # any halogen - }, - }, - 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': '>=1'}, - }, - }, - 'Steglich_esterification': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - 'atomtype': { - 'removed': {'O': 1, 'H': 2}, - }, - }, - 'Benzyl_alcohol_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 1, - 'atomtype': { - 'removed': {'C': 7, 'H': 6}, - }, - }, - 'N-Bn_deprotection': { - 'heavy_atoms_diff': 7, - 'rings_diff': 1, - }, - 'Formation_of_urea_from_two_amines': { - 'heavy_atoms_diff': -2, - 'rings_diff': 0, - }, - 'Amide_Schotten-Baumann_with_amine': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - }, - 'Nucleophilic_substitution': { - 'heavy_atoms_diff': 1, - 'rings_diff': 0, - }, -} - - -def check_reaction_types(types: list[str]) -> None: - """ - Prints a warning if any of the reaction type strings in ``types`` are not in - ``SUPPORTED_CHEMISTRY`` - - :param types: A list of reaction type strings to check - """ - - for reaction_type in types: - if reaction_type not in SUPPORTED_CHEMISTRY: - mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") - - -def check_chemistry( - reaction_type: str, - reactants: 'CompoundSet', - product: CompoundModel, - debug: bool = False, -) -> bool: - """Check chemistry of given reaction""" - - if reaction_type not in SUPPORTED_CHEMISTRY: - mrich.var('reactants', reactants.ids) - mrich.var('product', product) - - raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') - - assert reactants - assert product - - CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] - - if 'heavy_atoms_diff' in CHEMISTRY: - check = check_count_diff( - 'heavy_atoms', reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if 'rings_diff' in CHEMISTRY: - check = check_count_diff( - 'rings', reaction_type, reactants, product, debug=debug - ) - if not check: - return False - - if 'atomtype' in CHEMISTRY: - check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) - if not check: - return False - - if debug: - mrich.success(f'{reaction_type}: All OK') - - return True - - -def check_count_diff( - check_type: str, - reaction_type: str, - reactants: 'CompoundSet', - product: 'CompoundModel', - debug: bool = False, -): - """Check integer difference""" - - # get target value - diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] - - # get attribute name - attr = f'num_{check_type}' - - # get values - reac_count = getattr(reactants, attr) - prod_count = getattr(product, attr) - if debug: - mrich.var(f'#{check_type} reactants', reac_count) - if debug: - mrich.var(f'#{check_type} product', prod_count) - - # check against target value - if isinstance(diff, str): - assert diff.startswith('>='), diff - - diff = int(diff[2:]) - - if reac_count - prod_count < diff: - if debug: - mrich.error( - f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' - ) - return False - - elif debug: - mrich.success(f'{reaction_type}: #{check_type} OK') - - else: - if reac_count - diff != prod_count: - if debug: - mrich.error(f'{reaction_type}: #{check_type} FAIL') - return False - - elif debug: - mrich.success(f'{reaction_type}: #{check_type} OK') - - return True - - -def check_atomtype_diff( - reaction_type: str, - reactants: 'CompoundSet', - product: 'CompoundModel', - debug: bool = False, -) -> bool: - """check atomtypes""" - - check_type = 'atomtype' - - # get values - reac = reactants.atomtype_dict - prod = product.atomtype_dict - - if debug: - mrich.var('reactants.atomtype_dict', str(reac)) - mrich.var('product.atomtype_dict', str(prod)) - - if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: - removal = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=True, debug=debug - ) - - if not removal: - return False - - if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: - addition = check_specific_atomtype_diff( - reaction_type, prod, reac, removal=False, debug=debug - ) - - if not addition: - return False - - if debug: - mrich.success(f'{reaction_type}: atomtypes OK') - - return True - - -def check_specific_atomtype_diff( - reaction_type: str, - prod: 'CompoundModel', - reac: 'CompoundModel', - removal: bool = False, - debug: bool = False, -) -> bool: - """check specific atomtype difference""" - - if removal: - add_str = 'removed' - else: - add_str = 'added' - - add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] - - if not add_dict: - return True - - if debug: - mrich.var(add_str, str(add_dict)) - - for symbol, count in add_dict.items(): - if symbol == 'Ha': - p_count = halogen_count(prod) - r_count = halogen_count(reac) - - elif symbol == '*': - assert count == '*', (symbol, count) - if debug: - mrich.debug('Allowing wildcard atomtype differences') - continue - - else: - p_count = prod[symbol] if symbol in prod else 0 - r_count = reac[symbol] if symbol in reac else 0 - - if isinstance(count, str): - assert count.startswith('>='), (symbol, count) - - count = int(count[2:]) - - if removal and r_count - p_count < count: - if debug: - mrich.error( - f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' - ) - mrich.error( - f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' - ) - return False - - elif not removal and p_count - r_count < count: - if debug: - mrich.error( - f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' - ) - mrich.error( - f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' - ) - return False - - else: - if removal and r_count - p_count != count: - if debug: - mrich.error( - f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' - ) - mrich.error( - f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' - ) - return False - - elif not removal and p_count - r_count != count: - if debug: - mrich.error( - f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' - ) - mrich.error( - f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' - ) - return False - - return True - - -def halogen_count(atomtype_dict: dict[str, int]) -> int: - """Count halogens""" - count = 0 - symbols = ['F', 'Cl', 'Br', 'I'] - for symbol in symbols: - if symbol in atomtype_dict: - count += atomtype_dict[symbol] - return count - - -class InvalidChemistryError(Exception): - """Chemistry is not valid""" - - ... - - -class UnsupportedChemistryError(Exception): - """Chemistry is not supported""" - - ... diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index df8868a..0f3e266 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -7,10 +7,10 @@ import molparse as mp import mrich import pandas as pd -from designdb.chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry from designdb.components.compound import Ingredient from designdb.components.recipe import Recipe, Route from designdb.models import ( + ComponentModel, CompoundModel, PoseModel, ReactantModel, @@ -31,6 +31,7 @@ remove_other_ligands, sanitise_smiles, ) +from designdb.utils_chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode from django.db import connection from numpy import isnan @@ -306,6 +307,8 @@ def ingest_filesystem( skip_records: list[str], compound_tag_list: list[str], metadata_file: Path | str, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, ) -> IngestionBatchResult: # this is now strictly for loading frag data. cannot switch inner funcs easily @@ -368,6 +371,8 @@ def ingest_filesystem( metadata=metadata, inchikey=inchikey, smiles=smiles, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, ) if pose_created: result.poses_created += 1 @@ -407,6 +412,8 @@ def ingest_sdf( skip_not_equal, convert_floats: bool = True, field_warning=None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, ) -> IngestionBatchResult: result = IngestionBatchResult() @@ -520,6 +527,8 @@ def ingest_sdf( inchikey=inchikey, smiles=smiles, reference=reference, + check_rmsd=check_rmsd, + rmsd_threshold=rmsd_threshold, ) if pose_created: result.poses_created += 1 @@ -801,6 +810,9 @@ def ingest_syndirella_elabs( elif scaffold_route: ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE + mrich.var('DEBUG reaction_ids in ComponentModel', list(ComponentModel.objects.filter(route=scaffold_route._id, component_type=1).values_list('component_ref', flat=True))) + mrich.var('DEBUG ReactionModel PKs', list(ReactionModel.objects.values_list('pk', flat=True))) + assert scaffold_route.num_reactions == 1 product = scaffold_route.products[0].compound diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index 27f146d..aa717d3 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -10,6 +10,7 @@ # from rdkit.Chem import inchi from designdb.models import CompoundModel, PoseModel, PoseTagModel, TargetModel from designdb.utils import normalize_string_list +from designdb.utils_chem import get_best_rmsd from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS from django.db.models import Q # from mypackage.services.compound import CompoundService @@ -46,6 +47,8 @@ def create( inchikey: str, smiles: str, reference: int | None = None, + check_rmsd: bool = False, + rmsd_threshold: float = 1.0, ): try: @@ -60,6 +63,21 @@ def create( pose.save() created = False except PoseModel.DoesNotExist: + if check_rmsd: + for existing in PoseModel.objects.filter(compound=compound, target=target): + try: + rmsd = get_best_rmsd(mol, existing.pose_mol) + if rmsd < rmsd_threshold: + logger.warning( + 'Pose RMSD %.3f Å below threshold %.3f Å, skipping duplicate (alias=%s)', + rmsd, + rmsd_threshold, + existing.pose_alias, + ) + return existing, False + except Exception: + logger.warning('RMSD calculation failed for pose pk=%s', existing.pk) + pose = PoseModel( compound=compound, target=target, From 13a417026a8fc4a929afeb00d0f7810a9bdea459 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 22 May 2026 17:01:56 +0100 Subject: [PATCH 142/163] feat: add alignment check to pose creation Activated with: - check_rmsd: bool = False - rmsd_threshold: float = 1.0 --- hippo/designdb/services/ingestion.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 0f3e266..8d2355c 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -10,7 +10,6 @@ from designdb.components.compound import Ingredient from designdb.components.recipe import Recipe, Route from designdb.models import ( - ComponentModel, CompoundModel, PoseModel, ReactantModel, @@ -810,9 +809,6 @@ def ingest_syndirella_elabs( elif scaffold_route: ### SUPPLEMENT THE SCAFFOLD ROWS FROM KNOWN ROUTE - mrich.var('DEBUG reaction_ids in ComponentModel', list(ComponentModel.objects.filter(route=scaffold_route._id, component_type=1).values_list('component_ref', flat=True))) - mrich.var('DEBUG ReactionModel PKs', list(ReactionModel.objects.values_list('pk', flat=True))) - assert scaffold_route.num_reactions == 1 product = scaffold_route.products[0].compound From 14e18d901837d099f45d4c619db78da56aaf3f90 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 27 May 2026 15:28:07 +0100 Subject: [PATCH 143/163] fix: mostly fixes to get the wf2 working (add_hits) --- hippo/designdb/animal.py | 51 +++- hippo/designdb/services/ingestion.py | 28 +- hippo/designdb/services/method.py | 46 +++ hippo/designdb/services/pose.py | 41 ++- hippo/designdb/services/subsite.py | 54 ++++ hippo/designdb/sets/pose.py | 50 ++-- hippo/designdb/settings.py | 1 + hippo/designdb/utils_chem.py | 408 +++++++++++++++++++++++++++ 8 files changed, 620 insertions(+), 59 deletions(-) create mode 100644 hippo/designdb/services/method.py create mode 100644 hippo/designdb/services/subsite.py create mode 100644 hippo/designdb/settings.py create mode 100644 hippo/designdb/utils_chem.py diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 80d3c3f..6f57d0c 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -9,10 +9,13 @@ import pandas as pd from django.db import transaction -from .models import CompoundModel, PoseModel, TargetModel +from .models import CompoundModel, PoseMethodModel, PoseModel, TargetModel from .services.ingestion import IngestionBatchResult, IngestionService +from .services.method import MethodService +from .services.subsite import SubsiteService from .sets.compound import CompoundSet from .sets.pose import PoseSet +from .settings import DEFAULT_POSE_METHODS from .utils import make_warn_once_per_key logger = logging.getLogger(__name__) @@ -94,6 +97,7 @@ def add_hits( metadata_csv: str | Path, aligned_directory: str | Path, tags: list | None = None, + pose_methods: list[str] | None = None, skip: list | None = None, check_rmsd: bool = False, rmsd_threshold: float = 1.0, @@ -124,7 +128,8 @@ def add_hits( assert aligned_directory, 'aligned_directory must be provided' skip = skip or [] - tags = tags or ['hits'] + tags = tags or [] + pose_methods = pose_methods or DEFAULT_POSE_METHODS if not isinstance(aligned_directory, Path): aligned_directory = Path(aligned_directory) @@ -178,6 +183,16 @@ def __str__(self) -> str: mrich.var('data_format', data_format) + pose_method_objs = [] + for name in pose_methods: + obj = PoseMethodModel.objects.filter(pose_method_name=name).first() + if obj is None: + raise ValueError( + f"Pose method '{name}' not found. " + "Call register_pose_method() first." + ) + pose_method_objs.append(obj) + try: with transaction.atomic(): result: IngestionBatchResult = IngestionService.ingest_filesystem( @@ -186,6 +201,7 @@ def __str__(self) -> str: skip_records=skip, compound_tag_list=tags, metadata_file=metadata_csv, + pose_methods=pose_method_objs, check_rmsd=check_rmsd, rmsd_threshold=rmsd_threshold, ) @@ -443,3 +459,34 @@ def add_syndirella_elabs( logger.error(exc, exc_info=True) # TODO: handle gracefully raise Exception from exc + + def set_derivative_subsites(self) -> None: + """Propagate subsite assignments from inspiration poses to their derivatives.""" + SubsiteService.set_derivative_subsites() + + def register_enumeration_method(self, name: str, version: str, description: str = ''): + """Register an enumeration method, or retrieve it if already registered.""" + return MethodService.register_enumeration_method(name, version, description) + + def register_pose_method(self, name: str, version: str, description: str = ''): + """Register a pose method, or retrieve it if already registered.""" + return MethodService.register_pose_method(name, version, description) + + def register_scoring_method(self, name: str, version: str, description: str = ''): + """Register a scoring method, or retrieve it if already registered.""" + return MethodService.register_scoring_method(name, version, description) + + @property + def enumeration_methods(self): + """All registered enumeration methods.""" + return MethodService.get_enumeration_methods() + + @property + def pose_methods(self): + """All registered pose methods.""" + return MethodService.get_pose_methods() + + @property + def scoring_methods(self): + """All registered scoring methods.""" + return MethodService.get_scoring_methods() diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 8d2355c..4568eca 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -11,6 +11,7 @@ from designdb.components.recipe import Recipe, Route from designdb.models import ( CompoundModel, + PoseMethodModel, PoseModel, ReactantModel, ReactionModel, @@ -43,14 +44,14 @@ # from .validation.compound import ValidationError, validate_compound_data SDF_XCAv2_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' + r'^[^.]*-.\d{4}_._\d*_\d_.*-.\d{4}\+.\+\d*\+\d_ligand\.sdf$' ) SDF_XCAV3_PATTERN = re.compile( - r'^.*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' + r'^[^.]*-.\d{4}_._\d*_._\d_.*-.\d{4}\+.\+\d*\+.\+\d_ligand\.sdf$' ) -SDF_FRAGALYSIS_PATTERN = re.compile(r'^.*\d{4}[a-z].sdf$') +SDF_FRAGALYSIS_PATTERN = re.compile(r'^[^.].*\d{4}[a-z].sdf$') PDBID_PATTERN = re.compile(r'^[A-Za-z0-9]{4}-[a-z].sdf$') @@ -99,12 +100,12 @@ def parse_pdb_mp(pdb_path: Path, residue: int, chain: str) -> str: def iter_fs_fragalysis(root_path, skip_records): assert skip_records is not None, '"None" passed instead as skip_records' - for dset_path in list(sorted(root_path.glob('*'))): + for dset_path in list(sorted(root_path.glob('[!.]*'))): if dset_path.name in skip_records: continue sdfs = [] - for sdf_path in dset_path.glob('*.sdf'): + for sdf_path in dset_path.glob('[!.]*.sdf'): sdf_name = sdf_path.name if ( @@ -132,7 +133,7 @@ def iter_fs_fragalysis(root_path, skip_records): pdbs = [ p - for p in dset_path.glob('*.pdb') + for p in dset_path.glob('[!.]*.pdb') if '_ligand' not in p.name and '_apo' not in p.name and '_hippo' not in p.name @@ -157,7 +158,7 @@ def iter_fs_xca(root_path, skip): sdfs = [] - for sdf_path in sorted(dset_path.glob('*.sdf')): + for sdf_path in sorted(dset_path.glob('[!.]*.sdf')): sdf_name = sdf_path.name # TODO: switch between patterns?? @@ -306,6 +307,7 @@ def ingest_filesystem( skip_records: list[str], compound_tag_list: list[str], metadata_file: Path | str, + pose_methods: list[PoseMethodModel] | None = None, check_rmsd: bool = False, rmsd_threshold: float = 1.0, ) -> IngestionBatchResult: @@ -378,18 +380,12 @@ def ingest_filesystem( pose.tags.add(*pose_tags) + if pose_methods: + pose.methods.add(*pose_methods) + # it seems fragalysis data is not expected to contain # scores - # in original code. what's that for? - # what I can think of is previously existing pose without mol - # if load_pose_mols: - # try: - # pose.mol - # except Exception as e: - # mrich.error('Could not load molecule', pose) - # mrich.error(e) - return result @classmethod diff --git a/hippo/designdb/services/method.py b/hippo/designdb/services/method.py new file mode 100644 index 0000000..3687b95 --- /dev/null +++ b/hippo/designdb/services/method.py @@ -0,0 +1,46 @@ +import logging + +from designdb.models import EnumerationMethodModel, PoseMethodModel, ScoringMethodModel + +logger = logging.getLogger(__name__) + + +class MethodService: + @classmethod + def register_enumeration_method(cls, name: str, version: str, description: str = ''): + obj, created = EnumerationMethodModel.objects.get_or_create( + enum_name=name, + enum_version=version, + defaults={'enum_description': description}, + ) + return obj, created + + @classmethod + def register_pose_method(cls, name: str, version: str, description: str = ''): + obj, created = PoseMethodModel.objects.get_or_create( + pose_method_name=name, + pose_method_version=version, + defaults={'pose_method_description': description}, + ) + return obj, created + + @classmethod + def register_scoring_method(cls, name: str, version: str, description: str = ''): + obj, created = ScoringMethodModel.objects.get_or_create( + method_name=name, + method_version=version, + defaults={'method_description': description}, + ) + return obj, created + + @classmethod + def get_enumeration_methods(cls): + return EnumerationMethodModel.objects.all() + + @classmethod + def get_pose_methods(cls): + return PoseMethodModel.objects.all() + + @classmethod + def get_scoring_methods(cls): + return ScoringMethodModel.objects.all() diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index aa717d3..7c58abe 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -63,20 +63,10 @@ def create( pose.save() created = False except PoseModel.DoesNotExist: - if check_rmsd: - for existing in PoseModel.objects.filter(compound=compound, target=target): - try: - rmsd = get_best_rmsd(mol, existing.pose_mol) - if rmsd < rmsd_threshold: - logger.warning( - 'Pose RMSD %.3f Å below threshold %.3f Å, skipping duplicate (alias=%s)', - rmsd, - rmsd_threshold, - existing.pose_alias, - ) - return existing, False - except Exception: - logger.warning('RMSD calculation failed for pose pk=%s', existing.pk) + if check_rmsd and ( + duplicate := cls.find_rmsd_duplicate(mol, compound, target, rmsd_threshold) + ): + return duplicate, False pose = PoseModel( compound=compound, @@ -99,6 +89,29 @@ def create( return pose, created + @classmethod + def find_rmsd_duplicate( + cls, + mol: 'Chem.rdchem.Mol', + compound: 'CompoundModel', + target: 'TargetModel', + rmsd_threshold: float, + ) -> 'PoseModel | None': + for existing in PoseModel.objects.filter(compound=compound, target=target): + try: + rmsd = get_best_rmsd(mol, existing.pose_mol) + if rmsd < rmsd_threshold: + logger.warning( + 'Pose RMSD %.3f Å below threshold %.3f Å, skipping duplicate (alias=%s)', + rmsd, + rmsd_threshold, + existing.pose_alias, + ) + return existing + except Exception: + logger.warning('RMSD calculation failed for pose pk=%s', existing.pk) + return None + @classmethod def create_from_record( cls, diff --git a/hippo/designdb/services/subsite.py b/hippo/designdb/services/subsite.py new file mode 100644 index 0000000..ab8b308 --- /dev/null +++ b/hippo/designdb/services/subsite.py @@ -0,0 +1,54 @@ +import logging +from collections import defaultdict + +from designdb.models import InspirationModel, SubsiteModel, SubsiteTagModel + +logger = logging.getLogger(__name__) + + +class SubsiteService: + @classmethod + def set_derivative_subsites(cls) -> None: + """Propagate subsite assignments from inspiration poses to their derivatives.""" + subsite_tags = SubsiteTagModel.objects.filter( + pose__in=InspirationModel.objects.values('original_pose') + ).select_related('subsite') + + inspirations = InspirationModel.objects.filter( + original_pose__in=subsite_tags.values('pose') + ).select_related('derivative_pose') + + original_to_derivatives = defaultdict(list) + for insp in inspirations: + original_to_derivatives[insp.original_pose_id].append(insp.derivative_pose) + + new_tags = [ + SubsiteTagModel(pose=derivative, subsite=st.subsite) + for st in subsite_tags + for derivative in original_to_derivatives[st.pose_id] + ] + + SubsiteTagModel.objects.bulk_create(new_tags, ignore_conflicts=True) + + @classmethod + def set_subsites_from_metadata_field( + cls, + poses, + field: str = 'CanonSites alias', + ) -> None: + """Create and assign subsite entries from a pose metadata field.""" + assignments = [] + + for pose in poses.select_related('target'): + metadata = pose.pose_metadata or {} + name = metadata.get(field) + if not name: + logger.warning('Field "%s" not in metadata for pose pk=%s', field, pose.pk) + continue + subsite, _ = SubsiteModel.objects.get_or_create( + target=pose.target, + subsite_name=name, + ) + assignments.append(SubsiteTagModel(pose=pose, subsite=subsite)) + + SubsiteTagModel.objects.bulk_create(assignments, ignore_conflicts=True) diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 339a7bf..c11f0b3 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -20,6 +20,8 @@ CompoundModel, InspirationModel, InteractionModel, + PoseMethodJunctionModel, + PoseMethodModel, PoseModel, PoseTagJunctionModel, PoseTagModel, @@ -27,7 +29,9 @@ SubsiteTagModel, TargetModel, ) +from designdb.services.subsite import SubsiteService from designdb.sets.interaction import InteractionSet +from designdb.settings import DEFAULT_POSE_METHODS from designdb.utils import ScoreSubquery, normalize_string_list from designdb.utils_frag import generate_header from django.conf import settings @@ -289,15 +293,18 @@ def __call__( self, *, tag: str = None, + pose_method: str = None, target: int = None, subsite: int = None, ) -> 'PoseSet': - """Filter poses by a given tag, SubsiteModel ID, or target ID. See - :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_target`, amd - :meth:`.PoseSet.get_by_subsite`""" + """Filter poses by a given tag, pose method name, SubsiteModel ID, or target ID. See + :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_method`, + :meth:`.PoseSet.get_by_target`, and :meth:`.PoseSet.get_by_subsite`""" if tag: return self.get_by_tag(tag) + elif pose_method: + return self.get_by_method(pose_method) elif target: return self.get_by_target(target=TargetModel.objects.get(pk=target)) elif subsite: @@ -351,6 +358,14 @@ def get_by_tag( else: return PoseSet(self._queryset.filter(has_tag=True)) + def get_by_method(self, method: str) -> 'PoseSet': + """Get all poses associated with a given pose method name.""" + return PoseSet( + self._queryset.filter( + methods__pose_method_name=method, + ) + ) + def get_by_metadata( self, key: str, value: str | None = None, debug: bool = False ) -> 'PoseSet': @@ -688,6 +703,10 @@ def get_by_subsite( return PoseSet(qs, name=name) + def set_subsites_from_metadata_field(self, field: str = 'CanonSites alias') -> None: + """Create and assign subsite entries from a pose metadata field.""" + SubsiteService.set_subsites_from_metadata_field(self._queryset, field) + # def get_best_placed_poses_per_compound(self): # """Choose the best placed pose (best distance_score) grouped by compound""" @@ -801,29 +820,6 @@ def append_to_metadata( pose.save() self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) - def set_subsites_from_metadata_field(self, field='CanonSites alias') -> None: - """Create and assign subsite entries from a metadata field - - :param field: the metadata field to use - - """ - for pose in self._queryset: - metadata = json.loads(pose.payload) - key = metadata.get(field) - if not key: - mrich.warning(field, 'not in metadata pose_id=', pose_id) - continue - - # I'm still not entirely clear can you really have - # posesets from different target, if not, and it really - # seems that not, this should be a single subsite - subsite, _ = SubsiteModel.get_or_create( - target=pose.target, subsite_name=key - ) - subsite_tag = SubsiteTagModel(subsite=subsite, pose=pose) - subsite_tag.save() - - self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) # TODO: implement scores # def calculate_inspiration_scores( @@ -1490,7 +1486,7 @@ def to_knitwork( for pose in self._queryset: assert pose.pose_alias - assert pose.tags.filter(pose_tag_name='hits').exists() + assert pose.methods.filter(pose_method_name__in=DEFAULT_POSE_METHODS).exists() if aligned_files_dir: mol = str(pose.mol_path) diff --git a/hippo/designdb/settings.py b/hippo/designdb/settings.py new file mode 100644 index 0000000..0fb2130 --- /dev/null +++ b/hippo/designdb/settings.py @@ -0,0 +1 @@ +DEFAULT_POSE_METHODS: list[str] = ['xray'] diff --git a/hippo/designdb/utils_chem.py b/hippo/designdb/utils_chem.py new file mode 100644 index 0000000..ccd46a9 --- /dev/null +++ b/hippo/designdb/utils_chem.py @@ -0,0 +1,408 @@ +"""functions for validating chemistry""" + +import logging + +import mrich +from designdb.models import CompoundModel +from rdkit import Chem +from rdkit.Chem import rdMolAlign + +""" + +Checks +====== + +- Num heavy atoms difference +- Formula checks +- Num rings difference + +""" + +logger = logging.getLogger(__name__) + +SUPPORTED_CHEMISTRY = { + 'Amidation': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Ester_amidation': { + 'heavy_atoms_diff': '>=3', + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': '>=1', '*': '*'}, + }, + }, + 'Williamson_ether_synthesis': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'N-Boc_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 2, 'C': 5, 'H': 8}, + }, + }, + 'TBS_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'C': 6, 'Si': 1, 'H': 14}, + }, + }, + 'Sp3-sp2_Suzuki_coupling': { + # "heavy_atoms_diff": 10, + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + # "removed": {"C": 6, "O": 2, "B": 1, "Ha": 1, "H": 12}, # any halogen + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Sp2-sp2_Suzuki_coupling': { + 'heavy_atoms_diff': '>=4', + 'rings_diff': '>=0', + 'atomtype': { + 'removed': {'C': '>=0', 'O': 2, 'B': 1, 'Ha': 1, 'H': '>=2'}, # any halogen + }, + }, + 'Buchwald-Hartwig_amidation_with_amide-like_nucleophile': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Buchwald-Hartwig_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'Nucleophilic_substitution_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, + }, + }, + 'N-nucleophilic_aromatic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'Ha': 1, 'H': 1}, # any halogen + }, + }, + 'Reductive_amination': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1}, # any halogen + }, + }, + 'Mitsunobu_reaction_with_amine_alcohol_and_thioalcohol': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': '>=1'}, + }, + }, + 'Steglich_esterification': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + 'atomtype': { + 'removed': {'O': 1, 'H': 2}, + }, + }, + 'Benzyl_alcohol_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + 'atomtype': { + 'removed': {'C': 7, 'H': 6}, + }, + }, + 'N-Bn_deprotection': { + 'heavy_atoms_diff': 7, + 'rings_diff': 1, + }, + 'Formation_of_urea_from_two_amines': { + 'heavy_atoms_diff': -2, + 'rings_diff': 0, + }, + 'Amide_Schotten-Baumann_with_amine': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, + 'Nucleophilic_substitution': { + 'heavy_atoms_diff': 1, + 'rings_diff': 0, + }, +} + + +def get_best_rmsd(mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol) -> float: + """Return the minimum RMSD between two molecules after optimal rigid-body alignment.""" + return rdMolAlign.GetBestRMS(mol1, mol2) + + +def check_reaction_types(types: list[str]) -> None: + """ + Prints a warning if any of the reaction type strings in ``types`` are not in + ``SUPPORTED_CHEMISTRY`` + + :param types: A list of reaction type strings to check + """ + + for reaction_type in types: + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.error(f"Can't check chemistry of unsupported {reaction_type=}") + + +def check_chemistry( + reaction_type: str, + reactants: 'CompoundSet', + product: CompoundModel, + debug: bool = False, +) -> bool: + """Check chemistry of given reaction""" + + if reaction_type not in SUPPORTED_CHEMISTRY: + mrich.var('reactants', reactants.ids) + mrich.var('product', product) + + raise UnsupportedChemistryError(f'Unsupported {reaction_type=}') + + assert reactants + assert product + + CHEMISTRY = SUPPORTED_CHEMISTRY[reaction_type] + + if 'heavy_atoms_diff' in CHEMISTRY: + check = check_count_diff( + 'heavy_atoms', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'rings_diff' in CHEMISTRY: + check = check_count_diff( + 'rings', reaction_type, reactants, product, debug=debug + ) + if not check: + return False + + if 'atomtype' in CHEMISTRY: + check = check_atomtype_diff(reaction_type, reactants, product, debug=debug) + if not check: + return False + + if debug: + mrich.success(f'{reaction_type}: All OK') + + return True + + +def check_count_diff( + check_type: str, + reaction_type: str, + reactants: 'CompoundSet', + product: 'CompoundModel', + debug: bool = False, +): + """Check integer difference""" + + # get target value + diff = SUPPORTED_CHEMISTRY[reaction_type][f'{check_type}_diff'] + + # get attribute name + attr = f'num_{check_type}' + + # get values + reac_count = getattr(reactants, attr) + prod_count = getattr(product, attr) + if debug: + mrich.var(f'#{check_type} reactants', reac_count) + if debug: + mrich.var(f'#{check_type} product', prod_count) + + # check against target value + if isinstance(diff, str): + assert diff.startswith('>='), diff + + diff = int(diff[2:]) + + if reac_count - prod_count < diff: + if debug: + mrich.error( + f'{reaction_type}: #{check_type} {(reac_count - prod_count)=} FAIL' + ) + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + else: + if reac_count - diff != prod_count: + if debug: + mrich.error(f'{reaction_type}: #{check_type} FAIL') + return False + + elif debug: + mrich.success(f'{reaction_type}: #{check_type} OK') + + return True + + +def check_atomtype_diff( + reaction_type: str, + reactants: 'CompoundSet', + product: 'CompoundModel', + debug: bool = False, +) -> bool: + """check atomtypes""" + + check_type = 'atomtype' + + # get values + reac = reactants.atomtype_dict + prod = product.atomtype_dict + + if debug: + mrich.var('reactants.atomtype_dict', str(reac)) + mrich.var('product.atomtype_dict', str(prod)) + + if 'removed' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + removal = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=True, debug=debug + ) + + if not removal: + return False + + if 'added' in SUPPORTED_CHEMISTRY[reaction_type]['atomtype']: + addition = check_specific_atomtype_diff( + reaction_type, prod, reac, removal=False, debug=debug + ) + + if not addition: + return False + + if debug: + mrich.success(f'{reaction_type}: atomtypes OK') + + return True + + +def check_specific_atomtype_diff( + reaction_type: str, + prod: 'CompoundModel', + reac: 'CompoundModel', + removal: bool = False, + debug: bool = False, +) -> bool: + """check specific atomtype difference""" + + if removal: + add_str = 'removed' + else: + add_str = 'added' + + add_dict = SUPPORTED_CHEMISTRY[reaction_type]['atomtype'][add_str] + + if not add_dict: + return True + + if debug: + mrich.var(add_str, str(add_dict)) + + for symbol, count in add_dict.items(): + if symbol == 'Ha': + p_count = halogen_count(prod) + r_count = halogen_count(reac) + + elif symbol == '*': + assert count == '*', (symbol, count) + if debug: + mrich.debug('Allowing wildcard atomtype differences') + continue + + else: + p_count = prod[symbol] if symbol in prod else 0 + r_count = reac[symbol] if symbol in reac else 0 + + if isinstance(count, str): + assert count.startswith('>='), (symbol, count) + + count = int(count[2:]) + + if removal and r_count - p_count < count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} >= {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count < count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} >= {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + else: + if removal and r_count - p_count != count: + if debug: + mrich.error( + f'{symbol}: {r_count=} - {p_count=} = {r_count - p_count}' + ) + mrich.error( + f'{reaction_type}: atomtype removal {symbol} × {count} FAIL' + ) + return False + + elif not removal and p_count - r_count != count: + if debug: + mrich.error( + f'{symbol}: {p_count=} - {r_count=} = {p_count - r_count}' + ) + mrich.error( + f'{reaction_type}: atomtype addition {symbol} × {count} FAIL' + ) + return False + + return True + + +def halogen_count(atomtype_dict: dict[str, int]) -> int: + """Count halogens""" + count = 0 + symbols = ['F', 'Cl', 'Br', 'I'] + for symbol in symbols: + if symbol in atomtype_dict: + count += atomtype_dict[symbol] + return count + + +class InvalidChemistryError(Exception): + """Chemistry is not valid""" + + ... + + +class UnsupportedChemistryError(Exception): + """Chemistry is not supported""" + + ... From f32d7974d2bd3d4e7323ae50890dd448b58f90e6 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 2 Jun 2026 15:34:25 +0100 Subject: [PATCH 144/163] fix: data loading part of the workflow --- hippo/designdb/animal.py | 86 ++++++++++++++++++++- hippo/designdb/models.py | 2 + hippo/designdb/services/ingestion.py | 107 ++++++++++++++++++++++++++- hippo/designdb/services/route.py | 49 ++++++++++++ hippo/designdb/services/score.py | 29 +++++--- hippo/designdb/sets/pose.py | 102 +++++++++++++++++++------ 6 files changed, 338 insertions(+), 37 deletions(-) diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 6f57d0c..e91ed7b 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -9,9 +9,17 @@ import pandas as pd from django.db import transaction -from .models import CompoundModel, PoseMethodModel, PoseModel, TargetModel +from .models import ( + CompoundModel, + EnumerationMethodModel, + PoseMethodModel, + PoseModel, + ScoringMethodModel, + TargetModel, +) from .services.ingestion import IngestionBatchResult, IngestionService from .services.method import MethodService +from .services.route import RouteService from .services.subsite import SubsiteService from .sets.compound import CompoundSet from .sets.pose import PoseSet @@ -229,6 +237,10 @@ def load_sdf( inspirations: list[int] | PoseSet | None = None, compound_tags: None | list[str] = None, pose_tags: None | list[str] = None, + enumeration_method: tuple[str, str] | None = None, + pose_method: tuple[str, str] | None = None, + score_cols: list[str] | None = None, + scoring_methods: list[tuple[str, str]] | None = None, mol_col: str = 'ROMol', name_col: str = 'ID', inspiration_col: str = 'ref_mols', @@ -287,6 +299,11 @@ def load_sdf( if not isinstance(path, Path): path = Path(path) + if name_col is None: + raise ValueError( + "name_col cannot be None. Provide the SDF column name that contains pose identifiers." + ) + skip_equal_dict = skip_equal_dict or {} skip_not_equal_dict = skip_not_equal_dict or {} @@ -311,6 +328,45 @@ def load_sdf( if inspiration_map is None: inspiration_map = {} + enumeration_method_obj = None + if enumeration_method is not None: + name, version = enumeration_method + enumeration_method_obj = EnumerationMethodModel.objects.filter( + enum_name=name, enum_version=version + ).first() + if enumeration_method_obj is None: + raise ValueError( + f"Enumeration method '{name}' v{version} not found. " + "Call register_enumeration_method() first." + ) + + pose_method_obj = None + if pose_method is not None: + name, version = pose_method + pose_method_obj = PoseMethodModel.objects.filter( + pose_method_name=name, pose_method_version=version + ).first() + if pose_method_obj is None: + raise ValueError( + f"Pose method '{name}' v{version} not found. " + "Call register_pose_method() first." + ) + + score_method_map = {} + if score_cols and scoring_methods: + if len(score_cols) != len(scoring_methods): + raise ValueError('score_cols and scoring_methods must be the same length') + for col, (method_name, method_version) in zip(score_cols, scoring_methods): + obj = ScoringMethodModel.objects.filter( + method_name=method_name, method_version=method_version + ).first() + if obj is None: + raise ValueError( + f"Scoring method '{method_name}' v{method_version} not found. " + "Call register_scoring_method() first." + ) + score_method_map[col] = obj + warn = make_warn_once_per_key() try: @@ -320,6 +376,9 @@ def load_sdf( target=self.target, compound_tag_list=compound_tags, pose_tag_list=pose_tags, + enumeration_method_obj=enumeration_method_obj, + pose_method_obj=pose_method_obj, + score_method_map=score_method_map, mol_col=mol_col, name_col=name_col, inspiration_col=inspiration_col, @@ -384,6 +443,31 @@ def add_syndirella_routes( # TODO: handle gracefully raise Exception from exc + def add_enamine_real_routes( + self, + csv_path: str | Path, + check_chemistry: bool = True, + register_routes: bool = True, + ) -> pd.DataFrame: + """Add synthesis routes from an Enamine REAL CSV export""" + + try: + with transaction.atomic(): + result = IngestionService.ingest_enamine_real_routes( + csv_path=csv_path, + do_check_chemistry=check_chemistry, + register_routes=register_routes, + ) + except Exception as exc: + logger.error(exc, exc_info=True) + raise Exception from exc + + return result + + def prune_duplicate_routes(self) -> int: + """Remove duplicate routes from the database""" + return RouteService.prune_duplicate_routes() + def add_syndirella_elabs( self, df_path: str | Path, diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index e688b00..66db2b3 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -274,9 +274,11 @@ class PoseModel(BaseModel): ) # unlike others, this wasn't clearly defined as m2m. may not want # to keep it + # through_fields: the calling pose is the derivative, the added pose is the original inspirations = models.ManyToManyField( 'self', through='InspirationModel', + through_fields=('derivative_pose', 'original_pose'), symmetrical=False, ) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 4568eca..8b1c87e 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -11,11 +11,13 @@ from designdb.components.recipe import Recipe, Route from designdb.models import ( CompoundModel, + EnumerationMethodModel, PoseMethodModel, PoseModel, ReactantModel, ReactionModel, ScaffoldModel, + ScoringMethodModel, TargetModel, ) from designdb.services.compound import CompoundService, CompoundTagService @@ -396,6 +398,9 @@ def ingest_sdf( target, compound_tag_list: list[str], pose_tag_list: list[str], + enumeration_method_obj: 'EnumerationMethodModel | None' = None, + pose_method_obj: PoseMethodModel | None = None, + score_method_map: dict[str, 'ScoringMethodModel'] | None = None, mol_col: str, name_col: str, inspiration_col: str | None = None, @@ -491,6 +496,8 @@ def ingest_sdf( # inchikey=sane_inchikey, ) compound.tags.add(*compound_tags) + if enumeration_method_obj is not None: + compound.enumeration_methods.add(enumeration_method_obj) if compound_created: result.compounds_created += 1 @@ -529,8 +536,14 @@ def ingest_sdf( result.poses_created += 1 pose.tags.add(*pose_tags) + if pose_method_obj is not None: + pose.methods.add(pose_method_obj) pose.inspirations.add(*PoseModel.objects.filter(pk__in=pose_inspirations)) - scorer.add_scores_from_record(pose=pose, record=r) + + if score_method_map: + scorer.add_scores_from_record(pose=pose, record=r, score_method_map=score_method_map) + else: + scorer.add_scores_from_record(pose=pose, record=r) # re-enable trigger and populate matview cursor.execute( @@ -681,6 +694,98 @@ def ingest_syndirella_routes( return df + @classmethod + def ingest_enamine_real_routes( + cls, + csv_path: str | Path, + do_check_chemistry: bool = True, + register_routes: bool = True, + ): + df = pd.read_csv(csv_path) + steps = len([col for col in df.columns if 'product_step' in col]) + + for i, row in mrich.track(df.iterrows(), total=len(df)): + mrich.set_progress_field('i', i) + mrich.set_progress_field('n', len(df)) + + d = row.to_dict() + + reactions = ReactionSet() + reactants = IngredientSet() + intermediates = IngredientSet() + products = IngredientSet() + + product = None + try: + for step_id in range(1, steps + 1): + r1_smiles = d.get(f'reactant_step{step_id}') + if not r1_smiles or (isinstance(r1_smiles, float) and isnan(r1_smiles)): + continue + + reaction_type = d[f'reaction_name_step{step_id}'] + product = CompoundService.get_by_smiles(smiles=d['smiles']) + + mrich.print(i, step_id, reaction_type, product) + + reactant_smiles = [r1_smiles] + r2_smiles = d.get(f'reactant2_step{step_id}') + if r2_smiles and not (isinstance(r2_smiles, float) and isnan(r2_smiles)): + reactant_smiles.append(r2_smiles) + + reaction, _ = ReactionModel.objects.get_or_create( + reaction_type=reaction_type, + product_compound=product, + ) + + rs = [] + for smiles in reactant_smiles: + reactant_comp, _ = CompoundService.create(smiles=smiles) + reactant, _ = ReactantModel.objects.get_or_create( + compound=reactant_comp, + reaction=reaction, + ) + rs.append(reactant.pk) + + if do_check_chemistry and not check_chemistry(reaction_type, rs, product): + raise InvalidChemistryError( + f'{reaction_type=}, {rs=}, {product.id=}', + ) + + for r_id in rs: + if r_id in reactants: + intermediates.add(compound_id=r_id, amount=1) + else: + reactants.add(compound_id=r_id, amount=1) + + reactions.add(reaction) + + except InvalidChemistryError: + continue + except UnsupportedChemistryError: + mrich.warning('Skipping unsupported chemistry:', reaction_type) + continue + except Exception: + mrich.error('Uncaught error with row', i) + raise + + if product is None: + continue + + products.add(Ingredient.from_compound(product, amount=1)) + + recipe = Recipe( + reactions=reactions, + reactants=reactants, + intermediates=intermediates, + products=products, + ) + + if register_routes: + route, _ = RouteService.create_from_recipe(recipe=recipe) + mrich.success('registered route', route.pk) + + return df + @classmethod def ingest_syndirella_elabs( cls, diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py index 17afdd3..c2ac897 100644 --- a/hippo/designdb/services/route.py +++ b/hippo/designdb/services/route.py @@ -1,9 +1,15 @@ # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi +import logging +from collections import Counter + +import mrich from designdb.components.recipe import Recipe from designdb.models import ComponentModel, RouteModel +logger = logging.getLogger(__name__) + class RouteService: @classmethod @@ -72,6 +78,49 @@ def create_from_recipe( return route, created + @classmethod + def prune_duplicate_routes(cls) -> int: + """Delete duplicate routes, keeping the lowest-id copy of each. + + A duplicate is any pair of routes with the same product compound and + identical sets of (component_ref, component_type) pairs. + + Returns the number of routes deleted. + """ + rows = ComponentModel.objects.values_list( + 'route_id', 'route__product_compound_id', 'component_ref', 'component_type' + ) + + route_fingerprints: dict[int, tuple] = {} + for route_id, product_id, comp_ref, comp_type in rows: + if route_id not in route_fingerprints: + route_fingerprints[route_id] = (product_id, set()) + route_fingerprints[route_id][1].add((comp_ref, comp_type)) + + # freeze the sets so they're hashable + frozen = {rid: (fp[0], frozenset(fp[1])) for rid, fp in route_fingerprints.items()} + + mrich.var('#routes', len(frozen)) + + counter = Counter(frozen.values()) + duplicates = {fp: count for fp, count in counter.items() if count > 1} + mrich.var('products with duplicate routes', len(duplicates)) + + if not duplicates: + mrich.success('No duplicate routes found') + return 0 + + to_delete: set[int] = set() + for fp in duplicates: + matched = sorted(rid for rid, v in frozen.items() if v == fp) + mrich.print('compound', fp[0], 'has', len(matched), 'duplicate routes') + to_delete.update(matched[1:]) + + ComponentModel.objects.filter(route_id__in=to_delete).delete() + deleted, _ = RouteModel.objects.filter(pk__in=to_delete).delete() + mrich.success('Deleted', len(to_delete), 'duplicate routes') + return len(to_delete) + # @property # def id_amount_pairs(self) -> list[tuple]: # """Get a list of compound ID and amount pairs""" diff --git a/hippo/designdb/services/score.py b/hippo/designdb/services/score.py index 7eff26b..a91a3bf 100644 --- a/hippo/designdb/services/score.py +++ b/hippo/designdb/services/score.py @@ -43,19 +43,24 @@ def add_scores_from_record( *, pose: PoseModel, record: dict[str, str | float], + score_method_map: dict[str, ScoringMethodModel] | None = None, ): - - # FIXME: this because don't know how to select - scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} - - for method_name, score_value in scores.items(): - try: - method = self.scoring_methods[method_name] - except KeyError: - # there's so many more fields, should I really be creating them? - method, _ = ScoringMethodModel.objects.get_or_create( - method_name=method_name, - ) + if score_method_map: + scores = {col: record[col] for col in score_method_map if col in record} + else: + # FIXME: this because don't know how to select + scores = {k: v for k, v in record.items() if k.lower().find('score') >= 0} + + for col_or_method_name, score_value in scores.items(): + if score_method_map: + method = score_method_map[col_or_method_name] + else: + try: + method = self.scoring_methods[col_or_method_name] + except KeyError: + method, _ = ScoringMethodModel.objects.get_or_create( + method_name=col_or_method_name, + ) score = ScoreValueModel( pose=pose, diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index c11f0b3..5ba3315 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -25,6 +25,7 @@ PoseModel, PoseTagJunctionModel, PoseTagModel, + ScoreValueModel, SubsiteModel, SubsiteTagModel, TargetModel, @@ -36,7 +37,20 @@ from designdb.utils_frag import generate_header from django.conf import settings from django.db import IntegrityError -from django.db.models import Exists, OuterRef, Q, QuerySet, Subquery +from django.db.models import ( + Exists, + F, + FloatField, + Max, + Min, + OuterRef, + Q, + QuerySet, + Subquery, + Window, +) +from django.db.models.fields.json import KeyTextTransform +from django.db.models.functions import Cast from IPython.display import display from ipywidgets import ( BoundedIntText, @@ -441,6 +455,8 @@ def get_df( tags: bool = False, expand_tags: bool = False, subsites: bool = False, + scoring_methods: list[tuple[str, str]] | None = None, + pose_method: bool = False, # skip_no_mol=True, reference: str = "name", mol: bool = False, **kwargs ) -> 'pandas.DataFrame': """Get a DataFrame of the poses in this set. @@ -475,7 +491,7 @@ def get_df( flags = { name: locals()[name] for name in sig.parameters - if name not in ('self', 'debug', 'expand_tags', 'expand_metadata') + if name not in ('self', 'debug', 'expand_tags', 'expand_metadata', 'scoring_methods') } # need id in output flags['id'] = True @@ -562,6 +578,11 @@ def get_df( # JsonGroupArray('subsites__subsite_name', # filter=Q(subsites__isnull=False),), ), + 'pose_method': ( + 'pose_method', + 'pose_method_names', + ArrayAgg('methods__pose_method_name', distinct=True), + ), } annotations = { @@ -570,6 +591,22 @@ def get_df( values = [v[1] for k, v in fields.items() if flags.get(k, False)] columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} + for method_name, version in (scoring_methods or []): + score_sq = Subquery( + ScoreValueModel.objects.filter( + pose=OuterRef('pk'), + compound=OuterRef('compound'), + scoring_method__method_name=method_name, + scoring_method__method_version=version, + ).annotate( + score_val=Cast(KeyTextTransform('score', 'score'), output_field=FloatField()) + ).values('score_val')[:1], + output_field=FloatField(), + ) + annotations[method_name] = score_sq + values.append(method_name) + columns[method_name] = method_name + print('df values', values) print('df columns', columns) qs = self._queryset.annotate(**annotations).values(*values) @@ -707,21 +744,43 @@ def set_subsites_from_metadata_field(self, field: str = 'CanonSites alias') -> N """Create and assign subsite entries from a pose metadata field.""" SubsiteService.set_subsites_from_metadata_field(self._queryset, field) - # def get_best_placed_poses_per_compound(self): - # """Choose the best placed pose (best distance_score) grouped by compound""" - - # sql = f""" - # SELECT pose_id, MIN(pose_distance_score) - # FROM {self.db.SQL_SCHEMA_PREFIX}pose - # WHERE pose_id IN {self.str_ids} - # GROUP BY pose_compound - # """ + def get_best_scoring_poses_per_compound( + self, + scoring_method: str, + version: str | None = None, + inverse: bool = False, + ) -> 'PoseSet': + """Return one pose per compound with the best score for the given scoring method. - # cursor = self.db.execute(sql) + :param scoring_method: ``ScoringMethodModel.method_name`` to rank by + :param version: ``ScoringMethodModel.method_version`` — required when multiple + versions of the same method exist + :param inverse: if ``True``, higher score is better (default: lower is better) + """ + score_num = Cast(KeyTextTransform('score', 'score'), output_field=FloatField()) + agg = Max('score_num') if inverse else Min('score_num') + + filters = {'scoring_method__method_name': scoring_method} + if version is not None: + filters['scoring_method__method_version'] = version + + best_pose_ids = ( + ScoreValueModel.objects + .filter(pose__in=self._queryset, **filters) + .annotate(score_num=score_num) + .annotate( + compound_best=Window( + expression=agg, + partition_by=['compound_id'], + ) + ) + .filter(score_num=F('compound_best')) + .values_list('pose_id', flat=True) + .distinct() + ) - # ids = [i for i, _ in cursor] + return PoseSet(PoseModel.objects.filter(pk__in=best_pose_ids)) - # return PoseSet(self._queryset) # def filter( # self, @@ -1087,14 +1146,11 @@ def to_fragalysis( 'derivative_pose', ) - if not values.exists(): - mrich.debug('no inspirations, quitting') - logger.warning('no inspirations, quitting') - return - - poses = PoseSet(PoseModel.objects.filter(pk__in=values)) - - mrich.debug(len(poses), 'remaining after skipping null inspirations') + if values.exists(): + poses = PoseSet(PoseModel.objects.filter(pk__in=values)) + mrich.debug(len(poses), 'remaining after skipping null inspirations') + else: + logger.warning('no inspirations found; per-pose fallback will set inspiration to self') if not poses: # huh? @@ -1160,7 +1216,7 @@ def fix_subsites(subsite_list: list[str]): pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) if tags: - pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(x)) + pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(v for v in x if v is not None)) # pose_df['ref_mols'] = inspiration_strs pose_df['ref_mols'] = 'inspiration_strs' From cede087a83fb40218b7a836a70fc8d18b050a619 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 3 Jun 2026 16:28:40 +0100 Subject: [PATCH 145/163] feat: ISPyB project permissions --- hippo/bootstrap.py | 17 +++++++++++++-- hippo/designdb/animal.py | 12 ++++++++++- hippo/designdb/models.py | 24 +++++++++++++++++++++ images/xchem-designdb/init-db/01_schema.sql | 10 +++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index 7725e8d..ce11e3e 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -6,6 +6,8 @@ import mrich from django.conf import settings +from .ta_auth_connector import get_auth_target_access + # fix path ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) @@ -55,8 +57,10 @@ def configure_django(db_config, manage_models: bool): def load_hippo( - target_name: str, *, + target_name: str, + target_access_string: str, + username: str, db: str | Path | dict | None = None, # copy_from: str | Path | None = None, # overwrite_existing: bool = False, @@ -70,6 +74,15 @@ def load_hippo( mrich.bold('Creating HIPPO animal') mrich.var('target_name', target_name, color='arg') + tas_list = get_auth_target_access(username) + + # mock response until auth pod is externally accessible + tas_list = ('lb18145-1') + + if not target_access_string in tas_list: + mrich.error(f'User {username} does not have access to {target_access_string}') + return + if db is None: # populate from env @@ -119,7 +132,7 @@ def load_hippo( # import .testmodule from designdb.animal import HIPPO - animal = HIPPO(target_name) + animal = HIPPO(target_name, target_access_string) mrich.success('Initialised animal', f'{target_name}') return animal diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index e91ed7b..743e7ef 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -14,6 +14,7 @@ EnumerationMethodModel, PoseMethodModel, PoseModel, + Project, ScoringMethodModel, TargetModel, ) @@ -38,10 +39,19 @@ class HIPPO: def __init__( self, target_name: str, + target_access_string: str, ) -> None: + # TODO: with working db, hippo shouldn't be creating projects + project, _ = Project.objects.get_or_create( + project_name=target_access_string, + ) + # TODO: user- or project based targets - self._target, _ = TargetModel.objects.get_or_create(target_name=target_name) + self._target, _ = TargetModel.objects.get_or_create( + target_name=target_name, + project=project, + ) # TODO: the way this worked previously was it gave the HIPPO # instance full access to the pose table. When working with diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index 66db2b3..6a48cee 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -91,11 +91,35 @@ class Meta: default_related_name = '%(class)ss' +class Project(BaseModel): + project_name = models.TextField(null=False, unique=True) + open_to_public = models.BooleanField(default=False) + + class Meta(BaseModel.Meta): + db_table = 'projects' + constraints = [ + models.UniqueConstraint( + fields=[ + 'project_name', + ], + name='uc_project', + ), + ] + + def __str__(self) -> str: + return f"{self.project_name}" + + class TargetModel(BaseModel): id = models.BigAutoField(primary_key=True) external_target_id = models.BigIntegerField(null=True, blank=True) target_name = models.TextField() target_metadata = models.TextField(null=True, blank=True) + project = models.ForeignKey( + Project, + on_delete=models.RESTRICT, + db_column='project_id', + ) class Meta(BaseModel.Meta): db_table = 'targets' diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index dddc1a0..7ead379 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -24,11 +24,21 @@ REVOKE CREATE ON SCHEMA public FROM PUBLIC; -- TABLES (ordered by FK dependencies) -- ========================================================= +CREATE TABLE IF NOT EXISTS designdb.projects ( + id BIGSERIAL PRIMARY KEY, + project_name TEXT NOT NULL, + open_to_public BOOLEAN NOT NULL DEFAULT FALSE, + created_on TIMESTAMPTZ DEFAULT now(), + updated_on TIMESTAMPTZ DEFAULT now(), + CONSTRAINT uc_project UNIQUE (project_name) +); + CREATE TABLE IF NOT EXISTS designdb.targets ( id BIGSERIAL PRIMARY KEY, --Internal ID inserted when registering target via Fragalysis external_target_id BIGINT, -- ID of this target in the external database (Scarab link) target_name TEXT NOT NULL, --Insert from HIPPO codebase. Must be a link to Scarab protein production target target_metadata TEXT, -- Not populated by code + project_id BIGINT NOT NULL REFERENCES designdb.projects (id) ON DELETE RESTRICT, created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now(), CONSTRAINT uc_target UNIQUE (target_name) From b45d5b4d46f78d3e7416db83eec19d9e7401a955 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 4 Jun 2026 08:27:28 +0100 Subject: [PATCH 146/163] fix: SynchronousOnlyOperation when working in notebooks --- hippo/bootstrap.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index 7725e8d..f593058 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -38,6 +38,10 @@ def configure_django(db_config, manage_models: bool): }, } + # fix for SynchronousOnlyOperation when working in notebooks. safe + # to use for now + os.environ.setdefault('DJANGO_ALLOW_ASYNC_UNSAFE', 'true') + settings.configure( INSTALLED_APPS=[ 'designdb.apps.DesigndbConfig', From 0b81fd1a12f101c9a34e923999d4cb8d73b9c823 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 4 Jun 2026 14:11:30 +0100 Subject: [PATCH 147/163] fix: partially implemented recipe stashing unfinished work --- hippo/designdb/components/compound.py | 17 +- hippo/designdb/components/price.py | 8 + hippo/designdb/components/reaction.py | 6 +- hippo/designdb/components/recipe.py | 2698 ++++++------------------- hippo/designdb/models.py | 8 +- hippo/designdb/services/recipe.py | 567 +++++- hippo/designdb/services/route.py | 24 +- hippo/designdb/sets/compound.py | 108 +- hippo/designdb/sets/reaction.py | 16 +- hippo/designdb/sets/route.py | 132 +- 10 files changed, 1301 insertions(+), 2283 deletions(-) diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index 3199839..f592459 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -425,7 +425,8 @@ def get_reactions( ) if permitted_reactions: - reaction_ids = [i for i in reaction_ids if i in permitted_reactions] + permitted_ids = set(permitted_reactions.ids) + reaction_ids = [i for i in reaction_ids if i in permitted_ids] rset = ReactionSet(reaction_ids) if not as_reactant and not permitted_reactions: @@ -1077,18 +1078,18 @@ def get_cheapest_quote_id( to raise print an error. """ - query = Q(compound=self.compound) + qs = CataloguePriceModel.objects.filter(compounds=self.compound) if supplier: - query &= Q(quote_supplier=supplier) + qs = qs.filter(supplier=supplier) if min_amount: - query &= Q(quote_amount__gte=min_amount) + qs = qs.filter(amount__gte=min_amount) if max_lead_time: - query &= Q(quote_lead_time__lte=max_lead_time) + qs = qs.filter(lead_time__lte=max_lead_time) - return CataloguePriceModel.objects.filter(query).order_by('quote_price').first() + return qs.order_by('price').first() ### PROPERTIES @@ -1100,12 +1101,12 @@ def amount(self) -> float: @property def id(self) -> int: """Returns the ID of the associated :class:`.Compound`""" - return self._compound_id + return self._compound.id @property def compound_id(self) -> int: """Returns the ID of the associated :class:`.Compound`""" - return self._compound_id + return self._compound.id @property def quote(self) -> int: diff --git a/hippo/designdb/components/price.py b/hippo/designdb/components/price.py index 88f74b2..fc9b237 100644 --- a/hippo/designdb/components/price.py +++ b/hippo/designdb/components/price.py @@ -103,6 +103,14 @@ def copy(self) -> 'Price': ### DUNDERS + def __bool__(self) -> bool: + """A null Price is falsy; a real (priced) Price is truthy. + + Recipe selection relies on ``if recipe.get_price()`` to drop unpriced + solutions, so this must reflect :attr:`.Price.is_null`. + """ + return not self.is_null + def __str__(self) -> str: """Unformatted string representation""" if self.currency is None: diff --git a/hippo/designdb/components/reaction.py b/hippo/designdb/components/reaction.py index 580570c..57b292e 100644 --- a/hippo/designdb/components/reaction.py +++ b/hippo/designdb/components/reaction.py @@ -139,9 +139,11 @@ def get_recipes( :param amount: amount in mg """ - from .recipe import Recipe # local to break circular import + # convenience bridge to the service layer (local import keeps the + # component -> service dependency out of the module import graph) + from designdb.services.recipe import RecipeService - return Recipe.from_reaction( + return RecipeService.from_reaction( self._instance, amount=amount, debug=debug, diff --git a/hippo/designdb/components/recipe.py b/hippo/designdb/components/recipe.py index 63051b0..368d775 100644 --- a/hippo/designdb/components/recipe.py +++ b/hippo/designdb/components/recipe.py @@ -1,15 +1,35 @@ -"""Classes for working with Recipes (reaction networks)""" +"""Classes for working with Recipes (reaction networks). -from itertools import product +A :class:`.Recipe` is a lean *aggregate*: it holds the products, reactants, +intermediates, reactions and (no-chem) compounds that make up a synthetic recipe, +and exposes price/serialisation/presentation on top of them. + +All construction and DB-traversal *orchestration* lives in the service layer +(:class:`.RecipeService` in ``services/recipe.py``). The ``from_*`` and export +methods on :class:`.Recipe` are **deprecated shims** that delegate to the service +— see the ``DEPRECATED`` banner below. They use a local import of the service so +there is no module-level ``component -> service`` dependency. +""" + +import warnings import mcol import mrich from designdb.models import ComponentModel, CompoundModel, ReactionModel, RouteModel -from designdb.sets.compound import IngredientSet +from designdb.sets.compound import CompoundSet, IngredientSet from designdb.sets.reaction import ReactionSet -from .compound import Compound -from .reaction import DEFAULT_PRODUCT_YIELD, Reaction +from .reaction import Reaction + + +def _deprecated(old: str, new: str) -> None: + """Emit a uniform deprecation warning for a relocated method.""" + warnings.warn( + f'{old} is deprecated; use {new}. ' + 'The Recipe shim will be removed after the migration settles.', + DeprecationWarning, + stacklevel=3, + ) class Recipe: @@ -29,16 +49,12 @@ def __init__( if products is None: products = IngredientSet() - if reactants is None: reactants = IngredientSet() - if intermediates is None: intermediates = IngredientSet() - if compounds is None: compounds = IngredientSet() - if reactions is None: reactions = ReactionSet() @@ -64,651 +80,93 @@ def __init__( self._interactions = None self._combined_compounds = None - ### FACTORIES + ### DEPRECATED — construction shims (relocated to RecipeService) + # These delegate to designdb.services.recipe.RecipeService and exist only to + # keep legacy `Recipe.from_*(...)` call sites working during the migration. @classmethod - def from_reaction( - cls, - reaction, - amount=1, - *, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - quoted_only: bool = False, - supplier: None | str = None, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] = None, - reaction_reactant_cache: dict[int, bool] = None, - inner: bool = False, - get_ingredient_quotes: bool = True, - ) -> 'Recipe | list[Recipe]': - """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream - dependencies - - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set - (Default value = None) - :param quoted_only: bool: only allow reactants with quotes - (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier - (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has - unavailable reactants (Default value = 'error') - :param inner: used to indicate that this is a recursive call - (Default value = False) - :param get_ingredient_quotes: get quotes for ingredients in this recipe - - """ - - assert isinstance(reaction, ReactionModel) - reaction_component = Reaction(reaction) - - if debug: - mrich.debug( - f'Recipe.from_reaction(R{reaction.id}, {amount=}, {pick_cheapest=})' - ) - mrich.debug(f'{reaction.product_compound.pk=}') - mrich.debug(f'{reaction_component.reactant_ids=}') - - if permitted_reactions: - assert reaction in permitted_reactions - # raise NotImplementedError - - recipe = cls.__new__(cls) - recipe.__init__( - products=IngredientSet( - [ - Compound(reaction.product_compound).as_ingredient( - amount=amount, get_quote=get_ingredient_quotes - ) - ], - ), - reactants=IngredientSet([], supplier=supplier), - intermediates=IngredientSet([]), - reactions=ReactionSet([reaction.id], sort=False), - ) - - recipes = [recipe] - - if quoted_only or supplier: - if debug: - mrich.debug(f'Checking reactant_availability: {reaction=}') - if reaction_checking_cache and reaction.id in reaction_checking_cache: - ok = reaction_checking_cache[reaction.id] - print('reaction_checking_cache used') - else: - ok = reaction_component.check_reactant_availability(supplier=supplier) - if reaction_checking_cache is not None: - reaction_checking_cache[reaction.id] = ok - if not ok: - if unavailable_reaction == 'error': - mrich.error(f'Reactants not available for {reaction=}') - if pick_cheapest: - return None - else: - return [] - - def get_reactant_amount_pairs( - reaction_model: ReactionModel, - ) -> list[tuple[int, float]]: - """Get pairs of reactant ID and float amounts""" - if reaction_reactant_cache and reaction_model.id in reaction_reactant_cache: - print('reaction_reactant_cache used') - return reaction_reactant_cache[reaction_model.id] - else: - pairs = Reaction(reaction_model).get_reactant_amount_pairs( - compound_object=False - ) - if reaction_reactant_cache is not None: - reaction_reactant_cache[reaction_model.id] = pairs - return pairs - - if debug: - mrich.debug(f'get_reactant_amount_pairs({reaction.id})') - pairs = get_reactant_amount_pairs(reaction) - - for reactant, reactant_amount in pairs: - # reactant = db.get_compound(id=reactant) - reactant = Compound(CompoundModel.objects.get(pk=reactant)) - - if debug: - mrich.debug(f'{reactant.id=}, {reactant_amount=}') - - # scale amount - reactant_amount *= amount - reactant_amount /= reaction.reaction_product_yield or DEFAULT_PRODUCT_YIELD + def from_reaction(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reaction`.""" + from designdb.services.recipe import RecipeService - inner_reactions = reactant.get_reactions( - none='quiet', permitted_reactions=permitted_reactions - ) - - if inner_reactions: - if debug: - if len(inner_reactions) == 1: - mrich.debug('ReactantModel has ONE inner reaction') - else: - mrich.warning(f'{reactant=} has MULTIPLE inner reactions') - - new_recipes = [] - - inner_recipes = [] - for reaction in inner_reactions: - reaction_recipes = Recipe.from_reaction( - reaction=reaction, - amount=reactant_amount, - debug=debug, - pick_cheapest=False, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - inner=True, - ) - inner_recipes += reaction_recipes - - for recipe in recipes: - for inner_recipe in inner_recipes: - combined_recipe = recipe.copy() - - combined_recipe.reactants += inner_recipe.reactants - combined_recipe.intermediates += inner_recipe.intermediates - combined_recipe.reactions += inner_recipe.reactions - combined_recipe.intermediates.add( - reactant.as_ingredient(reactant_amount, supplier=supplier) - ) - - new_recipes.append(combined_recipe) - - recipes = new_recipes - - else: - ingredient = reactant.as_ingredient(reactant_amount, supplier=supplier) - for recipe in recipes: - recipe.reactants.add(ingredient) - - # reverse ReactionSet's - if not inner: - for recipe in recipes: - recipe.reactions.reverse() - - if pick_cheapest: - if debug: - mrich.debug('Picking cheapest') - priced = [r for r in recipes if r.get_price(supplier=supplier)] - # priced = [r for r in recipes if r.price] - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return recipes - sorted_recipes = sorted( - priced, key=lambda r: r.get_price(supplier=supplier) - ) - - if debug: - for recipe in recipes: - mrich.debug(f'{recipe}, {recipe.price}') - - return sorted_recipes[0] - # return sorted(priced, key=lambda r: r.price)[0] - - return recipes + _deprecated('Recipe.from_reaction()', 'RecipeService.from_reaction()') + return RecipeService.from_reaction(*args, **kwargs) @classmethod - def from_reactions( - cls, - reactions: 'ReactionSet', - amount: float = 1, - pick_cheapest: bool = True, - permitted_reactions: 'ReactionSet | None' = None, - final_products_only: bool = True, - return_products: bool = False, - supplier: str | None = None, - use_routes: bool = False, - debug: bool = False, - **kwargs, - ) -> 'Recipe | list[Recipe] | CompoundSet': - """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream - dependencies - - :param reactions: reactions to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set - (Default value = None) - :param final_products_only: don't get routes to intermediates - (Default value = True) - :param return_products: return the :class:`.CompoundSet` of products instead - (Default value = False) - - """ - - from designdb.sets.compound import CompoundSet - from designdb.sets.reaction import ReactionSet - - assert isinstance(reactions, ReactionSet) - - if debug: - mrich.debug('Recipe.from_reactions()') - mrich.var('reactions', reactions) - mrich.var('amount', amount) - mrich.var('final_products_only', final_products_only) - mrich.var('permitted_reactions', permitted_reactions) - - # get all the products - products = reactions.products - - if debug: - mrich.var('products', products) - - # return products - - if final_products_only: - if debug: - mrich.var('products.str_ids', products.str_ids) - - # raise NotImplementedError - ids = reactions.db.execute( - f""" - SELECT DISTINCT compound_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN {self.db.SQL_SCHEMA_PREFIX}reactant - ON compound_id = reactant_compound - WHERE reactant_compound IS NULL - AND compound_id IN {products.str_ids} - """ - ).fetchall() - - ids = [i for (i,) in ids] - - products = CompoundSet(db, ids) - if debug: - mrich.var('final products', products) - - # return ids - - if return_products: - return products - - recipe = Recipe.from_compounds( - compounds=products, - amount=amount, - permitted_reactions=reactions, - pick_cheapest=pick_cheapest, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) + def from_reactions(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reactions`.""" + from designdb.services.recipe import RecipeService - return recipe + _deprecated('Recipe.from_reactions()', 'RecipeService.from_reactions()') + return RecipeService.from_reactions(*args, **kwargs) @classmethod - def from_compounds( - cls, - compounds: 'CompoundSet', - amount: float = 1, - debug: bool = False, - pick_cheapest: bool = True, - permitted_reactions=None, - quoted_only: bool = False, - supplier: None | str = None, - solve_combinations: bool = True, - pick_first: bool = False, - warn_multiple_solutions: bool = True, - pick_cheapest_inner_routes: bool = False, - unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] | None = None, - reaction_reactant_cache: dict[int, bool] | None = None, - use_routes: bool = False, - **kwargs, - ): - """Create recipe(s) to synthesis products in the :class:`.CompoundSet` - - :param compounds: set of compounds to find routes for - :param solve_combinations: bool: combinatorially combine all individual routes - (Default value = True) - :param pick_first: return the first solution without comparison - (Default value = False) - :param warn_multiple_solutions: warn if a compound has multiple routes - (Default value = True) - :param pick_cheapest_inner_routes: for each compound choose the cheapest route - (Default value = False) - :param reaction: reaction to create recipe from - :param amount: amount in ``mg`` (Default value = 1) - :param debug: bool: increase verbosity for debugging (Default value = False) - :param pick_cheapest: bool: choose the cheapest solution (Default value = True) - :param permitted_reactions: once consider reactions in this set - (Default value = None) - :param quoted_only: bool: only allow reactants with quotes - (Default value = False) - :param supplier: None | str: optionally restrict quotes to only this supplier - (Default value = None) - :param unavailable_reaction: define the behaviour for when a reaction has - unavailable reactants (Default value = 'error') - - """ - - # from .sets.compound import CompoundSet - - # assert isinstance(compounds, CompoundSet) - compounds = [compounds] - - n_comps = len(compounds) - - assert n_comps - - if not hasattr(amount, '__iter__'): - amount = [amount] * n_comps - - if use_routes and supplier: - raise NotImplementedError - - options = [] - - ok = 0 - mrich.var('#compounds', n_comps) - - for comp, a in mrich.track( - zip(compounds, amount, strict=False), - prefix='Solving individual compound recipes...', - total=n_comps, - ): - comp_options = [] - - if use_routes: - route_qs = RouteModel.objects.filter(product_compound__id=comp.id) - if not route_qs.exists(): - mrich.error('No routes to', comp) - continue - - comp_options = [] - for route in route_qs: - comp_options.append(route) - - else: - # this assuming i'm not going to use wrapper class - for reaction in Compound(comp).reactions: - if permitted_reactions and reaction not in permitted_reactions: - continue - - sol = Recipe.from_reaction( - reaction=reaction, - amount=a, - pick_cheapest=pick_cheapest_inner_routes, - debug=debug, - permitted_reactions=permitted_reactions, - quoted_only=quoted_only, - supplier=supplier, - unavailable_reaction=unavailable_reaction, - reaction_checking_cache=reaction_checking_cache, - reaction_reactant_cache=reaction_reactant_cache, - **kwargs, - ) - - if pick_cheapest_inner_routes: - if sol: - comp_options.append(sol) - else: - assert isinstance(sol, list) - comp_options += sol + def from_compounds(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_compounds`.""" + from designdb.services.recipe import RecipeService - if not comp_options: - mrich.error( - f'No solutions for compound={comp} ' - f'({Compound(comp).reactions.ids=})' - ) - continue - - if pick_cheapest and len(comp_options) > 1: - if warn_multiple_solutions: - mrich.warning( - 'Multiple solutions for', comp, '(', len(comp_options), ')' - ) - if debug: - mrich.debug('Picking cheapest...') - priced = [r for r in comp_options if r.price] - comp_options = sorted(priced, key=lambda r: r.price)[:1] - - if warn_multiple_solutions and len(comp_options) > 1: - mrich.warning(f'Multiple solutions for compound={comp}') - if debug: - mrich.debug(f'{comp_options=}') - else: - if n_comps <= 200: - mrich.success(f'Found solution for compound={comp}') - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', n_comps) - - options.append(comp_options) - - assert all(options) - - mrich.print('Solving recipe combinations...') - combinations = list(product(*options)) - - if not solve_combinations: - return combinations - - solutions = [] - - if n_comps > 1: - generator = mrich.track( - combinations, prefix='Combining recipes...', total=len(combinations) - ) - else: - generator = combinations - - ok = 0 - for combo in generator: - if debug: - mrich.debug(f'Combination of {len(combo)} recipes') - - if not combo: - continue - - solution = combo[0] - - for i, recipe in enumerate(combo[1:]): - if debug: - mrich.debug(i + 1) - solution += recipe - - solutions.append(solution) - ok += 1 - mrich.set_progress_field('ok', ok) - mrich.set_progress_field('n', len(combinations)) - - if not solutions: - mrich.error('No solutions') - return None - - if pick_first: - return solutions[0] - - if pick_cheapest: - mrich.debug('Calculating prices...') - priced = [r for r in solutions if r.price] - mrich.print('Picking cheapest from', len(priced), 'options') - if not priced: - mrich.error("0 recipes with prices, can't choose cheapest") - return solutions - return sorted(priced, key=lambda r: r.price)[0] - - return solutions + _deprecated('Recipe.from_compounds()', 'RecipeService.from_compounds()') + return RecipeService.from_compounds(*args, **kwargs) @classmethod - def from_reactants( - cls, - reactants: 'CompoundSet | IngredientSet', - amount: float = 1, - debug: bool = False, - return_products: bool = False, - supplier: str | None = None, - pick_cheapest: bool = False, - use_routes: bool = False, - **kwargs, - ) -> 'list[Recipe] | Recipe | CompoundSet': - """Find the maximal recipe from a given set of reactants - - :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` for the - reactants. Ingredient amounts are ignored - :param amount: amount of each product needed (Default value = 1) - :param debug: increase verbosity (Default value = False) - :param return_products: return products instead of recipe - (Default value = False) - :param kwargs: passed to :meth:`.Recipe.from_reactions` - - """ - - from designdb.sets.compound import IngredientSet - - if isinstance(reactants, IngredientSet): - reactant_ids = reactants.compound_ids - else: - reactant_ids = reactants.ids - - db = reactants.db - - all_reactants = set(reactant_ids) - - possible_reactions = [] - - # recursively search for possible reactions - for i in range(300): - if debug: - mrich.debug(i) - - # reaction_ids = db.get_possible_reaction_ids(compound_ids=compound_ids) - reaction_ids = db.get_possible_reaction_ids(compound_ids=all_reactants) - - if not reaction_ids: - break - - if debug: - mrich.debug(f'Adding {len(reaction_ids)} reactions') - - possible_reactions += reaction_ids - - if debug: - mrich.var('reaction_ids', reaction_ids) - - product_ids = db.get_possible_reaction_product_ids( - reaction_ids=reaction_ids - ) - - if debug: - mrich.var('product_ids', product_ids) - - n_prev = len(all_reactants) - - all_reactants |= set(product_ids) - - if n_prev == len(all_reactants): - break - - else: - raise NotImplementedError('Maximum recursion depth exceeded') - - possible_reactions = list(set(possible_reactions)) - - if debug: - mrich.var('all possible reactions', possible_reactions) - - from designdb.sets.reaction import ReactionSet + def from_reactants(cls, *args, **kwargs): + """DEPRECATED: use :meth:`.RecipeService.from_reactants`.""" + from designdb.services.recipe import RecipeService - rset = ReactionSet(db, possible_reactions, sort=False) - - recipe = cls.from_reactions( - rset, - amount=amount, - permitted_reactions=rset, - debug=debug, - return_products=return_products, - supplier=supplier, - use_routes=use_routes, - **kwargs, - ) + _deprecated('Recipe.from_reactants()', 'RecipeService.from_reactants()') + return RecipeService.from_reactants(*args, **kwargs) - return recipe + ### FACTORIES @classmethod def from_json( cls, - db: 'Database', - path: 'str | Path', - debug: bool = True, - allow_db_mismatch: bool = False, + path: 'str | Path | None' = None, + *, + data: dict | None = None, clear_quotes: bool = False, - data: dict = None, - db_mismatch_warning: bool = True, - ): - """Load a serialised recipe from a JSON file - - :param db: database to link - :param path: path to JSON - :param debug: increase verbosity (Default value = True) - :param allow_db_mismatch: allow a database mismatch (Default value = False) - :param clear_quotes: ignore reactant quotes (Default value = False) - :param data: serialised data (Default value = None) + debug: bool = False, + ) -> 'Recipe': + """Load a serialised recipe from a JSON file (see :meth:`.Recipe.get_dict`). + :param path: path to JSON (ignored if ``data`` is provided) + :param data: pre-loaded serialised data (Default value = None) + :param clear_quotes: ignore stored reactant/compound quotes + :param debug: increase verbosity """ - # imports import json - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet - - # load JSON if not data: if debug: mrich.reading(path) data = json.load(open(path)) - # check metadata - if str(db.path.resolve()) != data['database']: - if db_mismatch_warning: - mrich.var('session', str(db.path.resolve())) - mrich.var('in file', data['database']) - if allow_db_mismatch: - if db_mismatch_warning: - mrich.warning('Database path mismatch') - else: - mrich.error( - 'Database path mismatch, set allow_db_mismatch=True to ignore' - ) - return None - - if debug: + if debug and 'timestamp' in data: mrich.print(f'Recipe was generated at: {data["timestamp"]}') - price = data['price'] - # IngredientSets - products = IngredientSet.from_ingredient_dicts(db, data['products']) - intermediates = IngredientSet.from_ingredient_dicts(db, data['intermediates']) - reactants = IngredientSet.from_ingredient_dicts( - db, data['reactants'], supplier=data['reactant_supplier'] + # IngredientSets are stored column-oriented (df.to_dict(orient='list')) + products = IngredientSet.from_json(path=None, data=data['products']) + intermediates = IngredientSet.from_json(path=None, data=data['intermediates']) + reactants = IngredientSet.from_json( + path=None, data=data['reactants'], supplier=data.get('reactant_supplier') ) if 'compounds' in data: - compounds = IngredientSet.from_ingredient_dicts( - db, data['compounds'], supplier=data['compound_supplier'] + compounds = IngredientSet.from_json( + path=None, + data=data['compounds'], + supplier=data.get('compound_supplier'), ) else: - compounds = IngredientSet(db) + compounds = IngredientSet() if clear_quotes: - reactants.df['quote_id'] = None - reactants.df['quoted_amount'] = None - compounds.df['quote_id'] = None - compounds.df['quoted_amount'] = None + for iset in (reactants, compounds): + iset.df['quote_id'] = None + iset.df['quoted_amount'] = None - # ReactionSet - reactions = ReactionSet(db, data['reaction_ids'], sort=False) + reactions = ReactionSet(data['reaction_ids'], sort=False) if debug: mrich.var('reactants', reactants) @@ -717,9 +175,7 @@ def from_json( mrich.var('reactions', reactions) mrich.var('compounds', compounds) - # Create the object - self = cls.__new__(cls) - self.__init__( + return cls( products=products, reactants=reactants, intermediates=intermediates, @@ -727,8 +183,6 @@ def from_json( compounds=compounds, ) - return self - ### PROPERTIES @property @@ -736,9 +190,15 @@ def products(self) -> 'IngredientSet': """Product :class:`.IngredientSet`""" return self._products + @products.setter + def products(self, a: 'IngredientSet'): + """Set the products""" + self._products = a + self.__flag_modification() + @property def compounds(self) -> 'IngredientSet': - """Product :class:`.IngredientSet`""" + """No-chem (directly purchased) :class:`.IngredientSet`""" return self._compounds @compounds.setter @@ -748,12 +208,42 @@ def compounds(self, a: 'IngredientSet'): self.__flag_modification() @property - def poses(self) -> 'PoseSet': - """Product poses""" - if self._poses is None: - self._poses = self.combined_compounds.poses - self._poses._name = f'poses of {self}' - return self._poses + def reactants(self) -> 'IngredientSet': + """Reactant :class:`.IngredientSet`""" + return self._reactants + + @reactants.setter + def reactants(self, a: 'IngredientSet'): + """Set the reactants""" + self._reactants = a + self.__flag_modification() + + @property + def intermediates(self) -> 'IngredientSet': + """Intermediate :class:`.IngredientSet`""" + return self._intermediates + + @intermediates.setter + def intermediates(self, a: 'IngredientSet'): + """Set the intermediates""" + self._intermediates = a + + @property + def reactions(self) -> 'ReactionSet': + """:class:`.ReactionSet` for this recipe""" + return self._reactions + + @reactions.setter + def reactions(self, a: 'ReactionSet'): + """Set the reactions""" + self._reactions = a + self.__flag_modification() + + @property + def product(self) -> 'Ingredient': + """Return the single product (if there's only one)""" + assert len(self.products) == 1 + return self.products[0] @property def product_compounds(self) -> 'CompoundSet': @@ -773,12 +263,18 @@ def combined_compound_ids(self) -> set[int]: def combined_compounds(self) -> 'CompoundSet': """Combined product and no-chem compounds""" if self._combined_compounds is None: - from designdb.sets.compound import CompoundSet - - self._combined_compounds = CompoundSet(self.db, self.combined_compound_ids) + self._combined_compounds = CompoundSet(list(self.combined_compound_ids)) self._combined_compounds._name = f'combined compounds of {self}' return self._combined_compounds + @property + def poses(self) -> 'PoseSet': + """Poses of the combined compounds""" + if self._poses is None: + self._poses = self.combined_compounds.poses + self._poses._name = f'poses of {self}' + return self._poses + @property def interactions(self) -> 'InteractionSet': """Product pose interactions""" @@ -787,98 +283,53 @@ def interactions(self) -> 'InteractionSet': return self._interactions @property - def product(self) -> 'Ingredient': - """Return single product (if there's only one)""" - assert len(self.products) == 1 - return self.products[0] - - @products.setter - def products(self, a: 'IngredientSet'): - """Set the products""" - self._products = a - self.__flag_modification() + def price(self) -> 'Price': + """Total price of the reactants and no-chem compounds""" + return self.reactants.get_price() + self.compounds.get_price() @property - def reactants(self): - """ReactantModel :class:`.IngredientSet`""" - return self._reactants + def num_products(self) -> int: + """Number of products""" + return len(self.products) - @reactants.setter - def reactants(self, a: 'IngredientSet'): - """Set the reactants""" - self._reactants = a - self.__flag_modification() + @property + def num_compounds(self) -> int: + """Number of combined compounds""" + return len(self.combined_compound_ids) @property - def intermediates(self) -> 'IngredientSet': - """Intermediates :class:`.IngredientSet`""" - return self._intermediates + def num_reactions(self) -> int: + """Number of reactions""" + return len(self.reactions) - @intermediates.setter - def intermediates(self, a: 'IngredientSet'): - """Set the intermediates""" - self._intermediates = a - # self.__flag_modification() + @property + def num_reaction_types(self) -> int: + """Number of distinct reaction types""" + return self.reactions.num_types @property - def reactions(self) -> 'ReactionSet': - """Intermediates :class:`.IngredientSet`""" - return self._reactions - - @reactions.setter - def reactions(self, a: 'ReactionSet'): - """Set the reactions""" - self._reactions = a - self.__flag_modification() - - @property - def price(self) -> 'Price': - """Get the price of the reactants""" - return self.reactants.get_price() + self.compounds.get_price() - - @property - def num_products(self) -> int: - """Return the number of products""" - return len(self.products) - - @property - def num_compounds(self) -> int: - """Return the number of compounds""" - return len(self.combined_compound_ids) - - @property - def num_reactions(self): - """Return the number of reactions""" - return len(self.reactions) - - @property - def num_reaction_types(self): - """Return the number of reactions""" - return self.reactions.num_types - - @property - def num_reactants(self): - """Return the number of reactants""" + def num_reactants(self) -> int: + """Number of reactants""" return len(self.reactants) @property - def num_intermediates(self): - """Return the number of intermediates""" + def num_intermediates(self) -> int: + """Number of intermediates""" return len(self.intermediates) @property def hash(self) -> str: - """Return the unique hash string""" + """Unique hash string (set when loaded from a RecipeSet)""" return self._hash @property def score(self): - """Return the Recipe score""" + """Recipe score""" return self._score @property def type(self) -> str: - """Get Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" + """Recipe type (EMPTY/MIXED/CHEM/NOCHEM)""" if self.empty: return 'EMPTY' @@ -888,351 +339,160 @@ def type(self) -> str: if chem and nochem: return 'MIXED' - if chem and not nochem: return 'CHEM' - if nochem and not chem: return 'NOCHEM' @property def empty(self) -> bool: """Is this Recipe empty?""" - - if self.reactants: - return False - - if self.products: - return False - - if self.intermediates: - return False - - if self.reactions: - return False - - if self.compounds: - return False - - return True + return not any( + ( + self.reactants, + self.products, + self.intermediates, + self.reactions, + self.compounds, + ) + ) ### METHODS def get_price(self, supplier: str | None = None) -> 'Price': - """get the reactants price. See :meth:`.IngredientSet.get_price` + """Get the reactants price. See :meth:`.IngredientSet.get_price` :param supplier: restrict quotes to this supplier - """ return self.reactants.get_price(supplier=supplier) - def draw(self, color_mapper=None, node_size=300, graph_only=False): - """draw graph of the reaction network - - :param color_mapper: (Default value = None) - :param node_size: (Default value = 300) - :param graph_only: (Default value = False) + def get_ingredient(self, id) -> 'Ingredient': + """Get an ingredient by its compound ID + :param id: compound ID """ + matches = [r for r in self.reactants if r.id == id] + if not matches: + matches = [r for r in self.intermediates if r.id == id] + if not matches: + matches = [r for r in self.products if r.id == id] - import networkx as nx - - color_mapper = color_mapper or {} - colors = {} - sizes = {} - - graph = nx.DiGraph() - - for reaction in (Reaction(r) for r in self.reactions): - for reactant in reaction.reactants: - key = str(reactant) - ingredient = self.get_ingredient(id=reactant.id) - - graph.add_node( - key, - id=reactant.id, - smiles=reactant.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = self.get_ingredient(id=reactant.id).amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for product in self.products: - key = str(product.compound) - ingredient = self.get_ingredient(id=product.id) - - graph.add_node( - key, - id=product.id, - smiles=product.smiles, - amount=ingredient.amount, - price=str(ingredient.price), - lead_time=ingredient.lead_time, - ) - - if not graph_only: - sizes[key] = product.amount - if key in color_mapper: - colors[key] = color_mapper[key] - else: - colors[key] = (0.7, 0.7, 0.7) - - for reaction in (Reaction(r) for r in self.reactions): - for reactant in reaction.reactants: - graph.add_edge( - str(reactant), - str(reaction.product), - id=reaction.id, - type=reaction.type, - product_yield=reaction.product_yield, - ) - - # rescale sizes - if not graph_only: - s_min = min(sizes.values()) - sizes = [s / s_min * node_size for s in sizes.values()] - - if graph_only: - return graph - else: - # return nx.draw(graph, pos, with_labels=True, font_weight='bold') - # pos = nx.spring_layout(graph, iterations=200, k=30) - pos = nx.spring_layout(graph) - return nx.draw( - graph, - pos=pos, - with_labels=True, - font_weight='bold', - node_color=list(colors.values()), - node_size=sizes, - ) + assert len(matches) == 1 + return matches[0] - def sankey(self, title: str | None = None) -> 'graph_objects.Figure': - """draw a plotly Sankey diagram + def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): + """Add an :class:`.Ingredient` for direct purchase (no associated reactions)""" + self.compounds.add(ingredient) - :param title: (Default value = None) + def add_to_all_reactants(self, amount: float = 20) -> None: + """Increment all reactants by this amount + :param amount: amount in ``mg`` (Default value = 20) """ + self.reactants.df['amount'] += amount - graph = self.draw(graph_only=True) - - import plotly.graph_objects as go - - nodes = {} - - for edge in graph.edges: - c = edge[0] - if c not in nodes: - nodes[c] = len(nodes) - - c = edge[1] - if c not in nodes: - nodes[c] = len(nodes) - - source = [nodes[a] for a, b in graph.edges] - target = [nodes[b] for a, b in graph.edges] - value = [1 for l in graph.edges] - - labels = list(nodes.keys()) - - hoverkeys = None - - customdata = [] - for key in nodes.keys(): - n = graph.nodes[key] - - if not hoverkeys: - hoverkeys = list(n.keys()) - - if not n: - mrich.error(f'problem w/ node {key=}') - compound_id = int(key[1:]) - customdata.append((compound_id, None)) - - else: - d = tuple(v if v is not None else 'N/A' for v in n.values()) - customdata.append(d) - - hoverkeys_edges = None - - customdata_edges = [] - - for s, t in graph.edges.keys(): - edge = graph.edges[s, t] + def copy(self) -> 'Recipe': + """Copy this recipe""" + return Recipe( + products=self.products.copy(), + reactants=self.reactants.copy(), + intermediates=self.intermediates.copy(), + reactions=self.reactions.copy(), + compounds=self.compounds.copy(), + ) - if not hoverkeys_edges: - hoverkeys_edges = list(edge.keys()) + def check_integrity(self, debug: bool = False) -> bool: + """Verify the internal integrity of this recipe.""" - if not n: - mrich.error(f'problem w/ edge {s=} {t=}') - customdata_edges.append((None, None, None)) + if debug: + mrich.debug('Checking integrity:', self) + mrich.debug('Checking for duplicate compounds') - else: - d = tuple(v if v is not None else 'N/A' for v in edge.values()) - customdata_edges.append(d) + for label, iset in ( + ('Reactant', self.reactants), + ('Intermediate', self.intermediates), + ('Product', self.products), + ): + if len(iset.compound_ids) != len(set(iset.compound_ids)): + mrich.error(f"{label} compound ID's are not unique") + return False - hoverlines = [] - for i, key in enumerate(hoverkeys): - hoverlines.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate = 'CompoundModel ' + '
'.join(hoverlines) + '' + if debug: + mrich.debug('Checking for missing references') - hoverlines_edges = [] - for i, key in enumerate(hoverkeys_edges): - hoverlines_edges.append(f'{key}=%{{customdata[{i}]}}') - hovertemplate_edges = ( - 'ReactionModel ' + '
'.join(hoverlines_edges) + '' - ) + # all references should exist in the database + if ReactionModel.objects.filter(pk__in=self.reactions.ids).count() < len( + self.reactions + ): + mrich.error('Not all Reactions in Database') + return False - fig = go.Figure( - data=[ - go.Sankey( - node=dict( - # pad = 15, - # thickness = 20, - # line = dict(color = "black", width = 0.5), - label=labels, - # color = "blue" - customdata=customdata, - # customdata = ["Long name A1", "Long name A2", "Long name B1", - # "Long name B2", "Long name C1", "Long name C2"], - # hovertemplate='CompoundModel %{label}

' - # 'smiles=%{customdata}', - hovertemplate=hovertemplate, - ), - link=dict( - customdata=customdata_edges, - hovertemplate=hovertemplate_edges, - source=source, - target=target, - value=value, - ), - ) - ] + checks = ( + ('product', self.product_compounds.ids, len(self.products)), + ('reactant', self.reactants.compounds.ids, len(self.reactants)), + ('intermediate', self.intermediates.compounds.ids, len(self.intermediates)), ) + for label, ids, expected in checks: + if CompoundModel.objects.filter(pk__in=list(ids)).count() < expected: + mrich.error(f'Not all {label} Compounds in Database') + return False - if not title: - try: - title = f'Recipe
price={self.price}' - except AssertionError: - title = 'Recipe' - - fig.update_layout(title=title) + reaction_intermediates = self.reactions.intermediates + reaction_products = self.reactions.products + reaction_reactants = self.reactions.reactants - return fig + if debug: + mrich.debug('Checking for missing reactions') - def summary(self, price: bool = True) -> None: - """Print a summary of this recipe + for product in self.products: + if product not in reaction_products: + mrich.error(f'Product: {product} does not have associated reaction') + return False - :param price: print the price (Default value = True) + for intermediate in self.intermediates: + if intermediate not in reaction_intermediates: + mrich.error( + f'Intermediate: {intermediate} is not in ' + f'self.reactions.intermediates' + ) + return False - """ + for reactant in self.reactants: + if reactant not in reaction_reactants: + mrich.error(f'Reactant: {reactant} is not in self.reactions.reactants') + return False - mrich.h1(str(self)) + if debug: + mrich.debug('Checking reactant quantities') - if price: - price = self.price - if price: - mrich.var('\nprice', price.amount, price.currency) - # mrich.var('lead-time', self.lead_time, 'working days)) + for reaction in (Reaction(r) for r in self.reactions): + product_ingredient = self.products(compound_id=reaction.product.id) + if product_ingredient is None: + product_ingredient = self.intermediates(compound_id=reaction.product.id) - if self.products: - mrich.h3(f'{len(self.products)} products') + if debug and reaction.product_yield < 1.0: + mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') - if len(self.products) < 100: - for product in self.products: - mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') + for reactant in reaction.reactants: + reactant_ingredient = self.intermediates(compound_id=reactant.id) + if reactant_ingredient is None: + reactant_ingredient = self.reactants(compound_id=reactant.id) - if self.intermediates: - mrich.h3(f'{len(self.intermediates)} intermediates') + required_amount = product_ingredient.amount / reaction.product_yield - if len(self.intermediates) < 100: - for intermediate in self.intermediates: - mrich.var( - str(intermediate.compound), - f'{intermediate.amount:.2f}', - 'mg', + if reactant_ingredient.amount < required_amount: + mrich.error( + f'Not enough of {reactant_ingredient.compound}: ' + f'{reactant_ingredient.amount} < {required_amount}' ) + return False - if self.reactants: - mrich.h3(f'{len(self.reactants)} reactants') - - if len(self.reactants) < 100: - for reactant in self.reactants: - mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') - - if self.reactions: - mrich.h3(f'{len(self.reactions)} reactions') - - if len(self.reactions) < 100: - for reaction in (Reaction(r) for r in self.reactions): - mrich.var(str(reaction), reaction.reaction_str, reaction.type) - - if hasattr(self, '_compounds') and self.compounds: - mrich.h3(f'{len(self.compounds)} compounds') - - if len(self.compounds) < 100: - for compound in self.compounds: - mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') - - def get_ingredient(self, id) -> 'Ingredient': - """Get an ingredient by its compound ID - - :param id: compound ID - - """ - matches = [r for r in self.reactants if r.id == id] - if not matches: - matches = [r for r in self.intermediates if r.id == id] - if not matches: - matches = [r for r in self.products if r.id == id] - - assert len(matches) == 1 - return matches[0] - - def add_to_all_reactants(self, amount: float = 20) -> None: - """Increment all reactants by this amount - - :param amount: amount in ``mg`` (Default value = 20) - - """ - self.reactants.df['amount'] += amount - - def write_json( - self, - file: 'str | Path', - *, - extra: dict | None = None, - indent: str = '\t', - **kwargs, - ) -> None: - """Serialise this recipe object and write it to disk - - :param file: write to this path - :param extra: extra data to serialise - :param indent: indentation whitespace (Default value = '\t') - - """ - import json - from pathlib import Path - - file = Path(file).resolve() - - assert file.parent.exists(), f'Directory does not exist: {file.parent}' - - data = self.get_dict(serialise_price=True, **kwargs) + if debug: + mrich.success(self, 'OK') - if extra: - data.update(extra) + return True - mrich.writing(file) - json.dump(data, open(file, 'w'), indent=indent) + ### SERIALISATION def get_dict( self, @@ -1240,48 +500,29 @@ def get_dict( price: bool = True, reactant_supplier: bool = True, compound_supplier: bool = True, - database: bool = True, timestamp: bool = True, compound_ids_only: bool = False, products: bool = True, serialise_price: bool = False, - ): - """Serialise this recipe object - - Store - ===== - - - Path to database - - Timestamp - - Reactants (& their quotes, amounts) - - Intermediates (& their quotes) - - Products (& their poses/scores/fingerprints) - - Reactions - - Total Price - - Lead time - - :param price: include the price (Default value = True) - :param reactant_supplier: include the supplier (Default value = True) - :param database: include the database (Default value = True) - :param timestamp: add a timestamp (Default value = True) - :param compound_ids_only: ID's only (instead of full :attr:`.IngredientSet.df`) - (Default value = False) - :param products: include products (Default value = True) - :param serialise_price: serialise :class:`.Price` object (Default value = False) - + ) -> dict: + """Serialise this recipe to a dictionary. + + :param price: include the price + :param reactant_supplier: include the reactant supplier + :param compound_supplier: include the compound supplier + :param timestamp: add a timestamp + :param compound_ids_only: store IDs only (instead of full ingredient dataframes) + :param products: include products + :param serialise_price: serialise the :class:`.Price` object """ from datetime import datetime data = {} - # Database - if database: - data['database'] = str(self.db.path.resolve()) if timestamp: data['timestamp'] = str(datetime.now()) - # Recipe properties try: if price and serialise_price: data['price'] = self.price.get_dict() @@ -1293,18 +534,15 @@ def get_dict( if reactant_supplier: data['reactant_supplier'] = self.reactants.supplier - if compound_supplier: data['compound_supplier'] = self.compounds.supplier - # IngredientSets if compound_ids_only: data['reactant_ids'] = self.reactants.compound_ids data['intermediate_ids'] = self.intermediates.compound_ids if products: data['products_ids'] = self.products.compound_ids data['compound_ids'] = self.compounds.compound_ids - else: data['reactants'] = self.reactants.df.to_dict(orient='list') data['intermediates'] = self.intermediates.df.to_dict(orient='list') @@ -1312,1031 +550,315 @@ def get_dict( data['products'] = self.products.df.to_dict(orient='list') data['compounds'] = self.compounds.df.to_dict(orient='list') - # ReactionSet data['reaction_ids'] = self.reactions.ids return data - def get_routes(self, return_ids: bool = False) -> 'RouteSet': - """Get routes""" - return self.products.get_routes( - permitted_reactions=self.reactions, return_ids=return_ids - ) - - def register_missing_routes( - self, missing_only: bool = True, supplier: str = 'Enamine' + def write_json( + self, + file: 'str | Path', + *, + extra: dict | None = None, + indent: str = '\t', + **kwargs, ) -> None: - """Calculate missing routes to products of this Recipe""" - - return products.compounds.register_missing_routes( - missing_only=missing_only, supplier=supplier - ) - - if missing_only: - from designdb.sets.compound import CompoundSet - - records = self.db.select_where( - table='route', - key=f'route_product IN {products.str_ids}', - query='route_product', - multiple=True, - ) - existing = set(i for (i,) in records) - missing = set(products.ids) - existing - products = CompoundSet(self.db, missing) - - mrich.var('#products', len(products)) - - for i, c in mrich.track(enumerate(products), total=len(products)): - try: - reactions = c.reactions - except Exception as e: - mrich.error(f"Error getting {c}'s reactions", e) - continue - - for reaction in reactions: - try: - recipes = reaction.get_recipes(supplier=supplier) - except Exception as e: - mrich.error(f"Error getting {reaction}'s ({c}) recipes", e) - continue - - for recipe in recipes: - route = self.db.register_route(recipe=recipe) - - mrich.print(f'registered {route=}') - - self.db.prune_duplicate_routes() - - def write_CAR_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'DataFrame | None': - """Prepares CSVs for use with CAR. - - .. attention:: - - This method requires a populated `route` table. For a workaround use - :meth:`.CompoundSet.write_CAR_csv` instead - - Columns: - - * target-name - * no-steps - * concentration = None - * amount-required - * batch-tag - - per reaction - - * reactant-1-1 - * reactant-2-1 - * reaction-product-smiles-1 - * reaction-name-1 - * reaction-recipe-1 - * reaction-groupby-column-1 - - :param file: file to write to - :param return_df: return the dataframe (Default value = False) + """Serialise this recipe and write it to disk. + :param file: write to this path + :param extra: extra data to serialise + :param indent: indentation whitespace (Default value = '\\t') """ - + import json from pathlib import Path - from pandas import DataFrame - - # solve each product's reaction - - file = str(Path(file).resolve()) - - rows = [] - - routes = self.get_routes() - - for sub_recipe in routes: - product = sub_recipe.product - - row = { - 'target-names': str(product.compound), - 'no-steps': 0, - 'concentration-required-mM': None, - 'amount-required-uL': None, - 'batch-tag': None, - } - - for i, reaction in enumerate(Reaction(r) for r in sub_recipe.reactions): - i = i + 1 - - row['no-steps'] += 1 - - match len(reaction.reactants): - case 1: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = None - case 2: - row[f'reactant-1-{i}'] = reaction.reactants[0].smiles - row[f'reactant-2-{i}'] = reaction.reactants[1].smiles - case _: - # mrich.warning(f"More than two reactants for {reaction=}") - for j, r in enumerate(reaction.reactants): - row[f'reactant-{j + 1}-{i}'] = reaction.reactants[j].smiles - - row[f'reaction-product-smiles-{i}'] = reaction.product_smiles - row[f'reaction-name-{i}'] = reaction.type - row[f'reaction-recipe-{i}'] = None - row[f'reaction-groupby-column-{i}'] = None - # row[f'reaction-id-{i}'] = int(reaction.id) - - rows.append(row) - - df = DataFrame(rows) - - if len(df[df.duplicated()]): - mrich.warning('Removing duplicates from CAR DataFrame') - df = df.drop_duplicates() - - df = df.convert_dtypes() + file = Path(file).resolve() + assert file.parent.exists(), f'Directory does not exist: {file.parent}' - for n_steps in set(df['no-steps']): - subset = df[df['no-steps'] == n_steps] - this_file = file.replace('.csv', f'_{n_steps}steps.csv') - mrich.writing(this_file) - subset.to_csv(this_file, index=False) + data = self.get_dict(serialise_price=True, **kwargs) + if extra: + data.update(extra) mrich.writing(file) - df.to_csv(file, index=False) - - return df - - def write_reactant_csv( - self, - file: 'str | Path', - reaction_type_counts: bool = True, - return_df: bool = False, - ) -> 'DataFrame | None': - """Detailed CSV output including reactant information for purchasing and - information on the downstream synthetic use - - ReactantModel - ======== - - - ID - - SMILES - - Inchikey - - Quote - ===== - - - Supplier - - Catalogue - - Entry - - Lead-time - - Quoted amount - - Quote currency - - Quote price - - Quote purity - - Downstream - ========== - - - num_reaction_dependencies - - num_product_dependencies - - reaction_dependencies - - product_dependencies - - """ - # - remove_with - - # from rich import print - - data = [] - - ### Get lookup data + json.dump(data, open(file, 'w'), indent=indent) - route_ids = self.get_routes(return_ids=True) + ### PRESENTATION - sql = f""" - SELECT component_ref, route_product FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}route ON route_id = component_route - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - AND component_route IN {str(tuple(route_ids)).replace(',)', ')')} - """ - product_lookup = {} - for reactant_id, product_id in self.db.execute(sql): - product_lookup.setdefault(reactant_id, set()) - product_lookup[reactant_id].add(product_id) - - sql = f""" - WITH reactants AS ( - SELECT component_ref AS reactant_id, component_route AS route_id - FROM {self.db.SQL_SCHEMA_PREFIX}component - WHERE component_type = 2 - AND component_ref IN {self.reactants.compounds.str_ids} - ), - - reactions AS ( - SELECT component_ref AS reaction_id, component_route AS route_id, - reaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}component - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reaction - ON component_ref = reaction_id - WHERE component_type = 1 - AND component_ref IN {self.reactions.str_ids} - ) + def summary(self, price: bool = True) -> None: + """Print a summary of this recipe - SELECT reactants.reactant_id, reactions.reaction_id, reactions.reaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}reactants - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}reactions - ON reactants.route_id = reactions.route_id + :param price: print the price (Default value = True) """ - reaction_lookup = {} - for reactant_id, reaction_id, reaction_type in self.db.execute(sql): - reaction_lookup.setdefault(reactant_id, dict(ids=set(), types=set())) - reaction_lookup[reactant_id]['ids'].add(reaction_id) - reaction_lookup[reactant_id]['types'].add(reaction_type) - reaction_lookup[reactant_id].setdefault('counts', {}) - reaction_lookup[reactant_id]['counts'].setdefault(reaction_type, 0) - reaction_lookup[reactant_id]['counts'][reaction_type] += 1 - - smiles_lookup = self.db.get_compound_id_smiles_dict(self.reactants.compounds) - - inchikey_lookup = self.db.get_compound_id_inchikey_dict( - self.reactants.compounds - ) - - ### ReactantModel Dataframe - - df = self.reactants.df - - df['smiles'] = df['compound_id'].apply(lambda x: smiles_lookup[x]) - df['inchikey'] = df['compound_id'].apply(lambda x: inchikey_lookup[x]) - df = df.drop(columns=['supplier', 'max_lead_time', 'quoted_amount']) - - ### Quote DataFrame - - qdf = self.db.get_quote_df(self.reactants.quote_ids) - qdf = qdf.rename( - columns={ - 'id': 'quote_id', - 'smiles': 'quoted_smiles', - 'purity': 'quoted_purity', - 'date': 'quote_date', - 'lead_time': 'quote_lead_time_days', - 'price': 'quote_price', - 'currency': 'quote_currency', - 'catalogue': 'quote_catalogue', - 'supplier': 'quote_supplier', - 'entry': 'quote_entry', - 'amount': 'quoted_amount_mg', - } - ) - qdf = qdf.drop(columns=['compound']) - - ### Downstream info - - try: - df['downstream_product_ids'] = df['compound_id'].apply( - lambda x: product_lookup.get(x, set()) - ) - - df['downstream_reaction_ids'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['ids'] - ) - df['downstream_reaction_types'] = df['compound_id'].apply( - lambda x: reaction_lookup[x]['types'] - ) - except KeyError as e: - mrich.error(f'ReactantModel C{e} is missing downstream reaction') - mrich.error( - 'Are all routes enumerated? Try running calculate_missing_routes()' - ) - return None + mrich.h1(str(self)) - df['num_downstream_reactions'] = df['downstream_reaction_ids'].apply(len) - df['num_downstream_reaction_types'] = df['downstream_reaction_types'].apply(len) - df['num_downstream_products'] = df['downstream_product_ids'].apply(len) + if price: + price = self.price + if price: + mrich.var('\nprice', price.amount, price.currency) - ### Join and reformat + if self.products: + mrich.h3(f'{len(self.products)} products') + if len(self.products) < 100: + for product in self.products: + mrich.var(str(product.compound), f'{product.amount:.2f}', 'mg') - df = df.merge(qdf, on='quote_id', how='left') + if self.intermediates: + mrich.h3(f'{len(self.intermediates)} intermediates') + if len(self.intermediates) < 100: + for intermediate in self.intermediates: + mrich.var( + str(intermediate.compound), f'{intermediate.amount:.2f}', 'mg' + ) - df = df.rename( - columns={ - 'amount': 'required_amount_mg', - } - ) + if self.reactants: + mrich.h3(f'{len(self.reactants)} reactants') + if len(self.reactants) < 100: + for reactant in self.reactants: + mrich.var(str(reactant.compound), f'{reactant.amount:.2f}', 'mg') - cols = [ - 'compound_id', - 'smiles', - 'inchikey', - 'required_amount_mg', - 'quoted_amount_mg', - 'quote_id', - 'quote_supplier', - 'quote_catalogue', - 'quote_entry', - 'quote_price', - 'quote_currency', - 'quote_lead_time_days', - 'quoted_purity', - 'quoted_smiles', - 'quote_date', - 'num_downstream_products', - 'num_downstream_reaction_types', - 'num_downstream_reactions', - ] + if self.reactions: + mrich.h3(f'{len(self.reactions)} reactions') + if len(self.reactions) < 100: + for reaction in (Reaction(r) for r in self.reactions): + mrich.var(str(reaction), reaction.reaction_str, reaction.type) - if reaction_type_counts: - for i, row in df.iterrows(): - counts = reaction_lookup[row['compound_id']]['counts'] + if self.compounds: + mrich.h3(f'{len(self.compounds)} compounds') + if len(self.compounds) < 100: + for compound in self.compounds: + mrich.var(str(compound.compound), f'{compound.amount:.2f}', 'mg') - for reaction_type, count in counts.items(): - key = f'num_downstream ({reaction_type})' - df.loc[i, key] = count - if key not in cols: - cols.append(key) + def draw(self, color_mapper=None, node_size=300, graph_only=False): + """Draw a graph of the reaction network - cols += [ - 'downstream_product_ids', - 'downstream_reaction_types', - 'downstream_reaction_ids', - ] + :param color_mapper: (Default value = None) + :param node_size: (Default value = 300) + :param graph_only: (Default value = False) + """ - df = df[[c for c in cols if c in df.columns]] + import networkx as nx - ### Add estimated quotes + color_mapper = color_mapper or {} + colors = {} + sizes = {} - unquoted = df[df['quote_id'].isna()] + graph = nx.DiGraph() - if len(unquoted): - for i, row in unquoted.iterrows(): - compound = self.db.get_compound(id=row['compound_id']) - ingredient = compound.as_ingredient( - amount=row['required_amount_mg'], get_quote=False + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + key = str(reactant) + ingredient = self.get_ingredient(id=reactant.id) + graph.add_node( + key, + id=reactant.id, + smiles=reactant.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, ) - - quote = ingredient.quote - - df.loc[i, 'quoted_amount_mg'] = quote.amount - df.loc[i, 'quote_supplier'] = quote.supplier - df.loc[i, 'quote_catalogue'] = quote.catalogue - df.loc[i, 'quote_entry'] = quote.entry - df.loc[i, 'quote_price'] = quote.price.amount - df.loc[i, 'quote_currency'] = quote.price.currency - df.loc[i, 'quote_lead_time_days'] = quote.lead_time - df.loc[i, 'quoted_purity'] = quote.purity - df.loc[i, 'quoted_smiles'] = quote.smiles - df.loc[i, 'quote_date'] = quote.date - - ### N.B. scaffold series no longer output - - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_product_csv( - self, file: 'str | Path', return_df: bool = False - ) -> 'pd.DataFrame | None': - """Detailed CSV output including product information for selection and - synthesis""" - - # from rich import print - from designdb.sets.pose import PoseSet - from designdb.sets.reaction import ReactionSet - from pandas import DataFrame - - data = [] - - routes = self.get_routes() - - pose_map = self.db.get_compound_id_pose_ids_dict(self.products.compounds) - - inspiration_map = self.db.get_compound_id_inspiration_ids_dict() - - for product in mrich.track( - self.products, prefix='Constructing product DataFrame' - ): - d = dict( - hippo_id=product.compound_id, - smiles=product.smiles, - inchikey=product.inchikey, - required_amount_mg=product.amount, - ) - - upstream_routes = [] - upstream_reactions = [] - - for route in routes: - if product in route.products: - upstream_routes.append(route) - - for reaction in route.reactions: - upstream_reactions.append(reaction) - - upstream_reactions = ReactionSet( - self.db, set(reaction.id for reaction in upstream_reactions) - ) - - if not upstream_routes: - mrich.error('No upstream routes for', product) - continue - - if not upstream_reactions: - mrich.error('No upstream reactions for', product) - continue - - def get_scaffold_series() -> tuple[list[int], bool]: - """Get scaffold series value""" - - if scaffolds := product.scaffolds: - return scaffolds.ids, False - - else: - return [product.id], True - - poses = pose_map.get(product.id, set()) - - d['num_poses'] = len(poses) - d['poses'] = poses - d['tags'] = product.tags - d['num_routes'] = len(upstream_routes) - d['num_reaction_steps'] = set( - len(route.reactions) for route in upstream_routes - ) - d['reaction_dependencies'] = upstream_reactions.ids - d['reactant_dependencies'] = set( - sum([route.reactants.ids for route in upstream_routes], []) - ) - d['route_ids'] = [route.id for route in upstream_routes] - d['chemistry_types'] = ', '.join(upstream_reactions.types) - series, is_scaffold = get_scaffold_series() - d['is_scaffold'] = is_scaffold - d['scaffold_series'] = series - - inspirations = inspiration_map.get(product.id, None) - - if not inspirations and not is_scaffold: - scaffold = product.scaffolds[0] - inspirations = inspiration_map.get(scaffold.id, None) - - if not inspirations and 'inspiration_pose_ids' in scaffold.metadata: - inspirations = scaffold.metadata['inspiration_pose_ids'] - - if ( - not inspirations - and is_scaffold - and 'inspiration_pose_ids' in product.metadata - ): - inspirations = product.metadata['inspiration_pose_ids'] - - if inspirations: - inspirations = PoseSet(self.db, inspirations) - d['inspirations'] = ', '.join(n for n in inspirations.names) - else: - d['inspirations'] = '' - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def write_chemistry_csv( - self, file: 'str | Path', return_df: bool = True - ) -> 'pd.DataFrame | None': - """Detailed CSV output synthetis information for chemistry types in this set""" - - from designdb.sets.compound import CompoundSet - from pandas import DataFrame - - data = [] - - # get compounds - - scaffolds = CompoundSet(self.db) + if not graph_only: + sizes[key] = ingredient.amount + colors[key] = color_mapper.get(key, (0.7, 0.7, 0.7)) for product in self.products: - if scaffolds := product.scaffolds: - scaffolds += scaffolds - else: - scaffolds.add(product.compound) - - routes = self.get_routes() - - route_types = {} - - for compound in scaffolds: - elabs = ( - self.products.compounds.get_by_scaffold(scaffold=compound, none='quiet') - or [] - ) - - d = dict( - scaffold_id=compound.id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elabs), - is_scaffold=True, - ) - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.warning(f'No routes to scaffold={compound}') - continue - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([Reaction(r).type for r in route.reactions])) - - for k, reaction in enumerate(Reaction(r) for r in route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = reaction.product_smiles - d[f'{key}_product_id'] = reaction.product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - missing_scaffolds = {} - - for compound in self.products.compounds: - if compound in scaffolds: - continue - - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - scaffolds = compound.scaffolds - - for scaffold in scaffolds: - if scaffold.id not in route_types: - group = missing_scaffolds.setdefault(scaffold.id, []) - group.append(compound.id) - continue - - else: - for route in upstream_routes: - chem_types = tuple([Reaction(r).type for r in route.reactions]) - - if chem_types not in route_types[base.id]: - mrich.success(scaffold) - mrich.success(chem_types) - raise ValueError( - 'ScaffoldModel has route not present in dataframe' - ) - - for scaffold_id, elab_ids in missing_scaffolds.items(): - compound = self.db.get_compound(id=sorted(elab_ids)[0]) - - d = dict( - scaffold_id=scaffold_id, - product_id=compound.id, - smiles=compound.smiles, - inchikey=compound.inchikey, - num_elaborations=len(elab_ids), - is_scaffold=False, + key = str(product.compound) + ingredient = self.get_ingredient(id=product.id) + graph.add_node( + key, + id=product.id, + smiles=product.smiles, + amount=ingredient.amount, + price=str(ingredient.price), + lead_time=ingredient.lead_time, ) + if not graph_only: + sizes[key] = product.amount + colors[key] = color_mapper.get(key, (0.7, 0.7, 0.7)) - upstream_routes = [] - for route in routes: - if compound in route.products: - upstream_routes.append(route) - - if not upstream_routes: - mrich.error(f'No routes to elab {compound}') - raise ValueError(f'No routes to elab {compound}') - - d['num_routes'] = len(upstream_routes) - - for j, route in enumerate(upstream_routes): - d[f'route_{j + 1}_num_steps'] = len(route.reactions) - - group = route_types.setdefault(compound.id, set()) - group.add(tuple([Reaction(r).type for r in route.reactions])) - - for k, reaction in enumerate(Reaction(r) for r in route.reactions): - key = f'route_{j + 1}_reaction_{k + 1}' - - d[f'{key}_type'] = reaction.type - d[f'{key}_product_smiles'] = reaction.product_smiles - d[f'{key}_product_id'] = reaction.product.id - d[f'{key}_product_yield'] = reaction.product_yield - - for i, reactant in enumerate(reaction.reactants): - d[f'{key}_reactant_{i + 1}_smiles'] = reactant.smiles - d[f'{key}_reactant_{i + 1}_id'] = reactant.id - - data.append(d) - - df = DataFrame(data) - mrich.writing(file) - df.to_csv(file, index=False) - - if return_df: - return df - - return None - - def to_syndirella( - self, - out_key: 'str | Path', - poses: 'PoseSet', - *, - separate: bool = False, - ) -> 'DataFrame': - """Generate inputs for running syndirella elaboration""" - - import shutil - from pathlib import Path - - out_key = Path('.') / out_key - out_dir = out_key.parent - out_key = out_key.name + for reaction in (Reaction(r) for r in self.reactions): + for reactant in reaction.reactants: + graph.add_edge( + str(reactant), + str(reaction.product), + id=reaction.id, + type=reaction.type, + product_yield=reaction.product_yield, + ) - mrich.var('out_key', out_key) - mrich.var('out_dir', out_dir) + if graph_only: + return graph - if not out_dir.exists(): - mrich.writing(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) + s_min = min(sizes.values()) + sizes = [s / s_min * node_size for s in sizes.values()] + pos = nx.spring_layout(graph) + return nx.draw( + graph, + pos=pos, + with_labels=True, + font_weight='bold', + node_color=list(colors.values()), + node_size=sizes, + ) - template_dir = out_dir / 'templates' - if not template_dir.exists(): - mrich.writing(template_dir) - template_dir.mkdir(parents=True, exist_ok=True) + def sankey(self, title: str | None = None) -> 'graph_objects.Figure': + """Draw a plotly Sankey diagram + :param title: (Default value = None) """ - Need to create dataframe with columns: - - compound_id - - pose_id - - smiles - - reaction_name_step1 - - reactant_step1 - - reactant2_step1 - - product_step1 - ... - - hit1 - - hit2 - ... - - template - - compound_set + graph = self.draw(graph_only=True) - """ + import plotly.graph_objects as go - pose_compounds = poses.compounds - assert set(self.products.compound_ids) == set(pose_compounds.ids), ( - 'supplied poses have different compounds to Recipe products' - ) - assert len(poses) == len(self.products), ( - 'some duplicate compounds in supplied poses' - ) + nodes = {} + for edge in graph.edges: + for c in edge: + if c not in nodes: + nodes[c] = len(nodes) - df = poses.get_df( - inchikey=False, - alias=False, - name=False, - compound_id=True, - reference_id=True, - inspiration_aliases=True, - ) + source = [nodes[a] for a, b in graph.edges] + target = [nodes[b] for a, b in graph.edges] + value = [1 for _ in graph.edges] + labels = list(nodes.keys()) - df = df.reset_index() - df = df.rename(columns={'id': 'pose_id'}) - df['compound_set'] = df['compound_id'].apply(lambda x: f'C{x}') - df = df.set_index(['compound_id', 'pose_id']) + hoverkeys = None + customdata = [] + for key in nodes.keys(): + n = graph.nodes[key] + if not hoverkeys: + hoverkeys = list(n.keys()) + if not n: + mrich.error(f'problem w/ node {key=}') + customdata.append((int(key[1:]), None)) + else: + customdata.append( + tuple(v if v is not None else 'N/A' for v in n.values()) + ) - ## CHECKS + hoverkeys_edges = None + customdata_edges = [] + for s, t in graph.edges.keys(): + edge = graph.edges[s, t] + if not hoverkeys_edges: + hoverkeys_edges = list(edge.keys()) + customdata_edges.append( + tuple(v if v is not None else 'N/A' for v in edge.values()) + ) - no_refs = df[df['reference_id'].isna()] + hoverlines = [f'{key}=%{{customdata[{i}]}}' for i, key in enumerate(hoverkeys)] + hovertemplate = 'Compound ' + '
'.join(hoverlines) + '' - if len(no_refs): - mrich.error(len(no_refs), 'poses without reference!') - ids = set(no_refs.index.get_level_values('pose_id')) - mrich.print(ids) + hoverlines_edges = [ + f'{key}=%{{customdata[{i}]}}' for i, key in enumerate(hoverkeys_edges) + ] + hovertemplate_edges = ( + 'Reaction ' + '
'.join(hoverlines_edges) + '' + ) - no_insps = bool([1 for i in df['inspiration_aliases'].values if not len(i)]) + fig = go.Figure( + data=[ + go.Sankey( + node=dict( + label=labels, + customdata=customdata, + hovertemplate=hovertemplate, + ), + link=dict( + customdata=customdata_edges, + hovertemplate=hovertemplate_edges, + source=source, + target=target, + value=value, + ), + ) + ] + ) - if no_insps: - mrich.error(len(no_insps), 'poses without inspirations!') - return None + if not title: + try: + title = f'Recipe
price={self.price}' + except AssertionError: + title = 'Recipe' - ## TEMPLATES + fig.update_layout(title=title) + return fig - references = poses.references - ref_lookup = self.db.get_pose_id_alias_dict(references) - df['template'] = df['reference_id'].apply(lambda x: ref_lookup[x]) + ### DEPRECATED — traversal/export shims (relocated to RecipeService) - for ref_pose in references: - assert ref_pose.apo_path, f'Reference {ref_pose} has no apo_path' + def get_routes(self, return_ids: bool = False) -> 'RouteSet': + """DEPRECATED: use :meth:`.RecipeService.get_routes`.""" + from designdb.services.recipe import RecipeService - template = template_dir / ref_pose.apo_path.name + _deprecated('Recipe.get_routes()', 'RecipeService.get_routes()') + return RecipeService.get_routes(self, return_ids=return_ids) - if not template.exists(): - mrich.writing(template) - shutil.copy(ref_pose.apo_path, template) + def register_missing_routes( + self, missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """DEPRECATED: use :meth:`.RecipeService.register_missing_routes`.""" + from designdb.services.recipe import RecipeService - ## INSPIRATIONS + _deprecated( + 'Recipe.register_missing_routes()', + 'RecipeService.register_missing_routes()', + ) + return RecipeService.register_missing_routes( + self, missing_only=missing_only, supplier=supplier + ) - for i, row in df.iterrows(): - for j, alias in enumerate(row['inspiration_aliases']): - df.loc[i, f'hit{j + 1}'] = alias + def write_CAR_csv(self, file: 'str | Path', return_df: bool = False): + """DEPRECATED: use :meth:`.RecipeService.write_CAR_csv`.""" + from designdb.services.recipe import RecipeService - inspirations = poses.inspirations + _deprecated('Recipe.write_CAR_csv()', 'RecipeService.write_CAR_csv()') + return RecipeService.write_CAR_csv(self, file, return_df=return_df) - sdf_name = out_dir / f'{out_key}_syndirella_inspiration_hits.sdf' + def write_reactant_csv( + self, file: 'str | Path', reaction_type_counts: bool = True, return_df=False + ): + """DEPRECATED: use :meth:`.RecipeService.write_reactant_csv`.""" + from designdb.services.recipe import RecipeService - inspirations.write_sdf( - sdf_name, - tags=False, - metadata=False, - name_col='name', + _deprecated('Recipe.write_reactant_csv()', 'RecipeService.write_reactant_csv()') + return RecipeService.write_reactant_csv( + self, file, reaction_type_counts=reaction_type_counts, return_df=return_df ) - ## ADD ROUTE INFO - - routes = self.get_routes() - - for sub_recipe in mrich.track(routes, prefix='Adding chemistry info...'): - product = sub_recipe.product - - product_id = product.compound_id - - matches = df.xs(product_id, level='compound_id') - - if len(matches) > 1: - mrich.warning('Multiple rows for compound', product_id) - - for i, row in matches.iterrows(): - key = (product_id, i) - - for j, reaction in enumerate(Reaction(r) for r in sub_recipe.reactions): - j = j + 1 - - match len(reaction.reactants): - case 1: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = None - case 2: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - case 3: - df.loc[key, f'reactant_step{j}'] = reaction.reactants[ - 0 - ].smiles - df.loc[key, f'reactant2_step{j}'] = reaction.reactants[ - 1 - ].smiles - df.loc[key, f'reactant3_step{j}'] = reaction.reactants[ - 2 - ].smiles - case _: - raise NotImplementedError('Too many reactants') - - df.loc[key, f'product_step{j}'] = reaction.product_smiles - df.loc[key, f'reaction_name_step{j}'] = reaction.type - - break - - ## REMOVE UNECESSARY COLS - - df = df.drop(columns=['reference_id', 'inspiration_aliases']) - - ## REORDER COLUMNS - - cols = [ - 'smiles', - 'reaction_name_step1', - 'reactant_step1', - 'reactant2_step1', - 'reactant3_step1', - 'product_step11', - 'hit1', - 'hit2', - 'hit3', - 'hit4', - 'hit5', - 'hit6', - 'hit7', - 'hit8', - 'hit9', - 'template', - 'compound_set', - ] - - if not any([c not in cols for c in df.columns]): - df = df[[c for c in cols if c in df.columns]] + def write_product_csv(self, file: 'str | Path', return_df: bool = False): + """DEPRECATED: use :meth:`.RecipeService.write_product_csv`.""" + from designdb.services.recipe import RecipeService - if not separate: - out_path = out_dir / f'{out_key}_syndirella_input.csv' - mrich.writing(out_path) - df.to_csv(out_path) - return df + _deprecated('Recipe.write_product_csv()', 'RecipeService.write_product_csv()') + return RecipeService.write_product_csv(self, file, return_df=return_df) - for idx, row in df.iterrows(): - out_path = out_dir / f'{out_key}_{row["compound_set"]}_syndirella_input.csv' - mrich.writing(out_path) - single_df = row.to_frame().T - single_df = single_df.dropna(axis=1, how='all') - single_df.to_csv(out_path, index=False) - - return df - - def copy(self) -> 'Recipe': - """Copy this recipe""" + def to_syndirella(self, out_key: 'str | Path', poses: 'PoseSet', *, separate=False): + """DEPRECATED: use :meth:`.RecipeService.to_syndirella`.""" + from designdb.services.recipe import RecipeService - if hasattr(self, 'compounds'): - compounds = self.compounds.copy() - else: - compounds = None + _deprecated('Recipe.to_syndirella()', 'RecipeService.to_syndirella()') + return RecipeService.to_syndirella(self, out_key, poses, separate=separate) - return Recipe( - self.db, - products=self.products.copy(), - reactants=self.reactants.copy(), - intermediates=self.intermediates.copy(), - reactions=self.reactions.copy(), - compounds=compounds, - # supplier=self.supplier - ) + ### INTERNALS def __flag_modification(self) -> None: - """Flag this recipe as modified""" - self._product_interactions = None + """Invalidate cached derived data after a mutation""" + self._interactions = None self._score = None self._product_compounds = None - self._product_poses = None - - def check_integrity(self, debug: bool = False) -> bool: - """Verify integrity of this recipe""" - - # no duplicate ingredients - - if debug: - mrich.debug('Checking integrity:', self) - mrich.debug('Checking for duplicate compounds') - - if len(self.reactants.compound_ids) != len(set(self.reactants.compound_ids)): - mrich.error("ReactantModel compound ID's are not unique") - return False - if len(self.intermediates.compound_ids) != len( - set(self.intermediates.compound_ids) - ): - mrich.error("Intermediate compound ID's are not unique") - return False - if len(self.products.compound_ids) != len(set(self.products.compound_ids)): - mrich.error("Product compound ID's are not unique") - return False - - # all references should exist - - if debug: - mrich.debug('Checking for missing references') - - if self.db.count_where( - table='reaction', key=f'reaction_id IN {self.reactions.str_ids}' - ) < len(self.reactions): - mrich.error('Not all Reactions in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.product_compounds.str_ids}' - ) < len(self.products): - mrich.error('Not all product Compounds in Database') - return False - - if self.db.count_where( - table='compound', key=f'compound_id IN {self.reactants.compounds.str_ids}' - ) < len(self.reactants): - mrich.error('Not all reactant Compounds in Database') - return False - - if self.db.count_where( - table='compound', - key=f'compound_id IN {self.intermediates.compounds.str_ids}', - ) < len(self.intermediates): - mrich.error('Not all intermediate Compounds in Database') - return False - - reaction_intermediates = self.reactions.intermediates - reaction_products = self.reactions.products - reaction_reactants = self.reactions.reactants - - if debug: - mrich.debug('Checking for missing reactions') - - # all products should have a reaction - for product in self.products: - if product not in reaction_products: - mrich.error(f'Product: {product} does not have associated reaction') - return False - - # intermediates - for intermediate in self.intermediates: - if intermediate not in reaction_intermediates: - mrich.error( - f'Intermediate: {intermediate} is not in ' - f'self.reactions.intermediates' - ) - return False - - # reactants - for reactant in self.reactants: - if reactant not in reaction_reactants: - mrich.error( - f'ReactantModel: {reactant} is not in self.reactions.reactants' - ) - return False - - # all reactions should have enough reactant - - if debug: - mrich.debug('Checking reactant quantities') - - for reaction in (Reaction(r) for r in self.reactions): - product_ingredient = self.products(compound_id=reaction.product.id) - - if product_ingredient is None: - product_ingredient = self.intermediates(compound_id=reaction.product.id) - - if debug and reaction.product_yield < 1.0: - mrich.debug(f'{reaction}.product_yield={reaction.product_yield}') - - for reactant in reaction.reactants: - reactant_ingredient = self.intermediates(compound_id=reactant.id) - - if reactant_ingredient is None: - reactant_ingredient = self.reactants(compound_id=reactant.id) - - required_amount = product_ingredient.amount / reaction.product_yield - - if reactant_ingredient.amount < required_amount: - mrich.error( - f'Not enough of {reactant_ingredient.compound}: ' - f'{reactant_ingredient.amount} < {required_amount}' - ) - return False - - if debug: - mrich.success(self, 'OK') - - return True - - def add_ingredient(self, ingredient: 'Ingredient', amount: float = 1): - """Add an :class:`.Ingredient` object for direct purchase (no associated - reactions)""" - self.compounds.add(ingredient) + self._poses = None + self._combined_compounds = None ### DUNDERS def __str__(self) -> str: """Unformatted string representation""" - - if self.score: - s = f'(score={self.score:.3f})' - else: - s = '' - + s = f'(score={self.score:.3f})' if self.score else '' if self.hash: return f'Recipe_{self.hash}{s}' - return f'Recipe{s}' def __longstr(self) -> str: - """Unformatted string representation""" + """Long unformatted string representation""" if self.empty: return 'Empty Recipe()' @@ -2352,19 +874,13 @@ def __longstr(self) -> str: if self.score: s += f', score={self.score:.3f}' - if self.hash: return f'Recipe_{self.hash}({s})' - return f'Recipe({s})' - else: - s = f'{self.compounds}' - - if self.hash: - return f'Recipe_{self.hash}({s})' - - return f'Recipe(#compounds={self.num_compounds} [no-chem])' + if self.hash: + return f'Recipe_{self.hash}({self.compounds})' + return f'Recipe(#compounds={self.num_compounds} [no-chem])' def __repr__(self) -> str: """ANSI Formatted string representation""" @@ -2377,19 +893,18 @@ def __rich__(self) -> str: """Rich Formatted string representation""" return f'[bold underline]{self.__longstr()}' - def __add__(self, other: 'Recipe'): + def __add__(self, other: 'Recipe') -> 'Recipe': """Add another :class:`.Recipe` to this one""" result = self.copy() result.reactants += other.reactants result.intermediates += other.intermediates result.reactions += other.reactions result.products += other.products - if hasattr(other, 'compounds'): - result.compounds += other.compounds + result.compounds += other.compounds return result -# name conflict with route model. Trying to get rid of this entirely +# name conflict with RouteModel. Trying to get rid of this entirely class Route(Recipe): """A recipe with a single product, that is stored in the database""" @@ -2402,11 +917,7 @@ def __init__( intermediates: 'IngredientSet', reactions: 'ReactionSet', ) -> None: - """RouteModel initialisation""" - - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet + """Route initialisation""" # check typing assert isinstance(product, IngredientSet) @@ -2424,22 +935,25 @@ def __init__( self._reactants = reactants self._intermediates = intermediates self._reactions = reactions + self._compounds = IngredientSet() + self._hash = None + self._score = None + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None ### FACTORIES @classmethod - def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteModel': + def from_json(cls, path: 'str | Path | None' = None, data: dict = None) -> 'Route': """Load a serialised route from a JSON file - :param db: database to link :param path: path to JSON :param data: serialised data (Default value = None) - """ - # avoiding circular imports - from designdb.sets.compound import IngredientSet - from designdb.sets.reaction import ReactionSet + import json if data is None: data = json.load(open(path)) @@ -2447,12 +961,8 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteModel': self = cls.__new__(cls) self._id = data['id'] - self._product_id = data['product_id'] - self._products = IngredientSet.from_compounds( - compounds=None, ids=[self._product_id] - ) # IngredientSet - + self._products = IngredientSet.from_compounds(ids=[self._product_id]) self._reactants = IngredientSet.from_json( path=None, data=data['reactants']['data'], @@ -2463,32 +973,25 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteModel': data=data['intermediates']['data'], supplier=data['intermediates']['supplier'], ) - self._reactions = ReactionSet( - ReactionModel.objects.filter(pk__in=data['reactions']['indices']) - ) # ReactionSet + self._reactions = ReactionSet(data['reactions']['indices']) + self._compounds = IngredientSet() + self._hash = None + self._score = None + self._product_compounds = None + self._poses = None + self._interactions = None + self._combined_compounds = None return self @classmethod - def get_route( - cls, - *, - id: int, - debug: bool = False, - ) -> 'Route': - """Fetch a :class:`.RouteModel` object stored in the :class:`.Database`. - - :param id: the ID of the :class:`.RouteModel` to be retrieved - :param debug: increase verbosity for debugging, defaults to False - :returns: :class:`.RouteModel` object + def get_route(cls, *, id: int, debug: bool = False) -> 'Route': + """Fetch a :class:`.RouteModel` stored in the database and wrap it. + :param id: the ID of the :class:`.RouteModel` to retrieve + :param debug: increase verbosity for debugging """ - # avoiding circular dependencies - from designdb.sets.compound import CompoundSet, IngredientSet - from designdb.sets.reaction import ReactionSet - - # multiples?? route = RouteModel.objects.get(pk=id) if debug: @@ -2502,7 +1005,6 @@ def get_route( intermediate_ids = [] intermediate_amounts = [] - # for ref, c_type, amount in triples: for k in qs: ref = k.component_ref c_type = k.component_type @@ -2520,19 +1022,19 @@ def get_route( raise ValueError(f'Unknown component type {c_type}') if debug: - mrich.var('pairs', qs) + mrich.var('components', qs) - products = CompoundSet([route.pk]) - reactants = CompoundSet(reactant_ids) - intermediates = CompoundSet(intermediate_ids) + def _ingredients(ids, amounts): + """Build an IngredientSet, returning an empty one for no ids.""" + if not ids: + return IngredientSet() + return IngredientSet.from_compounds(ids=ids, amount=amounts) - products = IngredientSet.from_compounds(compounds=products, amount=1) - reactants = IngredientSet.from_compounds( - compounds=reactants, amount=reactant_amounts - ) - intermediates = IngredientSet.from_compounds( - compounds=intermediates, amount=intermediate_amounts + products = IngredientSet.from_compounds( + ids=[route.product_compound_id], amount=1 ) + reactants = _ingredients(reactant_ids, reactant_amounts) + intermediates = _ingredients(intermediate_ids, intermediate_amounts) reactions = ReactionSet(reaction_ids) @@ -2563,7 +1065,7 @@ def product_compound(self) -> 'CompoundModel': @property def id(self) -> int: - """RouteModel ID""" + """Route ID""" return self._id @property @@ -2575,21 +1077,129 @@ def price(self) -> 'Price': def get_dict(self) -> dict: """Serialisable dictionary""" - data = {} + return { + 'id': self.id, + 'product_id': self.product.id, + 'reactants': self.reactants.get_dict(), + 'intermediates': self.intermediates.get_dict(), + 'reactions': self.reactions.get_dict, + } + + ### DUNDERS - data['id'] = self.id - data['product_id'] = self.product.id - data['reactants'] = self.reactants.get_dict() - data['intermediates'] = self.intermediates.get_dict() - data['reactions'] = self.reactions.get_dict() + def __str__(self) -> str: + """Unformatted string representation""" + return f'Route #{self.id}: {self.product_compound}' - return data + def __repr__(self) -> str: + """ANSI Formatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Rich Formatted string representation""" + return f'[bold underline]{self}' + + +class RecipeSet: + """A set of :class:`.Recipe` objects stored on disk as JSON.""" + + def __init__( + self, + directory: 'str | Path', + pattern: str = '*.json', + ): + """Load all recipes matching ``pattern`` in ``directory``.""" + + from json import JSONDecodeError + from pathlib import Path + + self._json_directory = Path(directory) + self._json_pattern = pattern + + self._json_paths = {} + for path in self._json_directory.glob(self._json_pattern): + key = path.name.removeprefix('Recipe_').removesuffix('.json') + self._json_paths[key] = path.resolve() + + mrich.reading(f'{directory}/{pattern}') + + self._recipes = {} + for key, path in mrich.track( + self._json_paths.items(), prefix='Loading recipes' + ): + try: + recipe = Recipe.from_json(path=path, debug=False) + except JSONDecodeError: + mrich.error(f'Bad JSON in {path}') + continue + recipe._hash = key + self._recipes[key] = recipe + + mrich.success('Loaded', len(self), 'Recipes') + + ### METHODS + + def get_values( + self, key: str, progress: bool = False, serialise_price: bool = False + ): + """Get the value of attribute ``key`` for each member recipe.""" + values = [] + recipes = self._recipes.values() + if progress: + recipes = mrich.track(recipes, prefix=f'Calculating {self} values...') + for recipe in recipes: + value = getattr(recipe, key) + if serialise_price and key == 'price': + value = value.amount + values.append(value) + return values + + def get_df(self, **kwargs) -> 'pandas.DataFrame': + """Get a dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`.""" + from pandas import DataFrame + + data = [ + recipe.get_dict(timestamp=False, **kwargs) for recipe in self + ] + return DataFrame(data) + + def items(self) -> 'list[tuple[str, Recipe]]': + """Data dictionary items""" + return self._recipes.items() + + def keys(self) -> list[str]: + """Data dictionary keys (recipe hashes)""" + return self._recipes.keys() ### DUNDERS + def __len__(self) -> int: + """Number of recipes in this set""" + return len(self._recipes) + + def __getitem__(self, key: int | str) -> Recipe: + """Get a :class:`.Recipe` by index or hash""" + match key: + case int(): + return list(self._recipes.values())[key] + case str(): + return self._recipes[key] + case _: + mrich.error(f'Unsupported RecipeSet key: {key=} {type(key)}') + return None + + def __iter__(self): + """Iterate over member recipes""" + return iter(self._recipes.values()) + + def __contains__(self, key: str) -> bool: + """Is this hash present in the set?""" + assert isinstance(key, str) + return key in self._recipes + def __str__(self) -> str: """Unformatted string representation""" - return f'RouteModel #{self.id}: {self.product_compound}' + return f'{{Recipe × {len(self)}}}' def __repr__(self) -> str: """ANSI Formatted string representation""" diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index 66db2b3..ccbf140 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -71,11 +71,9 @@ def deconstruct(self): if settings.MANAGE_MODELS: - # sqlite3, rdkit field types not available - # shouldn't this be binary as well? - pass - - # from .models import RDKitMolField as MolField + # sqlite3: the RDKit cartridge field types are unavailable, fall back to the + # plain-text RDKitMolField shim defined above. + MolField = RDKitMolField else: from django_rdkit.models import MolField diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 2435b03..6823ed8 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -1,11 +1,29 @@ +"""Service layer that owns Recipe construction and DB traversal. + +This is the canonical home for the orchestration logic that builds +:class:`.Recipe` objects from reactions/compounds/reactants. The :class:`.Recipe` +component itself is a lean aggregate; its ``from_*`` classmethods are deprecated +shims that delegate here (see ``components/recipe.py``). + +Layering: ``services -> recipe -> sets -> components``. This module may import +from every lower layer. +""" + +from itertools import product + import mrich -from designdb.components.recipe import Recipe -from designdb.models import CompoundModel, ReactionModel -from designdb.sets.compound import IngredientSet +from designdb.components.compound import Compound +from designdb.components.reaction import DEFAULT_PRODUCT_YIELD, Reaction +from designdb.models import CompoundModel, ReactionModel, RouteModel +from designdb.sets.compound import CompoundSet, IngredientSet from designdb.sets.reaction import ReactionSet class RecipeService: + """Construction and traversal logic for :class:`.Recipe` objects.""" + + ### FACTORIES + @staticmethod def from_reaction( reaction, @@ -17,24 +35,39 @@ def from_reaction( quoted_only: bool = False, supplier: None | str = None, unavailable_reaction: str = 'error', - reaction_checking_cache: dict[int, bool] = None, - reaction_reactant_cache: dict[int, bool] = None, + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, inner: bool = False, get_ingredient_quotes: bool = True, - ) -> 'Recipe | list[Recipe]': - """Create a Recipe from a ReactionModel and its upstream dependencies.""" + ) -> 'Recipe | list[Recipe] | None': + """Create a :class:`.Recipe` from a :class:`.ReactionModel` and its upstream + dependencies. + + :param reaction: :class:`.ReactionModel` to create the recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param debug: increase verbosity (Default value = False) + :param pick_cheapest: return only the cheapest solution (Default value = True) + :param permitted_reactions: only consider reactions in this set + :param quoted_only: only allow reactants with quotes (Default value = False) + :param supplier: restrict quotes to this supplier (Default value = None) + :param unavailable_reaction: behaviour when a reaction has unavailable + reactants (Default value = 'error') + :param inner: indicates a recursive call (Default value = False) + :param get_ingredient_quotes: get quotes for product ingredients + """ from designdb.components.recipe import Recipe assert isinstance(reaction, ReactionModel) + reaction_c = Reaction(reaction) if debug: mrich.debug( f'RecipeService.from_reaction(R{reaction.id}, ' f'{amount=}, {pick_cheapest=})' ) - mrich.debug(f'{reaction.product.id=}') - mrich.debug(f'{reaction.reactants.ids=}') + mrich.debug(f'{reaction_c.product.id=}') + mrich.debug(f'{reaction_c.reactant_ids=}') if permitted_reactions: assert reaction in permitted_reactions @@ -42,7 +75,7 @@ def from_reaction( recipe = Recipe( products=IngredientSet( [ - reaction.product.as_ingredient( + reaction_c.product.as_ingredient( amount=amount, get_quote=get_ingredient_quotes ) ], @@ -59,64 +92,57 @@ def from_reaction( mrich.debug(f'Checking reactant_availability: {reaction=}') if reaction_checking_cache and reaction.id in reaction_checking_cache: ok = reaction_checking_cache[reaction.id] - print('reaction_checking_cache used') else: - ok = reaction.check_reactant_availability(supplier=supplier) - # print('cache not used') + ok = reaction_c.check_reactant_availability(supplier=supplier) if reaction_checking_cache is not None: reaction_checking_cache[reaction.id] = ok if not ok: if unavailable_reaction == 'error': mrich.error(f'Reactants not available for {reaction=}') - if pick_cheapest: - return None - else: - return [] + return None if pick_cheapest else [] def get_reactant_amount_pairs( - reaction: 'ReactionModel', + reaction_model: 'ReactionModel', ) -> list[tuple[int, float]]: """Get pairs of reactant ID and float amounts""" - if reaction_reactant_cache and reaction.id in reaction_reactant_cache: - print('reaction_reactant_cache used') - return reaction_reactant_cache[reaction.id] - else: - pairs = reaction.get_reactant_amount_pairs(compound_object=False) - if reaction_reactant_cache is not None: - reaction_reactant_cache[reaction.id] = pairs - return pairs + if reaction_reactant_cache and reaction_model.id in reaction_reactant_cache: + return reaction_reactant_cache[reaction_model.id] + pairs = Reaction(reaction_model).get_reactant_amount_pairs( + compound_object=False + ) + if reaction_reactant_cache is not None: + reaction_reactant_cache[reaction_model.id] = pairs + return pairs if debug: mrich.debug(f'get_reactant_amount_pairs({reaction.id})') pairs = get_reactant_amount_pairs(reaction) - for reactant, reactant_amount in pairs: - reactant = CompoundModel.objects.get(pk=reactant) + for reactant_id, reactant_amount in pairs: + reactant = Compound(CompoundModel.objects.get(pk=reactant_id)) if debug: mrich.debug(f'{reactant.id=}, {reactant_amount=}') # scale amount reactant_amount *= amount - reactant_amount /= reaction.product_yield + reactant_amount /= reaction_c.product_yield or DEFAULT_PRODUCT_YIELD inner_reactions = reactant.get_reactions( none='quiet', permitted_reactions=permitted_reactions ) - if inner_reactions: + if len(inner_reactions): if debug: if len(inner_reactions) == 1: - mrich.debug('ReactantModel has ONE inner reaction') + mrich.debug('Reactant has ONE inner reaction') else: mrich.warning(f'{reactant=} has MULTIPLE inner reactions') - new_recipes = [] - inner_recipes = [] - for reaction in inner_reactions: + for inner_reaction in inner_reactions: reaction_recipes = RecipeService.from_reaction( - reaction=reaction, + reaction=inner_reaction, amount=reactant_amount, debug=debug, pick_cheapest=False, @@ -129,6 +155,7 @@ def get_reactant_amount_pairs( ) inner_recipes += reaction_recipes + new_recipes = [] for recipe in recipes: for inner_recipe in inner_recipes: combined_recipe = recipe.copy() @@ -149,7 +176,7 @@ def get_reactant_amount_pairs( for recipe in recipes: recipe.reactants.add(ingredient) - # reverse ReactionSet's + # reverse ReactionSet's (outermost call only) if not inner: for recipe in recipes: recipe.reactions.reverse() @@ -158,18 +185,13 @@ def get_reactant_amount_pairs( if debug: mrich.debug('Picking cheapest') priced = [r for r in recipes if r.get_price(supplier=supplier)] - # priced = [r for r in recipes if r.price] if not priced: mrich.error("0 recipes with prices, can't choose cheapest") return recipes - sorted_recipes = sorted( - priced, key=lambda r: r.get_price(supplier=supplier) - ) - + sorted_recipes = sorted(priced, key=lambda r: r.get_price(supplier=supplier)) if debug: for recipe in recipes: mrich.debug(f'{recipe}, {recipe.price}') - return sorted_recipes[0] return recipes @@ -186,10 +208,18 @@ def from_reactions( use_routes: bool = False, debug: bool = False, **kwargs, - ) -> 'Recipe | list[Recipe]': - """Create a Recipe from a ReactionSet and its upstream dependencies.""" - - from designdb.components.recipe import Recipe + ) -> 'Recipe | list[Recipe] | CompoundSet': + """Create a :class:`.Recipe` from a :class:`.ReactionSet` and its upstream + dependencies. + + :param reactions: reactions to create the recipe from + :param amount: amount in ``mg`` (Default value = 1) + :param pick_cheapest: choose the cheapest solution (Default value = True) + :param permitted_reactions: only consider reactions in this set + :param final_products_only: don't make routes to intermediates + (Default value = True) + :param return_products: return the :class:`.CompoundSet` of products instead + """ assert isinstance(reactions, ReactionSet) @@ -200,29 +230,458 @@ def from_reactions( mrich.var('final_products_only', final_products_only) mrich.var('permitted_reactions', permitted_reactions) - # get all the products + # all products synthesisable from these reactions products = reactions.products if debug: mrich.var('products', products) if final_products_only: - if debug: - mrich.var('products.str_ids', products.str_ids) + # keep only compounds that are never used as a reactant (i.e. leaves) + from designdb.models import ReactantModel - # TODO: port to Django ORM — reactions.db.execute() is the old API - raise NotImplementedError( - 'final_products_only branch not yet ported to Django ORM' + products = CompoundSet( + CompoundModel.objects.filter(pk__in=list(products.ids)).exclude( + pk__in=ReactantModel.objects.values('compound'), + ) ) + if debug: + mrich.var('final products', products) + + if return_products: + return products - recipe = Recipe.from_compounds( + return RecipeService.from_compounds( compounds=products, amount=amount, permitted_reactions=reactions, pick_cheapest=pick_cheapest, supplier=supplier, use_routes=use_routes, + debug=debug, + **kwargs, + ) + + @staticmethod + def from_compounds( + compounds: 'CompoundSet', + amount: float = 1, + debug: bool = False, + pick_cheapest: bool = True, + permitted_reactions: 'ReactionSet | None' = None, + quoted_only: bool = False, + supplier: None | str = None, + solve_combinations: bool = True, + pick_first: bool = False, + warn_multiple_solutions: bool = True, + pick_cheapest_inner_routes: bool = False, + unavailable_reaction: str = 'error', + reaction_checking_cache: dict[int, bool] | None = None, + reaction_reactant_cache: dict[int, bool] | None = None, + use_routes: bool = False, + **kwargs, + ): + """Create recipe(s) to synthesise the products in a :class:`.CompoundSet`. + + :param compounds: set of compounds to find routes for + :param solve_combinations: combinatorially combine the individual solutions + (Default value = True) + :param pick_first: return the first solution without comparison + :param warn_multiple_solutions: warn if a compound has multiple routes + :param pick_cheapest_inner_routes: for each compound choose the cheapest route + :param use_routes: use stored :class:`.RouteModel` rows instead of solving + reactions on the fly + """ + + from designdb.components.recipe import Route + + assert isinstance(compounds, CompoundSet) + + n_comps = len(compounds) + assert n_comps + + if not hasattr(amount, '__iter__'): + amount = [amount] * n_comps + + if use_routes and supplier: + raise NotImplementedError( + 'use_routes combined with a supplier filter is not supported' + ) + + options = [] + ok = 0 + mrich.var('#compounds', n_comps) + + for comp, a in mrich.track( + zip(compounds, amount, strict=False), + prefix='Solving individual compound recipes...', + total=n_comps, + ): + comp_options = [] + + if use_routes: + route_ids = list( + RouteModel.objects.filter( + product_compound__id=comp.id + ).values_list('id', flat=True) + ) + if not route_ids: + mrich.error('No routes to', comp) + continue + comp_options = [Route.get_route(id=route_id) for route_id in route_ids] + + else: + for reaction in Compound(comp).reactions: + if permitted_reactions and reaction not in permitted_reactions: + continue + + sol = RecipeService.from_reaction( + reaction=reaction, + amount=a, + pick_cheapest=pick_cheapest_inner_routes, + debug=debug, + permitted_reactions=permitted_reactions, + quoted_only=quoted_only, + supplier=supplier, + unavailable_reaction=unavailable_reaction, + reaction_checking_cache=reaction_checking_cache, + reaction_reactant_cache=reaction_reactant_cache, + **kwargs, + ) + + if pick_cheapest_inner_routes: + if sol: + comp_options.append(sol) + else: + assert isinstance(sol, list) + comp_options += sol + + if not comp_options: + mrich.error( + f'No solutions for compound={comp} ' + f'({Compound(comp).reactions.ids=})' + ) + continue + + if pick_cheapest and len(comp_options) > 1: + if warn_multiple_solutions: + mrich.warning( + 'Multiple solutions for', comp, '(', len(comp_options), ')' + ) + if debug: + mrich.debug('Picking cheapest...') + priced = [r for r in comp_options if r.price] + comp_options = sorted(priced, key=lambda r: r.price)[:1] + + if warn_multiple_solutions and len(comp_options) > 1: + mrich.warning(f'Multiple solutions for compound={comp}') + if debug: + mrich.debug(f'{comp_options=}') + else: + if n_comps <= 200: + mrich.success(f'Found solution for compound={comp}') + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', n_comps) + + options.append(comp_options) + + assert all(options) + + mrich.print('Solving recipe combinations...') + combinations = list(product(*options)) + + if not solve_combinations: + return combinations + + solutions = [] + + if n_comps > 1: + generator = mrich.track( + combinations, prefix='Combining recipes...', total=len(combinations) + ) + else: + generator = combinations + + ok = 0 + for combo in generator: + if debug: + mrich.debug(f'Combination of {len(combo)} recipes') + + if not combo: + continue + + solution = combo[0] + for i, recipe in enumerate(combo[1:]): + if debug: + mrich.debug(i + 1) + solution += recipe + + solutions.append(solution) + ok += 1 + mrich.set_progress_field('ok', ok) + mrich.set_progress_field('n', len(combinations)) + + if not solutions: + mrich.error('No solutions') + return None + + if pick_first: + return solutions[0] + + if pick_cheapest: + mrich.debug('Calculating prices...') + priced = [r for r in solutions if r.price] + mrich.print('Picking cheapest from', len(priced), 'options') + if not priced: + mrich.error("0 recipes with prices, can't choose cheapest") + return solutions + return sorted(priced, key=lambda r: r.price)[0] + + return solutions + + @staticmethod + def from_reactants( + reactants: 'CompoundSet | IngredientSet', + amount: float = 1, + debug: bool = False, + return_products: bool = False, + supplier: str | None = None, + pick_cheapest: bool = False, + use_routes: bool = False, + **kwargs, + ) -> 'list[Recipe] | Recipe | CompoundSet': + """Find the maximal recipe reachable from a given set of reactants. + + :param reactants: :class:`.CompoundSet` or :class:`.IngredientSet` of + reactants (ingredient amounts are ignored) + :param amount: amount of each product needed (Default value = 1) + :param return_products: return the products instead of the recipe + """ + + if isinstance(reactants, IngredientSet): + reactant_ids = reactants.compound_ids + else: + reactant_ids = reactants.ids + + all_reactants = set(reactant_ids) + possible_reactions: set[int] = set() + + # recursively expand the set of reachable reactions/products + for _ in range(300): + reaction_ids = RecipeService._possible_reaction_ids(all_reactants) + + if not reaction_ids: + break + + if debug: + mrich.debug(f'Adding {len(reaction_ids)} reactions') + + possible_reactions |= set(reaction_ids) + + product_ids = list( + ReactionModel.objects.filter( + pk__in=reaction_ids + ).values_list('product_compound_id', flat=True) + ) + + n_prev = len(all_reactants) + all_reactants |= set(product_ids) + + if n_prev == len(all_reactants): + break + else: + raise NotImplementedError('Maximum recursion depth exceeded') + + if debug: + mrich.var('all possible reactions', possible_reactions) + + rset = ReactionSet(list(possible_reactions), sort=False) + + return RecipeService.from_reactions( + rset, + amount=amount, + permitted_reactions=rset, + debug=debug, + return_products=return_products, + supplier=supplier, + use_routes=use_routes, + pick_cheapest=pick_cheapest, **kwargs, ) - return recipe + ### TRAVERSAL + + @staticmethod + def get_routes(recipe: 'Recipe', return_ids: bool = False) -> 'RouteSet': + """Get stored routes to the products of ``recipe`` restricted to its + reactions.""" + return recipe.products.compounds.get_routes( + permitted_reactions=recipe.reactions, return_ids=return_ids + ) + + ### EXPORTERS + + @staticmethod + def write_CAR_csv( + recipe: 'Recipe', file: 'str | Path', return_df: bool = False + ) -> 'DataFrame | None': + """Write CSV(s) for use with CAR. + + Requires a populated ``route`` table (see :meth:`.RecipeService.get_routes`). + One row per route; reactions are flattened into ``reactant-N-i`` / + ``reaction-product-smiles-i`` / ``reaction-name-i`` columns. + + :param recipe: the :class:`.Recipe` to export + :param file: output path (per-step files are also written alongside) + :param return_df: return the assembled DataFrame + """ + + from pathlib import Path + + from pandas import DataFrame + + file = str(Path(file).resolve()) + rows = [] + + for sub_recipe in RecipeService.get_routes(recipe): + product = sub_recipe.product + + row = { + 'target-names': str(product.compound), + 'no-steps': 0, + 'concentration-required-mM': None, + 'amount-required-uL': None, + 'batch-tag': None, + } + + for i, reaction_model in enumerate(sub_recipe.reactions): + i = i + 1 + reaction = Reaction(reaction_model) + row['no-steps'] += 1 + + reactants = reaction.reactants + match len(reactants): + case 1: + row[f'reactant-1-{i}'] = reactants[0].smiles + row[f'reactant-2-{i}'] = None + case 2: + row[f'reactant-1-{i}'] = reactants[0].smiles + row[f'reactant-2-{i}'] = reactants[1].smiles + case _: + for j, reactant in enumerate(reactants): + row[f'reactant-{j + 1}-{i}'] = reactant.smiles + + row[f'reaction-product-smiles-{i}'] = reaction.product_smiles + row[f'reaction-name-{i}'] = reaction.type + row[f'reaction-recipe-{i}'] = None + row[f'reaction-groupby-column-{i}'] = None + + rows.append(row) + + df = DataFrame(rows) + + if len(df[df.duplicated()]): + mrich.warning('Removing duplicates from CAR DataFrame') + df = df.drop_duplicates() + + df = df.convert_dtypes() + + for n_steps in set(df['no-steps']): + subset = df[df['no-steps'] == n_steps] + this_file = file.replace('.csv', f'_{n_steps}steps.csv') + mrich.writing(this_file) + subset.to_csv(this_file, index=False) + + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + return None + + @staticmethod + def write_reactant_csv(recipe: 'Recipe', file, reaction_type_counts=True, **kwargs): + """Detailed reactant-purchasing CSV. + + Not yet ported: depends on the legacy quote-dataframe assembly + (``db.get_quote_df``) and raw component/route SQL. Port together with the + quoting subsystem. + """ + raise NotImplementedError( + 'write_reactant_csv requires the unported quote-dataframe / downstream ' + 'route lookups; port alongside the quoting subsystem' + ) + + @staticmethod + def write_product_csv(recipe: 'Recipe', file, return_df: bool = False): + """Detailed product-selection CSV. + + Not yet ported: depends on unported Pose machinery + (``get_compound_id_pose_ids_dict``, ``get_compound_id_inspiration_ids_dict``, + ``PoseSet`` construction from IDs). Port alongside the Pose subsystem. + """ + raise NotImplementedError( + 'write_product_csv requires unported Pose/inspiration lookups; port ' + 'alongside the Pose subsystem' + ) + + @staticmethod + def to_syndirella(recipe: 'Recipe', out_key, poses, *, separate: bool = False): + """Generate Syndirella elaboration inputs from this recipe. + + Not yet ported: depends on unported Pose machinery (reference/template + handling, ``get_pose_id_alias_dict``, inspiration SDF export). + """ + raise NotImplementedError( + 'RecipeService.to_syndirella requires unported Pose machinery ' + '(templates, alias/inspiration lookups); port alongside the Pose subsystem' + ) + + @staticmethod + def register_missing_routes( + recipe: 'Recipe', missing_only: bool = True, supplier: str = 'Enamine' + ) -> None: + """Calculate and register missing routes to the products of ``recipe``. + + Not yet ported: depends on the unported + ``CompoundSet.register_missing_routes`` / route-registration helpers. + """ + raise NotImplementedError( + 'register_missing_routes depends on the unported route-registration ' + 'helpers (CompoundSet.register_missing_routes / db.register_route)' + ) + + ### HELPERS + + @staticmethod + def _possible_reaction_ids(compound_ids: set[int]) -> list[int]: + """Return reaction IDs whose every reactant is in ``compound_ids``. + + ORM replacement for the legacy ``db.get_possible_reaction_ids``. + """ + from designdb.models import ReactantModel + + compound_ids = set(compound_ids) + + # reactions that use at least one of these compounds as a reactant + candidate_ids = ( + ReactantModel.objects.filter(compound_id__in=compound_ids) + .values_list('reaction_id', flat=True) + .distinct() + ) + + # of those, keep reactions whose reactants are all available + rows = ReactantModel.objects.filter( + reaction_id__in=list(candidate_ids) + ).values_list('reaction_id', 'compound_id') + + reaction_reactants: dict[int, set[int]] = {} + for reaction_id, compound_id in rows: + reaction_reactants.setdefault(reaction_id, set()).add(compound_id) + + return [ + reaction_id + for reaction_id, reactants in reaction_reactants.items() + if reactants <= compound_ids + ] diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py index c2ac897..48ed2b2 100644 --- a/hippo/designdb/services/route.py +++ b/hippo/designdb/services/route.py @@ -26,8 +26,11 @@ def create_from_recipe( # are you joking?? reactants and intermediates are all of the # sudden components - # reactions + # component_type encoding (see Route.get_route): 1=reaction, 2=reactant, + # 3=intermediate components = [] + + # reactions components.extend( [ ComponentModel(route=route, component_type=1, component_ref=ref.pk) @@ -35,20 +38,12 @@ def create_from_recipe( ], ) - # this part needs data from ingredient df, which I don't have - # and is not implemented - # reactants - # for ref, amount in recipe.reactants.id_amount_pairs: - # self.insert_component( - # component_type=2, ref=ref, route=route_id, amount=amount, commit=False - # ) - components.extend( [ ComponentModel( route=route, - component_type=1, + component_type=2, component_ref=ref, component_amount=amount, ) @@ -56,17 +51,12 @@ def create_from_recipe( ], ) - # # intermediates - # for ref, amount in recipe.intermediates.id_amount_pairs: - # self.insert_component( - # component_type=3, ref=ref, route=route_id, amount=amount, commit=False - # ) - + # intermediates components.extend( [ ComponentModel( route=route, - component_type=1, + component_type=3, component_ref=ref, component_amount=amount, ) diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index ef849b9..c98b22b 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -269,13 +269,21 @@ def __rich__(self) -> str: """Representation for mrich""" return f'[bold underline]{self}' - def __contains__(self, other: CompoundModel | int): - """Check if compound or ingredient is a member of this set""" + def __contains__(self, other: 'CompoundModel | int | Ingredient'): + """Check if a compound or ingredient is a member of this set""" match other: case CompoundModel(): - ik = other.pk + pk = other.pk case int(): pk = other + case _: + # Ingredient (or anything exposing a compound id) + pk = getattr(other, 'compound_id', None) + if pk is None: + pk = getattr(other, 'id', None) + + if pk is None: + return False return self._queryset.filter(pk=pk).exists() @@ -790,13 +798,13 @@ def get_recipes( ): """Generate the :class:`.Recipe` to make these compounds. - See :meth:`.Recipe.from_compounds` + See :meth:`.RecipeService.from_compounds` """ - # avoiding circular imports - from designdb.components.recipe import Recipe + # convenience bridge to the service layer + from designdb.services.recipe import RecipeService - return Recipe.from_compounds( + return RecipeService.from_compounds( self, amount=amount, debug=debug, @@ -820,80 +828,52 @@ def get_routes( """ - if 'route' not in self.db.table_names: - mrich.error('route table not in Database') - raise NotImplementedError + from designdb.models import ComponentModel, RouteModel - if permitted_reactions is not None: - sql = f""" - SELECT route_id, route_product, component_ref - FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component - ON route_id = component_route - WHERE route_product IN {self.str_ids} - AND component_type = 1 - """ + from .route import RouteSet - permitted_reactions = set(permitted_reactions.ids) + base_qs = RouteModel.objects.filter(product_compound_id__in=list(self.ids)) + + if permitted_reactions is not None: + permitted = set(permitted_reactions.ids) if debug: mrich.debug('Querying database for routes') - records = self.db.execute(sql).fetchall() - if debug: - mrich.debug('Assembling route dictionary') + # reaction components (component_type == 1) grouped per route + rows = ComponentModel.objects.filter( + route__in=base_qs, + component_type=1, + ).values_list('route_id', 'component_ref') - routes = {} - for route_id, route_product, reaction_id in records: - if route_id not in routes: - routes[route_id] = dict(product=route_product, reactions=set()) - assert routes[route_id]['product'] == route_product - routes[route_id]['reactions'].add(reaction_id) + route_reactions: dict[int, set[int]] = {} + for route_id, reaction_id in rows: + route_reactions.setdefault(route_id, set()).add(reaction_id) if debug: mrich.debug('Checking availability') - available_routes = set() - for route_id, route_dict in routes.items(): - product = route_dict['product'] - assert product in self - reactions = route_dict['reactions'] - if all(r in permitted_reactions for r in reactions): - available_routes.add(route_id) - - if return_ids: - return list(available_routes) - - routes = [ - self.db.get_route(id=route_id) - for route_id in mrich.track(available_routes, prefix='Getting routes') + available_routes = [ + route_id + for route_id, reactions in route_reactions.items() + if reactions <= permitted ] - else: - sql = f""" - SELECT route_id FROM {self.db.SQL_SCHEMA_PREFIX}route - WHERE route_product IN {self.str_ids} - """ - - if debug: - mrich.debug('Querying database for routes') - records = self.db.execute(sql).fetchall() - if return_ids: - return [i for (i,) in records] + return available_routes - routes = [ - self.db.get_route(id=route_id) - for (route_id,) in mrich.track(records, prefix='Getting routes') - ] + return RouteSet.from_ids(available_routes) - from .route import RouteSet + route_ids = list(base_qs.values_list('id', flat=True)) + + if return_ids: + return route_ids - return RouteSet(self.db, routes) + return RouteSet.from_ids(route_ids) def copy(self) -> 'CompoundSet': """Returns a copy of this set""" - return CompoundSet(self.db, self.ids) + return CompoundSet(self.ids) def shuffled(self) -> 'CompoundSet': """Returns a randomised copy of this set""" @@ -2225,13 +2205,13 @@ def get_price( qs = CataloguePriceModel.objects.filter(pk__in=quote_ids) if supplier: - qs = qs.filter(quote_supplier=supplier) + qs = qs.filter(supplier=supplier) if qs.exists(): prices = [ Price( - amount=k.quote_amount, - currency=k.quote_currency, + amount=k.price, + currency=k.currency, ) for k in qs ] diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index 48f3573..c205280 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -277,13 +277,14 @@ def get_recipes(self, amounts: float | list[float] = 1.0, **kwargs): :param amounts: float or list/generator of product amounts in mg, (Default value = 1.0) - :param kwargs: keyword arguments are passed on to :meth:`.Recipe.from_reactions: + :param kwargs: keyword arguments are passed on to + :meth:`.RecipeService.from_reactions` """ - # avoiding circular imports - from designdb.components.recipe import Recipe + # convenience bridge to the service layer + from designdb.services.recipe import RecipeService - return Recipe.from_reactions(reactions=self, amounts=1, **kwargs) + return RecipeService.from_reactions(reactions=self, amount=amounts, **kwargs) def summary(self) -> None: """Print a summary of the Reactions""" @@ -299,6 +300,11 @@ def name(self) -> str | None: """Returns the name of set""" return self._name + @property + def queryset(self): + """Returns the underlying Django queryset""" + return self._queryset + @property def indices(self) -> list[int]: """Returns the ids of reactions in this set""" @@ -307,7 +313,7 @@ def indices(self) -> list[int]: @property def ids(self) -> list[int]: """Returns the ids of reactions in this set""" - return self._indices + return list(self.indices) @property def types(self) -> list[str]: diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index f2fd14d..f576f20 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -80,6 +80,8 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': """ + from designdb.components.recipe import Route + self = cls.__new__(cls) if data is None: @@ -88,7 +90,7 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': new_data = {} for d in mrich.track(data['routes'].values(), prefix='Loading Routes...'): route_id = d['id'] - new_data[route_id] = RouteModel.from_json(db=db, path=None, data=d) + new_data[route_id] = Route.from_json(path=None, data=d) self._data = new_data self._cluster_map = None @@ -106,8 +108,13 @@ def data(self) -> 'dict[int, RouteModel]': @property def db(self): - """Get associated database""" - return self._db + """Deprecated: the modern ORM RouteSet has no associated ``db`` handle. + + Raises to surface any remaining legacy ``self.db`` callers explicitly. + """ + raise NotImplementedError( + 'RouteSet no longer holds a `db` handle; use the Django ORM directly' + ) @property def routes(self) -> 'list[Route]': @@ -158,61 +165,25 @@ def cluster_map(self) -> dict[tuple, set]: to a set of :class:`.RouteModel` ID's to their superstructures. """ - if self._cluster_map is None: - # get route mapping - pairs = self.db.select_where( - query='route_product, route_id', - key=f'route_id IN {self.str_ids}', - table='route', - multiple=True, - ) - - route_map = {route_product: route_id for route_product, route_id in pairs} - - # group compounds by cluster - compound_clusters = self.db.get_compound_cluster_dict(cset=self.products) - - # create the map - self._cluster_map = {} - for cluster, compounds in compound_clusters.items(): - self._cluster_map[cluster] = [] - for compound in compounds: - route_id = route_map.get(compound, None) - if not route_id: - continue - self._cluster_map[cluster].append(route_id) - - if not self._cluster_map[cluster]: - del self._cluster_map[cluster] - - return self._cluster_map + # NOTE: scaffold-cluster grouping depends on the not-yet-ported + # `get_compound_cluster_dict` machinery. This property is only consumed by + # `balanced_pop`, which in turn is only used by the (out-of-scope) rgen + # RandomRecipeSelectionGenerator. Port alongside rgen. + raise NotImplementedError( + 'RouteSet.cluster_map requires the unported compound-clustering helper ' + '(get_compound_cluster_dict); port it together with the rgen subsystem' + ) ### METHODS def copy(self) -> 'RouteSet': """Copy this RouteSet""" - return RouteSet(self.db, self.data.values()) - - def set_db_pointers(self, db: 'Database') -> None: - """ - - :param db: - - """ - self._db = db - for route in self.data.values(): - route._db = db - - # def clear_db_pointers(self): - # """ """ - # self._db = None - # for route in self.data.values(): - # route._db = None + return RouteSet(list(self.data.values())) def get_dict(self): """Get serialisable dictionary""" - data = dict(db=str(self.db), routes={}) + data = dict(routes={}) # populate with routes for route_id, route in self.data.items(): @@ -221,48 +192,41 @@ def get_dict(self): return data def prune_unavailable(self, suppliers: list[str]): - """Remove routes that don't have all reactants available from given suppliers""" - - suppliers_str = str(tuple(suppliers)).replace(',)', ')') - - sql = f""" - WITH possible_reactants AS ( - SELECT quote_compound, COUNT( - CASE - WHEN quote_supplier IN {suppliers_str} THEN 1 - END) AS [count_valid] - FROM {self.db.SQL_SCHEMA_PREFIX}quote - GROUP BY quote_compound - ), - - route_reactants AS ( - SELECT route_id, route_product, - COUNT( - CASE - WHEN count_valid = 0 THEN 1 - WHEN count_valid IS NULL THEN 1 - END) - AS [count_unavailable] FROM {self.db.SQL_SCHEMA_PREFIX}route - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}component - ON component_route = route_id - LEFT JOIN possible_reactants ON quote_compound = component_ref - WHERE component_type = 2 - GROUP BY route_id - ) + """Remove routes that don't have all reactants available from given suppliers. - SELECT route_id FROM route_reactants - WHERE count_unavailable = 0 - AND route_id IN {self.str_ids} + Keeps only routes where every reactant component (``component_type == 2``) + has at least one catalogue price from one of the given ``suppliers``. """ - route_ids = self.db.execute(sql).fetchall() + from designdb.models import CataloguePriceCompoundJunctionModel + + # compound IDs quotable from the permitted suppliers + quotable = set( + CataloguePriceCompoundJunctionModel.objects.filter( + catalogue_price__supplier__in=list(suppliers), + ).values_list('compound_id', flat=True) + ) + + # reactant components grouped by route + reactant_rows = ComponentModel.objects.filter( + route_id__in=list(self.ids), + component_type=2, + ).values_list('route_id', 'component_ref') + + route_reactants: dict[int, set[int]] = {} + for route_id, ref in reactant_rows: + route_reactants.setdefault(route_id, set()).add(ref) - route_ids = [i for (i,) in route_ids] + kept = [ + route_id + for route_id, reactants in route_reactants.items() + if reactants <= quotable + ] mrich.var('#routes before pruning', len(self)) - mrich.var('#routes after pruning', len(route_ids)) + mrich.var('#routes after pruning', len(kept)) - return RouteSet.from_ids(self.db, route_ids) + return RouteSet.from_ids(kept) def pop_id(self) -> int: """Pop the last route from the set and return it's id""" From 689dde373b4842623c1f8f39b1fc4477470fbf3a Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 10 Jun 2026 09:33:18 +0100 Subject: [PATCH 148/163] feat: Fragalysis download service + apo_desolv HIPPO helper - DownloadService (services/download.py): async download_structures flow (POST -> poll task_status_url -> GET file), apo_desolv-only payload. - HIPPO._ensure_apo_desolv_files(): lazy per-instance fetch into data/downloads//; wipes prior downloads on init. - declare requests dependency; add localhost stack URL to utils_frag. - PoseModel.pose_path -> protein_link (field resolving apo-desolv paths). Co-Authored-By: Claude Opus 4.8 --- hippo/designdb/animal.py | 91 +++++++ hippo/designdb/models.py | 8 +- hippo/designdb/services/download.py | 390 ++++++++++++++++++++++++++++ hippo/designdb/utils_frag.py | 2 + pyproject.toml | 1 + uv.lock | 2 + 6 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 hippo/designdb/services/download.py diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index e91ed7b..5542b2f 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -2,6 +2,8 @@ import logging import re +import shutil +from datetime import datetime from enum import Enum from pathlib import Path @@ -17,6 +19,7 @@ ScoringMethodModel, TargetModel, ) +from .services.download import DownloadService from .services.ingestion import IngestionBatchResult, IngestionService from .services.method import MethodService from .services.route import RouteService @@ -28,6 +31,11 @@ logger = logging.getLogger(__name__) +# Root directory under which Fragalysis downloads are extracted, laid out as +# data/downloads//. The whole tree is wiped on each +# HIPPO instantiation so a session never works with superseded data. +DOWNLOADS_DIR = Path('data') / 'downloads' + class HIPPO: """Entry-point class of the xchem-hippo package. @@ -43,6 +51,19 @@ def __init__( # TODO: user- or project based targets self._target, _ = TargetModel.objects.get_or_create(target_name=target_name) + # apo_desolv download state (see _ensure_apo_desolv_files); the download + # is performed lazily, at most once per instance + self._apo_desolv_path: Path | None = None + self._apo_desolv_downloaded_at: datetime | None = None + + # Wipe any previous downloads so this session always fetches fresh data + # (Fragalysis downloads can be superseded server-side). Downloads are + # confined to DOWNLOADS_DIR specifically so this never touches other + # data/ contents. + if DOWNLOADS_DIR.is_dir(): + logger.debug('Wiping previous downloads at %s', DOWNLOADS_DIR) + shutil.rmtree(DOWNLOADS_DIR) + # TODO: the way this worked previously was it gave the HIPPO # instance full access to the pose table. When working with # multi-project central postgres db, this is almost certainly @@ -99,6 +120,76 @@ def num_poses(self) -> int: """Total number of Poses in the Database""" return self.poses.count() + def _ensure_apo_desolv_files( + self, auth_token: str | None = None, stack: str = 'production' + ) -> Path: + """Ensure this target's apo-desolvated PDB files are available locally. + + Downloads the ``apo_desolv`` structures for this target's observations + from Fragalysis (via :class:`.DownloadService`) and returns the path to + the extracted directory. The download is performed at most once per + instance (the resolved path is cached on the instance). Previous + downloads are wiped on :class:`.HIPPO` instantiation, so a fresh instance + always re-fetches the latest data. + + Everything needed for the request is taken from this animal: the target + name and project (target access string) from :attr:`.target`, and the + observation shortcodes from the ``pose_alias`` of this target's poses. + + .. note:: + This is a HIPPO-level helper, intended to be called from user-facing + :class:`.HIPPO` methods the first time PDB files are needed. It's not + expected to be called from the components or services layer. This + method won't be necessary once HIPPO functions as a web service as + intended. + + :param auth_token: optional Fragalysis ``sessionid``; otherwise the + ``FRAGALYSIS_AUTH_TOKEN`` environment variable is used + :param stack: Fragalysis stack to download from, a key into + :data:`.STACK_URLS` (e.g. ``'production'``, ``'staging'``, + ``'localhost'``); defaults to ``'production'`` + :returns: path to the extracted download directory + """ + + # already resolved during this session? + if self._apo_desolv_path is not None and self._apo_desolv_path.exists(): + return self._apo_desolv_path + + target_name = self._target.target_name + project_name = self._target.project.project_name + + # downloads are laid out as data/downloads//; + # DownloadService extracts into destination/, so we pass + # data/downloads/ as the destination + destination = DOWNLOADS_DIR / project_name + + # observation shortcodes to request, from the database + proteins = list( + PoseModel.objects.filter(target=self._target) + .exclude(pose_alias__isnull=True) + .exclude(pose_alias='') + .values_list('pose_alias', flat=True) + .distinct() + ) + if not proteins: + raise ValueError( + f'No pose aliases found for target {target_name!r}; ' + 'cannot determine which structures to download' + ) + + path = DownloadService.download_target( + target_name=target_name, + target_access_string=project_name, + proteins=','.join(proteins), + stack=stack, + auth_token=auth_token, + destination=destination, + ) + + self._apo_desolv_path = path + self._apo_desolv_downloaded_at = datetime.now() + return path + def add_hits( self, *, diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index ccbf140..1271aa5 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -232,7 +232,7 @@ class PoseModel(BaseModel): pose_smiles = models.TextField(null=True, blank=True) pose_reference = models.IntegerField(null=True, blank=True) - pose_path = models.TextField(null=True, blank=True) + protein_link = models.TextField(null=True, blank=True) compound = models.ForeignKey( CompoundModel, @@ -296,14 +296,14 @@ class Meta(BaseModel.Meta): indexes = [ models.Index(fields=['compound'], name='idx_pose_compound_id'), models.Index(fields=['target'], name='idx_pose_target_id'), - models.Index(fields=['pose_path'], name='idx_pose_path'), + models.Index(fields=['protein_link'], name='idx_protein_link'), models.Index(fields=['created_on'], name='idx_pose_created'), ] @property def mol_path(self) -> Path | None: """Get Path to molecule file""" - path = Path(self.pose_path) + path = Path(self.protein_link) if path.name.endswith('.pdb'): mol_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( '.pdb', '_ligand.mol' @@ -324,7 +324,7 @@ def mol_path(self) -> Path | None: @property def apo_path(self) -> Path | None: """Get path to apo protein file""" - path = Path(self.pose_path) + path = Path(self.protein_link) if path.name.endswith('.pdb'): apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( '.pdb', '_apo-desolv.pdb' diff --git a/hippo/designdb/services/download.py b/hippo/designdb/services/download.py new file mode 100644 index 0000000..325467e --- /dev/null +++ b/hippo/designdb/services/download.py @@ -0,0 +1,390 @@ +"""Service layer for downloading target data from Fragalysis. + +Wraps the Fragalysis ``/api/download_structures/`` endpoint, which builds a +(g)zipped archive on demand and returns it in two steps: + +1. ``POST`` the download specification -> the response JSON contains a + ``file_url`` once the archive is ready (this is *not* async on the server, so + the POST can block while the archive is assembled). +2. ``GET`` that ``file_url`` -> stream the archive to disk. + +The endpoint's serializer exposes many boolean flags. For hippo we only need the +apo-desolvated protein structures, so :meth:`.DownloadService.download_target` +defaults ``apo_desolv_file=True`` and every other flag to ``False``. Individual +flags can still be overridden per call via keyword arguments. + +Adapted and hardened from the ``downloader.py`` prototype. +""" + +import os +import tarfile +import time +import zipfile +from pathlib import Path +from urllib.parse import urljoin + +import mrich +import requests +from designdb.utils_frag import STACK_URLS +from requests.exceptions import JSONDecodeError + +LOGIN_URL = '/accounts/login/' +DOWNLOAD_URL = '/api/download_structures/' +LANDING_PAGE_URL = '/viewer/react/landing/' + +# Keep reasonably current; some endpoints reject obviously-bot user agents. +USER_AGENT = ( + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/120.0.0.0 Safari/537.36' +) + +# Every BooleanField on DownloadStructuresSerializer except ``use_zip`` (which is +# handled as a dedicated parameter). This tuple is the single source of truth for +# "apo_desolv default, everything else False" and should track the serializer. +BOOLEAN_FLAGS = ( + 'all_aligned_structures', + 'apo_file', + 'bound_file', + 'apo_solv_file', + 'apo_desolv_file', + 'ligand_pdb', + 'ligand_sdf', + 'ligand_smiles', + 'sdf_info', + 'smiles_info', + 'pdb_info', + 'cif_info', + 'mtz_info', + 'diff_file', + 'event_file', + 'sigmaa_file', + 'map_info', + 'single_sdf_file', + 'metadata_info', + 'trans_matrix_info', + 'compound_sets', + 'soakdb_files', + 'yaml_files', + 'extra_files', + 'pymol_scripts', + 'readme', + 'static_link', +) + +# The flag enabled by default by :meth:`.DownloadService.download_target`. +DEFAULT_FLAG = 'apo_desolv_file' + +# (connect timeout, read timeout) in seconds. +DEFAULT_TIMEOUT = (30, 1800) + +# Task-status polling. The POST only *triggers* archive creation and returns a +# task status URL; we poll it until the task reaches a terminal state and a +# file_url becomes available. +FAILURE_STATUSES = ('FAILED', 'CANCELED', 'FATAL') +DEFAULT_POLL_INTERVAL = 2 +DEFAULT_POLL_TIMEOUT = 1800 + + +class DownloadService: + """Download target data archives from Fragalysis.""" + + @staticmethod + def _build_payload( + *, + target_name: str, + target_access_string: str = '', + proteins: str = '', + use_zip: bool = False, + **flag_overrides: bool, + ) -> dict: + """Build the request payload. + + Every boolean flag defaults to ``False`` except :data:`.DEFAULT_FLAG` + (``apo_desolv_file``). Pass any flag in :data:`.BOOLEAN_FLAGS` as a keyword + argument to override it. + + :param target_name: Fragalysis target name + :param target_access_string: target access string (proposal) + :param proteins: comma-separated observation shortcodes (empty = all) + :param use_zip: request a ``.zip`` instead of a ``.tar.gz`` archive + :param flag_overrides: per-call overrides for any flag in + :data:`.BOOLEAN_FLAGS` + """ + + unknown = set(flag_overrides) - set(BOOLEAN_FLAGS) + if unknown: + raise TypeError( + f'Unknown download flag(s): {sorted(unknown)}. ' + f'Valid flags: {", ".join(BOOLEAN_FLAGS)}' + ) + + payload = {flag: False for flag in BOOLEAN_FLAGS} + payload[DEFAULT_FLAG] = True + payload.update(flag_overrides) + + payload.update( + target_name=target_name, + target_access_string=target_access_string or '', + proteins=proteins or '', + # NB: must be '' (not False) on the initial request, see prototype + file_url='', + use_zip=bool(use_zip), + ) + + return payload + + @staticmethod + def download_target( + *, + target_name: str, + target_access_string: str = '', + proteins: str = '', + stack: str = 'production', + url: str | None = None, + auth_token: str | None = None, + destination: 'str | Path | None' = None, + extract: bool = True, + use_zip: bool = False, + timeout: 'tuple[int, int] | int | None' = DEFAULT_TIMEOUT, + poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, + **flag_overrides: bool, + ) -> Path: + """Download (and optionally extract) a target archive from Fragalysis. + + :param target_name: Fragalysis target name + :param target_access_string: target access string (proposal) + :param proteins: comma-separated observation shortcodes (empty = all) + :param stack: key into :data:`.STACK_URLS` (``'production'``/``'staging'``) + :param url: explicit base URL, overrides ``stack`` if given + :param auth_token: Fragalysis ``sessionid`` cookie; falls back to the + ``FRAGALYSIS_AUTH_TOKEN`` environment variable + :param destination: directory to download into (default: current dir) + :param extract: extract the archive and return the extracted directory + :param use_zip: request a ``.zip`` instead of a ``.tar.gz`` archive + :param timeout: requests timeout (connect, read) in seconds + :param poll_interval: seconds between task-status polls + :param poll_timeout: max seconds to wait for the archive task to finish + :param flag_overrides: per-call overrides for any flag in + :data:`.BOOLEAN_FLAGS` + :returns: path to the extracted directory (``extract=True``) or the + downloaded archive (``extract=False``) + """ + + base_url = url or STACK_URLS.get(stack) + if not base_url: + raise ValueError( + f'Unknown stack {stack!r}; choose from {sorted(STACK_URLS)} ' + 'or pass an explicit url' + ) + + auth_token = auth_token or os.environ.get('FRAGALYSIS_AUTH_TOKEN') + + destination = Path(destination) if destination else Path.cwd() + destination.mkdir(parents=True, exist_ok=True) + + payload = DownloadService._build_payload( + target_name=target_name, + target_access_string=target_access_string, + proteins=proteins, + use_zip=use_zip, + **flag_overrides, + ) + + download_api_url = urljoin(base_url, DOWNLOAD_URL) + landing_page_url = urljoin(base_url, LANDING_PAGE_URL) + + mrich.var('target_name', target_name) + mrich.var('target_access_string', target_access_string) + mrich.var('stack url', base_url) + + with requests.Session() as session: + session.headers.update( + { + 'User-Agent': USER_AGENT, + 'Referer': landing_page_url, + 'Referrer-policy': 'same-origin', + } + ) + + # set the csrftoken cookie + session.get(landing_page_url, timeout=timeout) + csrftoken = session.cookies.get('csrftoken', None) + if csrftoken: + session.headers.update({'X-CSRFToken': csrftoken}) + + if auth_token: + session.cookies.update({'sessionid': auth_token}) + + # Step 1: trigger archive creation. This returns a task status URL + # (the archive is built asynchronously on the server). + mrich.print('Requesting download from Fragalysis...') + start_response = session.post( + download_api_url, data=payload, timeout=timeout + ) + start_response.raise_for_status() + start_json = start_response.json() + + # Step 2: resolve the file_url. Newer servers return a task status + # URL to poll; older/cached responses may return file_url directly. + file_url = start_json.get('file_url') + if not file_url: + task_status_url = start_json.get('task_status_url') + if not task_status_url: + raise RuntimeError( + 'Fragalysis returned neither file_url nor task_status_url: ' + f'{start_json}' + ) + task_status_url = urljoin(base_url, task_status_url) + file_url = DownloadService._poll_task( + session, + task_status_url, + timeout=timeout, + poll_interval=poll_interval, + poll_timeout=poll_timeout, + ) + + # Step 3: stream the archive to disk + archive_path = destination / Path(file_url).name + mrich.writing(archive_path) + with session.get( + download_api_url, + params={'file_url': file_url}, + stream=True, + timeout=timeout, + ) as r: + r.raise_for_status() + with open(archive_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + + mrich.success('Downloaded', archive_path) + + if not extract: + return archive_path + + return DownloadService._extract(archive_path, destination) + + @staticmethod + def _poll_task( + session: requests.Session, + task_status_url: str, + *, + timeout: 'tuple[int, int] | int | None' = DEFAULT_TIMEOUT, + poll_interval: float = DEFAULT_POLL_INTERVAL, + poll_timeout: float = DEFAULT_POLL_TIMEOUT, + ) -> str: + """Poll a Fragalysis task-status URL until the archive is ready. + + :param session: the authenticated :class:`requests.Session` + :param task_status_url: absolute task-status URL returned by the POST + :param timeout: per-request timeout + :param poll_interval: seconds between polls + :param poll_timeout: max seconds to wait before giving up + :returns: the ``file_url`` of the assembled archive + """ + + mrich.print('Waiting for Fragalysis to build the archive...') + + waited = 0.0 + seen_messages = 0 + + while True: + resp = session.get(task_status_url, timeout=timeout) + try: + data = resp.json() + except JSONDecodeError: + # task is too early in its lifecycle to have a JSON body yet + data = {} + + if error := data.get('error'): + raise RuntimeError(f'Fragalysis download task failed: {error}') + + status = data.get('status') + if status in FAILURE_STATUSES: + raise RuntimeError(f'Fragalysis download task {status}: {data}') + + finished = ( + status == 'SUCCESS' + or data.get('finished') is True + or data.get('ready') is True + ) + + if finished: + # On success the download endpoint delivers the archive path in + # `messages` (a string), not a dedicated `file_url` key. + file_url = DownloadService._extract_file_url(data) + if not file_url: + raise RuntimeError( + f'Download task finished but returned no file_url: {data}' + ) + return file_url + + # not finished yet: surface any new progress messages + messages = data.get('messages') + if messages is not None: + messages = messages if isinstance(messages, list) else [messages] + for m in messages[seen_messages:]: + mrich.print(m) + seen_messages = len(messages) + + if waited >= poll_timeout: + raise TimeoutError( + f'Download task did not finish within {poll_timeout}s ' + f'({task_status_url})' + ) + + time.sleep(poll_interval) + waited += poll_interval + + @staticmethod + def _extract_file_url(data: dict) -> 'str | None': + """Extract the archive path from a completed task-status response. + + The download endpoint returns the path in ``file_url`` on some servers and + in ``messages`` (a string, occasionally a list) on others. + """ + file_url = data.get('file_url') + if file_url: + return file_url + + messages = data.get('messages') + if isinstance(messages, str): + return messages + if isinstance(messages, list) and messages: + return messages[-1] + return None + + @staticmethod + def _extract(archive_path: Path, destination: Path) -> Path: + """Extract a ``.zip`` or ``.tar(.gz)`` archive into a sibling directory. + + :param archive_path: path to the downloaded archive + :param destination: directory to extract into + :returns: path to the extracted directory + """ + + extract_dir = destination / _archive_stem(archive_path.name) + extract_dir.mkdir(parents=True, exist_ok=True) + + mrich.print('Extracting', archive_path.name, '->', extract_dir) + + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path) as zf: + zf.extractall(extract_dir) + elif tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path) as tf: + tf.extractall(extract_dir) + else: + raise ValueError(f'Unrecognised archive format: {archive_path}') + + mrich.success('Extracted to', extract_dir) + return extract_dir + + +def _archive_stem(name: str) -> str: + """Strip a ``.zip`` / ``.tar`` / ``.tar.gz`` / ``.tgz`` suffix from a filename.""" + for suffix in ('.tar.gz', '.tar.bz2', '.tgz', '.tar', '.zip', '.gz'): + if name.endswith(suffix): + return name[: -len(suffix)] + return Path(name).stem diff --git a/hippo/designdb/utils_frag.py b/hippo/designdb/utils_frag.py index 08add9b..0311f1c 100644 --- a/hippo/designdb/utils_frag.py +++ b/hippo/designdb/utils_frag.py @@ -188,6 +188,8 @@ def find_observation_longcode_matches( STACK_URLS = { 'production': 'https://fragalysis.diamond.ac.uk', 'staging': 'https://fragalysis.xchem.diamond.ac.uk', + # testing + 'localhost': 'http://localhost:8080', } diff --git a/pyproject.toml b/pyproject.toml index 02a5743..2dfd630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "neo4j>=6.1.0", "gemmi>=0.7.5", "mrich>=1.0", + "requests>=2.32", "pdbfixer>=1.12.0", # Max's hippo depends on this. cannot install with uv, comes from conda # "chemicalite==2024.5.1", diff --git a/uv.lock b/uv.lock index 9b75ef8..143024b 100644 --- a/uv.lock +++ b/uv.lock @@ -4315,6 +4315,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "python-louvain" }, { name = "rdkit" }, + { name = "requests" }, { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "syndirella" }, @@ -4357,6 +4358,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.3" }, { name = "python-louvain", specifier = ">=0.16" }, { name = "rdkit", specifier = "==2025.9.5" }, + { name = "requests", specifier = ">=2.32" }, { name = "scikit-learn", specifier = ">=1.7" }, { name = "syndirella", specifier = ">=5.0.7a0" }, { name = "typer", specifier = ">=0.24.1" }, From c1ac39d7f410ff424e7ca64134b9a3fca56d2769 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 10 Jun 2026 11:14:41 +0100 Subject: [PATCH 149/163] feat: download and auth Download - downloads protein structures from fragalysis given target, project and protein list Auth - Checks if user has permissions to access target from ISPyB (but doesn't enforce it because library) Had to merge auth path now because project info necessary for target download from frag. --- docker-compose.yaml | 2 +- hippo/designdb/services/pose.py | 4 ++-- images/xchem-designdb/init-db/01_schema.sql | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 3284b62..0da0b5d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -25,7 +25,7 @@ services: container_name: xchem_designdb restart: unless-stopped ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${POSTGRES_HOST_PORT:-5433}:5432" volumes: - postgres_data:/var/lib/postgresql/data env_file: diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index 7c58abe..b7ca6bb 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -72,7 +72,7 @@ def create( compound=compound, target=target, pose_alias=alias, - pose_path=path, + protein_link=path, pose_inchikey=inchikey, # SQLITE_RELIC pose_smiles=smiles, # SQLITE_RELIC pose_metadata=json.dumps(metadata), @@ -126,7 +126,7 @@ def create_from_record( pose, created = PoseModel.objects.get_or_create( compound=compound, target=target, - pose_path=path, + protein_link=path, reference=reference, ) return pose, created diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index 7ead379..a3aa669 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -82,7 +82,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( pose_alias TEXT, pose_smiles TEXT, -- Populated by RDKit cartridge trigger from pose_mol (do not insert by code). LR - necessary because will contain defined stereochemistry - should these be canonicalised? Is it done by codebase from pose.mol? Could be done by RDkit cartridge. pose_reference INTEGER, - pose_path TEXT, + protein_link TEXT, compound_id BIGINT NOT NULL REFERENCES designdb.compounds (id) ON DELETE RESTRICT, target_id BIGINT NOT NULL REFERENCES designdb.targets (id) ON DELETE RESTRICT, pose_mol rdkit.mol, -- Insert by codebase. Trigger populates pose_inchikey and pose_smiles via cartridge. @@ -97,7 +97,7 @@ CREATE TABLE IF NOT EXISTS designdb.poses ( created_on TIMESTAMPTZ DEFAULT now(), updated_on TIMESTAMPTZ DEFAULT now() -- CONSTRAINT uc_pose_alias UNIQUE (pose_alias), -- Removed - -- CONSTRAINT uc_pose_path UNIQUE (pose_path) -- Removed + -- CONSTRAINT uc_protein_link UNIQUE (protein_link) -- Removed ); CREATE TABLE IF NOT EXISTS designdb.subsite_tags ( @@ -481,7 +481,7 @@ CREATE INDEX IF NOT EXISTS idx_reaction_created ON designdb.reactions(created_on CREATE INDEX IF NOT EXISTS idx_pose_compound_id ON designdb.poses(compound_id); CREATE INDEX IF NOT EXISTS idx_pose_target_id ON designdb.poses(target_id); -CREATE INDEX IF NOT EXISTS idx_pose_path ON designdb.poses(pose_path); +CREATE INDEX IF NOT EXISTS idx_protein_link ON designdb.poses(protein_link); CREATE INDEX IF NOT EXISTS idx_pose_created ON designdb.poses(created_on); CREATE INDEX IF NOT EXISTS idx_score_values_pose_id ON designdb.score_values(pose_id); From c0fdbaa0149b53bebf14c50d9f09b1d124854a32 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 12 Jun 2026 14:05:22 +0100 Subject: [PATCH 150/163] feat: downloadable hit data Includes some bugfixes --- .gitignore | 4 + hippo/designdb/animal.py | 184 ++++++++++++++++++++++----- hippo/designdb/models.py | 19 ++- hippo/designdb/services/download.py | 21 ++- hippo/designdb/services/ingestion.py | 10 +- hippo/designdb/services/pose.py | 4 +- hippo/designdb/sets/pose.py | 13 +- hippo/designdb/utils_chem.py | 10 +- hippo/designdb/utils_frag.py | 93 ++++++++++---- 9 files changed, 279 insertions(+), 79 deletions(-) diff --git a/.gitignore b/.gitignore index f27a22d..819d59d 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,10 @@ celerybeat.pid .venv env/ venv/ + +# Local docker-compose overrides (per-developer, not shared) +docker-compose.override.yaml +docker-compose.override.yml ENV/ env.bak/ venv.bak/ diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 46cb13a..7abfe8c 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -2,7 +2,6 @@ import logging import re -import shutil from datetime import datetime from enum import Enum from pathlib import Path @@ -33,10 +32,24 @@ logger = logging.getLogger(__name__) # Root directory under which Fragalysis downloads are extracted, laid out as -# data/downloads//. The whole tree is wiped on each -# HIPPO instantiation so a session never works with superseded data. +# data/downloads//. DOWNLOADS_DIR = Path('data') / 'downloads' +# Fragalysis download flags requested when fetching the full hit data for a +# target (see HIPPO._ensure_hit_data). Everything else stays False. +HIT_DATA_FLAGS = ( + 'apo_file', + 'bound_file', + 'apo_solv_file', + 'apo_desolv_file', + 'ligand_pdb', + 'ligand_sdf', + 'ligand_smiles', + 'sdf_info', + 'smiles_info', + 'metadata_info', +) + class HIPPO: """Entry-point class of the xchem-hippo package. @@ -61,19 +74,13 @@ def __init__( project=project, ) - # apo_desolv download state (see _ensure_apo_desolv_files); the download - # is performed lazily, at most once per instance + # Download state (see _ensure_hit_data / _ensure_apo_desolv_files). The + # full hit data persists on disk and is reused across sessions; the + # apo_desolv subset is re-downloaded once per instance to stay fresh. + self._hit_data_path: Path | None = None self._apo_desolv_path: Path | None = None self._apo_desolv_downloaded_at: datetime | None = None - # Wipe any previous downloads so this session always fetches fresh data - # (Fragalysis downloads can be superseded server-side). Downloads are - # confined to DOWNLOADS_DIR specifically so this never touches other - # data/ contents. - if DOWNLOADS_DIR.is_dir(): - logger.debug('Wiping previous downloads at %s', DOWNLOADS_DIR) - shutil.rmtree(DOWNLOADS_DIR) - # TODO: the way this worked previously was it gave the HIPPO # instance full access to the pose table. When working with # multi-project central postgres db, this is almost certainly @@ -130,6 +137,61 @@ def num_poses(self) -> int: """Total number of Poses in the Database""" return self.poses.count() + def _ensure_hit_data( + self, auth_token: str | None = None, stack: str = 'production' + ) -> Path: + """Ensure this target's full crystallographic hit data is available locally. + + Downloads the target's Fragalysis data (all observations) from the stack + via :class:`.DownloadService` and returns the path to the extracted + directory (``data/downloads//``). The requested file + types are :data:`.HIT_DATA_FLAGS` (apo/bound/ligand/sdf/smiles/metadata). + + Unlike :meth:`._ensure_apo_desolv_files`, this data **persists**: if a + previous full download is already on disk it is reused without + re-fetching (detected by the presence of ``metadata.csv``, which an + apo_desolv-only download does not produce). + + .. note:: + HIPPO-level helper, intended to be called from user-facing + :class:`.HIPPO` methods (e.g. :meth:`.add_hits`). It must not be + called from the components or services layer. + + :param auth_token: optional Fragalysis ``sessionid``; otherwise the + ``FRAGALYSIS_AUTH_TOKEN`` environment variable is used + :param stack: Fragalysis stack to download from, a key into + :data:`.STACK_URLS`; defaults to ``'production'`` + :returns: path to the extracted download directory + """ + + target_name = self._target.target_name + project_name = self._target.project.project_name + + destination = DOWNLOADS_DIR / project_name + target_dir = destination / target_name + + # reuse an existing full download (metadata.csv distinguishes it from an + # apo_desolv-only download, which has no metadata.csv) + if (target_dir / 'metadata.csv').is_file() and ( + target_dir / 'aligned_files' + ).is_dir(): + mrich.print('Using existing hit data download', target_dir) + self._hit_data_path = target_dir + return target_dir + + path = DownloadService.download_target( + target_name=target_name, + target_access_string=project_name, + proteins='', # all observations (no poses exist yet to filter by) + stack=stack, + auth_token=auth_token, + destination=destination, + **{flag: True for flag in HIT_DATA_FLAGS}, + ) + + self._hit_data_path = path + return path + def _ensure_apo_desolv_files( self, auth_token: str | None = None, stack: str = 'production' ) -> Path: @@ -138,9 +200,11 @@ def _ensure_apo_desolv_files( Downloads the ``apo_desolv`` structures for this target's observations from Fragalysis (via :class:`.DownloadService`) and returns the path to the extracted directory. The download is performed at most once per - instance (the resolved path is cached on the instance). Previous - downloads are wiped on :class:`.HIPPO` instantiation, so a fresh instance - always re-fetches the latest data. + instance and re-fetched each instance (overwriting any on-disk + apo_desolv files) so a fresh instance always works with current data. If + the full hit data was already downloaded this instance (via + :meth:`._ensure_hit_data`), that is reused since it already includes + fresh apo_desolv files. Everything needed for the request is taken from this animal: the target name and project (target access string) from :attr:`.target`, and the @@ -165,6 +229,12 @@ def _ensure_apo_desolv_files( if self._apo_desolv_path is not None and self._apo_desolv_path.exists(): return self._apo_desolv_path + # the full hit data downloaded this instance already includes fresh + # apo_desolv files, so reuse it instead of re-downloading the subset + if self._hit_data_path is not None and self._hit_data_path.exists(): + self._apo_desolv_path = self._hit_data_path + return self._apo_desolv_path + target_name = self._target.target_name project_name = self._target.project.project_name @@ -203,8 +273,10 @@ def _ensure_apo_desolv_files( def add_hits( self, *, - metadata_csv: str | Path, - aligned_directory: str | Path, + metadata_csv: str | Path | None = None, + aligned_directory: str | Path | None = None, + auth_token: str | None = None, + stack: str = 'production', tags: list | None = None, pose_methods: list[str] | None = None, skip: list | None = None, @@ -215,27 +287,38 @@ def add_hits( ) -> pd.DataFrame: """Crystallographic hits from a Fragalysis download or XChemAlign alignment. - For a Fragalysis download `aligned_directory` and `metadata_csv` - should point to the `aligned_files` and `metadata.csv` at the - root of the extracted download. - For an XChemAlign dataset the `aligned_directory` - should point to the `aligned_files`. + Provide both `metadata_csv` and `aligned_directory` to load existing + local data (for a Fragalysis download these point to the `metadata.csv` + and `aligned_files` at the root of the extracted download; for an + XChemAlign dataset `aligned_directory` points to the `aligned_files`). + Omit both to download this target's data from the Fragalysis stack first + (see :meth:`._ensure_hit_data`). - :param target_name: Name of this protein :class:`.TargetModel` - :param metadata_csv: Path to the metadata.csv from the Fragalysis download + :param metadata_csv: Path to the metadata.csv (omit to download) :param aligned_directory: Path to the aligned_files directory - from the Fragalysis download + (omit to download) + :param auth_token: optional Fragalysis ``sessionid`` for the download + (otherwise ``FRAGALYSIS_AUTH_TOKEN`` is used) + :param stack: Fragalysis stack to download from (default ``'production'``) :param skip: optional list of observation names to skip - :param debug: bool: (Default value = False) :returns: a DataFrame of metadata """ - ### Process arguments - # NB! meta not required when loading XCA data - assert metadata_csv, 'metadata.csv required' + ### Resolve the data source + # Path-driven: provide both metadata_csv and aligned_directory to load + # existing local data, or omit both to download the target's data from + # the Fragalysis stack (always Fragalysis-type). + if metadata_csv is None and aligned_directory is None: + hit_dir = self._ensure_hit_data(auth_token=auth_token, stack=stack) + aligned_directory = hit_dir / 'aligned_files' + metadata_csv = hit_dir / 'metadata.csv' + elif metadata_csv is None or aligned_directory is None: + raise ValueError( + 'Provide both metadata_csv and aligned_directory to use existing ' + 'data, or neither to download from the stack.' + ) - assert aligned_directory, 'aligned_directory must be provided' skip = skip or [] tags = tags or [] pose_methods = pose_methods or DEFAULT_POSE_METHODS @@ -245,6 +328,20 @@ def add_hits( mrich.var('aligned_directory', aligned_directory) + ### Validate inputs early with clear messages. A wrong/mismatched target + # name usually yields an aligned_directory (often derived from the target + # name) that doesn't exist or has no recognizable observation + # subdirectories; without these checks that surfaces later as a confusing + # "Unexpected mixed data format" assertion. We rely only on the aligned + # data structure here -- not on the directory name, and not on the + # presence of metadata (which is optional, e.g. for XChemAlign data). + target_name = self.target.target_name + if not aligned_directory.is_dir(): + raise NotADirectoryError( + f'aligned_directory not found: {aligned_directory}. Check the path ' + f'matches the data for target {target_name!r}.' + ) + ### Determine data format # TODO: as it appears that users are currently only loading @@ -262,7 +359,13 @@ def __str__(self) -> str: """name""" return self.name - subdirs = list(aligned_directory.glob('*')) + subdirs = [p for p in aligned_directory.glob('*') if p.is_dir()] + if not subdirs: + raise ValueError( + f'No observation subdirectories found in {aligned_directory}. Is the ' + 'path correct and the download extracted? A wrong target name (here ' + f'{target_name!r}) often points add_hits at an empty/missing directory.' + ) SUBDIR_PATTERN_FRAGALYSIS = re.compile(r'^.*\d{4}[a-z]$') SUBDIR_PATTERN_XCA = re.compile(r'^.*-.\d{4}$') @@ -273,9 +376,20 @@ def __str__(self) -> str: xca_subdirs_present = any( SUBDIR_PATTERN_XCA.match(subdir.name) for subdir in subdirs ) - assert fragalysis_subdirs_present ^ xca_subdirs_present, ( - 'Unexpected mixed data format' - ) + + # distinguish the two failure modes the old XOR assertion conflated + if fragalysis_subdirs_present and xca_subdirs_present: + raise ValueError( + 'Mixed Fragalysis and XChemAlign observation directories in ' + f'{aligned_directory}; expected a single consistent format.' + ) + if not (fragalysis_subdirs_present or xca_subdirs_present): + examples = ', '.join(p.name for p in subdirs[:3]) + raise ValueError( + 'Could not recognise any Fragalysis or XChemAlign observation ' + f'directories in {aligned_directory} (e.g. {examples}). Check that ' + f'the data matches target {target_name!r}.' + ) if fragalysis_subdirs_present: data_format = DataFormat.Fragalysis_v2 diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index a9fce29..8a0e882 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -347,15 +347,20 @@ def mol_path(self) -> Path | None: @property def apo_path(self) -> Path | None: - """Get path to apo protein file""" + """Get path to the apo (de-liganded, desolvated) protein file""" path = Path(self.protein_link) if path.name.endswith('.pdb'): - apo_path = path.parent / path.name.replace('_hippo.pdb', '.pdb').replace( - '.pdb', '_apo-desolv.pdb' - ) - if not apo_path.exists(): - return None - return apo_path + stem = path.name.replace('_hippo.pdb', '.pdb') + # current Fragalysis protein-file naming + apo_path = path.parent / stem.replace('.pdb', '_delig-desolv.pdb') + if apo_path.exists(): + return apo_path + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once + # all data uses 'delig' + legacy_path = path.parent / stem.replace('.pdb', '_apo-desolv.pdb') + if legacy_path.exists(): + return legacy_path + return None else: raise NotImplementedError diff --git a/hippo/designdb/services/download.py b/hippo/designdb/services/download.py index 325467e..9ef520e 100644 --- a/hippo/designdb/services/download.py +++ b/hippo/designdb/services/download.py @@ -77,6 +77,12 @@ # (connect timeout, read timeout) in seconds. DEFAULT_TIMEOUT = (30, 1800) +# Short timeout for the best-effort CSRF-priming GET. It hits a normal page just +# to obtain the csrftoken cookie, so it must fail fast (e.g. on a backend-only +# deployment that doesn't serve the frontend landing page) rather than block on +# the long download read timeout. +CSRF_TIMEOUT = (10, 10) + # Task-status polling. The POST only *triggers* archive creation and returns a # task status URL; we poll it until the task reaches a terminal state and a # file_url becomes available. @@ -178,7 +184,6 @@ def download_target( 'or pass an explicit url' ) - auth_token = auth_token or os.environ.get('FRAGALYSIS_AUTH_TOKEN') destination = Path(destination) if destination else Path.cwd() destination.mkdir(parents=True, exist_ok=True) @@ -207,8 +212,18 @@ def download_target( } ) - # set the csrftoken cookie - session.get(landing_page_url, timeout=timeout) + # Best-effort: prime the csrftoken cookie by hitting a normal page. + # Use a short timeout and tolerate failure so a backend-only stack + # that doesn't serve the landing page fails fast instead of hanging + # on the long download read timeout. If no token is obtained we still + # proceed; a CSRF-enforcing server would then return a clear error. + try: + session.get(landing_page_url, timeout=CSRF_TIMEOUT) + except requests.RequestException as exc: + mrich.warning( + f'Could not reach {landing_page_url} to obtain a CSRF token ' + f'({exc}); proceeding without it.' + ) csrftoken = session.cookies.get('csrftoken', None) if csrftoken: session.headers.update({'X-CSRFToken': csrftoken}) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 8b1c87e..54371f1 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -137,8 +137,11 @@ def iter_fs_fragalysis(root_path, skip_records): p for p in dset_path.glob('[!.]*.pdb') if '_ligand' not in p.name - and '_apo' not in p.name + and '_delig' not in p.name # current Fragalysis protein-file naming and '_hippo' not in p.name + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once + # all data uses 'delig' + and '_apo' not in p.name ] if not len(pdbs) == 1: @@ -882,7 +885,10 @@ def ingest_syndirella_elabs( (template_path,) = template_paths template_path = Path(template_path) mrich.var('template_path', template_path) - base_name = template_path.name.removesuffix('.pdb').removesuffix('_apo-desolv') + base_name = template_path.name.removesuffix('.pdb').removesuffix('_delig-desolv') + # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once all + # data uses 'delig' + base_name = base_name.removesuffix('_apo-desolv') # reference = self.poses[base_name] # TODO: error handling diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index b7ca6bb..753648d 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -10,7 +10,7 @@ # from rdkit.Chem import inchi from designdb.models import CompoundModel, PoseModel, PoseTagModel, TargetModel from designdb.utils import normalize_string_list -from designdb.utils_chem import get_best_rmsd +from designdb.utils_chem import get_rmsd from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS from django.db.models import Q # from mypackage.services.compound import CompoundService @@ -99,7 +99,7 @@ def find_rmsd_duplicate( ) -> 'PoseModel | None': for existing in PoseModel.objects.filter(compound=compound, target=target): try: - rmsd = get_best_rmsd(mol, existing.pose_mol) + rmsd = get_rmsd(mol, existing.pose_mol) if rmsd < rmsd_threshold: logger.warning( 'Pose RMSD %.3f Å below threshold %.3f Å, skipping duplicate (alias=%s)', diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 5ba3315..8e6fe52 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -1360,9 +1360,16 @@ def fix_subsites(subsite_list: list[str]): for ref_alias in pose_df['ref_pdb'].values: source_path = Path(lookup[ref_alias]) - apo_path = source_path.parent / source_path.name.replace( - '_hippo.pdb', '.pdb' - ).replace('.pdb', '_apo-desolv.pdb') + stem = source_path.name.replace('_hippo.pdb', '.pdb') + # current Fragalysis protein-file naming + apo_path = source_path.parent / stem.replace('.pdb', '_delig-desolv.pdb') + # DEPRECATED(apo-naming): fall back to pre-'delig' naming if present, + # remove once all data uses 'delig' + legacy_path = source_path.parent / stem.replace( + '.pdb', '_apo-desolv.pdb' + ) + if not apo_path.exists() and legacy_path.exists(): + apo_path = legacy_path if not apo_path.exists(): sys = mp.parse(source_path).protein_system diff --git a/hippo/designdb/utils_chem.py b/hippo/designdb/utils_chem.py index ccd46a9..4da66bd 100644 --- a/hippo/designdb/utils_chem.py +++ b/hippo/designdb/utils_chem.py @@ -147,9 +147,13 @@ } -def get_best_rmsd(mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol) -> float: - """Return the minimum RMSD between two molecules after optimal rigid-body alignment.""" - return rdMolAlign.GetBestRMS(mol1, mol2) +def get_rmsd(mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol) -> float: + """Return the RMSD between two molecules. + + NB! after discussion in #2094, *do not* align structures. They + are already expected to be in the correct coordinate system. + """ + return rdMolAlign.CalcRMS(mol1, mol2) def check_reaction_types(types: list[str]) -> None: diff --git a/hippo/designdb/utils_frag.py b/hippo/designdb/utils_frag.py index 0311f1c..584e05e 100644 --- a/hippo/designdb/utils_frag.py +++ b/hippo/designdb/utils_frag.py @@ -1,5 +1,7 @@ """Functions for interfacing with Fragalysis data""" +import os +import re from dataclasses import dataclass, fields import mrich @@ -87,55 +89,84 @@ def generate_header( @dataclass class LongcodeRecord: - target: str - crystal: str + crystal: str # full crystal token, e.g. A71EV2A-x0152 + protein_name: str # e.g. A71EV2A chain: str residue_number: int version: int + altloc: str | None = None + + +# Current Fragalysis longcode format. The code combines two sites +# (e.g. A71EV2A-x0152_A_147_0_1_A71EV2A-x0526+A+147+0+1__LIG); we parse only the +# first (underscore-separated) site and ignore the second (plus-separated) one. +# First-site groups: crystal token, then chain_resnum_altloc_version. +_LONGCODE_RE = re.compile( + r'(.*)_' # crystal token, e.g. A71EV2A-x0152 + r'([A-Za-z]+_[0-9]+_[A-Za-z0-9]+_[0-9]+)_' # chain_resnum_altloc_version + r'.*\+[A-Za-z]+\+[0-9]+\+[A-Za-z0-9]+\+[0-9]+' # second site (ignored) + r'_.LIG' +) + +# DEPRECATED(longcode-altloc): pre-altloc Fragalysis longcode format +# (e.g. D68EV3CPROB-x0455_A_209_1_7gp9+A+201+1__LIG, no altloc group). Remove this +# regex and its branch in parse_observation_longcode once all data uses the +# current format above. +_LONGCODE_RE_LEGACY = re.compile( + r'(.*)_([A-Za-z]_[0-9]*_[0-9])_(.*)\+([A-Za-z]\+[0-9]*\+[0-9])_.LIG' +) + +# Split a crystal token (e.g. A71EV2A-x0152) into protein name and crystal id. +_CRYSTAL_RE = re.compile(r'(.*)-(\w[0-9]{4})') def parse_observation_longcode(longcode: str) -> LongcodeRecord: - """Parse a Fragalysis longcode and try to extract the following information: + """Parse a Fragalysis observation longcode (first site only). - - TargetModel name (target) - - Crystal/dataset code (crystal) + Extracts: + + - Crystal token (crystal), e.g. ``A71EV2A-x0152`` + - Protein name (protein_name), e.g. ``A71EV2A`` - Chain letter (chain) - Residue number (residue_number) + - Altloc (altloc; ``None`` for the older format) - Version number (version) - - :returns: dictionary of the above keys in parentheses """ - import re + altloc = None - match = re.search( - r'(.*)_([A-z]_[0-9]*_[0-9])_(.*)\+([A-z]\+[0-9]*\+[0-9])_.LIG', longcode - ) - - if not match: - raise UnsupportedFragalysisLongcodeError(longcode) - - cryst_str, lig_str, _, _ = match.groups() - - chain, residue_number, version = lig_str.split('_') + match = _LONGCODE_RE.search(longcode) + if match: + cryst_str = match.group(1) + chain, residue_number, altloc, version = match.group(2).split('_') + else: + # DEPRECATED(longcode-altloc): pre-altloc format had no altloc group; + # remove this branch (and _LONGCODE_RE_LEGACY) once all data uses the + # current format. + match = _LONGCODE_RE_LEGACY.search(longcode) + if not match: + raise UnsupportedFragalysisLongcodeError(longcode) + cryst_str = match.group(1) + chain, residue_number, version = match.group(2).split('_') + # end DEPRECATED(longcode-altloc) residue_number = int(residue_number) version = int(version) - if match := re.search(r'(.*)-(\w[0-9]{4})', cryst_str): - target_name = match.group(0) - crystal = match.group(1) - + if m := _CRYSTAL_RE.search(cryst_str): + crystal = m.group(0) # full token, e.g. A71EV2A-x0152 + protein_name = m.group(1) # e.g. A71EV2A else: - target_name = '' crystal = cryst_str + protein_name = '' return LongcodeRecord( - target=target_name, crystal=crystal, + protein_name=protein_name, chain=chain, residue_number=residue_number, version=version, + altloc=altloc, ) @@ -192,6 +223,20 @@ def find_observation_longcode_matches( 'localhost': 'http://localhost:8080', } +# Developers can add or override stacks via the environment without editing this +# shared file. Each ``HIPPO_STACK_URL_`` variable becomes the ```` +# stack (lower-cased), e.g. set in your (gitignored) .env: +# HIPPO_STACK_URL_DOCKERHOST=http://host.docker.internal:8080 +# then use stack='dockerhost'. +_STACK_URL_ENV_PREFIX = 'HIPPO_STACK_URL_' +STACK_URLS.update( + { + key[len(_STACK_URL_ENV_PREFIX) :].lower(): value + for key, value in os.environ.items() + if key.startswith(_STACK_URL_ENV_PREFIX) and value + } +) + class UnsupportedFragalysisLongcodeError(NotImplementedError): """Provided Fragalysis observation long code syntax is not supported""" From 8182a4c8179e27c8dbb040bca111d7ee8686426a Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Mon, 15 Jun 2026 14:19:14 +0100 Subject: [PATCH 151/163] fix: rmsd calculation --- hippo/designdb/animal.py | 87 ++++++++++++++++++++++------ hippo/designdb/services/ingestion.py | 11 ++-- hippo/designdb/services/pose.py | 85 ++++++++++++++++----------- hippo/designdb/services/quote.py | 47 +++++++++++++++ hippo/designdb/services/reaction.py | 18 ++++++ hippo/designdb/sets/pose.py | 4 +- 6 files changed, 193 insertions(+), 59 deletions(-) create mode 100644 hippo/designdb/services/quote.py diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 7abfe8c..e488a10 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -22,6 +22,8 @@ from .services.download import DownloadService from .services.ingestion import IngestionBatchResult, IngestionService from .services.method import MethodService +from .services.quote import QuoteService +from .services.reaction import ReactionService from .services.route import RouteService from .services.subsite import SubsiteService from .sets.compound import CompoundSet @@ -132,11 +134,57 @@ def compounds(self) -> CompoundSet: """Return all compounds in the database""" return CompoundSet(CompoundModel.compound_filter.all()) + @property + def reactants(self) -> CompoundSet: + """Compounds that are reactants of a reaction and not a product of any + (leaf reactants / purchasable building blocks).""" + return CompoundSet(list(ReactionService.reactant_compound_ids())) + @property def num_poses(self) -> int: """Total number of Poses in the Database""" return self.poses.count() + def quote_compounds( + self, compounds: 'CompoundSet | None' = None + ) -> tuple[CompoundSet, CompoundSet]: + """Report which compounds have catalogue quotes. + + In the modern DesignDB catalogue prices live in the same database and are + linked to compounds automatically (the DB matches the registration hash + and populates ``compound_catalogue_map``). This therefore no longer + transfers quotes from a separate catalogue animal — it reports, for the + current database, which compounds are quoted (have at least one linked + catalogue price) and which are not. + + :param compounds: optional :class:`.CompoundSet` to restrict to; defaults + to all compounds in the database + :returns: ``(quoted, unquoted)`` :class:`.CompoundSet` objects + """ + if compounds is None: + compounds = self.compounds + elif not isinstance(compounds, CompoundSet): + raise TypeError( + f'compounds must be a CompoundSet or None, got {type(compounds)}' + ) + + quoted_ids, unquoted_ids = QuoteService.partition_quoted(compounds.ids) + + mrich.var('#quoted compounds', len(quoted_ids)) + mrich.var('#unquoted compounds', len(unquoted_ids)) + + return CompoundSet(list(quoted_ids)), CompoundSet(list(unquoted_ids)) + + def quote_reactants(self) -> tuple[CompoundSet, CompoundSet]: + """Report which reactant compounds have catalogue quotes. + + Convenience wrapper around :meth:`.quote_compounds` restricted to the + animal's reactants (see :attr:`.reactants`). + + :returns: ``(quoted, unquoted)`` :class:`.CompoundSet` objects + """ + return self.quote_compounds(self.reactants) + def _ensure_hit_data( self, auth_token: str | None = None, stack: str = 'production' ) -> Path: @@ -546,40 +594,43 @@ def load_sdf( enumeration_method_obj = None if enumeration_method is not None: name, version = enumeration_method - enumeration_method_obj = EnumerationMethodModel.objects.filter( - enum_name=name, enum_version=version - ).first() - if enumeration_method_obj is None: + try: + enumeration_method_obj = EnumerationMethodModel.objects.get( + enum_name=name, enum_version=version + ) + except EnumerationMethodModel.DoesNotExist: raise ValueError( f"Enumeration method '{name}' v{version} not found. " - "Call register_enumeration_method() first." - ) + 'Call register_enumeration_method() first.' + ) from None pose_method_obj = None if pose_method is not None: name, version = pose_method - pose_method_obj = PoseMethodModel.objects.filter( - pose_method_name=name, pose_method_version=version - ).first() - if pose_method_obj is None: + try: + pose_method_obj = PoseMethodModel.objects.get( + pose_method_name=name, pose_method_version=version + ) + except PoseMethodModel.DoesNotExist: raise ValueError( f"Pose method '{name}' v{version} not found. " - "Call register_pose_method() first." - ) + 'Call register_pose_method() first.' + ) from None score_method_map = {} if score_cols and scoring_methods: if len(score_cols) != len(scoring_methods): raise ValueError('score_cols and scoring_methods must be the same length') for col, (method_name, method_version) in zip(score_cols, scoring_methods): - obj = ScoringMethodModel.objects.filter( - method_name=method_name, method_version=method_version - ).first() - if obj is None: + try: + obj = ScoringMethodModel.objects.get( + method_name=method_name, method_version=method_version + ) + except ScoringMethodModel.DoesNotExist: raise ValueError( f"Scoring method '{method_name}' v{method_version} not found. " - "Call register_scoring_method() first." - ) + 'Call register_scoring_method() first.' + ) from None score_method_map[col] = obj warn = make_warn_once_per_key() diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 54371f1..48fad07 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -377,6 +377,10 @@ def ingest_filesystem( metadata=metadata, inchikey=inchikey, smiles=smiles, + # the first method is the uniqueness/producing method; create() + # associates it. add_hits may request additional method tags, + # which are associated below. + pose_method=pose_methods[0] if pose_methods else None, check_rmsd=check_rmsd, rmsd_threshold=rmsd_threshold, ) @@ -385,8 +389,8 @@ def ingest_filesystem( pose.tags.add(*pose_tags) - if pose_methods: - pose.methods.add(*pose_methods) + if pose_methods and len(pose_methods) > 1: + pose.methods.add(*pose_methods[1:]) # it seems fragalysis data is not expected to contain # scores @@ -532,6 +536,7 @@ def ingest_sdf( inchikey=inchikey, smiles=smiles, reference=reference, + pose_method=pose_method_obj, check_rmsd=check_rmsd, rmsd_threshold=rmsd_threshold, ) @@ -539,8 +544,6 @@ def ingest_sdf( result.poses_created += 1 pose.tags.add(*pose_tags) - if pose_method_obj is not None: - pose.methods.add(pose_method_obj) pose.inspirations.add(*PoseModel.objects.filter(pk__in=pose_inspirations)) if score_method_map: diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index 753648d..fd54954 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -8,7 +8,7 @@ import pandas as pd import rdkit # from rdkit.Chem import inchi -from designdb.models import CompoundModel, PoseModel, PoseTagModel, TargetModel +from designdb.models import CompoundModel, PoseMethodModel, PoseModel, PoseTagModel, TargetModel from designdb.utils import normalize_string_list from designdb.utils_chem import get_rmsd from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS @@ -47,47 +47,57 @@ def create( inchikey: str, smiles: str, reference: int | None = None, + pose_method: 'PoseMethodModel | None' = None, check_rmsd: bool = False, rmsd_threshold: float = 1.0, ): + qs = PoseModel.objects.filter( + target=target, + compound=compound, + pose_alias=alias, + ) + if pose_method: + qs = qs.filter(methods=pose_method) + # (target, compound, pose_alias, method) should identify one pose try: - pose = PoseModel.objects.get( - target=target, - compound=compound, - pose_alias=alias, - ) - # default is to overwrite metadata. what about other props? - # also, shoulnd't this be JSON? - pose.metadata = metadata - pose.save() - created = False + existing = qs.get() except PoseModel.DoesNotExist: - if check_rmsd and ( - duplicate := cls.find_rmsd_duplicate(mol, compound, target, rmsd_threshold) - ): - return duplicate, False - - pose = PoseModel( - compound=compound, - target=target, - pose_alias=alias, - protein_link=path, - pose_inchikey=inchikey, # SQLITE_RELIC - pose_smiles=smiles, # SQLITE_RELIC - pose_metadata=json.dumps(metadata), - pose_mol=mol, - # pose_mol=Chem.MolToMolBlock(mol), - rdkit_version=rdkit.__version__, - inchi_version=Chem.inchi.GetInchiVersion(), - pose_reference=reference, + existing = None + else: + # overwrite metadata on the matching pose + existing.pose_metadata = json.dumps(metadata) + existing.save() + return existing, False + + if check_rmsd and ( + duplicate := cls.find_rmsd_duplicate( + mol, compound, target, rmsd_threshold, pose_method=pose_method ) - pose.save() - created = True - # except MultipleObjectsReturned: - # pass + ): + return duplicate, False - return pose, created + pose = PoseModel( + compound=compound, + target=target, + pose_alias=alias, + protein_link=path, + pose_inchikey=inchikey, # SQLITE_RELIC + pose_smiles=smiles, # SQLITE_RELIC + pose_metadata=json.dumps(metadata), + pose_mol=mol, + # pose_mol=Chem.MolToMolBlock(mol), + rdkit_version=rdkit.__version__, + inchi_version=Chem.inchi.GetInchiVersion(), + pose_reference=reference, + ) + pose.save() + + # associate the method here so subsequent loads dedup correctly + if pose_method is not None: + pose.methods.add(pose_method) + + return pose, True @classmethod def find_rmsd_duplicate( @@ -96,8 +106,13 @@ def find_rmsd_duplicate( compound: 'CompoundModel', target: 'TargetModel', rmsd_threshold: float, + pose_method: 'PoseMethodModel | None' = None, ) -> 'PoseModel | None': - for existing in PoseModel.objects.filter(compound=compound, target=target): + # only compare against poses produced by the same method + candidates = PoseModel.objects.filter(compound=compound, target=target) + if pose_method is not None: + candidates = candidates.filter(methods=pose_method) + for existing in candidates: try: rmsd = get_rmsd(mol, existing.pose_mol) if rmsd < rmsd_threshold: diff --git a/hippo/designdb/services/quote.py b/hippo/designdb/services/quote.py new file mode 100644 index 0000000..3041cd5 --- /dev/null +++ b/hippo/designdb/services/quote.py @@ -0,0 +1,47 @@ +"""Service layer for catalogue-price quoting. + +In the modern DesignDB, catalogue prices live in the same database as the design +compounds (``catalogue_compounds`` / ``catalogue_prices``) and are linked to +compounds automatically by database triggers that match the registration hash +(``compounds.compound_hash == catalogue_compounds.catalogue_hash``), populating +the ``compound_catalogue_map`` junction. + +Quoting is therefore a read against that junction: a compound is "quoted" if it +has at least one linked catalogue price. There is no longer any cross-database +transfer (the legacy ``HIPPO.quote_compounds(ref_animal)`` behaviour). +""" + +from designdb.models import CataloguePriceCompoundJunctionModel + + +class QuoteService: + """Report catalogue-price quoting for compounds using the current database.""" + + @staticmethod + def quoted_compound_ids(compound_ids: 'list[int] | set[int]') -> set[int]: + """Return the subset of ``compound_ids`` that have at least one linked + catalogue price. + + :param compound_ids: compound IDs to check + :returns: the IDs that are quoted + """ + return set( + CataloguePriceCompoundJunctionModel.objects.filter( + compound_id__in=list(compound_ids) + ) + .values_list('compound_id', flat=True) + .distinct() + ) + + @classmethod + def partition_quoted( + cls, compound_ids: 'list[int] | set[int]' + ) -> tuple[set[int], set[int]]: + """Split ``compound_ids`` into ``(quoted, unquoted)`` ID sets. + + :param compound_ids: compound IDs to partition + :returns: ``(quoted_ids, unquoted_ids)`` + """ + ids = set(compound_ids) + quoted = cls.quoted_compound_ids(ids) + return quoted, ids - quoted diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py index 37abf33..f432e91 100644 --- a/hippo/designdb/services/reaction.py +++ b/hippo/designdb/services/reaction.py @@ -9,6 +9,24 @@ class ReactionService: + @staticmethod + def reactant_compound_ids() -> set[int]: + """Return compound IDs that are reactants of at least one reaction and not + a product of any (i.e. leaf reactants / purchasable building blocks). + + Mirrors the legacy ``CompoundSet.reactants`` (reactant compounds minus + compounds that are a reaction product). + """ + reactant_ids = set( + ReactantModel.objects.values_list('compound_id', flat=True).distinct() + ) + product_ids = set( + ReactionModel.objects.values_list( + 'product_compound_id', flat=True + ).distinct() + ) + return reactant_ids - product_ids + @classmethod def create_from_lists( cls, diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 8e6fe52..59a9be4 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -526,7 +526,7 @@ def get_df( ).values('pose_alias')[0:1] ), ), - 'path': ('pose_path', 'pose_path', None), + 'path': ('protein_link', 'protein_link', None), 'mol': ('mol', 'pose_mol', None), 'energy_score': ( 'energy_score', @@ -1354,7 +1354,7 @@ def fix_subsites(subsite_list: list[str]): references = self.references # lookup = self.db.get_pose_alias_path_dict(references) - lookup = {k.pose_alias: k.pose_path for k in self._queryset} + lookup = {k.pose_alias: k.protein_link for k in self._queryset} zips = set() for ref_alias in pose_df['ref_pdb'].values: From 757eae63176959b26fe86fc273af21c5f9f3bee7 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 16 Jun 2026 11:09:28 +0100 Subject: [PATCH 152/163] fix: docs --- README-test.md | 347 +++++++++++++++++++++++++++++++++++++++ hippo/designdb/animal.py | 10 +- 2 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 README-test.md diff --git a/README-test.md b/README-test.md new file mode 100644 index 0000000..5a2454b --- /dev/null +++ b/README-test.md @@ -0,0 +1,347 @@ +## Testing new HIPPO + +Hippo testing during active development phase: mount the code in git branch directly to container and have it handle notebook server. + + +## Environment variables +Not strictly necessary but it's convenient to have database connection parameters in `.env` file, in project root directory. Should contain minimally: + +``` +DB_NAME=designdb +DB_USER=postgres +DB_PASSWORD=s_URzt7CWfWZ.AXD7RcF +DB_HOST=database + +TA_AUTH_SERVICE=https://ta-authenticator.xchem.diamond.ac.uk/ +TA_AUTH_QUERY_KEY= +``` + +These are for local connection, values may be different for kubernetes deployment + + +## Running services + +NB! depending on your environment, you may have to prefix your docker commands with `sudo` + +### Build the app container +In project root directory: + +```bash +docker build --no-cache . -t hippo_backend:latest +``` + +`--no-cache` may not be necessary, but I find that without that it sometimes does not upgrade numpy. + + +### If using local database, build the designdb postgres container +In `/images/xchem-designdb` directory: + +```bash +docker build -t xchem_designdb:latest . +``` +This may take some time + + +### Launch the service(s) + +`docker-compose.yaml` contains instructions for docker to run the services. + +```bash +docker compose up +``` + +If not using local database, just the app container: + +```bash +docker compose up backend +``` + +After running the `up` command, at the very end you should see notebook server addres: + +``` + +hippo_backend | Or copy and paste one of these URLs: +hippo_backend | http://localhost:8888/lab?token=90de0d2f297079ee393cdc202c06064406c5cb3a8032e8d8 +hippo_backend | http://127.0.0.1:8888/lab?token=90de0d2f297079ee393cdc202c06064406c5cb3a8032e8d8 +hippo_backend | [I 2026-04-23 09:49:32.420 ServerApp] Skipped non-installed server(s): basedpyright, bash-language-server, dockerfile-language-server-nodejs, javascript-typescript-langserver, jedi-language-server, julia-language-server, pyrefly, pyright, python-language-server, python-lsp-server, r-languageserver, sql-language-server, texlab, typescript-language-server, unified-language-server, vscode-css-languageserver-bin, vscode-html-languageserver-bin, vscode-json-languageserver-bin, yaml-language-server + +``` +Copy one of the addresses to a broser tab and you're in jupyter lab environment. + + +### Cleaning up when done + +```bash +docker compose down +``` + +When using local database and it's necessary to wipe db contents: + +```bash +docker compose down -v +``` + + +## Test commands you originally shared with me + +I tried to keep the commands as they were before but there are some changes, and considering the upcoming permissions ands scope issues, there will be more. + +### Imports + +Original imports + +``` +import hippo +``` + +New imports + +``` +from hippo import HIPPO +``` +(+ whatever else you might need) + + + +### Setup animal +Original setup + +``` +target_name = "Flavi_NS5_RdRp" +animal = hippo.HIPPO(target_name, f"{target_name}.sqlite", update_legacy=True) +``` + +New animal setup: + +``` +target_name = "Flavi_NS5_RdRp" +target_access_string = "lb18145-1" +username = '' + +animal = hippo.HIPPO( + target_name=target_name, + target_access_string=target_access_string, + username=username, +) + + +``` + +If you didn't set up an `.env` file, you need to give connection parameters here (using the appropriate values of course): + +``` +db = { + 'DB_NAME': designdb, + 'DB_USER': postgres, + 'DB_PASSWORD': s_URzt7CWfWZ.AXD7RcF, + 'DB_HOST': database, + 'POSTGRES_PORT': '5432', +} +animal = hippo.HIPPO( + target_name=target_name, + target_access_string=target_access_string, + username=username, + db=db, +) +``` + +Unlike before, `HIPPO` is now a bootstrap function that sets up database. Since we're using it as a library not a standalone app, it needs to be called by user. That means, many if not most library objects cannot be imported before the animal is initialised. For example `RouteSet` (used below), can be imported only now: + +``` +from designdb.sets.route import RouteSet +``` + + + +Most important change here (even though it's not visible) - the `target_name` argument, which used to be just a project name, now actually is a target name. If there's no target by that name, it will be created, otherwise it will be fetched from the db. `animal` will remain associated with this target during it's lifecycle, you cannot work with other targets once initialised. + + +`update_legacy` flag doesn't work at the moment and going forward, probably won't be necessary at all. + +It's still possible to work with local sqlite databases. Let me know if you're interested in this, I may have to do some tweaks to make it more convenient. + + +### Registering methods + +The following methods seem necessary for the test workflow +``` +animal.register_enumeration_method(name="fragmenstein", version="1.0.0", description="XChem's implementation of Fragmenstein merges") + + +animal.register_pose_method(name="fragmenstein", version="1.0.0", description="XChem's implementation of Fragmenstein placement") +animal.register_pose_method(name="xray", version="1.0.0", description="Appropriate description") +animal.register_pose_method(name="gnina_repose", version="1.0.0", description="Appropriate description") + + +animal.register_scoring_method(name="gnina_cnn_vs", version="1.3.2", description="GNINA CNN VS score") +animal.register_scoring_method(name="fragmenstein_energy", version="1.0.0", description="appropriate description") +animal.register_scoring_method(name="fragmenstein_distance", version="1.0.0", description="appropriate description") +animal.register_scoring_method(name="moc_combo_multiref", version="1.3.2", description="Symmetric similarity score (equal weighting given to references and queries) with given reference/inspiration compounds set as references, and placed designs as the query") +animal.register_scoring_method(name="moc_combo_multiref", version="0.1.0", description="Symmetric similarity score (equal weighting given to references and queries) with given reference/inspiration compounds set as references, and placed designs as the query") +``` + + +### Add fragalysis hits +Original command: + +``` +animal.add_hits( + target_name=target_name, + metadata_csv=f"{target_name}/metadata.csv", + aligned_directory= f"{target_name}/aligned_files", + load_pose_mols=True, +) +``` + +New command: + +``` +animal.add_hits( + metadata_csv=f"{target_name}/metadata.csv", + aligned_directory= f"{target_name}/aligned_files", +) +``` + +Since the animal already knows about the target, there' no need to specify it. +`load_pose_mols` is gone because pose registration is done quite differently now. + + +## Create input for Fragmenstein and Knitwork +No changes here: + +``` +fragment_hits = animal.poses(tag="hits") +fragment_hits + +fragment_hits.write_sdf("fragment_hits.sdf") +fragment_hits.to_knitwork("knitwork_input.csv", aligned_files_dir="aligned_files") +``` + +### Load BulkDock SDF +Original code: + +``` +SDFs = [ + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch002_820954.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch000_820952.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch001_820953.sdf" +] + +for sd in SDFs: + full_path = os.path.join("bulkdock", sd) + + key = sd.strip(".sdf") + + animal.load_sdf( + target= target_name, + path=full_path, + inspiration_col="inspiration_ids", + reference_col="reference_id", + compound_tags = [key], + pose_tags = ["fragmenstein_placed", key], + name_col = "ID", + ) +``` + +New code, `target_name` is gone: + +``` +SDFs = [ + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch002_820954.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch000_820952.sdf", + "openbind_flavi_ns5_rdrp_c1_fragmenstein_split2000_batch001_820953.sdf" +] + +for sd in SDFs: + full_path = os.path.join("bulkdock", sd) + + key = sd.strip(".sdf") + + animal.load_sdf( + path=full_path, + inspiration_col="inspiration_ids", + reference_col="reference_id", + compound_tags = [key], + pose_tags = ["fragmenstein_placed", key], + name_col = "ID", + ) +``` + +### Generate Fragalysis RHS input +No changes + +``` +poses = animal.poses.get_by_tag("fragmenstein_placed") + +poses.to_fragalysis("flavi_ns5_rdrp_bulkdock_poses.sdf", method="fragmenstein", submitter_name = "Lauren Reid", submitter_email= "lauren.reid@medchemica.com", submitter_institution="MedChemica", copy_reference_pdbs=True) +``` + + +### Load GNINA poses and scores +Drops `target_name`, otherwise no changes + +``` +animal.load_sdf( + path="gnina/output_sdfs/z0625b_ligands_minimized.sdf", + pose_tags = ["gnina_minimised"], +) +``` + + +### Create Syndirella inputs +No changes + +``` +poses.to_syndirella("syndirella_input.csv") +``` + +### Load Syndirella retrosynthesis routes +No changes + +``` +animal.add_syndirella_routes( + "syndirella/retro/justretroquery_manifold_ZWWZBAUAVBEARZ-UHFFFAOYSA-N-scaffold-A.pkl.gz", + CAR_only=False, + check_chemistry=False, +) +``` + + +### Load Syndirella elaborations +Old code: + +``` +comp = animal.compounds.get_by_smiles("CN(C[C@H]1CCNC1)c1ccc2ccccc2n1") + +print(comp.id) +print(comp.smiles) + +routes = hippo.RouteSet.from_product_ids(animal.db, [comp.id]) + +assert len(routes) == 1, "Wrong number of routes" + +route = routes.pop() + +animal.add_syndirella_elabs("syndirella/elabs/ZWWZBAUAVBEARZ-UHFFFAOYSA-N_a7d7696daae73aca44078d04fc8c3093_structured_output.pkl.gz", scaffold_route=route) +``` + +New code: + +``` +comp = animal.compounds.get_by_smiles("CN(C[C@H]1CCNC1)c1ccc2ccccc2n1") + +print(comp.id) +print(comp.smiles) + +routes = RouteSet.from_product_ids(animal.db, [comp.id]) + +assert len(routes) == 1, "Wrong number of routes" + +route = routes.pop() + +animal.add_syndirella_elabs("syndirella/elabs/ZWWZBAUAVBEARZ-UHFFFAOYSA-N_a7d7696daae73aca44078d04fc8c3093_structured_output.pkl.gz", scaffold_route=route) +``` + +RouteSet must be imported and called directly, not through animal. This is something that probably needs to change, but I'm not sure which way. I'll know better once work on target and TAS scope begins. + +NB! the last command did not run successfully for me, I didn't have original SDF files on disk, so it couldn't find the reference files, and even if I disabled that, there was nan reference in the data frame. If the files were indeed correct, I'll have to revisit that. diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index e488a10..323c513 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -150,13 +150,6 @@ def quote_compounds( ) -> tuple[CompoundSet, CompoundSet]: """Report which compounds have catalogue quotes. - In the modern DesignDB catalogue prices live in the same database and are - linked to compounds automatically (the DB matches the registration hash - and populates ``compound_catalogue_map``). This therefore no longer - transfers quotes from a separate catalogue animal — it reports, for the - current database, which compounds are quoted (have at least one linked - catalogue price) and which are not. - :param compounds: optional :class:`.CompoundSet` to restrict to; defaults to all compounds in the database :returns: ``(quoted, unquoted)`` :class:`.CompoundSet` objects @@ -192,8 +185,7 @@ def _ensure_hit_data( Downloads the target's Fragalysis data (all observations) from the stack via :class:`.DownloadService` and returns the path to the extracted - directory (``data/downloads//``). The requested file - types are :data:`.HIT_DATA_FLAGS` (apo/bound/ligand/sdf/smiles/metadata). + directory (``data/downloads//``). Unlike :meth:`._ensure_apo_desolv_files`, this data **persists**: if a previous full download is already on disk it is reused without From 98f34db3cc5c032bfd261415744b7408da612d70 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 16 Jun 2026 15:45:43 +0100 Subject: [PATCH 153/163] stashing --- hippo/__init__.py | 17 ++++++++- hippo/designdb/animal.py | 55 ++++++++++++++++++++++++++++- hippo/designdb/components/recipe.py | 33 +++++++++++++---- hippo/designdb/services/recipe.py | 4 ++- hippo/designdb/sets/compound.py | 28 +++++++-------- 5 files changed, 112 insertions(+), 25 deletions(-) diff --git a/hippo/__init__.py b/hippo/__init__.py index e49f7b7..29cbb67 100644 --- a/hippo/__init__.py +++ b/hippo/__init__.py @@ -1,3 +1,18 @@ from .bootstrap import load_hippo as HIPPO -__all__ = ['HIPPO'] +__all__ = ['HIPPO', 'IngredientSet'] + + +def __getattr__(name): + """Lazily expose select designdb classes at the package top level. + + Imported on first access (after ``load_hippo()``/``HIPPO()`` has configured + Django) rather than at ``import hippo`` time, which runs before Django is + configured. NB: transitional -- exposing internal classes like this is + pending the client-exposure design (see RecipeManager discussion). + """ + if name == 'IngredientSet': + from designdb.sets.compound import IngredientSet + + return IngredientSet + raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 323c513..5537403 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -24,10 +24,12 @@ from .services.method import MethodService from .services.quote import QuoteService from .services.reaction import ReactionService +from .services.recipe import RecipeService from .services.route import RouteService from .services.subsite import SubsiteService -from .sets.compound import CompoundSet +from .sets.compound import CompoundSet, IngredientSet from .sets.pose import PoseSet +from .sets.reaction import ReactionSet from .settings import DEFAULT_POSE_METHODS from .utils import make_warn_once_per_key @@ -53,6 +55,52 @@ ) +class RecipeManager: + """Client-side accessor for building recipes, bound to a :class:`.HIPPO`. + + Groups the recipe-construction entry points and delegates to the + :class:`.RecipeService` (backend), keeping recipe construction on the + user-facing client surface (``animal.recipes.from_*``) instead of exposing + the component/service layers directly. This is also the seam where target / + auth scoping will be attached once the client/backend split lands. + + Access it via :attr:`.HIPPO.recipes`. + """ + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: CompoundSet, **kwargs): + """Build recipe(s) to synthesise a :class:`.CompoundSet`. + + See :meth:`.RecipeService.from_compounds` for keyword arguments. + """ + if not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return RecipeService.from_compounds(compounds, **kwargs) + + def from_reactions(self, reactions: ReactionSet, **kwargs): + """Build recipe(s) from a :class:`.ReactionSet`. + + See :meth:`.RecipeService.from_reactions` for keyword arguments. + """ + if not isinstance(reactions, ReactionSet): + raise TypeError(f'reactions must be a ReactionSet, got {type(reactions)}') + return RecipeService.from_reactions(reactions, **kwargs) + + def from_reactants(self, reactants: 'CompoundSet | IngredientSet', **kwargs): + """Build the maximal recipe reachable from a set of reactants. + + See :meth:`.RecipeService.from_reactants` for keyword arguments. + """ + if not isinstance(reactants, (CompoundSet, IngredientSet)): + raise TypeError( + 'reactants must be a CompoundSet or IngredientSet, ' + f'got {type(reactants)}' + ) + return RecipeService.from_reactants(reactants, **kwargs) + + class HIPPO: """Entry-point class of the xchem-hippo package. @@ -140,6 +188,11 @@ def reactants(self) -> CompoundSet: (leaf reactants / purchasable building blocks).""" return CompoundSet(list(ReactionService.reactant_compound_ids())) + @property + def recipes(self) -> RecipeManager: + """Client-side accessor for building recipes (see :class:`.RecipeManager`).""" + return RecipeManager(self) + @property def num_poses(self) -> int: """Total number of Poses in the Database""" diff --git a/hippo/designdb/components/recipe.py b/hippo/designdb/components/recipe.py index 368d775..352f8ac 100644 --- a/hippo/designdb/components/recipe.py +++ b/hippo/designdb/components/recipe.py @@ -19,6 +19,7 @@ from designdb.sets.compound import CompoundSet, IngredientSet from designdb.sets.reaction import ReactionSet +from .compound import Ingredient from .reaction import Reaction @@ -985,10 +986,14 @@ def from_json(cls, path: 'str | Path | None' = None, data: dict = None) -> 'Rout return self @classmethod - def get_route(cls, *, id: int, debug: bool = False) -> 'Route': + def get_route( + cls, *, id: int, get_quote: bool = True, debug: bool = False + ) -> 'Route': """Fetch a :class:`.RouteModel` stored in the database and wrap it. :param id: the ID of the :class:`.RouteModel` to retrieve + :param get_quote: fetch catalogue quotes for the reactants so the route is + priced (mirrors :meth:`.RecipeService.from_reaction`), defaults to ``True`` :param debug: increase verbosity for debugging """ @@ -1024,16 +1029,30 @@ def get_route(cls, *, id: int, debug: bool = False) -> 'Route': if debug: mrich.var('components', qs) - def _ingredients(ids, amounts): - """Build an IngredientSet, returning an empty one for no ids.""" - if not ids: - return IngredientSet() - return IngredientSet.from_compounds(ids=ids, amount=amounts) + def _ingredients(ids, amounts, quote=False): + """Build an IngredientSet, returning an empty one for no ids. + + When ``quote`` is set, each ingredient fetches its cheapest catalogue + quote (via the ``compound_catalogue_map`` junction) so the route can be + priced; otherwise ingredients are left unquoted. + """ + iset = IngredientSet() + for cid, amount in zip(ids, amounts): + iset.add( + Ingredient.from_compound( + compound=CompoundModel.objects.get(pk=cid), + amount=amount, + get_quote=quote, + ) + ) + return iset + # products are made, not purchased, so they are never quoted products = IngredientSet.from_compounds( ids=[route.product_compound_id], amount=1 ) - reactants = _ingredients(reactant_ids, reactant_amounts) + # reactants are the building blocks that get bought -> fetch quotes + reactants = _ingredients(reactant_ids, reactant_amounts, quote=get_quote) intermediates = _ingredients(intermediate_ids, intermediate_amounts) reactions = ReactionSet(reaction_ids) diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 6823ed8..90fbfa8 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -435,7 +435,9 @@ def from_compounds( mrich.print('Picking cheapest from', len(priced), 'options') if not priced: mrich.error("0 recipes with prices, can't choose cheapest") - return solutions + # fall back to the first (unpriced) solution so the return type + # stays a single Recipe, consistent with pick_first / the priced path + return solutions[0] return sorted(priced, key=lambda r: r.price)[0] return solutions diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index c98b22b..d745839 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -299,8 +299,8 @@ def get_by_tag( self._queryset = self._queryset.annotate( has_tag=Exists( CompoundTagJunctionModel.objects.filter( - pose=OuterRef('pk'), - pose_tag__pose_tag_name=tag, + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, ), ), ) @@ -1072,26 +1072,24 @@ def get_quoted( *, supplier: str = 'any', ) -> 'CompoundSet': - """Get all member compounds that have a quote from given supplier + """Get all member compounds that have a catalogue quote. - :param supplier: supplier name (Default value = 'any') + Quotes live in ``catalogue_prices`` and are linked to compounds via the + ``compound_catalogue_map`` junction (see :class:`.QuoteService`). + :param supplier: restrict to this supplier, or ``'any'`` (default) """ - if supplier == 'any': - key = f'quote_compound IN {self.str_ids}' - else: - key = f'quote_compound IN {self.str_ids} AND quote_supplier = "{supplier}"' + from designdb.models import CataloguePriceCompoundJunctionModel - ids = self.db.select_where( - table='quote', - query='DISTINCT quote_compound', - key=key, - multiple=True, + qs = CataloguePriceCompoundJunctionModel.objects.filter( + compound_id__in=list(self.ids) ) + if supplier != 'any': + qs = qs.filter(catalogue_price__supplier=supplier) - ids = [i for (i,) in ids] - return CompoundSet(self.db, ids) + quoted = set(qs.values_list('compound_id', flat=True).distinct()) + return CompoundSet([i for i in self.ids if i in quoted]) def get_unquoted( self, From cb1ccbd78ed01e70eb59fbe69d4ca46bf2abc4f2 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 17 Jun 2026 10:31:08 +0100 Subject: [PATCH 154/163] fix: quote fixes --- hippo/designdb/components/compound.py | 5 +++++ hippo/designdb/sets/compound.py | 9 +++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index f592459..0ec042e 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -1113,6 +1113,11 @@ def quote(self) -> int: """Returns the ID of the associated :class:`.Quote`""" return self._quote + @property + def quote_id(self) -> int | None: + """ID of the associated quote (:class:`.CataloguePriceModel`), or None.""" + return self._quote.id if self._quote is not None else None + @property def price(self) -> Price: """Returns the price from the associated quote, or a null Price if diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index c98b22b..d4af728 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -299,8 +299,8 @@ def get_by_tag( self._queryset = self._queryset.annotate( has_tag=Exists( CompoundTagJunctionModel.objects.filter( - pose=OuterRef('pk'), - pose_tag__pose_tag_name=tag, + compound=OuterRef('pk'), + compound_tag__compound_tag_name=tag, ), ), ) @@ -2299,10 +2299,7 @@ def add( compound_id = ingredient.compound.pk amount = ingredient.amount - if (q := ingredient.quote) and not ingredient.quote_id: - # I don't understand the logic for this. it's always - # true now. what was the meaning of storing id? - mrich.warning(f'Losing quote! {ingredient.quote=}') + q = ingredient.quote supplier = ingredient.supplier max_lead_time = ingredient.max_lead_time From ff96b71c495f674d480d686698abbb34a1329df6 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Fri, 19 Jun 2026 09:31:06 +0100 Subject: [PATCH 155/163] feat: wf2 mostly working end-to-end. Plotting not been tested yet --- hippo/bootstrap.py | 6 +- hippo/designdb/animal.py | 251 +++++++++- hippo/designdb/components/compound.py | 92 +++- hippo/designdb/components/pose.py | 143 ++++++ hippo/designdb/components/quote.py | 127 +++++ hippo/designdb/interactions.py | 41 ++ hippo/designdb/services/generation.py | 462 ++++++++++++++++++ hippo/designdb/services/interaction.py | 368 ++++++++++++++ hippo/designdb/services/recipe.py | 145 +++++- hippo/designdb/services/scoring.py | 633 +++++++++++++++++++++++++ hippo/designdb/sets/compound.py | 198 ++++---- hippo/designdb/sets/interaction.py | 55 +-- hippo/designdb/sets/pose.py | 41 +- hippo/designdb/sets/reaction.py | 8 +- 14 files changed, 2377 insertions(+), 193 deletions(-) create mode 100644 hippo/designdb/components/pose.py create mode 100644 hippo/designdb/components/quote.py create mode 100644 hippo/designdb/interactions.py create mode 100644 hippo/designdb/services/generation.py create mode 100644 hippo/designdb/services/interaction.py create mode 100644 hippo/designdb/services/scoring.py diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index 71466c4..0f650dc 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -66,6 +66,8 @@ def load_hippo( target_access_string: str, username: str, db: str | Path | dict | None = None, + stack: str = 'production', + auth_token: str | None = None, # copy_from: str | Path | None = None, # overwrite_existing: bool = False, # update_legacy: bool = False, @@ -136,7 +138,9 @@ def load_hippo( # import .testmodule from designdb.animal import HIPPO - animal = HIPPO(target_name, target_access_string) + animal = HIPPO( + target_name, target_access_string, stack=stack, auth_token=auth_token + ) mrich.success('Initialised animal', f'{target_name}') return animal diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index 5537403..eb48f16 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -10,6 +10,7 @@ import pandas as pd from django.db import transaction +from .components.recipe import Recipe from .models import ( CompoundModel, EnumerationMethodModel, @@ -20,16 +21,23 @@ TargetModel, ) from .services.download import DownloadService +from .services.generation import ( + RandomRecipeGenerator, + RandomRecipeSelectionGenerator, + RandomSelectionGenerator, +) from .services.ingestion import IngestionBatchResult, IngestionService from .services.method import MethodService from .services.quote import QuoteService from .services.reaction import ReactionService from .services.recipe import RecipeService from .services.route import RouteService +from .services.scoring import Scorer from .services.subsite import SubsiteService from .sets.compound import CompoundSet, IngredientSet from .sets.pose import PoseSet from .sets.reaction import ReactionSet +from .sets.route import RouteSet from .settings import DEFAULT_POSE_METHODS from .utils import make_warn_once_per_key @@ -100,6 +108,192 @@ def from_reactants(self, reactants: 'CompoundSet | IngredientSet', **kwargs): ) return RecipeService.from_reactants(reactants, **kwargs) + def from_json(self, path, **kwargs) -> 'Recipe': + """Load a serialised :class:`.Recipe` from a JSON file. + + See :meth:`.Recipe.from_json` for keyword arguments (``data``, + ``clear_quotes``, ``debug``). + """ + return Recipe.from_json(path, **kwargs) + + +class IngredientManager: + """Client-side accessor for building :class:`.IngredientSet`\\ s, bound to a + :class:`.HIPPO`. + + Mirrors :class:`.RecipeManager`: keeps :class:`.IngredientSet` construction on + the user-facing client surface (``animal.ingredients.from_*``) instead of + exposing the set layer directly. This is also the seam where target / auth + scoping will be attached once the client/backend split lands. + + Access it via :attr:`.HIPPO.ingredients`. + """ + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: 'CompoundSet | None' = None, **kwargs): + """Build an :class:`.IngredientSet` from a :class:`.CompoundSet` (or IDs). + + See :meth:`.IngredientSet.from_compounds` for keyword arguments. + """ + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return IngredientSet.from_compounds(compounds=compounds, **kwargs) + + +class RouteManager: + """Client-side accessor for building :class:`.RouteSet`\\ s, bound to a + :class:`.HIPPO`. + + Mirrors :class:`.RecipeManager` / :class:`.IngredientManager`: keeps + :class:`.RouteSet` construction on the user-facing client surface + (``animal.routes.from_*``) instead of exposing the set layer directly. This is + also the seam where target / auth scoping will be attached once the + client/backend split lands. + + Access it via :attr:`.HIPPO.routes`. + """ + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_product_ids(self, ids: 'CompoundSet | list[int]', *, progress=True): + """Build a :class:`.RouteSet` of stored routes to the given products. + + :param ids: product :class:`.CompoundModel` IDs (or a :class:`.CompoundSet`) + :param progress: show a progress bar while building + """ + if isinstance(ids, CompoundSet): + ids = ids.ids + return RouteSet.from_product_ids(ids, progress=progress) + + +class ScorerManager: + """Client-side accessor for recipe scoring, bound to a :class:`.HIPPO`. + + Mirrors the other managers: keeps :class:`.Scorer` construction on the + user-facing client surface (``animal.scorers.*``) instead of exposing the + service directly. The :class:`.Scorer` loads recipes from a directory of + ``Recipe_*.json`` files (as written by the generators) -- no ``db`` needed. + + Access it via :attr:`.HIPPO.scorers`. + """ + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def default(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with the default attributes. + + See :meth:`.Scorer.default` for keyword arguments (``skip``, + ``load_cache``, ``allowed_poses``, ``out_key``, ...). + """ + return Scorer.default(directory, **kwargs) + + def create(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with explicit attributes. + + See :class:`.Scorer` for keyword arguments. + """ + return Scorer(directory, **kwargs) + + +class GeneratorManager: + """Client-side accessor for the random recipe / selection generators, bound to + a :class:`.HIPPO`. + + Mirrors :class:`.RecipeManager` / :class:`.IngredientManager` / + :class:`.RouteManager`: keeps generator construction on the user-facing client + surface (``animal.generators.*``) and validates inputs before delegating to the + backend generators in :mod:`designdb.services.generation`. Generators are + in-memory: their ``generate(...)`` returns a :class:`.Recipe`. + + Access it via :attr:`.HIPPO.generators`. + """ + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + @staticmethod + def _check(route_pool, compounds) -> None: + if route_pool is not None and not isinstance(route_pool, RouteSet): + raise TypeError(f'route_pool must be a RouteSet, got {type(route_pool)}') + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + + def random_recipe( + self, + *, + out_key: str, + route_pool=None, + suppliers=None, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeGenerator: + """A generator that samples synthetic :class:`.Route`\\ s. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, None) + return RandomRecipeGenerator( + out_key=out_key, + route_pool=route_pool, + suppliers=suppliers, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_selection( + self, + *, + out_key: str, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomSelectionGenerator: + """A generator that samples (catalogue) compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(None, compounds) + return RandomSelectionGenerator( + out_key=out_key, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_recipe_selection( + self, + *, + out_key: str, + route_pool=None, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeSelectionGenerator: + """A generator combining routes and compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, compounds) + return RandomRecipeSelectionGenerator( + out_key=out_key, + route_pool=route_pool, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) + class HIPPO: """Entry-point class of the xchem-hippo package. @@ -111,6 +305,9 @@ def __init__( self, target_name: str, target_access_string: str, + *, + stack: str = 'production', + auth_token: str | None = None, ) -> None: # TODO: with working db, hippo shouldn't be creating projects @@ -124,6 +321,11 @@ def __init__( project=project, ) + # Fragalysis stack / auth used for any downloads triggered by this + # instance (see _ensure_hit_data / _ensure_apo_desolv_files). + self._stack = stack + self._auth_token = auth_token + # Download state (see _ensure_hit_data / _ensure_apo_desolv_files). The # full hit data persists on disk and is reused across sessions; the # apo_desolv subset is re-downloaded once per instance to stay fresh. @@ -131,6 +333,24 @@ def __init__( self._apo_desolv_path: Path | None = None self._apo_desolv_downloaded_at: datetime | None = None + # Download policy: instantiation triggers no download on a *first* run + # (the full hit data, including apo_desolv proteins, is fetched by the + # first add_hits against a remote stack). On *re-instantiation* of the + # same target/project -- detected by a persisted full download already on + # disk -- only the protein (apo_desolv) files are refreshed here, so a new + # session always has current PDBs for interaction calculations. + target_dir = DOWNLOADS_DIR / project.project_name / target_name + if (target_dir / 'metadata.csv').is_file() and ( + target_dir / 'aligned_files' + ).is_dir(): + try: + self._ensure_apo_desolv_files( + auth_token=self._auth_token, stack=self._stack + ) + except ValueError as e: + # no poses in the DB yet -> can't tell which structures to fetch + mrich.warning(f'Skipping apo_desolv refresh on init: {e}') + # TODO: the way this worked previously was it gave the HIPPO # instance full access to the pose table. When working with # multi-project central postgres db, this is almost certainly @@ -193,6 +413,29 @@ def recipes(self) -> RecipeManager: """Client-side accessor for building recipes (see :class:`.RecipeManager`).""" return RecipeManager(self) + @property + def ingredients(self) -> IngredientManager: + """Client-side accessor for building IngredientSets (see + :class:`.IngredientManager`).""" + return IngredientManager(self) + + @property + def routes(self) -> RouteManager: + """Client-side accessor for building RouteSets (see + :class:`.RouteManager`).""" + return RouteManager(self) + + @property + def generators(self) -> GeneratorManager: + """Client-side accessor for the random recipe/selection generators (see + :class:`.GeneratorManager`).""" + return GeneratorManager(self) + + @property + def scorers(self) -> ScorerManager: + """Client-side accessor for recipe scoring (see :class:`.ScorerManager`).""" + return ScorerManager(self) + @property def num_poses(self) -> int: """Total number of Poses in the Database""" @@ -369,7 +612,7 @@ def add_hits( metadata_csv: str | Path | None = None, aligned_directory: str | Path | None = None, auth_token: str | None = None, - stack: str = 'production', + stack: str | None = None, tags: list | None = None, pose_methods: list[str] | None = None, skip: list | None = None, @@ -402,6 +645,12 @@ def add_hits( # Path-driven: provide both metadata_csv and aligned_directory to load # existing local data, or omit both to download the target's data from # the Fragalysis stack (always Fragalysis-type). + # fall back to the stack/auth configured at instantiation + if stack is None: + stack = self._stack + if auth_token is None: + auth_token = self._auth_token + if metadata_csv is None and aligned_directory is None: hit_dir = self._ensure_hit_data(auth_token=auth_token, stack=stack) aligned_directory = hit_dir / 'aligned_files' diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index 0ec042e..9311c25 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -28,6 +28,7 @@ from rdkit.Chem.Scaffolds import MurckoScaffold from .price import Price +from .quote import Quote class Compound: @@ -900,18 +901,38 @@ def __init__( self, compound: CompoundModel, # or CatalogueCompoundModel? amount: float, - quote: CataloguePriceModel, + quote: 'Quote | CataloguePriceModel | int | None' = None, max_lead_time: float | None = None, supplier: str | None = None, ): - """Ingredient initialisation""" + """Ingredient initialisation + + ``quote`` may be a :class:`.Quote`, a :class:`.CataloguePriceModel`, a quote + ID (``int``), or ``None``. When only an ID (or nothing) is stored, the quote + is resolved lazily on first access via :attr:`.quote` -- which re-quotes the + catalogue (estimating a price if no pack is large enough). + """ self._compound = compound - self._quote = quote self._amount = amount self._max_lead_time = max_lead_time self._supplier = supplier + # `_quote` holds a resolved Quote component (or None); `_quote_id` holds a + # persisted quote ID awaiting lazy resolution. Estimated quotes have no ID + # and so are always carried as a resolved `_quote` object. + self._quote = None + self._quote_id = None + + if isinstance(quote, Quote): + self._quote = quote + self._quote_id = quote.id + elif isinstance(quote, CataloguePriceModel): + self._quote = Quote(quote) + self._quote_id = quote.pk + elif quote is not None: + self._quote_id = int(quote) + def __str__(self) -> str: """Plain string representation""" return f'{self.amount:.2f}mg of C{self._compound.id}' @@ -1040,17 +1061,27 @@ def get_quotes( if max_lead_time: qs = qs.filter(lead_time__lte=max_lead_time) + estimate = None + if min_amount: - qs = qs.filter(amount__gte=min_amount) + suitable = qs.filter(amount__gte=min_amount) - if not qs.exists(): + if suitable.exists(): + qs = suitable + else: + # no single pack is large enough -> estimate by scaling the biggest + # available pack's unit price (mirrors legacy Quote.combination) mrich.debug( f'No quote available for C{compound.pk} with amount >=' f' {min_amount} mg. Estimating price...' ) + estimate = Quote.estimate(min_amount, [Quote(m) for m in qs]) if pick_cheapest: - return qs.order_by('price').first() + if estimate is not None: + return estimate + cheapest = qs.order_by('price').first() + return Quote(cheapest) if cheapest is not None else None if df: return pd.DataFrame(qs.values()).drop(columns='compound') @@ -1109,22 +1140,45 @@ def compound_id(self) -> int: return self._compound.id @property - def quote(self) -> int: - """Returns the ID of the associated :class:`.Quote`""" + def quote(self) -> 'Quote | None': + """The associated :class:`.Quote`, resolved lazily. + + If a persisted quote ID is stored it is fetched; otherwise the catalogue is + re-quoted for this ingredient's amount (estimating a price if no single pack + is large enough). Returns ``None`` if no quote can be found or estimated. + """ + if self._quote is None: + if self._quote_id: + self._quote = Quote(CataloguePriceModel.objects.get(pk=self._quote_id)) + else: + self._quote = Ingredient.get_quotes( + compound=self._compound, + min_amount=self._amount, + supplier=self._supplier, + max_lead_time=self._max_lead_time, + pick_cheapest=True, + none='quiet', + ) + if self._quote is not None: + self._quote_id = self._quote.id return self._quote @property def quote_id(self) -> int | None: - """ID of the associated quote (:class:`.CataloguePriceModel`), or None.""" - return self._quote.id if self._quote is not None else None + """ID of the associated quote, or ``None`` (estimated/unquoted).""" + if self._quote_id is not None: + return self._quote_id + quote = self.quote + return quote.id if quote is not None else None @property def price(self) -> Price: """Returns the price from the associated quote, or a null Price if unavailable.""" - if self._quote is None: + quote = self.quote + if quote is None: return Price.null() - return Price(self._quote.price, self._quote.currency) + return quote.as_price @property def max_lead_time(self) -> float: @@ -1138,18 +1192,12 @@ def supplier(self) -> str: @amount.setter def amount(self, a) -> None: - """Set the amount and fetch updated :class:`.Quote`s""" - - quote = self.get_cheapest_quote_id( - min_amount=a, - max_lead_time=self._max_lead_time, - supplier=self._supplier, - none='quiet', - ) - - self._quote = quote + """Set the amount, invalidating the cached quote so it is re-quoted (and + re-estimated if needed) for the new amount on next access.""" self._amount = a + self._quote = None + self._quote_id = None @property def compound(self) -> CompoundModel: diff --git a/hippo/designdb/components/pose.py b/hippo/designdb/components/pose.py new file mode 100644 index 0000000..4fdc728 --- /dev/null +++ b/hippo/designdb/components/pose.py @@ -0,0 +1,143 @@ +"""Pose component wrapping a :class:`.PoseModel`. + +A :class:`.Pose` is a particular conformer of a :class:`.Compound` in a protein +environment. This component wraps the ORM :class:`.PoseModel`, exposing the ligand +molecule, the protein structure, and interaction-fingerprinting entry points. + +Missing attributes are delegated to the wrapped :class:`.PoseModel`, so model +fields/relations (``pose_alias``, ``tags``, ``inspirations``, ``save`` …) remain +accessible. +""" + +import mcol +import molparse as mp +from designdb.models import PoseModel + + +class Pose: + """A conformer of a :class:`.Compound` within a protein environment.""" + + def __init__(self, instance: 'PoseModel'): + """Pose initialisation""" + self._instance = instance + self._protein_system = None + + def __getattr__(self, key: str): + """Delegate unknown attributes to the wrapped :class:`.PoseModel`.""" + # guard internal attributes to avoid recursion before _instance is set + if key.startswith('_'): + raise AttributeError(key) + return getattr(self._instance, key) + + ### PROPERTIES + + @property + def instance(self) -> 'PoseModel': + """The wrapped :class:`.PoseModel`""" + return self._instance + + @property + def id(self) -> int: + """The pose's database ID""" + return self._instance.id + + @property + def pk(self) -> int: + """The pose's primary key""" + return self._instance.pk + + @property + def mol(self): + """The pose's ligand ``rdkit.Chem.Mol`` (stored in the DB)""" + return self._instance.pose_mol + + @property + def protein_link(self) -> str | None: + """Path/link to the pose's protein structure (PDB)""" + return self._instance.protein_link + + @property + def reference_id(self) -> int | None: + """ID of the pose's protein reference pose, if any""" + return self._instance.pose_reference + + @property + def reference(self) -> 'Pose | None': + """The pose's protein reference (another :class:`.Pose`), if any""" + ref_id = self._instance.pose_reference + if ref_id is None: + return None + return Pose(PoseModel.objects.get(pk=ref_id)) + + @property + def protein_system(self) -> 'mp.System | None': + """The pose's protein ``molparse.System`` (parsed from the PDB)""" + if self._protein_system is None: + link = self.protein_link + if link and str(link).endswith('.pdb'): + self._protein_system = mp.parse(link, verbosity=False).protein_system + return self._protein_system + + @protein_system.setter + def protein_system(self, system) -> None: + """Set the pose's protein ``molparse.System``""" + self._protein_system = system + + @property + def features(self) -> list: + """The pose ligand's ``molparse`` features""" + return mp.rdkit.features_from_mol(self.mol) + + @property + def has_fingerprint(self) -> bool: + """Whether this pose has had its interactions fingerprinted""" + return bool(self._instance.pose_fingerprint) + + ### METHODS + + def set_has_fingerprint(self, fp: bool, commit: bool = True) -> None: + """Record whether this pose has been fingerprinted. + + :param fp: fingerprint state + :param commit: persist to the database (Default value = True) + """ + assert isinstance(fp, bool) + self._instance.pose_fingerprint = int(fp) + if commit: + self._instance.save(update_fields=['pose_fingerprint']) + + def calculate_interactions(self, **kwargs) -> None: + """Enumerate valid interactions between this pose's ligand and protein. + + Delegates to :meth:`.InteractionService.calculate`. See that method for + keyword arguments. + """ + from designdb.services.interaction import InteractionService + + return InteractionService.calculate(self, **kwargs) + + ### DUNDERS + + def __str__(self) -> str: + """Plain string representation""" + return f'P{self.id}' + + 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)}' + + def __eq__(self, other) -> bool: + """Equality by pose ID""" + if isinstance(other, Pose): + return self.id == other.id + if isinstance(other, PoseModel): + return self.id == other.id + return NotImplemented + + def __hash__(self) -> int: + """Hash by pose ID""" + return hash(('Pose', self.id)) diff --git a/hippo/designdb/components/quote.py b/hippo/designdb/components/quote.py new file mode 100644 index 0000000..ce0022a --- /dev/null +++ b/hippo/designdb/components/quote.py @@ -0,0 +1,127 @@ +"""Component wrapping a catalogue price (quote). + +A :class:`.Quote` wraps a :class:`.CataloguePriceModel` row, exposing its price, +amount, supplier, etc. in a convenient form. It can also represent an *estimated* +quote (see :meth:`.Quote.estimate`) that is **not** backed by a saved database row +-- used when no single catalogue pack covers the required amount, mirroring the +legacy ``Quote.combination`` behaviour. +""" + +import mcol +from designdb.models import CataloguePriceModel + +from .price import Price + + +class Quote: + """A catalogue price for a :class:`.CompoundModel`, with a fixed amount. + + Wraps a :class:`.CataloguePriceModel`. Estimated quotes (built by + :meth:`.estimate`) wrap an *unsaved* model instance and therefore have + ``id is None`` and :attr:`.is_estimate` ``True``. + """ + + def __init__(self, instance: CataloguePriceModel): + """Quote initialisation""" + self._instance = instance + + ### FACTORIES + + @classmethod + def estimate(cls, required_amount: float, quotes: 'list[Quote]') -> 'Quote | None': + """Estimate a quote for ``required_amount`` when no single pack is big enough. + + Mirrors the legacy ``Quote.combination``: take the biggest available pack and + scale its unit price linearly to the required amount. The returned quote wraps + an *unsaved* :class:`.CataloguePriceModel` (``id is None``). + + :param required_amount: amount in ``mg`` + :param quotes: available :class:`.Quote` packs to scale from + :returns: an estimated :class:`.Quote`, or ``None`` if there is nothing to + scale from (no pack with a usable amount and price) + """ + + usable = [q for q in quotes if q.amount and q.price is not None] + + if not usable: + return None + + biggest_pack = max(usable, key=lambda q: q.amount) + + unit_price = biggest_pack.price / biggest_pack.amount + estimated_price = unit_price * required_amount + + instance = CataloguePriceModel( + catalogue_compound_id=biggest_pack._instance.catalogue_compound_id, + vendor=biggest_pack._instance.vendor, + supplier=biggest_pack.supplier, + amount=required_amount, + price=estimated_price, + currency=biggest_pack.currency, + purity=biggest_pack._instance.purity, + lead_time=biggest_pack.lead_time, + ) + + return cls(instance) + + ### PROPERTIES + + @property + def id(self) -> int | None: + """Database ID of the wrapped quote, or ``None`` for an estimate""" + return self._instance.pk + + @property + def is_estimate(self) -> bool: + """``True`` if this quote is an estimate not backed by a saved row""" + return self._instance.pk is None + + @property + def price(self) -> float | None: + """Price amount (currency-less float), see :attr:`.as_price`""" + return self._instance.price + + @property + def currency(self) -> str | None: + """Currency of the price""" + return self._instance.currency + + @property + def as_price(self) -> 'Price': + """The price as a :class:`.Price` object""" + return Price(self._instance.price, self._instance.currency) + + @property + def amount(self) -> float | None: + """Quoted amount in ``mg``""" + return self._instance.amount + + @property + def supplier(self) -> str | None: + """Supplier of the quote""" + return self._instance.supplier + + @property + def vendor(self) -> str | None: + """Vendor of the quote""" + return self._instance.vendor + + @property + def lead_time(self) -> int | None: + """Lead time of the quote (in days)""" + return self._instance.lead_time + + ### DUNDERS + + def __str__(self) -> str: + """Plain string representation""" + tag = 'estimate' if self.is_estimate else f'Q{self.id}' + return f'{tag}: {self.as_price} for {self.amount}mg' + + 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)}' diff --git a/hippo/designdb/interactions.py b/hippo/designdb/interactions.py new file mode 100644 index 0000000..af3ec41 --- /dev/null +++ b/hippo/designdb/interactions.py @@ -0,0 +1,41 @@ +"""Constants for protein-ligand interaction detection (fingerprinting). + +The feature families and complementary-feature / interaction-type maps are +re-exported from ``molparse`` (the single source of truth). The distance/angle +cutoffs live here, ported from the legacy ``hippo`` ``pose`` module. + +Used by :class:`.InteractionService` and the :class:`.Pose` component. +""" + +from molparse.rdkit.features import COMPLEMENTARY_FEATURES, FEATURE_FAMILIES, INTERACTION_TYPES + +# maximum centroid-centroid distance (Angstrom) for each interaction type +INTERACTION_CUTOFF = { + 'Hydrophobic': 4.5, + 'Hydrogen Bond': 3.5, + 'Electrostatic': 4.5, + 'π-stacking': 6.0, + 'π-cation': 4.5, + # https://pubs.acs.org/doi/full/10.1021/acs.cgd.5b01058 + 'Sulfur-Sulfur': 4.0, +} + +# π-stacking geometry cutoffs (Angstrom) +PI_STACK_MIN_CUTOFF = 3.8 +PI_STACK_F2F_CUTOFF = 4.5 +PI_STACK_E2F_CUTOFF = 6.0 + +# warn (rather than silently skip) when a residue mismatch (mutation) occurs +# within this distance (Angstrom) of any ligand feature +MUTATION_WARNING_DIST = 15 + +__all__ = [ + 'FEATURE_FAMILIES', + 'COMPLEMENTARY_FEATURES', + 'INTERACTION_TYPES', + 'INTERACTION_CUTOFF', + 'PI_STACK_MIN_CUTOFF', + 'PI_STACK_F2F_CUTOFF', + 'PI_STACK_E2F_CUTOFF', + 'MUTATION_WARNING_DIST', +] diff --git a/hippo/designdb/services/generation.py b/hippo/designdb/services/generation.py new file mode 100644 index 0000000..2367af9 --- /dev/null +++ b/hippo/designdb/services/generation.py @@ -0,0 +1,462 @@ +"""Service layer: random recipe / selection generators. + +Ported from the legacy ``rgen`` module and modernised: + +* **No legacy ``Database`` coupling.** The modern :class:`.Recipe` / :class:`.Route` + / :class:`.IngredientSet` / :class:`.Price` are self-contained (ORM-backed), so + the generators take pools + config only -- no ``db`` / ``db.path``. As there is no + database path to derive output filenames from, ``out_key`` is now **required**. +* **File output is preserved.** Each :meth:`generate` writes the generated recipe to + ``{recipe_dir}/Recipe_.json`` (via :meth:`.Recipe.write_json`), and each + generator dumps its state to ``{out_key}_.json`` on construction -- matching + legacy behaviour. ``generate`` also returns the :class:`.Recipe` so callers may + collect them in memory. +* The three legacy generators share one add-within-budget loop here + (:func:`_generate_recipe`) rather than duplicating it. + +Layering: ``services -> components/sets``. User entry point: ``animal.generators``. +""" + +import json +from pathlib import Path + +import mrich +from designdb.components.price import Price +from designdb.components.recipe import Recipe, Route +from designdb.sets.compound import IngredientSet +from designdb.sets.route import RouteSet +from designdb.utils import dt_hash + +# sentinel for "no limit" on a given count +_UNLIMITED = 10**9 + + +def _generate_recipe( + starting_recipe: 'Recipe', + pool: list, + *, + budget: 'Price', + suppliers: 'list | None', + max_products: int, + max_reactions: int, + max_compounds: int, + max_iter: int | None, + shuffle: bool, + debug: bool, +) -> 'tuple[Recipe, dict]': + """Randomly add routes/compounds from ``pool`` to a recipe within ``budget``. + + ``pool`` items are :class:`.Route` objects (added via ``recipe += route``) or + :class:`.Ingredient` objects (added to ``recipe.compounds``). Candidates whose + product/compound is already present are skipped; an addition that exceeds the + budget is reverted. Stops on budget/limit/pool-depletion or ``max_iter``. + + :returns: ``(recipe, stats)`` where ``stats`` has ``stop_reason``, + ``iterations`` and the resolved ``max_iter``. + """ + + from random import shuffle as shuffle_func + + recipe = starting_recipe.copy() + + if suppliers is not None: + recipe.reactants._supplier = suppliers + recipe.compounds._supplier = suppliers + + pool = list(pool) + if not pool: + raise ValueError('Route/compound pool is empty!') + + if max_iter is None: + max_iter = max_products + max_reactions + max_iter = min(max_iter, len(pool)) + + if shuffle: + mrich.debug('Shuffling pool') + shuffle_func(pool) + + old_recipe = recipe.copy() + stop_reason = 'max iterations reached' + iterations = 0 + + for i in mrich.track(range(max_iter), prefix='Generating recipe...'): + iterations = i + candidate = pool.pop() + + candidate_compound = ( + candidate.product if isinstance(candidate, Route) else candidate + ) + + if ( + candidate_compound in recipe.products + or candidate_compound in recipe.compounds + ): + continue + + if isinstance(candidate, Route): + recipe += candidate + else: + recipe.compounds.add(candidate) + + new_price = recipe.price + + if not pool: + stop_reason = 'Route/compound pool depleted' + break + + if new_price > budget: + recipe = old_recipe.copy() + continue + + if len(recipe.reactions) > max_reactions: + stop_reason = 'Max #reactions exceeded' + break + + if len(recipe.products) > max_products: + stop_reason = 'Max #products exceeded' + break + + if len(recipe.compounds) > max_compounds: + stop_reason = 'Max #compounds exceeded' + break + + old_recipe = recipe.copy() + + mrich.success(f'{stop_reason}!') + mrich.success(f'Completed after {iterations} iterations!') + + stats = {'stop_reason': stop_reason, 'iterations': iterations, 'max_iter': max_iter} + return recipe, stats + + +class _GeneratorBase: + """Shared state, I/O setup and representation for the random generators.""" + + _suppliers: 'list | None' = None + _max_lead_time: 'float | None' = None + _starting_recipe: 'Recipe | None' = None + _out_key: str | None = None + _data_path: 'Path | None' = None + _recipe_dir: 'Path | None' = None + + def _setup_io( + self, + out_key: str | None, + data_suffix: str, + dir_suffix: str, + skip_directory_creation: bool, + ) -> None: + """Resolve output paths from ``out_key`` and create directories. + + :param out_key: base path/key for output files (required -- there is no + database path to derive it from) + :param data_suffix: suffix for the state JSON, e.g. ``'_rsgen.json'`` + :param dir_suffix: suffix for the recipe directory, e.g. + ``'_recipes_and_selections'`` + :param skip_directory_creation: don't create a recipe directory (used for + the inner generators of a composed generator) + """ + if not out_key: + raise ValueError( + 'out_key is required (used for output filenames; there is no ' + 'database path to derive it from)' + ) + + self._out_key = out_key + + parent_dir = Path(out_key).parent + if not parent_dir.exists(): + parent_dir.mkdir(parents=True) + + self._data_path = Path(f'{out_key}{data_suffix}') + if self._data_path.exists(): + mrich.warning(f'Will overwrite existing data file: {self._data_path}') + + if skip_directory_creation: + self._recipe_dir = None + else: + path = Path(f'{out_key}{dir_suffix}') + if not path.exists(): + mrich.writing(f'{path}/') + path.mkdir() + self._recipe_dir = path + + def _dump_data(self, data: dict) -> None: + """Write the generator state dict to ``data_path``.""" + if self._recipe_dir is not None: + data['recipe_dir'] = str(self._recipe_dir.resolve()) + else: + data['recipe_dir'] = None + data['suppliers'] = self._suppliers + data['starting_recipe'] = self._starting_recipe.get_dict(serialise_price=True) + mrich.writing(self._data_path) + json.dump(data, open(self._data_path, 'wt'), indent=4) + + def _write_recipe(self, recipe: 'Recipe', budget: 'Price', stats: dict, params: dict): + """Write a generated recipe to ``{recipe_dir}/Recipe_.json``.""" + out_file = self._recipe_dir / f'Recipe_{dt_hash()}.json' + metadict = { + 'gen_data_path': str(self._data_path.resolve()), + 'gen_recipe_dir': str(self._recipe_dir.resolve()), + 'gen_max_lead_time': self._max_lead_time, + 'gen_suppliers': self._suppliers, + 'gen_budget': budget.amount, + 'gen_currency': budget.currency, + 'gen_stop_reason': stats['stop_reason'], + 'gen_iterations': stats['iterations'], + 'gen_max_iter': stats['max_iter'], + 'gen_recipe_path': str(out_file.resolve()), + **params, + } + recipe.write_json(out_file, extra=metadict) + return out_file + + @property + def suppliers(self) -> 'list | None': + """Restrict quoting to these suppliers""" + return self._suppliers + + @property + def max_lead_time(self) -> 'float | None': + """Maximum lead-time constraint""" + return self._max_lead_time + + @property + def starting_recipe(self) -> 'Recipe': + """The recipe every generation starts from (a copy)""" + return self._starting_recipe + + @property + def out_key(self) -> str | None: + """Base key for output filenames""" + return self._out_key + + @property + def recipe_dir(self) -> 'Path | None': + """Directory generated recipe JSONs are written to""" + return self._recipe_dir + + def __call__(self, *args, **kwargs) -> 'Recipe': + """Generate a recipe (alias for :meth:`generate`)""" + return self.generate(*args, **kwargs) + + def __repr__(self) -> str: + return f'{type(self).__name__}(out_key={self._out_key!r})' + + +class RandomRecipeGenerator(_GeneratorBase): + """Generate random recipes by sampling synthetic :class:`.Route`\\ s.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + route_pool: 'RouteSet | None' = None, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + skip_directory_creation: bool = False, + ) -> None: + mrich.debug('RandomRecipeGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._starting_recipe = start_with or Recipe() + self._route_pool = route_pool if route_pool is not None else RouteSet() + + self._setup_io(out_key, '_rgen.json', '_recipes', skip_directory_creation) + self._dump_data({'route_pool': self._route_pool.get_dict()}) + + @property + def route_pool(self) -> 'RouteSet': + """Pool of routes to sample from""" + return self._route_pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_products: int = 1000, + max_reactions: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + balance_clusters: bool = False, + permitted_clusters=None, + debug: bool = False, + ) -> 'Recipe': + """Generate a random recipe of routes within ``budget`` (also written to disk).""" + if balance_clusters: + raise NotImplementedError( + 'balance_clusters requires route clustering, which is not yet ported' + ) + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._route_pool), + budget=budget, + suppliers=self._suppliers, + max_products=max_products, + max_reactions=max_reactions, + max_compounds=_UNLIMITED, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe( + recipe, + budget, + stats, + {'gen_max_products': max_products, 'gen_max_reactions': max_reactions}, + ) + return recipe + + +class RandomSelectionGenerator(_GeneratorBase): + """Generate random selections of (catalogue) compounds.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + compounds=None, + amount: float = 1.0, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + skip_directory_creation: bool = False, + ) -> None: + mrich.debug('RandomSelectionGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._amount = amount + self._starting_recipe = start_with or Recipe() + if compounds is None: + self._compound_pool = IngredientSet() + else: + self._compound_pool = IngredientSet.from_compounds( + compounds=compounds, amount=amount + ) + + self._setup_io(out_key, '_sgen.json', '_selections', skip_directory_creation) + self._dump_data( + {'amount': amount, 'compound_pool': self._compound_pool.get_dict()} + ) + + @property + def compound_pool(self) -> 'IngredientSet': + """Pool of compound ingredients to sample from""" + return self._compound_pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_compounds: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + debug: bool = False, + ) -> 'Recipe': + """Generate a random compound selection within ``budget`` (also written to disk).""" + if max_iter is None: + max_iter = max_compounds * 3 + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._compound_pool), + budget=budget, + suppliers=self._suppliers, + max_products=_UNLIMITED, + max_reactions=_UNLIMITED, + max_compounds=max_compounds, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe(recipe, budget, stats, {'gen_max_compounds': max_compounds}) + return recipe + + +class RandomRecipeSelectionGenerator(_GeneratorBase): + """Generate random recipes combining synthetic routes and compound selections.""" + + def __init__( + self, + *, + out_key: str, + suppliers: 'list | None' = None, + route_pool: 'RouteSet | None' = None, + compounds=None, + amount: float = 1.0, + max_lead_time: 'float | None' = None, + start_with: 'Recipe | None' = None, + ) -> None: + mrich.debug('RandomRecipeSelectionGenerator.__init__()') + self._suppliers = suppliers + self._max_lead_time = max_lead_time + self._starting_recipe = start_with or Recipe() + + self._setup_io( + out_key, '_rsgen.json', '_recipes_and_selections', skip_directory_creation=False + ) + + # inner generators build the pools and dump their own state files; they do + # not create recipe directories (only this generator writes recipes) + self._rgen = RandomRecipeGenerator( + out_key=out_key, + suppliers=suppliers, + route_pool=route_pool, + max_lead_time=max_lead_time, + skip_directory_creation=True, + ) + self._sgen = RandomSelectionGenerator( + out_key=out_key, + suppliers=suppliers, + compounds=compounds, + amount=amount, + max_lead_time=max_lead_time, + skip_directory_creation=True, + ) + + # combined pool: compound ingredients followed by routes + self._pool = list(self._sgen.compound_pool) + list(self._rgen.route_pool) + + self._dump_data({}) + + @property + def compound_and_route_pool(self) -> list: + """Combined pool of compound ingredients and routes""" + return self._pool + + def generate( + self, + budget: float = 10000, + currency: str = 'EUR', + *, + max_products: int = 1000, + max_reactions: int = 1000, + max_iter: int | None = None, + shuffle: bool = True, + debug: bool = False, + ) -> 'Recipe': + """Generate a random recipe of routes + compound selections (also written to disk).""" + budget = Price(budget, currency) + recipe, stats = _generate_recipe( + self._starting_recipe, + list(self._pool), + budget=budget, + suppliers=self._suppliers, + max_products=max_products, + max_reactions=max_reactions, + max_compounds=_UNLIMITED, + max_iter=max_iter, + shuffle=shuffle, + debug=debug, + ) + self._write_recipe( + recipe, + budget, + stats, + {'gen_max_products': max_products, 'gen_max_reactions': max_reactions}, + ) + return recipe diff --git a/hippo/designdb/services/interaction.py b/hippo/designdb/services/interaction.py new file mode 100644 index 0000000..1914cec --- /dev/null +++ b/hippo/designdb/services/interaction.py @@ -0,0 +1,368 @@ +"""Service for computing protein-ligand interaction fingerprints. + +Owns interaction detection for a :class:`.Pose`: extracting protein features +(populating :class:`.FeatureModel`), running the geometric detector, resolving +duplicate / less-significant interactions, and populating +:class:`.InteractionModel`. + +Ported from the legacy ``Pose.calculate_interactions`` / ``Target.calculate_features`` +/ ``InteractionSet.resolve``. Two deliberate deviations from legacy: + +* Protein features are taken directly from *this pose's* ``protein_system`` (which + carries the geometry) and the matching :class:`.FeatureModel` row is + get-or-created for its ID -- rather than a target-wide feature cache plus a + chain/residue re-lookup. This drops the legacy mutation-mismatch handling + (features always match the structure they came from). +* Resolution runs in-memory (legacy used an in-memory SQLite temp table). + +.. attention:: + Geometry/resolution logic is a faithful but **unverified** port; it needs + checking against real protein structures. + +Layering: ``services -> components``. Entry point: :meth:`.Pose.calculate_interactions`. +""" + +import json + +import mrich +import numpy as np +from designdb.interactions import ( + COMPLEMENTARY_FEATURES, + INTERACTION_CUTOFF, + INTERACTION_TYPES, + PI_STACK_F2F_CUTOFF, + PI_STACK_MIN_CUTOFF, +) +from designdb.models import FeatureModel, InteractionModel + + +def _norm(coords) -> np.ndarray: + """Principal axis (first eigenvector of the covariance) of a set of points.""" + coords = np.array(coords).T + cov = np.cov(coords) + eig = np.linalg.eig(cov) + return eig[1][:, 0] + + +def _unit_vector(vector) -> np.ndarray: + """Unit vector in the direction of ``vector``.""" + return vector / np.linalg.norm(vector) + + +def _angle_between(v1, v2) -> float: + """Angle (degrees, folded to 0-90) between two vectors.""" + v1_u = _unit_vector(v1) + v2_u = _unit_vector(v2) + a = 180 * np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) / np.pi + if a > 90: + a = 180 - a + return a + + +class InteractionService: + """Construction and persistence of pose-protein interactions.""" + + @staticmethod + def calculate( + pose, + *, + resolve: bool = True, + distance_padding: float = 0.0, + angle_padding: float = 0.0, + force: bool = False, + commit: bool = True, + debug: bool = False, + ) -> None: + """Enumerate valid interactions between a pose's ligand and protein. + + :param pose: the :class:`.Pose` to fingerprint + :param resolve: cull duplicate / less-significant interactions + :param distance_padding: padding (Angstrom) added to all distance cutoffs + :param angle_padding: padding (degrees) added to all angle cutoffs + :param force: recalculate even if the pose is already fingerprinted + :param commit: persist the results to the database + :param debug: increase verbosity + """ + + if pose.has_fingerprint and not force: + if debug: + mrich.warning(f'{pose} is already fingerprinted') + return + + protein_system = pose.protein_system + if protein_system is None and pose.reference is not None: + protein_system = pose.reference.protein_system + + if protein_system is None: + raise NotImplementedError( + f'No protein system for {pose} ' + f'(protein_link={pose.protein_link!r}, reference={pose.reference_id})' + ) + + mol = pose.mol + if mol is None: + mrich.error(f'Could not read molecule for {pose}') + return + + candidates = InteractionService._detect( + pose=pose, + protein_system=protein_system, + mol=mol, + distance_padding=distance_padding, + angle_padding=angle_padding, + debug=debug, + ) + + if resolve: + candidates = InteractionService._resolve(candidates, debug=debug) + + if not commit: + return + + # replace any existing interactions for this pose + InteractionModel.objects.filter(pose_id=pose.id).delete() + + InteractionModel.objects.bulk_create( + [ + InteractionModel( + feature_id=c['feature_id'], + pose_id=pose.id, + interaction_type=c['type'], + interaction_family=c['family'], + interaction_atom_id=json.dumps(c['atom_ids']), + interaction_prot_coord=json.dumps(c['prot_coord']), + interaction_lig_coord=json.dumps(c['lig_coord']), + interaction_distance=c['distance'], + interaction_angle=c['angle'], + interaction_energy=None, + ) + for c in candidates + ] + ) + + pose.set_has_fingerprint(True, commit=commit) + + if debug: + mrich.success(f'{pose}: {len(candidates)} interactions') + + @staticmethod + def _protein_feature_id(target, prot_feature) -> int: + """Get-or-create the :class:`.FeatureModel` for a molparse protein feature.""" + atom_name = ' '.join(a.name for a in prot_feature.atoms) + feature, _ = FeatureModel.objects.get_or_create( + feature_family=prot_feature.family, + target=target, + feature_chain_name=prot_feature.res_chain, + feature_residue_name=prot_feature.res_name, + feature_residue_number=prot_feature.res_number, + feature_atom_name=atom_name, + ) + return feature.id + + @staticmethod + def _detect( + pose, protein_system, mol, distance_padding, angle_padding, debug + ) -> list[dict]: + """Run the geometric detector, returning candidate interaction dicts.""" + + target = pose.target + + # organise ligand features by family + comp_features_by_family: dict[str, list] = {} + for f in pose.features: + comp_features_by_family.setdefault(f.family, []).append(f) + + candidates: list[dict] = [] + + for prot_feature in protein_system.get_protein_features(): + + prot_family = prot_feature.family + + if prot_family not in COMPLEMENTARY_FEATURES: + continue + + prot_coords = [a.np_pos for a in prot_feature.atoms] + if not prot_coords: + continue + prot_coord = np.sum(prot_coords, axis=0) / len(prot_coords) + + feature_id = InteractionService._protein_feature_id(target, prot_feature) + + for complementary_family in COMPLEMENTARY_FEATURES[prot_family]: + + interaction_type = INTERACTION_TYPES[ + (prot_family, complementary_family) + ] + + for lig_feature in comp_features_by_family.get( + complementary_family, [] + ): + + lig_pos = np.asarray(lig_feature.position) + distance = float(np.linalg.norm(lig_pos - prot_coord)) + angle = None + + if ( + distance + > INTERACTION_CUTOFF[interaction_type] + distance_padding + ): + continue + + lig_coords = None + if interaction_type.startswith('π'): + conf = mol.GetConformer() + lig_coords = [ + np.array(conf.GetAtomPosition(i - 1)) + for i in lig_feature.atom_numbers + ] + + if interaction_type == 'π-stacking': + # require at least one atom within the min cutoff + min_distance = min( + float(np.linalg.norm(lig_coord - p_coord)) + for lig_coord in lig_coords + for p_coord in prot_coords + ) + if min_distance > PI_STACK_MIN_CUTOFF + distance_padding: + continue + + lig_norm = _norm([list(p) for p in lig_coords]) + prot_norm = _norm(prot_coords) + angle = _angle_between(lig_norm, prot_norm) + + # face-to-face has a stricter distance cutoff + if ( + angle < 40 - angle_padding + and distance > PI_STACK_F2F_CUTOFF + distance_padding + ): + continue + + elif interaction_type == 'π-cation': + if prot_family == 'Aromatic': + aromatic_norm = _norm(prot_coords) + cation_vec = lig_pos - prot_coord + else: + aromatic_norm = _norm([list(p) for p in lig_coords]) + cation_vec = prot_coord - lig_pos + + angle = _angle_between(aromatic_norm, cation_vec) + if angle > 30 + angle_padding: + continue + + candidates.append( + { + 'feature_id': feature_id, + 'feature_family': prot_family, + 'feature_atom_name': ' '.join( + a.name for a in prot_feature.atoms + ), + 'type': interaction_type, + 'family': lig_feature.family, + 'atom_ids': [int(i) for i in lig_feature.atom_numbers], + 'prot_coord': [float(x) for x in prot_coord], + 'lig_coord': [float(x) for x in lig_pos], + 'distance': distance, + 'angle': None if angle is None else float(angle), + } + ) + + if debug: + mrich.debug(f'{pose}: {len(candidates)} candidate interactions') + + return candidates + + @staticmethod + def _resolve(candidates: list[dict], debug: bool = False) -> list[dict]: + """Cull duplicate / less-significant interactions (port of the legacy rules). + + Keeps, per interaction type: the closest interaction per ligand-atom group + (Hydrogen Bond, π-cation, Electrostatic), the closest per protein feature + (π-stacking), all Sulfur-Sulfur, and -- for Hydrophobic -- de-duplicates + lumped vs. single hydrophobes then keeps the closest per protein feature. + """ + + for i, c in enumerate(candidates): + c['_idx'] = i + + keep: set[int] = set() + + def keep_min_per(predicate, key) -> None: + """Keep the min-distance candidate within each ``key`` group.""" + best: dict = {} + for c in candidates: + if not predicate(c): + continue + k = key(c) + if k not in best or c['distance'] < best[k]['distance']: + best[k] = c + keep.update(c['_idx'] for c in best.values()) + + keep_min_per( + lambda c: c['type'] == 'Hydrogen Bond', lambda c: tuple(c['atom_ids']) + ) + keep_min_per(lambda c: c['type'] == 'π-stacking', lambda c: c['feature_id']) + keep_min_per( + lambda c: c['type'] == 'π-cation', lambda c: tuple(c['atom_ids']) + ) + keep_min_per( + lambda c: c['type'] == 'Electrostatic', lambda c: tuple(c['atom_ids']) + ) + + # Sulfur-Sulfur: keep all + keep.update( + c['_idx'] for c in candidates if c['type'] == 'Sulfur-Sulfur' + ) + + # Hydrophobic: de-duplicate lumped vs. single hydrophobes + hydrophobic = [c for c in candidates if c['type'] == 'Hydrophobic'] + + covered: dict = {} + lumped_lumped: dict = {} + for c in hydrophobic: + families = (c['feature_family'], c['family']) + names = c['feature_atom_name'].split() + if families == ('LumpedHydrophobe', 'Hydrophobe'): + for name in names: + covered.setdefault((name, c['atom_ids'][0]), []).append(c['_idx']) + elif families == ('Hydrophobe', 'LumpedHydrophobe'): + for atom_id in c['atom_ids']: + covered.setdefault( + (c['feature_atom_name'], atom_id), [] + ).append(c['_idx']) + elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): + for name in names: + for atom_id in c['atom_ids']: + covered.setdefault((name, atom_id), []).append(c['_idx']) + lumped_lumped[c['feature_atom_name']] = tuple(c['atom_ids']) + + keep_hydrophobic = {c['_idx'] for c in hydrophobic} + for c in hydrophobic: + families = (c['feature_family'], c['family']) + if families == ('Hydrophobe', 'Hydrophobe'): + if (c['feature_atom_name'], c['atom_ids'][0]) in covered: + keep_hydrophobic.discard(c['_idx']) + elif families == ('LumpedHydrophobe', 'Hydrophobe'): + value = lumped_lumped.get(c['feature_atom_name']) + if value is not None and c['atom_ids'][0] in value: + keep_hydrophobic.discard(c['_idx']) + + # of the surviving hydrophobes, keep the closest per protein feature + by_idx = {c['_idx']: c for c in candidates} + best_per_feature: dict = {} + for idx in keep_hydrophobic: + c = by_idx[idx] + k = c['feature_id'] + if k not in best_per_feature or ( + c['distance'] < best_per_feature[k]['distance'] + ): + best_per_feature[k] = c + keep.update(c['_idx'] for c in best_per_feature.values()) + + resolved = [c for c in candidates if c['_idx'] in keep] + for c in candidates: + c.pop('_idx', None) + + if debug: + mrich.debug(f'resolved {len(candidates)} -> {len(resolved)} interactions') + + return resolved diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 90fbfa8..6a48334 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -14,8 +14,9 @@ import mrich from designdb.components.compound import Compound from designdb.components.reaction import DEFAULT_PRODUCT_YIELD, Reaction -from designdb.models import CompoundModel, ReactionModel, RouteModel +from designdb.models import CompoundModel, InspirationModel, PoseModel, ReactionModel, RouteModel from designdb.sets.compound import CompoundSet, IngredientSet +from designdb.sets.pose import PoseSet from designdb.sets.reaction import ReactionSet @@ -616,17 +617,141 @@ def write_reactant_csv(recipe: 'Recipe', file, reaction_type_counts=True, **kwar ) @staticmethod - def write_product_csv(recipe: 'Recipe', file, return_df: bool = False): - """Detailed product-selection CSV. + def write_product_csv( + recipe: 'Recipe', file, return_df: bool = False + ) -> 'DataFrame | None': + """Detailed CSV output including product information for selection/synthesis. + + One row per product compound: identifiers, required amount, associated poses, + tags, upstream route/reaction/reactant dependencies, scaffold series, and + inspiration pose names. - Not yet ported: depends on unported Pose machinery - (``get_compound_id_pose_ids_dict``, ``get_compound_id_inspiration_ids_dict``, - ``PoseSet`` construction from IDs). Port alongside the Pose subsystem. + :param recipe: the :class:`.Recipe` whose products to report + :param file: output CSV path + :param return_df: also return the assembled ``DataFrame`` """ - raise NotImplementedError( - 'write_product_csv requires unported Pose/inspiration lookups; port ' - 'alongside the Pose subsystem' - ) + + from pandas import DataFrame + + routes = RecipeService.get_routes(recipe) + + product_ids = list(recipe.products.compound_ids) + + # compound_id -> set of associated pose IDs + pose_map: dict[int, set] = {} + for comp_id, pose_id in PoseModel.objects.filter( + compound_id__in=product_ids + ).values_list('compound_id', 'id'): + pose_map.setdefault(comp_id, set()).add(pose_id) + + # compound_id -> set of inspiration (original) pose IDs, scoped to the + # product compounds and their scaffolds (the inspiration fallback needs both) + scaffold_ids = set() + for product in recipe.products: + # Ingredient.__getattr__ delegates to the CompoundModel (ORM), so wrap + # in the Compound component to reach component-level properties + if scaffolds := Compound(product.compound).scaffolds: + scaffold_ids.update(scaffolds.ids) + needed_ids = set(product_ids) | scaffold_ids + + inspiration_map: dict[int, set] = {} + for comp_id, original_pose_id in InspirationModel.objects.filter( + derivative_pose__compound_id__in=needed_ids + ).values_list('derivative_pose__compound_id', 'original_pose_id'): + inspiration_map.setdefault(comp_id, set()).add(original_pose_id) + + data = [] + + for product in mrich.track( + recipe.products, prefix='Constructing product DataFrame' + ): + # wrap in the Compound component for component-level properties + # (Ingredient.__getattr__ delegates to the CompoundModel ORM instead) + comp = Compound(product.compound) + + d = dict( + hippo_id=product.compound_id, + smiles=comp.smiles, + inchikey=comp.inchikey, + required_amount_mg=product.amount, + ) + + upstream_routes = [] + upstream_reaction_ids = [] + + for route in routes: + if route.product_compound.id == product.compound_id: + upstream_routes.append(route) + upstream_reaction_ids += route.reactions.ids + + if not upstream_routes: + mrich.error('No upstream routes for', product) + continue + + if not upstream_reaction_ids: + mrich.error('No upstream reactions for', product) + continue + + upstream_reactions = ReactionSet(list(set(upstream_reaction_ids))) + + # scaffold series: the product's scaffolds, or itself if it is one + if scaffolds := comp.scaffolds: + scaffold_series, is_scaffold = scaffolds.ids, False + else: + scaffold_series, is_scaffold = [product.compound_id], True + + poses = pose_map.get(product.compound_id, set()) + + d['num_poses'] = len(poses) + d['poses'] = poses + d['tags'] = comp.tags + d['num_routes'] = len(upstream_routes) + d['num_reaction_steps'] = {len(r.reactions) for r in upstream_routes} + d['reaction_dependencies'] = upstream_reactions.ids + d['reactant_dependencies'] = set( + sum((route.reactants.ids for route in upstream_routes), []) + ) + d['route_ids'] = [route.id for route in upstream_routes] + d['chemistry_types'] = ', '.join(t for t in upstream_reactions.types if t) + d['is_scaffold'] = is_scaffold + d['scaffold_series'] = scaffold_series + + # inspiration pose IDs, with fallback to the scaffold / metadata + inspirations = inspiration_map.get(product.compound_id, None) + + if not inspirations and not is_scaffold: + scaffold = Compound(comp.scaffolds[0]) + inspirations = inspiration_map.get(scaffold.id, None) + + scaffold_meta = scaffold.metadata or {} + if not inspirations and 'inspiration_pose_ids' in scaffold_meta: + inspirations = scaffold_meta['inspiration_pose_ids'] + + product_meta = comp.metadata or {} + if ( + not inspirations + and is_scaffold + and 'inspiration_pose_ids' in product_meta + ): + inspirations = product_meta['inspiration_pose_ids'] + + if inspirations: + inspiration_poses = PoseSet( + PoseModel.objects.filter(pk__in=list(inspirations)) + ) + d['inspirations'] = ', '.join(inspiration_poses.names) + else: + d['inspirations'] = '' + + data.append(d) + + df = DataFrame(data) + mrich.writing(file) + df.to_csv(file, index=False) + + if return_df: + return df + return None @staticmethod def to_syndirella(recipe: 'Recipe', out_key, poses, *, separate: bool = False): diff --git a/hippo/designdb/services/scoring.py b/hippo/designdb/services/scoring.py new file mode 100644 index 0000000..0d46b0c --- /dev/null +++ b/hippo/designdb/services/scoring.py @@ -0,0 +1,633 @@ +"""Service layer: recipe scoring. + +Ported from the legacy ``scoring`` module and modernised: + +* **No legacy ``Database`` coupling.** Recipes are loaded from a directory of + ``Recipe_*.json`` files via the modern :class:`.RecipeSet` (which already + supports directory loading), and the per-recipe child sets (compounds / poses / + interactions / pose-metadata) are pre-fetched via the ORM rather than legacy + ``db.get_*`` helpers. +* Output filenames use ``out_key`` (no sqlite path); the score cache is written to + ``{out_key}.json``. + +A :class:`.Scorer` evaluates a set of recipes against weighted :class:`.Attribute` +/ :class:`.CustomAttribute` objects; each attribute value is converted to a +percentile (0-1) and combined by weight. User entry point: ``animal.scorers``. + +.. attention:: + This is a faithful but **unverified** port; check scores/plots against real + generated recipes. +""" + +import json +from pathlib import Path + +import mrich +import numpy as np +import pandas as pd +from designdb.components.recipe import Recipe, RecipeSet +from designdb.models import InteractionModel, PoseModel, ScaffoldModel +from designdb.sets.compound import CompoundSet +from designdb.sets.interaction import InteractionSet +from designdb.sets.pose import PoseSet +from scipy.interpolate import interp1d + +# columns of the internal score-cache DataFrame (besides the attribute columns) +DATA_COLUMNS = [ + 'score', + 'price', + 'compound_ids', + 'pose_ids', + 'interaction_ids', + 'pose_metadata', +] + + +class Attribute: + """A scoring attribute evaluated over the recipes of a :class:`.Scorer`. + + The raw value (``recipe.``) is converted to a percentile in ``[0, 1]`` + across all recipes, optionally inverted, and scaled by ``weight``. + """ + + _type = 'Attribute' + + def __init__( + self, + scorer: 'Scorer', + key: str, + *, + inverse: bool = False, + weight: float = 1.0, + bins: int = 100, + ) -> None: + self._scorer = scorer + self._key = key + self._inverse = inverse + self._weight = weight + self._bins = bins + self._percentile_interpolator = None + + ### PROPERTIES + + @property + def scorer(self) -> 'Scorer': + return self._scorer + + @property + def key(self) -> str: + return self._key + + @property + def inverse(self) -> bool: + return self._inverse + + @property + def bins(self) -> int: + return self._bins + + @property + def values(self) -> list[float]: + """Attribute values across all recipes (computed/cached on access).""" + col = self.scorer._data[self.key] + null = col.isnull() + if null.sum(): + for key in col[null].index.values: + self.get_value(self.scorer.recipes[key], force=True) + self.scorer._dump_json() + return list(self.scorer._data[self.key].to_dict().values()) + + @property + def mean(self) -> float: + return float(np.mean(self.values)) + + @property + def std(self) -> float: + return float(np.std(self.values)) + + @property + def max(self) -> float: + return max(self.values) + + @property + def min(self) -> float: + return min(self.values) + + @property + def weight(self) -> float: + return self._weight + + @weight.setter + def weight(self, w) -> None: + self.scorer._flag_weight_modification() + self._weight = abs(w) + self._inverse = self._inverse or (w < 0) + + @property + def percentile_interpolator(self): + """Interpolator mapping a value to its cumulative percentile.""" + if self._percentile_interpolator is None: + count, bins_count = np.histogram(self.values, bins=self.bins) + pdf = count / sum(count) + cdf = np.cumsum(pdf) + self._percentile_interpolator = interp1d( + bins_count[1:], cdf, kind='linear', fill_value='extrapolate' + ) + return self._percentile_interpolator + + ### METHODS + + def get_value(self, recipe: 'Recipe', force: bool = False) -> float: + """Get (and cache) this attribute's raw value for ``recipe``.""" + if not force: + cached = self.scorer._data[self.key][recipe.hash] + if cached is not None: + return cached + value = getattr(recipe, self.key) + self.scorer._data.at[recipe.hash, self.key] = value + return value + + def unweighted(self, recipe: 'Recipe') -> float: + """Percentile score (0-1) for ``recipe``.""" + value = self.get_value(recipe) + score = float(self.percentile_interpolator(value)) + if self.inverse: + score = 1 - score + return score + + def __call__(self, recipe: 'Recipe') -> float: + """Weighted score for ``recipe``.""" + if not self.weight: + return 0.0 + return self.weight * self.unweighted(recipe) + + def __str__(self) -> str: + return f'{self._type}("{self.key}", weight={self.weight:.2f}, inverse={self.inverse})' + + def __repr__(self) -> str: + import mcol + + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + return f'[bold underline]{self}' + + +class CustomAttribute(Attribute): + """A scoring attribute whose value is computed by a user-supplied function.""" + + _type = 'CustomAttribute' + + def __init__(self, scorer: 'Scorer', key: str, function) -> None: + self._function = function + super().__init__(scorer=scorer, key=key) + + def get_value(self, recipe: 'Recipe', force: bool = False) -> float: + if not force: + cached = self.scorer._data[self.key][recipe.hash] + if cached is not None: + return cached + value = self._function(recipe) + self.scorer._data.at[recipe.hash, self.key] = value + return value + + +class Scorer: + """Score a set of recipes against weighted attributes.""" + + def __init__( + self, + directory: 'str | Path', + *, + pattern: str = '*.json', + attributes: list[str] | None = None, + populate: bool = True, + load_cache: bool = True, + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', + ) -> None: + """Scorer initialisation""" + + self._out_key = out_key + + if allowed_poses is None: + self._allowed_pose_ids = None + elif isinstance(allowed_poses, PoseSet): + self._allowed_pose_ids = set(allowed_poses.ids) + else: + self._allowed_pose_ids = set(allowed_poses) + + self._recipes = RecipeSet(directory, pattern=pattern) + + self._attributes = {} + for key in attributes or []: + self._attributes[key] = Attribute(self, key) + + self._data = pd.DataFrame( + index=self._recipes.keys(), + columns=DATA_COLUMNS + self.attribute_keys, + ) + self._data = self._data.replace({np.nan: None}) + + if populate: + if load_cache and self.json_path.exists(): + self._load_json() + else: + self._populate_query_cache() + self._populate_recipe_child_sets() + + self.weights = 1.0 + + ### FACTORIES + + @classmethod + def default( + cls, + directory: 'str | Path', + *, + pattern: str = '*.json', + skip: list[str] | None = None, + load_cache: bool = True, + subsites: bool = True, + allowed_poses: 'PoseSet | list[int] | None' = None, + out_key: str = 'scorer', + ) -> 'Scorer': + """Create a :class:`.Scorer` with the default attributes (minus ``skip``).""" + + self = cls.__new__(cls) + + standard = [ + k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard' + ] + + self.__init__( + directory=directory, + pattern=pattern, + attributes=standard, + populate=False, + allowed_poses=allowed_poses, + out_key=out_key, + ) + + skip = list(skip or []) + + # drop metrics whose data isn't present (ORM checks, no db) + if not InteractionModel.objects.exists(): + mrich.warning('No interactions in DB, skipping related metrics') + skip += ['interaction_count', 'interaction_balance'] + if not PoseModel.objects.exists(): + mrich.warning('No poses in DB, skipping related metrics') + skip += [ + 'num_inspirations', + 'num_inspiration_sets', + 'avg_energy_score', + 'avg_distance_score', + ] + if not ScaffoldModel.objects.exists(): + mrich.warning('No scaffold entries in DB, skipping related metrics') + skip += ['num_scaffolds', 'num_scaffolds_elaborated', 'elaboration_balance'] + + for key, attribute in [ + (k, v) for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'custom' + ]: + if key in skip: + continue + if not subsites and 'subsite' in key: + continue + self.add_custom_attribute( + key, attribute['function'], weight_reset_warning=False + ) + + if load_cache and self.json_path.exists(): + self._load_json() + else: + self._populate_query_cache() + self._populate_recipe_child_sets() + + # normalise weights from the DEFAULT_ATTRIBUTES config + wsum = sum(abs(d['weight']) for d in DEFAULT_ATTRIBUTES.values()) + for attribute in self.attributes: + attribute.weight = DEFAULT_ATTRIBUTES[attribute.key]['weight'] / wsum + + return self + + ### PROPERTIES + + @property + def num_recipes(self) -> int: + return len(self._recipes) + + @property + def attributes(self) -> list: + return list(self._attributes.values()) + + @property + def attribute_keys(self) -> list[str]: + return list(self._attributes.keys()) + + @property + def recipes(self) -> 'RecipeSet': + return self._recipes + + @property + def num_attributes(self) -> int: + return len(self._attributes) + + @property + def weights(self) -> list[float]: + return [a.weight for a in self.attributes] + + @weights.setter + def weights(self, ws) -> None: + self._flag_weight_modification() + if isinstance(ws, (int, float)): + ws = [ws] * self.num_attributes + ws = list(ws) + wsum = sum(abs(w) for w in ws) or 1.0 + for a, w in zip(self.attributes, ws): + a.weight = w / wsum + + @property + def json_path(self) -> 'Path': + """Path the score cache is written to (derived from ``out_key``).""" + return Path(f'{self._out_key}.json') + + @property + def score_dict(self) -> dict: + """Scores keyed by recipe hash (computed/cached on access).""" + col = self._data['score'] + null = col.isnull() + if null.sum(): + mrich.debug('Calculating scores...') + for key in col[null].index.values: + self._data.at[key, 'score'] = self.score(self.recipes[key]) + self._dump_json() + return self._data['score'].to_dict() + + @property + def scores(self) -> list[float]: + return list(self.score_dict.values()) + + @property + def best(self) -> 'Recipe': + """Highest-scoring recipe.""" + return self.top(1) + + ### METHODS + + def add_custom_attribute( + self, key: str, function, weight_reset_warning: bool = True + ) -> 'CustomAttribute': + """Add a custom scoring attribute computed by ``function(recipe)``.""" + ca = CustomAttribute(self, key, function) + if key not in self._attributes: + self._attributes[key] = ca + if weight_reset_warning: + mrich.warning('Attribute weights have been reset') + self.weights = 1.0 + self._data[key] = None + else: + mrich.warning(f'Existing attribute with {key=}') + return self._attributes[key] + + def score(self, recipe: 'Recipe', *, debug: bool = False) -> float: + """Total weighted score for ``recipe``.""" + score = sum(attribute(recipe) for attribute in self.attributes) + recipe._score = score + return score + + def get_sorted_df(self) -> 'pd.DataFrame': + """Score cache sorted by descending score.""" + self.scores + return self._data.sort_values(by='score', ascending=False) + + def top_keys(self, n: int) -> list[str]: + return list(self.get_sorted_df().index[:n]) + + def top(self, n: int) -> 'Recipe | list[Recipe]': + """Top-``n`` scoring recipes (a single recipe when ``n == 1``).""" + keys = self.top_keys(n) + recipes = [self.recipes[key] for key in keys] + return recipes[0] if n == 1 else recipes + + def plot(self, keys: list[str], budget: float | None = None): + """Scatter plot of two attributes, coloured by score.""" + import plotly.express as px + + if len(keys) != 2: + mrich.error('Only two keys supported') + return None + + self.scores + + df = self._data.drop( + columns=['compound_ids', 'pose_ids', 'interaction_ids', 'pose_metadata'] + ) + df['score'] = pd.to_numeric(df['score']) + + for key in keys: + if key not in df.columns: + raise KeyError(f'no attribute/column named "{key}"') + + if budget: + df = df[df['price'] < budget] + + df['hash'] = df.index.values + return px.scatter( + df, x=keys[0], y=keys[1], color='score', hover_data=list(df.columns) + ) + + ### INTERNALS + + def _flag_weight_modification(self) -> None: + """Reset cached scores (weights changed).""" + if hasattr(self, '_data'): + self._data['score'] = None + + def _populate_query_cache(self) -> None: + """Pre-fetch per-recipe compound/pose/interaction IDs + pose metadata (ORM).""" + + df = self._data + + # prices + combined compound IDs + mrich.debug('Populating _data["compound_ids"]...') + for recipe in self.recipes: + df.at[recipe.hash, 'price'] = recipe.price.amount + df.at[recipe.hash, 'compound_ids'] = recipe.combined_compound_ids + + # compound -> pose IDs + all_compound_ids = set().union( + *(set(ids) for ids in df['compound_ids'] if ids) + ) + mrich.debug(f'Getting poses for {len(all_compound_ids)} compounds') + compound_pose_map: dict[int, set] = {} + for c_id, p_id in PoseModel.objects.filter( + compound_id__in=all_compound_ids + ).values_list('compound_id', 'id'): + compound_pose_map.setdefault(c_id, set()).add(p_id) + + mrich.debug('Populating _data["pose_ids"]...') + for key in df.index.values: + pose_ids = set() + for c_id in df['compound_ids'][key]: + ids = compound_pose_map.get(c_id, set()) + if self._allowed_pose_ids is not None: + ids = {i for i in ids if i in self._allowed_pose_ids} + pose_ids |= ids + df.at[key, 'pose_ids'] = pose_ids + + # pose -> interaction IDs + all_pose_ids = set().union(*(set(ids) for ids in df['pose_ids'] if ids)) + mrich.debug(f'Getting interactions for {len(all_pose_ids)} poses') + pose_interaction_map: dict[int, set] = {} + if all_pose_ids: + for p_id, i_id in InteractionModel.objects.filter( + pose_id__in=all_pose_ids + ).values_list('pose_id', 'id'): + pose_interaction_map.setdefault(p_id, set()).add(i_id) + + mrich.debug('Populating _data["interaction_ids"]...') + for key in df.index.values: + interaction_ids = set() + for p_id in df['pose_ids'][key]: + interaction_ids |= pose_interaction_map.get(p_id, set()) + df.at[key, 'interaction_ids'] = interaction_ids + + # pose -> metadata + mrich.debug(f'Getting metadata for {len(all_pose_ids)} poses') + metadata_map: dict[int, dict] = {} + if all_pose_ids: + for p_id, meta in PoseModel.objects.filter( + pk__in=all_pose_ids + ).values_list('id', 'pose_metadata'): + metadata_map[p_id] = meta or {} + + mrich.debug('Populating _data["pose_metadata"]...') + for key in df.index.values: + df.at[key, 'pose_metadata'] = { + p_id: metadata_map.get(p_id, {}) for p_id in df['pose_ids'][key] + } + + def _populate_recipe_child_sets(self) -> None: + """Inject the pre-fetched child sets onto each recipe's caches.""" + mrich.debug('Populating recipe caches') + for key, recipe in self.recipes.items(): + row = self._data.loc[key] + + if recipe._combined_compounds is None: + recipe._combined_compounds = CompoundSet(list(row['compound_ids'])) + if recipe._poses is None: + recipe._poses = PoseSet( + PoseModel.objects.filter(pk__in=list(row['pose_ids'])) + ) + if recipe._interactions is None: + recipe._interactions = InteractionSet(list(row['interaction_ids'])) + if recipe._poses._metadata_dict is None: + recipe._poses._metadata_dict = row['pose_metadata'] + + def _dump_json(self) -> None: + path = self.json_path + if path.parent and not path.parent.exists(): + path.parent.mkdir(parents=True) + mrich.writing(path) + self._data.to_json(path) + + def _load_json(self) -> None: + path = self.json_path + mrich.reading(path) + cached = pd.read_json(path, orient='columns') + + if set(cached.columns) != set(self._data.columns): + raise ValueError("Cached score JSON columns don't match expectation") + if set(self._data.index.values) - set(cached.index.values): + raise ValueError('Cached score JSON is missing recipes') + + self._data = cached.replace({np.nan: None}) + + ### DUNDERS + + def __str__(self) -> str: + return f'Scorer(#recipes={self.num_recipes})' + + def __repr__(self) -> str: + import mcol + + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + return f'[bold underline]{self}' + + +DEFAULT_ATTRIBUTES = { + 'num_scaffolds': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.count_by_tag(tag='Syndirella scaffold'), + description='Number of Syndirella scaffold compounds. Higher is better.', + ), + 'num_compounds': dict( + type='standard', + weight=1.0, + description='Number of product compounds. Higher is better.', + ), + 'num_scaffolds_elaborated': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.num_scaffolds_elaborated, + description='Number of scaffolds with >=1 elaboration. Higher is better.', + ), + 'elaboration_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.combined_compounds.elaboration_balance, + description='Evenness of scaffold elaboration (h-index). Higher is better.', + ), + 'num_inspirations': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_inspirations, + description='Number of unique fragment inspirations. Higher is better.', + ), + 'num_inspiration_sets': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_inspiration_sets, + description='Number of unique fragment combinations. Higher is better.', + ), + 'interaction_count': dict( + type='custom', + weight=1.0, + function=lambda r: r.interactions.num_features, + description='Number of protein features interacted with. Higher is better.', + ), + 'interaction_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.interactions.per_feature_count_hirsch, + description='Evenness of interactions across features. Higher is better.', + ), + 'num_subsites': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.num_subsites, + description='Number of subsites occupied. Higher is better.', + ), + 'subsite_balance': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.subsite_balance, + description='Evenness of subsite occupancy. Higher is better.', + ), + 'avg_distance_score': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.avg_distance_score, + description='Average pose distance score.', + ), + 'avg_energy_score': dict( + type='custom', + weight=1.0, + function=lambda r: r.poses.avg_energy_score, + description='Average pose energy score.', + ), +} diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index ea3fa98..6fec1f9 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -12,8 +12,11 @@ CompoundModel, CompoundTagJunctionModel, CompoundTagModel, + PoseModel, ReactantModel, ReactionModel, + RouteModel, + ScaffoldModel, ) from django.db.models import Exists, OuterRef, Q from pandas import DataFrame, concat, isna @@ -936,66 +939,57 @@ def get_df( """ - data = [] + if mol: + raise NotImplementedError( + 'get_df(mol=True) depended on the RDKit cartridge ' + '(mol_to_binary_mol); not yet ported' + ) - query = ['compound_id'] + ids = list(self.ids) + # base columns straight off the CompoundModel rows + fields = ['id'] if smiles: - query.append('compound_smiles') - + fields.append('compound_smiles') if inchikey: - query.append('compound_inchikey') - + fields.append('compound_inchikey') if alias: - query.append('compound_alias') - - if mol: - query.append('mol_to_binary_mol(compound_mol)') - + fields.append('compound_alias') if metadata: - query.append('compound_metadata') - - query = ', '.join(query) - - sql = f""" - SELECT {query} - FROM {self.db.SQL_SCHEMA_PREFIX}compound - WHERE compound_id IN {self.str_ids} - """ + fields.append('compound_metadata') if debug: mrich.debug('querying...') - records = self.db.execute(sql).fetchall() - if debug: - generator = mrich.track(records) - else: - generator = records + rows = { + r['id']: r + for r in CompoundModel.objects.filter(pk__in=ids).values(*fields) + } - for row in generator: - row = list(row) + data = [] + for cid in ids: + row = rows.get(cid) + if row is None: + continue - d = dict(id=row.pop(0)) + d = dict(id=cid) if smiles: - d['smiles'] = row.pop(0) + d['smiles'] = row['compound_smiles'] if inchikey: - d['inchikey'] = row.pop(0) + d['inchikey'] = row['compound_inchikey'] if alias: - d['alias'] = row.pop(0) - - if mol: - d['mol'] = Mol(row.pop(0)) + d['alias'] = row['compound_alias'] - if metadata and (meta_str := row.pop(0)): - meta_dict = loads(meta_str) + # compound_metadata is JSON stored in a TextField + if metadata and (meta_str := row['compound_metadata']): + meta_dict = json.loads(meta_str) if expand_metadata: for k, v in meta_dict.items(): d[k] = v - else: d['metadata'] = meta_dict @@ -1003,65 +997,83 @@ def get_df( df = DataFrame(data) + if not data: + return df + if poses or num_poses: if debug: mrich.debug('adding pose column') - lookup = self.db.get_compound_id_pose_ids_dict(self) + lookup: dict[int, set] = {} + for cid, pid in PoseModel.objects.filter( + compound_id__in=ids + ).values_list('compound_id', 'id'): + lookup.setdefault(cid, set()).add(pid) + if poses: - df['poses'] = df['id'].apply(lambda x: lookup.get(x, {})) + df['poses'] = df['id'].apply(lambda x: lookup.get(x, set())) if num_poses: - df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, {}))) + df['num_poses'] = df['id'].apply(lambda x: len(lookup.get(x, set()))) - if num_reactant or num_reactions: + if num_reactant: + if debug: + mrich.debug('adding num_reactant column') + counts: dict[int, int] = {} + for cid in ReactantModel.objects.filter( + compound_id__in=ids + ).values_list('compound_id', flat=True): + counts[cid] = counts.get(cid, 0) + 1 + df['num_reactant'] = df['id'].apply(lambda x: counts.get(x, 0)) + + if num_reactions: + if debug: + mrich.debug('adding num_reactions column') + counts = {} + for cid in ReactionModel.objects.filter( + product_compound_id__in=ids + ).values_list('product_compound_id', flat=True): + counts[cid] = counts.get(cid, 0) + 1 + df['num_reactions'] = df['id'].apply(lambda x: counts.get(x, 0)) + + if scaffolds: if debug: - mrich.debug('adding reaction columns') - tuples = self.db.get_reactant_product_tuples(self.ids, deduplicated=False) - - if num_reactant: - lookup = {} - for r, p in tuples: - lookup.setdefault(r, 0) - lookup[r] += 1 - df['num_reactant'] = df['id'].apply(lambda x: lookup.get(x, 0)) - - if num_reactions: - lookup = {} - for r, p in tuples: - lookup.setdefault(p, 0) - lookup[p] += 1 - df['num_reactions'] = df['id'].apply(lambda x: lookup.get(x, 0)) - - if scaffolds or elabs: + mrich.debug('adding scaffolds column') + lookup = {} + for sup_id, base_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=ids + ).values_list('superstructure_compound_id', 'base_compound_id'): + lookup.setdefault(sup_id, set()).add(base_id) + df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) + + if elabs: if debug: - mrich.debug('adding scaffold columns') - tuples = self.db.get_scaffold_tuples(self.ids) - - if scaffolds: - lookup = {} - for b, e in tuples: - lookup.setdefault(e, set()) - lookup[e].add(b) - df['scaffolds'] = df['id'].apply(lambda x: lookup.get(x, set())) - - if elabs: - lookup = {} - for b, e in tuples: - lookup.setdefault(b, set()) - lookup[b].add(e) - df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) + mrich.debug('adding elabs column') + lookup = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + base_compound_id__in=ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + lookup.setdefault(base_id, set()).add(sup_id) + df['elabs'] = df['id'].apply(lambda x: lookup.get(x, set())) if tags: if debug: mrich.debug('adding tag column') - lookup = self.db.get_compound_tag_dict() - df['tags'] = df['id'].apply(lambda x: lookup.get(x, {})) + lookup = {} + for cid, name in CompoundTagJunctionModel.objects.filter( + compound_id__in=ids + ).values_list('compound_id', 'compound_tag__compound_tag_name'): + lookup.setdefault(cid, set()).add(name) + df['tags'] = df['id'].apply(lambda x: lookup.get(x, set())) if routes: if debug: mrich.debug('adding route column') - lookup = self.db.get_product_id_routes_dict() - df['routes'] = df['id'].apply(lambda x: lookup.get(x, {})) + lookup = {} + for pid, rid in RouteModel.objects.filter( + product_compound_id__in=ids + ).values_list('product_compound_id', 'id'): + lookup.setdefault(pid, set()).add(rid) + df['routes'] = df['id'].apply(lambda x: lookup.get(x, set())) df = df.set_index('id') @@ -2194,26 +2206,30 @@ def get_price( pairs = {i: q for i, q in enumerate(self.df['quote_id'])} - quote_ids = [q for q in pairs.values() if q is not None and not isnan(q)] + # coerce to int: df values may be stored as float/object (pandas) + quote_ids = [int(q) for q in pairs.values() if q is not None and not isna(q)] if debug: mrich.debug('quote_ids', quote_ids) if quote_ids: - qs = CataloguePriceModel.objects.filter(pk__in=quote_ids) + qs = CataloguePriceModel.objects.filter(pk__in=set(quote_ids)) if supplier: qs = qs.filter(supplier=supplier) if qs.exists(): - prices = [ - Price( - amount=k.price, - currency=k.currency, - ) - for k in qs - ] - quoted = sum(prices, Price.null()) + # map pk -> Price, then sum over quote_ids so that ingredients + # sharing the same catalogue row are counted with multiplicity + # (filter(pk__in=...) collapses duplicates to one row each) + price_by_id = { + k.pk: Price(amount=k.price, currency=k.currency) for k in qs + } + quoted = Price.null() + for q in quote_ids: + price = price_by_id.get(q) + if price is not None: + quoted += price else: quoted = Price.null() self.df['quote_id'] = None @@ -2225,7 +2241,7 @@ def get_price( if debug: mrich.debug('quoted', quoted) - unquoted = [i for i, q in pairs.items() if q is None or isnan(q)] + unquoted = [i for i, q in pairs.items() if q is None or isna(q)] unquoted_price = Price.null() @@ -2387,7 +2403,7 @@ def _get_ingredient( q_id = series['quote_id'] - if isinstance(q_id, float) and isnan(q_id): + if isinstance(q_id, float) and isna(q_id): q_id = None return Ingredient( diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index d19b29f..dc5e729 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -117,43 +117,13 @@ def from_pose( :returns: an :class:`.InteractionSet` """ - self = cls.__new__(cls) - - db = db or pose.db - - ### get the ID's - - from .pose import PoseSet - - if isinstance(pose, PoseSet): - # check if all poses have fingerprints - (has_invalid_fps,) = db.select_where( - query='COUNT(1)', - table='pose', - key=f'pose_id IN {pose.str_ids} AND pose_fingerprint = 0', - ) - - if has_invalid_fps: - mrich.warning(f'{has_invalid_fps} Poses have not been fingerprinted') - - sql = f""" - SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_pose IN {pose.str_ids} - """ - + # ``pose`` may be a PoseSet (has ``.ids``) or a single Pose/PoseModel + if hasattr(pose, 'ids'): + qs = InteractionModel.objects.filter(pose_id__in=list(pose.ids)) else: - sql = f""" - SELECT interaction_id FROM {db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_pose = {pose.id} - """ - - ids = db.execute(sql).fetchall() - - ids = [i for (i,) in ids] - - self.__init__(db, ids, table=table) + qs = InteractionModel.objects.filter(pose_id=pose.id) - return self + return cls(list(qs.values_list('id', flat=True))) @classmethod def all( @@ -394,18 +364,9 @@ def type_residue_number_chain_triples(self) -> list[tuple]: @property def num_features(self) -> int: - """Count the funmber of protein :class:`.FeatureModel`s with which interactions - are formed""" - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT interaction_feature) - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - """ - ).fetchone() - - return count + """Count the number of protein :class:`.FeatureModel`\\ s with which + interactions are formed""" + return self._qs.values('feature').distinct().count() @property def avg_num_interactions_per_feature(self) -> float: diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 59a9be4..0bd3483 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -188,18 +188,22 @@ def __len__(self) -> int: return self._queryset.count() def __iter__(self): - """Iterate through poses in this set""" - return iter(self._queryset) + """Iterate through poses in this set as :class:`.Pose` components""" + from designdb.components.pose import Pose + + return (Pose(p) for p in self._queryset) def __getitem__( self, key: int | slice, - ) -> 'PoseModel | PoseSet': + ) -> 'Pose | PoseSet': """Get poses or subsets thereof from this set :param key: integer index or slice of indices """ + from designdb.components.pose import Pose + match key: case int(): try: @@ -208,7 +212,7 @@ def __getitem__( mrich.error(f'list index out of range: {key=} for {self}') raise PoseModel.DoesNotExist from exc - return pose + return Pose(pose) case slice(): return PoseSet(PoseModel.objects.filter(pk__in=key)) @@ -2138,21 +2142,18 @@ def reference_ids(self) -> set[int]: return self.get_by_references(self).values_list('pk', flat=True) @property - def inspiration_sets(self) -> list[set[int]]: - """Return a list of unique sets of inspiration :class:`.PoseModel` IDs""" - - pairs = InspirationModel.objects.filter(derivative_pose__in=self._queryset) - data = {} - for p in pairs: - if p.derivative_pose not in data: - data[p.derivative_pose] = set() - data[p.derivative_pose].add(p.original_pose) + def inspiration_sets(self) -> set[tuple[int, ...]]: + """Return the unique sets of inspiration :class:`.PoseModel` IDs""" - data = {k: tuple(sorted(list(v))) for k, v in data.items()} + # group original (inspiration) pose IDs by derivative pose ID -- use the + # FK ids (sortable ints, no extra queries) rather than the model objects + data: dict[int, set[int]] = {} + for deriv_id, orig_id in InspirationModel.objects.filter( + derivative_pose__in=self._queryset + ).values_list('derivative_pose_id', 'original_pose_id'): + data.setdefault(deriv_id, set()).add(orig_id) - unique = set(data.values()) - - return unique + return {tuple(sorted(v)) for v in data.values()} @property def num_inspiration_sets(self) -> int: @@ -2242,7 +2243,11 @@ def fraction_fingerprinted(self) -> float: @property def num_subsites(self) -> int: """Count the number of subsites that poses in this set come into contact with""" - return SubsiteModel.objects.filter(pose__in=self._queryset).distinct().count() + return ( + SubsiteModel.objects.filter(posemodels__in=self._queryset) + .distinct() + .count() + ) @property def subsite_balance(self) -> float: diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index c205280..ac2e0f2 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -308,7 +308,7 @@ def queryset(self): @property def indices(self) -> list[int]: """Returns the ids of reactions in this set""" - return self._queryset.values_list('pk', flat=True) + return list(self._queryset.values_list('pk', flat=True)) @property def ids(self) -> list[int]: @@ -317,8 +317,10 @@ def ids(self) -> list[int]: @property def types(self) -> list[str]: - """Returns the types of reactions in this set""" - return self._queryset.values('reaction_type').distinct() + """Returns the unique reaction types in this set""" + return list( + self._queryset.values_list('reaction_type', flat=True).distinct() + ) @property def num_types(self) -> int: From 27e342801b118cc43b2bcadf1efd28ee7f1f6857 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Mon, 22 Jun 2026 16:56:28 +0100 Subject: [PATCH 156/163] feat: wf2 seemingly working Not getting all the same results, needs some debugging --- hippo/__init__.py | 2 +- hippo/designdb/animal.py | 337 +------- hippo/designdb/client.py | 214 +++++ hippo/designdb/components/compound.py | 37 +- hippo/designdb/components/quote.py | 17 +- hippo/designdb/interactions.py | 9 +- hippo/designdb/plotting.py | 111 +++ hippo/designdb/{components => }/recipe.py | 43 +- hippo/designdb/services/generation.py | 29 +- hippo/designdb/services/ingestion.py | 6 +- hippo/designdb/services/ingredient.py | 76 -- hippo/designdb/services/interaction.py | 28 +- .../services/{score.py => pose_score.py} | 0 hippo/designdb/services/recipe.py | 57 +- .../services/{scoring.py => recipe_score.py} | 25 +- hippo/designdb/services/route.py | 2 +- hippo/designdb/sets/compound.py | 671 +-------------- hippo/designdb/sets/ingredient.py | 659 +++++++++++++++ hippo/designdb/sets/interaction.py | 800 ++++-------------- hippo/designdb/sets/pose.py | 123 ++- hippo/designdb/sets/route.py | 4 +- 21 files changed, 1301 insertions(+), 1949 deletions(-) create mode 100644 hippo/designdb/client.py create mode 100644 hippo/designdb/plotting.py rename hippo/designdb/{components => }/recipe.py (95%) delete mode 100644 hippo/designdb/services/ingredient.py rename hippo/designdb/services/{score.py => pose_score.py} (100%) rename hippo/designdb/services/{scoring.py => recipe_score.py} (95%) create mode 100644 hippo/designdb/sets/ingredient.py diff --git a/hippo/__init__.py b/hippo/__init__.py index 29cbb67..9035006 100644 --- a/hippo/__init__.py +++ b/hippo/__init__.py @@ -12,7 +12,7 @@ def __getattr__(name): pending the client-exposure design (see RecipeManager discussion). """ if name == 'IngredientSet': - from designdb.sets.compound import IngredientSet + from designdb.sets.ingredient import IngredientSet return IngredientSet raise AttributeError(f'module {__name__!r} has no attribute {name!r}') diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index eb48f16..f0f6555 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -10,7 +10,7 @@ import pandas as pd from django.db import transaction -from .components.recipe import Recipe +from .client import GeneratorManager, IngredientManager, RecipeManager, RouteManager, ScorerManager from .models import ( CompoundModel, EnumerationMethodModel, @@ -21,23 +21,14 @@ TargetModel, ) from .services.download import DownloadService -from .services.generation import ( - RandomRecipeGenerator, - RandomRecipeSelectionGenerator, - RandomSelectionGenerator, -) from .services.ingestion import IngestionBatchResult, IngestionService from .services.method import MethodService from .services.quote import QuoteService from .services.reaction import ReactionService -from .services.recipe import RecipeService from .services.route import RouteService -from .services.scoring import Scorer from .services.subsite import SubsiteService -from .sets.compound import CompoundSet, IngredientSet +from .sets.compound import CompoundSet from .sets.pose import PoseSet -from .sets.reaction import ReactionSet -from .sets.route import RouteSet from .settings import DEFAULT_POSE_METHODS from .utils import make_warn_once_per_key @@ -63,238 +54,6 @@ ) -class RecipeManager: - """Client-side accessor for building recipes, bound to a :class:`.HIPPO`. - - Groups the recipe-construction entry points and delegates to the - :class:`.RecipeService` (backend), keeping recipe construction on the - user-facing client surface (``animal.recipes.from_*``) instead of exposing - the component/service layers directly. This is also the seam where target / - auth scoping will be attached once the client/backend split lands. - - Access it via :attr:`.HIPPO.recipes`. - """ - - def __init__(self, animal: 'HIPPO') -> None: - self._animal = animal - - def from_compounds(self, compounds: CompoundSet, **kwargs): - """Build recipe(s) to synthesise a :class:`.CompoundSet`. - - See :meth:`.RecipeService.from_compounds` for keyword arguments. - """ - if not isinstance(compounds, CompoundSet): - raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') - return RecipeService.from_compounds(compounds, **kwargs) - - def from_reactions(self, reactions: ReactionSet, **kwargs): - """Build recipe(s) from a :class:`.ReactionSet`. - - See :meth:`.RecipeService.from_reactions` for keyword arguments. - """ - if not isinstance(reactions, ReactionSet): - raise TypeError(f'reactions must be a ReactionSet, got {type(reactions)}') - return RecipeService.from_reactions(reactions, **kwargs) - - def from_reactants(self, reactants: 'CompoundSet | IngredientSet', **kwargs): - """Build the maximal recipe reachable from a set of reactants. - - See :meth:`.RecipeService.from_reactants` for keyword arguments. - """ - if not isinstance(reactants, (CompoundSet, IngredientSet)): - raise TypeError( - 'reactants must be a CompoundSet or IngredientSet, ' - f'got {type(reactants)}' - ) - return RecipeService.from_reactants(reactants, **kwargs) - - def from_json(self, path, **kwargs) -> 'Recipe': - """Load a serialised :class:`.Recipe` from a JSON file. - - See :meth:`.Recipe.from_json` for keyword arguments (``data``, - ``clear_quotes``, ``debug``). - """ - return Recipe.from_json(path, **kwargs) - - -class IngredientManager: - """Client-side accessor for building :class:`.IngredientSet`\\ s, bound to a - :class:`.HIPPO`. - - Mirrors :class:`.RecipeManager`: keeps :class:`.IngredientSet` construction on - the user-facing client surface (``animal.ingredients.from_*``) instead of - exposing the set layer directly. This is also the seam where target / auth - scoping will be attached once the client/backend split lands. - - Access it via :attr:`.HIPPO.ingredients`. - """ - - def __init__(self, animal: 'HIPPO') -> None: - self._animal = animal - - def from_compounds(self, compounds: 'CompoundSet | None' = None, **kwargs): - """Build an :class:`.IngredientSet` from a :class:`.CompoundSet` (or IDs). - - See :meth:`.IngredientSet.from_compounds` for keyword arguments. - """ - if compounds is not None and not isinstance(compounds, CompoundSet): - raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') - return IngredientSet.from_compounds(compounds=compounds, **kwargs) - - -class RouteManager: - """Client-side accessor for building :class:`.RouteSet`\\ s, bound to a - :class:`.HIPPO`. - - Mirrors :class:`.RecipeManager` / :class:`.IngredientManager`: keeps - :class:`.RouteSet` construction on the user-facing client surface - (``animal.routes.from_*``) instead of exposing the set layer directly. This is - also the seam where target / auth scoping will be attached once the - client/backend split lands. - - Access it via :attr:`.HIPPO.routes`. - """ - - def __init__(self, animal: 'HIPPO') -> None: - self._animal = animal - - def from_product_ids(self, ids: 'CompoundSet | list[int]', *, progress=True): - """Build a :class:`.RouteSet` of stored routes to the given products. - - :param ids: product :class:`.CompoundModel` IDs (or a :class:`.CompoundSet`) - :param progress: show a progress bar while building - """ - if isinstance(ids, CompoundSet): - ids = ids.ids - return RouteSet.from_product_ids(ids, progress=progress) - - -class ScorerManager: - """Client-side accessor for recipe scoring, bound to a :class:`.HIPPO`. - - Mirrors the other managers: keeps :class:`.Scorer` construction on the - user-facing client surface (``animal.scorers.*``) instead of exposing the - service directly. The :class:`.Scorer` loads recipes from a directory of - ``Recipe_*.json`` files (as written by the generators) -- no ``db`` needed. - - Access it via :attr:`.HIPPO.scorers`. - """ - - def __init__(self, animal: 'HIPPO') -> None: - self._animal = animal - - def default(self, directory, **kwargs) -> Scorer: - """Create a :class:`.Scorer` with the default attributes. - - See :meth:`.Scorer.default` for keyword arguments (``skip``, - ``load_cache``, ``allowed_poses``, ``out_key``, ...). - """ - return Scorer.default(directory, **kwargs) - - def create(self, directory, **kwargs) -> Scorer: - """Create a :class:`.Scorer` with explicit attributes. - - See :class:`.Scorer` for keyword arguments. - """ - return Scorer(directory, **kwargs) - - -class GeneratorManager: - """Client-side accessor for the random recipe / selection generators, bound to - a :class:`.HIPPO`. - - Mirrors :class:`.RecipeManager` / :class:`.IngredientManager` / - :class:`.RouteManager`: keeps generator construction on the user-facing client - surface (``animal.generators.*``) and validates inputs before delegating to the - backend generators in :mod:`designdb.services.generation`. Generators are - in-memory: their ``generate(...)`` returns a :class:`.Recipe`. - - Access it via :attr:`.HIPPO.generators`. - """ - - def __init__(self, animal: 'HIPPO') -> None: - self._animal = animal - - @staticmethod - def _check(route_pool, compounds) -> None: - if route_pool is not None and not isinstance(route_pool, RouteSet): - raise TypeError(f'route_pool must be a RouteSet, got {type(route_pool)}') - if compounds is not None and not isinstance(compounds, CompoundSet): - raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') - - def random_recipe( - self, - *, - out_key: str, - route_pool=None, - suppliers=None, - max_lead_time=None, - start_with=None, - ) -> RandomRecipeGenerator: - """A generator that samples synthetic :class:`.Route`\\ s. - - :param out_key: base path/key for the generator's output files - """ - self._check(route_pool, None) - return RandomRecipeGenerator( - out_key=out_key, - route_pool=route_pool, - suppliers=suppliers, - max_lead_time=max_lead_time, - start_with=start_with, - ) - - def random_selection( - self, - *, - out_key: str, - compounds=None, - suppliers=None, - amount: float = 1.0, - max_lead_time=None, - start_with=None, - ) -> RandomSelectionGenerator: - """A generator that samples (catalogue) compound selections. - - :param out_key: base path/key for the generator's output files - """ - self._check(None, compounds) - return RandomSelectionGenerator( - out_key=out_key, - compounds=compounds, - suppliers=suppliers, - amount=amount, - max_lead_time=max_lead_time, - start_with=start_with, - ) - - def random_recipe_selection( - self, - *, - out_key: str, - route_pool=None, - compounds=None, - suppliers=None, - amount: float = 1.0, - max_lead_time=None, - start_with=None, - ) -> RandomRecipeSelectionGenerator: - """A generator combining routes and compound selections. - - :param out_key: base path/key for the generator's output files - """ - self._check(route_pool, compounds) - return RandomRecipeSelectionGenerator( - out_key=out_key, - route_pool=route_pool, - compounds=compounds, - suppliers=suppliers, - amount=amount, - max_lead_time=max_lead_time, - start_with=start_with, - ) - - class HIPPO: """Entry-point class of the xchem-hippo package. @@ -333,12 +92,10 @@ def __init__( self._apo_desolv_path: Path | None = None self._apo_desolv_downloaded_at: datetime | None = None - # Download policy: instantiation triggers no download on a *first* run - # (the full hit data, including apo_desolv proteins, is fetched by the - # first add_hits against a remote stack). On *re-instantiation* of the - # same target/project -- detected by a persisted full download already on - # disk -- only the protein (apo_desolv) files are refreshed here, so a new - # session always has current PDBs for interaction calculations. + # Download policy: a first run downloads nothing here (the first add_hits + # fetches the full data). On re-instantiation (a persisted download already + # on disk) only the apo_desolv proteins are refreshed, so a new session has + # current PDBs. target_dir = DOWNLOADS_DIR / project.project_name / target_name if (target_dir / 'metadata.csv').is_file() and ( target_dir / 'aligned_files' @@ -474,29 +231,32 @@ def quote_reactants(self) -> tuple[CompoundSet, CompoundSet]: """ return self.quote_compounds(self.reactants) + def plot_interaction_punchcard( + self, poses: 'PoseSet | None' = None, *, subtitle=None, opacity=1.0, **kwargs + ): + """Plot an interaction punch-card for a :class:`.PoseSet` (default: all of + this target's poses). See :func:`.plotting.plot_interaction_punchcard`.""" + from .plotting import plot_interaction_punchcard + + return plot_interaction_punchcard( + self.poses if poses is None else poses, + title_prefix=self._target.target_name, + subtitle=subtitle, + opacity=opacity, + **kwargs, + ) + def _ensure_hit_data( self, auth_token: str | None = None, stack: str = 'production' ) -> Path: - """Ensure this target's full crystallographic hit data is available locally. - - Downloads the target's Fragalysis data (all observations) from the stack - via :class:`.DownloadService` and returns the path to the extracted - directory (``data/downloads//``). - - Unlike :meth:`._ensure_apo_desolv_files`, this data **persists**: if a - previous full download is already on disk it is reused without - re-fetching (detected by the presence of ``metadata.csv``, which an - apo_desolv-only download does not produce). - - .. note:: - HIPPO-level helper, intended to be called from user-facing - :class:`.HIPPO` methods (e.g. :meth:`.add_hits`). It must not be - called from the components or services layer. - - :param auth_token: optional Fragalysis ``sessionid``; otherwise the - ``FRAGALYSIS_AUTH_TOKEN`` environment variable is used - :param stack: Fragalysis stack to download from, a key into - :data:`.STACK_URLS`; defaults to ``'production'`` + """Ensure the target's full Fragalysis hit data is downloaded locally. + + Downloads all observations via :class:`.DownloadService` to + ``data/downloads//`` and returns that path. The data + persists: an existing download (detected by ``metadata.csv``) is reused. + + :param auth_token: Fragalysis ``sessionid`` (else ``FRAGALYSIS_AUTH_TOKEN``) + :param stack: Fragalysis stack to download from (default ``'production'``) :returns: path to the extracted download directory """ @@ -531,33 +291,14 @@ def _ensure_hit_data( def _ensure_apo_desolv_files( self, auth_token: str | None = None, stack: str = 'production' ) -> Path: - """Ensure this target's apo-desolvated PDB files are available locally. - - Downloads the ``apo_desolv`` structures for this target's observations - from Fragalysis (via :class:`.DownloadService`) and returns the path to - the extracted directory. The download is performed at most once per - instance and re-fetched each instance (overwriting any on-disk - apo_desolv files) so a fresh instance always works with current data. If - the full hit data was already downloaded this instance (via - :meth:`._ensure_hit_data`), that is reused since it already includes - fresh apo_desolv files. - - Everything needed for the request is taken from this animal: the target - name and project (target access string) from :attr:`.target`, and the - observation shortcodes from the ``pose_alias`` of this target's poses. - - .. note:: - This is a HIPPO-level helper, intended to be called from user-facing - :class:`.HIPPO` methods the first time PDB files are needed. It's not - expected to be called from the components or services layer. This - method won't be necessary once HIPPO functions as a web service as - intended. - - :param auth_token: optional Fragalysis ``sessionid``; otherwise the - ``FRAGALYSIS_AUTH_TOKEN`` environment variable is used - :param stack: Fragalysis stack to download from, a key into - :data:`.STACK_URLS` (e.g. ``'production'``, ``'staging'``, - ``'localhost'``); defaults to ``'production'`` + """Ensure the target's apo-desolvated protein PDBs are downloaded locally. + + Downloads the ``apo_desolv`` structures for this target's poses (by + ``pose_alias``) via :class:`.DownloadService`, once per instance. Reuses the + full hit data if it was already downloaded this instance. + + :param auth_token: Fragalysis ``sessionid`` (else ``FRAGALYSIS_AUTH_TOKEN``) + :param stack: Fragalysis stack to download from (default ``'production'``) :returns: path to the extracted download directory """ @@ -641,10 +382,6 @@ def add_hits( """ - ### Resolve the data source - # Path-driven: provide both metadata_csv and aligned_directory to load - # existing local data, or omit both to download the target's data from - # the Fragalysis stack (always Fragalysis-type). # fall back to the stack/auth configured at instantiation if stack is None: stack = self._stack diff --git a/hippo/designdb/client.py b/hippo/designdb/client.py new file mode 100644 index 0000000..7b2cf73 --- /dev/null +++ b/hippo/designdb/client.py @@ -0,0 +1,214 @@ +"""Client-side accessors (managers) for the HIPPO user-facing API. + +Each manager is bound to a :class:`.HIPPO` instance and groups a family of +construction entry points (recipes, ingredients, routes, scorers, generators), +validating input and delegating to the backend services/sets. Together they form +the client surface of the eventual client/backend split (see CLAUDE.md). + +Access them via the corresponding :class:`.HIPPO` properties: ``animal.recipes``, +``animal.ingredients``, ``animal.routes``, ``animal.scorers``, ``animal.generators``. +""" + +from typing import TYPE_CHECKING + +from .recipe import Recipe +from .services.generation import ( + RandomRecipeGenerator, + RandomRecipeSelectionGenerator, + RandomSelectionGenerator, +) +from .services.recipe import RecipeService +from .services.recipe_score import Scorer +from .sets.compound import CompoundSet +from .sets.ingredient import IngredientSet +from .sets.reaction import ReactionSet +from .sets.route import RouteSet + +if TYPE_CHECKING: + from .animal import HIPPO + + +class RecipeManager: + """Build recipes from compounds/reactions/reactants (via ``animal.recipes``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: CompoundSet, **kwargs): + """Build recipe(s) to synthesise a :class:`.CompoundSet`. + + See :meth:`.RecipeService.from_compounds` for keyword arguments. + """ + if not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return RecipeService.from_compounds(compounds, **kwargs) + + def from_reactions(self, reactions: ReactionSet, **kwargs): + """Build recipe(s) from a :class:`.ReactionSet`. + + See :meth:`.RecipeService.from_reactions` for keyword arguments. + """ + if not isinstance(reactions, ReactionSet): + raise TypeError(f'reactions must be a ReactionSet, got {type(reactions)}') + return RecipeService.from_reactions(reactions, **kwargs) + + def from_reactants(self, reactants: 'CompoundSet | IngredientSet', **kwargs): + """Build the maximal recipe reachable from a set of reactants. + + See :meth:`.RecipeService.from_reactants` for keyword arguments. + """ + if not isinstance(reactants, (CompoundSet, IngredientSet)): + raise TypeError( + 'reactants must be a CompoundSet or IngredientSet, ' + f'got {type(reactants)}' + ) + return RecipeService.from_reactants(reactants, **kwargs) + + def from_json(self, path, **kwargs) -> 'Recipe': + """Load a serialised :class:`.Recipe` from a JSON file. + + See :meth:`.Recipe.from_json` for keyword arguments (``data``, + ``clear_quotes``, ``debug``). + """ + return Recipe.from_json(path, **kwargs) + + +class IngredientManager: + """Build :class:`.IngredientSet`\\ s (via ``animal.ingredients``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_compounds(self, compounds: 'CompoundSet | None' = None, **kwargs): + """Build an :class:`.IngredientSet` from a :class:`.CompoundSet` (or IDs). + + See :meth:`.IngredientSet.from_compounds` for keyword arguments. + """ + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + return IngredientSet.from_compounds(compounds=compounds, **kwargs) + + +class RouteManager: + """Build :class:`.RouteSet`\\ s (via ``animal.routes``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def from_product_ids(self, ids: 'CompoundSet | list[int]', *, progress=True): + """Build a :class:`.RouteSet` of stored routes to the given products. + + :param ids: product :class:`.CompoundModel` IDs (or a :class:`.CompoundSet`) + :param progress: show a progress bar while building + """ + if isinstance(ids, CompoundSet): + ids = ids.ids + return RouteSet.from_product_ids(ids, progress=progress) + + +class ScorerManager: + """Build recipe :class:`.Scorer`\\ s (via ``animal.scorers``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + def default(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with the default attributes. + + See :meth:`.Scorer.default` for keyword arguments (``skip``, + ``load_cache``, ``allowed_poses``, ``out_key``, ...). + """ + return Scorer.default(directory, **kwargs) + + def create(self, directory, **kwargs) -> Scorer: + """Create a :class:`.Scorer` with explicit attributes. + + See :class:`.Scorer` for keyword arguments. + """ + return Scorer(directory, **kwargs) + + +class GeneratorManager: + """Build random recipe/selection generators (via ``animal.generators``).""" + + def __init__(self, animal: 'HIPPO') -> None: + self._animal = animal + + @staticmethod + def _check(route_pool, compounds) -> None: + if route_pool is not None and not isinstance(route_pool, RouteSet): + raise TypeError(f'route_pool must be a RouteSet, got {type(route_pool)}') + if compounds is not None and not isinstance(compounds, CompoundSet): + raise TypeError(f'compounds must be a CompoundSet, got {type(compounds)}') + + def random_recipe( + self, + *, + out_key: str, + route_pool=None, + suppliers=None, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeGenerator: + """A generator that samples synthetic :class:`.Route`\\ s. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, None) + return RandomRecipeGenerator( + out_key=out_key, + route_pool=route_pool, + suppliers=suppliers, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_selection( + self, + *, + out_key: str, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomSelectionGenerator: + """A generator that samples (catalogue) compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(None, compounds) + return RandomSelectionGenerator( + out_key=out_key, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) + + def random_recipe_selection( + self, + *, + out_key: str, + route_pool=None, + compounds=None, + suppliers=None, + amount: float = 1.0, + max_lead_time=None, + start_with=None, + ) -> RandomRecipeSelectionGenerator: + """A generator combining routes and compound selections. + + :param out_key: base path/key for the generator's output files + """ + self._check(route_pool, compounds) + return RandomRecipeSelectionGenerator( + out_key=out_key, + route_pool=route_pool, + compounds=compounds, + suppliers=suppliers, + amount=amount, + max_lead_time=max_lead_time, + start_with=start_with, + ) diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index 9311c25..f0acbf1 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -517,10 +517,9 @@ def get_recipes( ): """Get :class:`.Recipe` objects that result in this compound. See :meth:`.Recipe.from_compounds`""" + from designdb.recipe import Recipe from designdb.sets.compound import CompoundSet - from .recipe import Recipe - return Recipe.from_compounds( CompoundSet([self._instance.pk]), amount=amount, @@ -1088,40 +1087,6 @@ def get_quotes( return qs - ### METHODS - - def get_cheapest_quote_id( - self, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - ) -> 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. - """ - - qs = CataloguePriceModel.objects.filter(compounds=self.compound) - - if supplier: - qs = qs.filter(supplier=supplier) - - if min_amount: - qs = qs.filter(amount__gte=min_amount) - - if max_lead_time: - qs = qs.filter(lead_time__lte=max_lead_time) - - return qs.order_by('price').first() - ### PROPERTIES @property diff --git a/hippo/designdb/components/quote.py b/hippo/designdb/components/quote.py index ce0022a..c4153c3 100644 --- a/hippo/designdb/components/quote.py +++ b/hippo/designdb/components/quote.py @@ -1,10 +1,8 @@ """Component wrapping a catalogue price (quote). -A :class:`.Quote` wraps a :class:`.CataloguePriceModel` row, exposing its price, -amount, supplier, etc. in a convenient form. It can also represent an *estimated* -quote (see :meth:`.Quote.estimate`) that is **not** backed by a saved database row --- used when no single catalogue pack covers the required amount, mirroring the -legacy ``Quote.combination`` behaviour. +A :class:`.Quote` wraps a :class:`.CataloguePriceModel` row. It can also be an +*estimated* quote (see :meth:`.Quote.estimate`), not backed by a saved row, for +when no single catalogue pack covers the required amount. """ import mcol @@ -31,14 +29,13 @@ def __init__(self, instance: CataloguePriceModel): def estimate(cls, required_amount: float, quotes: 'list[Quote]') -> 'Quote | None': """Estimate a quote for ``required_amount`` when no single pack is big enough. - Mirrors the legacy ``Quote.combination``: take the biggest available pack and - scale its unit price linearly to the required amount. The returned quote wraps - an *unsaved* :class:`.CataloguePriceModel` (``id is None``). + Scales the biggest available pack's unit price to the required amount; the + returned quote wraps an *unsaved* :class:`.CataloguePriceModel` (``id is None``). :param required_amount: amount in ``mg`` :param quotes: available :class:`.Quote` packs to scale from - :returns: an estimated :class:`.Quote`, or ``None`` if there is nothing to - scale from (no pack with a usable amount and price) + :returns: the estimated :class:`.Quote`, or ``None`` if there's nothing usable + to scale from """ usable = [q for q in quotes if q.amount and q.price is not None] diff --git a/hippo/designdb/interactions.py b/hippo/designdb/interactions.py index af3ec41..139f436 100644 --- a/hippo/designdb/interactions.py +++ b/hippo/designdb/interactions.py @@ -1,10 +1,7 @@ -"""Constants for protein-ligand interaction detection (fingerprinting). +"""Constants for protein-ligand interaction detection. -The feature families and complementary-feature / interaction-type maps are -re-exported from ``molparse`` (the single source of truth). The distance/angle -cutoffs live here, ported from the legacy ``hippo`` ``pose`` module. - -Used by :class:`.InteractionService` and the :class:`.Pose` component. +Feature families and the complementary-feature / interaction-type maps are +re-exported from ``molparse``; the distance/angle cutoffs are defined here. """ from molparse.rdkit.features import COMPLEMENTARY_FEATURES, FEATURE_FAMILIES, INTERACTION_TYPES diff --git a/hippo/designdb/plotting.py b/hippo/designdb/plotting.py new file mode 100644 index 0000000..13452da --- /dev/null +++ b/hippo/designdb/plotting.py @@ -0,0 +1,111 @@ +"""Plotting helpers (plotly figures) operating on sets.""" + +import mrich + +HIPPO_HEAD_URL = ( + 'https://raw.githubusercontent.com/mwinokan/HIPPO/main/logos/hippo_assets-02.png' +) + + +def add_punchcard_logo(fig): + """Add the HIPPO logo to a punch-card figure.""" + fig.add_layout_image( + dict( + source=HIPPO_HEAD_URL, + xref='paper', + yref='paper', + x=1, + y=1, + sizex=0.25, + sizey=0.25, + xanchor='right', + yanchor='top', + ) + ) + return fig + + +def plot_interaction_punchcard( + poses, + *, + title_prefix: str | None = None, + subtitle: str | None = None, + opacity: float = 1.0, + group: str = 'pose_name', + ignore_chains: bool = False, +): + """Interaction punch-card (residue vs. feature family) for a :class:`.PoseSet`. + + :param poses: the :class:`.PoseSet` to plot + :param title_prefix: bold prefix for the title (e.g. the target name) + :param subtitle: optional subtitle + :param opacity: marker opacity + :param group: column to colour points by (default: per-pose) + :param ignore_chains: drop the chain from the residue axis label + """ + import plotly.express as px + import plotly.graph_objects as go + + iset = poses.interactions + mrich.var('#poses', len(poses)) + mrich.var('#interactions', len(iset)) + + plot_data = iset.df + if plot_data.empty: + mrich.warning('No interactions to plot') + return None + + name_lookup = poses.id_name_dict + plot_data['pose_name'] = [name_lookup.get(i) for i in plot_data['pose_id'].values] + + if ignore_chains: + x = 'res_name_number' + plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( + lambda r: ' '.join(str(i) for i in r), axis=1 + ) + sort_key = lambda v: v[1] + else: + x = 'chain_res_name_number_str' + plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( + lambda r: ' '.join(str(i) for i in r), axis=1 + ) + sort_key = lambda v: (v[2], v[1]) + + title = 'Interaction Punch-Card' + if title_prefix: + title = f'{title_prefix}: {title}' + if subtitle: + title += f'
{subtitle}' + + fig = px.scatter( + plot_data, + x=x, + y='type', + marginal_x='histogram', + marginal_y='histogram', + hover_data=plot_data.columns, + color=group, + title=title, + ) + + fig.update_layout(title=title, title_automargin=False, title_yref='container') + fig.update_layout(xaxis_title='Residue', yaxis_title='Feature Family') + + # order the residue axis by (residue_number, chain) + categoryarray = plot_data[[x, 'residue_number', 'chain_name']].agg(tuple, axis=1) + categoryarray = [v[0] for v in sorted(categoryarray.values, key=sort_key)] + fig.update_xaxes(categoryorder='array', categoryarray=categoryarray) + fig.update_yaxes(categoryorder='category descending') + + for trace in fig.data: + if isinstance(trace, go.Histogram): + trace.opacity = 1 + trace.xbins.size = 1 + else: + trace['marker']['size'] = 10 + trace['marker']['opacity'] = opacity + + fig.update_layout(barmode='stack') + fig.update_layout(scattermode='group', scattergap=0.75) + + return add_punchcard_logo(fig) diff --git a/hippo/designdb/components/recipe.py b/hippo/designdb/recipe.py similarity index 95% rename from hippo/designdb/components/recipe.py rename to hippo/designdb/recipe.py index 352f8ac..86b7201 100644 --- a/hippo/designdb/components/recipe.py +++ b/hippo/designdb/recipe.py @@ -4,34 +4,20 @@ intermediates, reactions and (no-chem) compounds that make up a synthetic recipe, and exposes price/serialisation/presentation on top of them. -All construction and DB-traversal *orchestration* lives in the service layer -(:class:`.RecipeService` in ``services/recipe.py``). The ``from_*`` and export -methods on :class:`.Recipe` are **deprecated shims** that delegate to the service -— see the ``DEPRECATED`` banner below. They use a local import of the service so -there is no module-level ``component -> service`` dependency. +Construction and DB-traversal *orchestration* lives in :class:`.RecipeService`. +The ``from_*`` and export methods on :class:`.Recipe` are **deprecated shims** that +delegate to it (see the ``DEPRECATED`` banner below) via a local import. """ -import warnings - import mcol import mrich +from designdb.components.compound import Ingredient +from designdb.components.reaction import Reaction from designdb.models import ComponentModel, CompoundModel, ReactionModel, RouteModel -from designdb.sets.compound import CompoundSet, IngredientSet +from designdb.sets.compound import CompoundSet +from designdb.sets.ingredient import IngredientSet from designdb.sets.reaction import ReactionSet -from .compound import Ingredient -from .reaction import Reaction - - -def _deprecated(old: str, new: str) -> None: - """Emit a uniform deprecation warning for a relocated method.""" - warnings.warn( - f'{old} is deprecated; use {new}. ' - 'The Recipe shim will be removed after the migration settles.', - DeprecationWarning, - stacklevel=3, - ) - class Recipe: """A Recipe stores data corresponding to a specific synthetic recipe involving @@ -90,7 +76,6 @@ def from_reaction(cls, *args, **kwargs): """DEPRECATED: use :meth:`.RecipeService.from_reaction`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.from_reaction()', 'RecipeService.from_reaction()') return RecipeService.from_reaction(*args, **kwargs) @classmethod @@ -98,7 +83,6 @@ def from_reactions(cls, *args, **kwargs): """DEPRECATED: use :meth:`.RecipeService.from_reactions`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.from_reactions()', 'RecipeService.from_reactions()') return RecipeService.from_reactions(*args, **kwargs) @classmethod @@ -106,7 +90,6 @@ def from_compounds(cls, *args, **kwargs): """DEPRECATED: use :meth:`.RecipeService.from_compounds`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.from_compounds()', 'RecipeService.from_compounds()') return RecipeService.from_compounds(*args, **kwargs) @classmethod @@ -114,7 +97,6 @@ def from_reactants(cls, *args, **kwargs): """DEPRECATED: use :meth:`.RecipeService.from_reactants`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.from_reactants()', 'RecipeService.from_reactants()') return RecipeService.from_reactants(*args, **kwargs) ### FACTORIES @@ -790,7 +772,6 @@ def get_routes(self, return_ids: bool = False) -> 'RouteSet': """DEPRECATED: use :meth:`.RecipeService.get_routes`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.get_routes()', 'RecipeService.get_routes()') return RecipeService.get_routes(self, return_ids=return_ids) def register_missing_routes( @@ -799,10 +780,6 @@ def register_missing_routes( """DEPRECATED: use :meth:`.RecipeService.register_missing_routes`.""" from designdb.services.recipe import RecipeService - _deprecated( - 'Recipe.register_missing_routes()', - 'RecipeService.register_missing_routes()', - ) return RecipeService.register_missing_routes( self, missing_only=missing_only, supplier=supplier ) @@ -811,7 +788,6 @@ def write_CAR_csv(self, file: 'str | Path', return_df: bool = False): """DEPRECATED: use :meth:`.RecipeService.write_CAR_csv`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.write_CAR_csv()', 'RecipeService.write_CAR_csv()') return RecipeService.write_CAR_csv(self, file, return_df=return_df) def write_reactant_csv( @@ -820,7 +796,6 @@ def write_reactant_csv( """DEPRECATED: use :meth:`.RecipeService.write_reactant_csv`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.write_reactant_csv()', 'RecipeService.write_reactant_csv()') return RecipeService.write_reactant_csv( self, file, reaction_type_counts=reaction_type_counts, return_df=return_df ) @@ -829,14 +804,12 @@ def write_product_csv(self, file: 'str | Path', return_df: bool = False): """DEPRECATED: use :meth:`.RecipeService.write_product_csv`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.write_product_csv()', 'RecipeService.write_product_csv()') return RecipeService.write_product_csv(self, file, return_df=return_df) def to_syndirella(self, out_key: 'str | Path', poses: 'PoseSet', *, separate=False): """DEPRECATED: use :meth:`.RecipeService.to_syndirella`.""" from designdb.services.recipe import RecipeService - _deprecated('Recipe.to_syndirella()', 'RecipeService.to_syndirella()') return RecipeService.to_syndirella(self, out_key, poses, separate=separate) ### INTERNALS @@ -993,7 +966,7 @@ def get_route( :param id: the ID of the :class:`.RouteModel` to retrieve :param get_quote: fetch catalogue quotes for the reactants so the route is - priced (mirrors :meth:`.RecipeService.from_reaction`), defaults to ``True`` + priced, defaults to ``True`` :param debug: increase verbosity for debugging """ diff --git a/hippo/designdb/services/generation.py b/hippo/designdb/services/generation.py index 2367af9..fbf65d8 100644 --- a/hippo/designdb/services/generation.py +++ b/hippo/designdb/services/generation.py @@ -1,20 +1,9 @@ -"""Service layer: random recipe / selection generators. - -Ported from the legacy ``rgen`` module and modernised: - -* **No legacy ``Database`` coupling.** The modern :class:`.Recipe` / :class:`.Route` - / :class:`.IngredientSet` / :class:`.Price` are self-contained (ORM-backed), so - the generators take pools + config only -- no ``db`` / ``db.path``. As there is no - database path to derive output filenames from, ``out_key`` is now **required**. -* **File output is preserved.** Each :meth:`generate` writes the generated recipe to - ``{recipe_dir}/Recipe_.json`` (via :meth:`.Recipe.write_json`), and each - generator dumps its state to ``{out_key}_.json`` on construction -- matching - legacy behaviour. ``generate`` also returns the :class:`.Recipe` so callers may - collect them in memory. -* The three legacy generators share one add-within-budget loop here - (:func:`_generate_recipe`) rather than duplicating it. - -Layering: ``services -> components/sets``. User entry point: ``animal.generators``. +"""Random recipe / selection generators (user entry point: ``animal.generators``). + +``generate()`` returns a :class:`.Recipe` and writes it to +``{recipe_dir}/Recipe_.json``; generator state is dumped to +``{out_key}_.json`` on construction. ``out_key`` is required (it names the +output files). """ import json @@ -22,8 +11,8 @@ import mrich from designdb.components.price import Price -from designdb.components.recipe import Recipe, Route -from designdb.sets.compound import IngredientSet +from designdb.recipe import Recipe, Route +from designdb.sets.ingredient import IngredientSet from designdb.sets.route import RouteSet from designdb.utils import dt_hash @@ -287,7 +276,7 @@ def generate( """Generate a random recipe of routes within ``budget`` (also written to disk).""" if balance_clusters: raise NotImplementedError( - 'balance_clusters requires route clustering, which is not yet ported' + 'balance_clusters requires route clustering, which is not implemented' ) budget = Price(budget, currency) recipe, stats = _generate_recipe( diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 48fad07..b1a7ab0 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -8,7 +8,6 @@ import mrich import pandas as pd from designdb.components.compound import Ingredient -from designdb.components.recipe import Recipe, Route from designdb.models import ( CompoundModel, EnumerationMethodModel, @@ -20,12 +19,13 @@ ScoringMethodModel, TargetModel, ) +from designdb.recipe import Recipe, Route from designdb.services.compound import CompoundService, CompoundTagService from designdb.services.pose import PoseService, PoseTagService +from designdb.services.pose_score import ScoreService from designdb.services.reaction import ReactionService from designdb.services.route import RouteService -from designdb.services.score import ScoreService -from designdb.sets.compound import IngredientSet +from designdb.sets.ingredient import IngredientSet from designdb.sets.reaction import ReactionSet from designdb.utils import ( SanitisationError, diff --git a/hippo/designdb/services/ingredient.py b/hippo/designdb/services/ingredient.py deleted file mode 100644 index 034cf26..0000000 --- a/hippo/designdb/services/ingredient.py +++ /dev/null @@ -1,76 +0,0 @@ -import mrich -import pandas as pd -from designdb.models import CataloguePriceCompoundJunctionModel, CataloguePriceModel, CompoundModel -from django.db.models import Exists, OuterRef, Q - - -class IngredientService: - @staticmethod - def get_quotes( - compound: CompoundModel, - 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, - ): - qs = CataloguePriceModel.objects.annotate( - has_compound=Exists( - CataloguePriceCompoundJunctionModel.objects.filter( - compound=compound, - catalogue_price=OuterRef('pk'), - ), - ), - ).filter( - has_compound=True, - ) - - if supplier: - if isinstance(supplier, str): - qs = qs.filter(supplier=supplier) - else: - qs = qs.filter(supplier__in=supplier) - - if not qs.exists(): - return None - - if max_lead_time: - qs = qs.filter(lead_time__lte=max_lead_time) - - if min_amount: - qs = qs.filter(amount__gte=min_amount) - - if not qs.exists(): - mrich.debug( - f'No quote available for C{compound.pk} with amount >= ' - f'{min_amount} mg. Estimating price...' - ) - - if pick_cheapest: - return qs.order_by('price').first() - - if df: - return pd.DataFrame(qs.values()).drop(columns='compound') - - return qs - - @staticmethod - def get_cheapest_quote_id( - compound: CompoundModel, - min_amount: float | None = None, - supplier: str | None = None, - max_lead_time: float | None = None, - ) -> int | None: - query = Q(compound=compound) - - if supplier: - query &= Q(quote_supplier=supplier) - - if min_amount: - query &= Q(quote_amount__gte=min_amount) - - if max_lead_time: - query &= Q(quote_lead_time__lte=max_lead_time) - - return CataloguePriceModel.objects.filter(query).order_by('quote_price').first() diff --git a/hippo/designdb/services/interaction.py b/hippo/designdb/services/interaction.py index 1914cec..f3dcf55 100644 --- a/hippo/designdb/services/interaction.py +++ b/hippo/designdb/services/interaction.py @@ -1,25 +1,9 @@ -"""Service for computing protein-ligand interaction fingerprints. +"""Protein-ligand interaction fingerprinting. -Owns interaction detection for a :class:`.Pose`: extracting protein features -(populating :class:`.FeatureModel`), running the geometric detector, resolving -duplicate / less-significant interactions, and populating -:class:`.InteractionModel`. - -Ported from the legacy ``Pose.calculate_interactions`` / ``Target.calculate_features`` -/ ``InteractionSet.resolve``. Two deliberate deviations from legacy: - -* Protein features are taken directly from *this pose's* ``protein_system`` (which - carries the geometry) and the matching :class:`.FeatureModel` row is - get-or-created for its ID -- rather than a target-wide feature cache plus a - chain/residue re-lookup. This drops the legacy mutation-mismatch handling - (features always match the structure they came from). -* Resolution runs in-memory (legacy used an in-memory SQLite temp table). - -.. attention:: - Geometry/resolution logic is a faithful but **unverified** port; it needs - checking against real protein structures. - -Layering: ``services -> components``. Entry point: :meth:`.Pose.calculate_interactions`. +Detects interactions for a :class:`.Pose`: extracts protein features (populating +:class:`.FeatureModel`), runs the geometric detector, resolves duplicate +interactions, and populates :class:`.InteractionModel`. Protein features come from +the pose's own ``protein_system``. Entry point: :meth:`.Pose.calculate_interactions`. """ import json @@ -273,7 +257,7 @@ def _detect( @staticmethod def _resolve(candidates: list[dict], debug: bool = False) -> list[dict]: - """Cull duplicate / less-significant interactions (port of the legacy rules). + """Cull duplicate / less-significant interactions. Keeps, per interaction type: the closest interaction per ligand-atom group (Hydrogen Bond, π-cation, Electrostatic), the closest per protein feature diff --git a/hippo/designdb/services/score.py b/hippo/designdb/services/pose_score.py similarity index 100% rename from hippo/designdb/services/score.py rename to hippo/designdb/services/pose_score.py diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 6a48334..8814eb0 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -1,12 +1,8 @@ -"""Service layer that owns Recipe construction and DB traversal. +"""Recipe construction and DB traversal. -This is the canonical home for the orchestration logic that builds -:class:`.Recipe` objects from reactions/compounds/reactants. The :class:`.Recipe` -component itself is a lean aggregate; its ``from_*`` classmethods are deprecated -shims that delegate here (see ``components/recipe.py``). - -Layering: ``services -> recipe -> sets -> components``. This module may import -from every lower layer. +Builds :class:`.Recipe` objects from reactions/compounds/reactants. The +:class:`.Recipe` aggregate itself is lean; its ``from_*`` classmethods are +deprecated shims that delegate here. """ from itertools import product @@ -15,7 +11,8 @@ from designdb.components.compound import Compound from designdb.components.reaction import DEFAULT_PRODUCT_YIELD, Reaction from designdb.models import CompoundModel, InspirationModel, PoseModel, ReactionModel, RouteModel -from designdb.sets.compound import CompoundSet, IngredientSet +from designdb.sets.compound import CompoundSet +from designdb.sets.ingredient import IngredientSet from designdb.sets.pose import PoseSet from designdb.sets.reaction import ReactionSet @@ -57,7 +54,7 @@ def from_reaction( :param get_ingredient_quotes: get quotes for product ingredients """ - from designdb.components.recipe import Recipe + from designdb.recipe import Recipe assert isinstance(reaction, ReactionModel) reaction_c = Reaction(reaction) @@ -294,7 +291,7 @@ def from_compounds( reactions on the fly """ - from designdb.components.recipe import Route + from designdb.recipe import Route assert isinstance(compounds, CompoundSet) @@ -605,16 +602,8 @@ def write_CAR_csv( @staticmethod def write_reactant_csv(recipe: 'Recipe', file, reaction_type_counts=True, **kwargs): - """Detailed reactant-purchasing CSV. - - Not yet ported: depends on the legacy quote-dataframe assembly - (``db.get_quote_df``) and raw component/route SQL. Port together with the - quoting subsystem. - """ - raise NotImplementedError( - 'write_reactant_csv requires the unported quote-dataframe / downstream ' - 'route lookups; port alongside the quoting subsystem' - ) + """Detailed reactant-purchasing CSV. Not implemented.""" + raise NotImplementedError('write_reactant_csv is not implemented') @staticmethod def write_product_csv( @@ -755,38 +744,22 @@ def write_product_csv( @staticmethod def to_syndirella(recipe: 'Recipe', out_key, poses, *, separate: bool = False): - """Generate Syndirella elaboration inputs from this recipe. - - Not yet ported: depends on unported Pose machinery (reference/template - handling, ``get_pose_id_alias_dict``, inspiration SDF export). - """ - raise NotImplementedError( - 'RecipeService.to_syndirella requires unported Pose machinery ' - '(templates, alias/inspiration lookups); port alongside the Pose subsystem' - ) + """Generate Syndirella elaboration inputs from this recipe. Not implemented.""" + raise NotImplementedError('RecipeService.to_syndirella is not implemented') @staticmethod def register_missing_routes( recipe: 'Recipe', missing_only: bool = True, supplier: str = 'Enamine' ) -> None: """Calculate and register missing routes to the products of ``recipe``. - - Not yet ported: depends on the unported - ``CompoundSet.register_missing_routes`` / route-registration helpers. - """ - raise NotImplementedError( - 'register_missing_routes depends on the unported route-registration ' - 'helpers (CompoundSet.register_missing_routes / db.register_route)' - ) + Not implemented.""" + raise NotImplementedError('register_missing_routes is not implemented') ### HELPERS @staticmethod def _possible_reaction_ids(compound_ids: set[int]) -> list[int]: - """Return reaction IDs whose every reactant is in ``compound_ids``. - - ORM replacement for the legacy ``db.get_possible_reaction_ids``. - """ + """Return reaction IDs whose every reactant is in ``compound_ids``.""" from designdb.models import ReactantModel compound_ids = set(compound_ids) diff --git a/hippo/designdb/services/scoring.py b/hippo/designdb/services/recipe_score.py similarity index 95% rename from hippo/designdb/services/scoring.py rename to hippo/designdb/services/recipe_score.py index 0d46b0c..8c04cba 100644 --- a/hippo/designdb/services/scoring.py +++ b/hippo/designdb/services/recipe_score.py @@ -1,22 +1,9 @@ -"""Service layer: recipe scoring. +"""Recipe scoring (user entry point: ``animal.scorers``). -Ported from the legacy ``scoring`` module and modernised: - -* **No legacy ``Database`` coupling.** Recipes are loaded from a directory of - ``Recipe_*.json`` files via the modern :class:`.RecipeSet` (which already - supports directory loading), and the per-recipe child sets (compounds / poses / - interactions / pose-metadata) are pre-fetched via the ORM rather than legacy - ``db.get_*`` helpers. -* Output filenames use ``out_key`` (no sqlite path); the score cache is written to - ``{out_key}.json``. - -A :class:`.Scorer` evaluates a set of recipes against weighted :class:`.Attribute` -/ :class:`.CustomAttribute` objects; each attribute value is converted to a -percentile (0-1) and combined by weight. User entry point: ``animal.scorers``. - -.. attention:: - This is a faithful but **unverified** port; check scores/plots against real - generated recipes. +A :class:`.Scorer` loads recipes from a directory of ``Recipe_*.json`` files and +evaluates them against weighted :class:`.Attribute` / :class:`.CustomAttribute` +objects: each attribute value is converted to a percentile (0-1) and combined by +weight. The score cache is written to ``{out_key}.json``. """ import json @@ -25,8 +12,8 @@ import mrich import numpy as np import pandas as pd -from designdb.components.recipe import Recipe, RecipeSet from designdb.models import InteractionModel, PoseModel, ScaffoldModel +from designdb.recipe import Recipe, RecipeSet from designdb.sets.compound import CompoundSet from designdb.sets.interaction import InteractionSet from designdb.sets.pose import PoseSet diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py index 48ed2b2..4b4bb43 100644 --- a/hippo/designdb/services/route.py +++ b/hippo/designdb/services/route.py @@ -5,8 +5,8 @@ from collections import Counter import mrich -from designdb.components.recipe import Recipe from designdb.models import ComponentModel, RouteModel +from designdb.recipe import Recipe logger = logging.getLogger(__name__) diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index 6fec1f9..f2dd3e3 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -169,6 +169,9 @@ def __add__( """Add a :class:`.CompoundModel` object or ID to this set, or add multiple at once when ``other`` is a :class:`.CompoundSet` or :class:`.IngredientSet`""" + # local import to avoid the IngredientSet <-> CompoundSet cycle + from designdb.sets.ingredient import IngredientSet + match other: case CompoundModel(): return CompoundSet( @@ -941,8 +944,7 @@ def get_df( if mol: raise NotImplementedError( - 'get_df(mol=True) depended on the RDKit cartridge ' - '(mol_to_binary_mol); not yet ported' + 'get_df(mol=True) requires the RDKit cartridge (mol_to_binary_mol)' ) ids = list(self.ids) @@ -1306,7 +1308,7 @@ def write_CAR_csv( """ # avoiding circular imports - from designdb.components.recipe import Recipe + from designdb.recipe import Recipe file = str(Path(file).resolve()) @@ -1404,6 +1406,8 @@ def as_ingredientset( supplier: str | list | None = None, ) -> 'IngredientSet': """Get an :class:`.IngredientSet` for these compounds""" + from designdb.sets.ingredient import IngredientSet + return IngredientSet.from_compounds( compounds=self, amount=amount, supplier=supplier ) @@ -1612,27 +1616,14 @@ def tags(self) -> set[str]: @property def num_poses(self) -> int: """Count the poses associated to this set of compounds""" - - return self.db.count_where(table='pose', key=f'pose_compound in {self.str_ids}') + return PoseModel.objects.filter(compound_id__in=self.ids).count() @property def poses(self) -> 'PoseSet': """Get the poses associated to this set of compounds""" from .pose import PoseSet - ids = self.db.select_where( - query='pose_id', - table='pose', - key=f'pose_compound in {self.str_ids}', - multiple=True, - none='warning', - ) - - if not ids: - return PoseSet(self.db, {}) - - ids = [v for (v,) in ids] - return PoseSet(self.db, ids) + return PoseSet(PoseModel.objects.filter(compound_id__in=self.ids)) @property def best_placed_poses(self) -> 'PoseSet': @@ -1928,647 +1919,3 @@ def reaction_ids(self) -> list[int]: if not records: return None return [r for (r,) in records] - - -class IngredientSet: - """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and - an attached quote, the :class:`.IngredientSet` is a object representing multiple - ingredients. - - .. attention:: - - :class:`.IngredientSet` objects should not be created directly. Instead they - are returned by several methods when working with :doc:`quoting` and - :doc:`rgen`. - - Selecting ingredients in the set - ================================ - - The :class:`.IngredientSet` can be indexed like a Python list: - - :: - - ingredient = ingredient_set[0] # first ingredient - - To get the ingredient for a specific :class:`.CompoundModel` ID: - - :: - - ingredient = ingredient_set(compound_id=13) - - """ - - _columns = [ - 'compound_id', - 'amount', - 'quote_id', - 'supplier', - 'max_lead_time', - 'quoted_amount', - ] - - def __init__( - self, - ingredients: 'None | list[Ingredient]' = None, - supplier: str | list | None = None, - debug: bool = False, - ) -> None: - """IngredientSet initialisation""" - - ingredients = ingredients or [] - - self._data = DataFrame(columns=self._columns, dtype=object) - - if debug: - mrich.debug(self._data) - - self._supplier = supplier - - for ingredient in ingredients: - self.add(ingredient) - - for col in self._columns: - assert col in self._data.columns, f'{col} not in df.columns' - - if debug: - mrich.debug(self._data) - - ### DUNDERS - - def __len__(self): - """The number of ingredients in this set""" - return len(self._data) - - def __str__(self) -> str: - """Unformatted string representation""" - return f'{{Ingredient × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI ormatted string representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Representation for mrich""" - return f'[bold underline]{self}' - - def __add__(self, other): - """Add another :class:`.IngredientSet` this set""" - - for i, row in other._data.iterrows(): - self.add( - compound_id=row.compound_id, - amount=row.amount, - quote_id=row.quote_id, - supplier=row.supplier, - max_lead_time=row.max_lead_time, - quoted_amount=row.quoted_amount, - ) - - return self - - def __getitem__(self, key: int) -> 'Ingredient': - """Get a member by it's index""" - match key: - case int(): - series = self.df.loc[key] - return self._get_ingredient(series) - - case _: - raise NotImplementedError - - def __iter__(self): - """Iterate through the ingredients""" - return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) - - def __call__( - self, - *, - compound_id: int | None = None, - tag: str | None = None, - ) -> 'IngredientSet | Ingredient | CompoundSet': - """Get members based on a compound_id or tag""" - - if compound_id: - # get the ingredient with the matching compound ID - matches = self.df[self.df['compound_id'] == compound_id] - - if len(matches) == 0: - return None - - elif len(matches) != 1: - mrich.warning(f'Multiple ingredients in set with {compound_id=}') - # print(matches) - - return IngredientSet( - self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] - ) - - return self._get_ingredient(matches.iloc[0]) - - # elif tag: - # return self.compounds(tag=tag) - - else: - raise NotImplementedError - - def __getattr__(self, key: str): - """For missing attributes try getting from associated :class:`.CompoundSet`""" - return getattr(self.compounds, key) - - def __contains__(self, other: CompoundModel | Ingredient | int): - """Check if compound or ingredient is a member of this set""" - match other: - case CompoundModel(): - id = other.id - case Ingredient(): - id = other.compound_id - case int(): - id = other - - return id in set(self.compound_ids) - - @classmethod - def from_ingredient_df( - cls, - df: 'DataFrame', - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from a DataFrame - - :param db: HIPPO Database - :param df: DataFrame of Ingredients - :param supplier: supplier to use for all quoting, (Default value = None) - - """ - # from numpy import nan - self = cls.__new__(cls) - - for col in cls._columns: - if col not in df.columns: - raise Exception(f'{col} not in df.columns') - df[col] = None - - self._data = df.copy() - self._supplier = supplier - - return self - - @classmethod - def from_json( - cls, - path: None | str, - supplier: str | list | None = None, - data: None | dict = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from JSON data or a JSON file - - :param db: HIPPO Database - :param path: path to JSON data (can be ``None`` if ``data`` provided) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - :param data: optional JSON data to parse, (Default value = ``None``) - - """ - - if not data: - data = json.load(open(path)) - - df = DataFrame(columns=cls._columns, dtype=object) - - for col in cls._columns: - df[col] = data[col] - - return cls.from_ingredient_df(df=df, supplier=supplier) - - @classmethod - def from_ingredient_dicts( - cls, - dicts: list[dict], - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries - - :param db: HIPPO Database - :param dicts: List of individual ingredient dictionaries - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - df = DataFrame(dicts, dtype=object) - return cls.from_ingredient_df(df=df, supplier=supplier) - - @classmethod - def from_compounds( - cls, - *, - compounds: 'CompoundSet | None' = None, - ids: list[int] | None = None, - amount: float | list[float] = 1, - supplier: str | list | None = None, - ) -> 'IngredientSet': - """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs - - :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide - ``ids`` and ``db`` (Default value = None) - :param ids: CompoundModel IDs (Default value = None) - :param db: HIPPO Database (Default value = None) - :param amount: Amount(s) in ``mg`` (Default value = 1) - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - if not ids: - ids = compounds.ids - - df = DataFrame( - dict( - compound_id=ids, - amount=amount, - quote_id=None, - supplier=supplier, - max_lead_time=None, - quoted_amount=None, - ), - dtype=object, - ) - - return cls.from_ingredient_df(df) - - ### METHODS - - def get_price( - self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False - ) -> 'Price': - """Calculate the price with a given supplier - - :param supplier: supplier to use for all quoting, (Default value = ``None``) - - """ - - pairs = {i: q for i, q in enumerate(self.df['quote_id'])} - - # coerce to int: df values may be stored as float/object (pandas) - quote_ids = [int(q) for q in pairs.values() if q is not None and not isna(q)] - - if debug: - mrich.debug('quote_ids', quote_ids) - - if quote_ids: - qs = CataloguePriceModel.objects.filter(pk__in=set(quote_ids)) - - if supplier: - qs = qs.filter(supplier=supplier) - - if qs.exists(): - # map pk -> Price, then sum over quote_ids so that ingredients - # sharing the same catalogue row are counted with multiplicity - # (filter(pk__in=...) collapses duplicates to one row each) - price_by_id = { - k.pk: Price(amount=k.price, currency=k.currency) for k in qs - } - quoted = Price.null() - for q in quote_ids: - price = price_by_id.get(q) - if price is not None: - quoted += price - else: - quoted = Price.null() - self.df['quote_id'] = None - pairs = {i: q for i, q in enumerate(self.df['quote_id'])} - - else: - quoted = Price.null() - - if debug: - mrich.debug('quoted', quoted) - - unquoted = [i for i, q in pairs.items() if q is None or isna(q)] - - unquoted_price = Price.null() - - for i in unquoted: - ingredient = self[i] - - if debug: - mrich.debug('unquoted', i, ingredient) - - p = ingredient.price - - unquoted_price += p - - if debug: - mrich.debug(unquoted_price) - - quote = ingredient.quote - - if not quote: - mrich.warning('NULL Quote:', ingredient) - continue - - self.df.loc[i, 'quote_id'] = quote.id - - assert quote.amount - - self.df.loc[i, 'quoted_amount'] = quote.amount - - if debug: - mrich.debug('quoted', quoted) - mrich.debug('unquoted_price', unquoted_price) - mrich.error('end of IngredientSet.get_price()') - - return quoted + unquoted_price - - def interactive(self, **kwargs) -> None: - """Wrapper for :meth:`.CompoundSet.interactive`""" - self.compounds.interactive(**kwargs) - - def add( - self, - ingredient: 'Ingredient | None' = None, - *, - compound_id: int | None = None, - amount: float | None = None, - quote_id: int | None = None, - supplier: str | list[str] | None = None, - max_lead_time: float | None = None, - quoted_amount: float | None = None, - debug: bool = False, - ) -> None: - """Add an :class:`.Ingredient` to this set - - :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify - other parameters, (Default value = None) - :param compound_id: :class:`.CompoundModel` ID (Default value = None) - :param amount: amount in ``mg`` (Default value = None) - :param quote_id: :class:`.Quote` ID (Default value = None) - :param supplier: supplier name string or list (Default value = None) - :param max_lead_time: maximum lead-time for quoting (in days) - (Default value = None) - :param quoted_amount: amount of associated :class:`.Quote` - (Default value = None) - :param debug: increase verbosity for debugging (Default value = False) - - """ - - if ingredient: - compound_id = ingredient.compound.pk - amount = ingredient.amount - - q = ingredient.quote - - supplier = ingredient.supplier - max_lead_time = ingredient.max_lead_time - - if q is None: - quote_id = None - quoted_amount = None - else: - quote_id = q.id - quoted_amount = q.amount - - else: - assert compound_id - assert amount - - if quote_id: - # if not quoted_amount: - # mrich.warning(f'Requoting C{compound_id}...') - - assert quoted_amount - - supplier = self.supplier - - if self._data.empty: - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - self._data = addition - - else: - if compound_id in self._data['compound_id'].values: - index = self._data.index[ - self._data['compound_id'] == compound_id - ].tolist()[0] - self._data.loc[index, 'amount'] += amount - - # discard if the quote is no longer valid - if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ - index, 'amount' - ]: - self._data.loc[index, 'quote_id'] = None - self._data.loc[index, 'quoted_amount'] = None - - if debug and supplier: - mrich.debug('Adding to existing ingredient') - mrich.debug(f'{self._data.loc[index, "supplier"]=}') - mrich.debug(f'{supplier=}') - - else: - # from numpy import nan - addition = DataFrame( - [ - dict( - compound_id=compound_id, - amount=amount, - quote_id=quote_id, - supplier=supplier, - max_lead_time=max_lead_time, - quoted_amount=quoted_amount, - ) - ], - dtype=object, - ) - - self._data = concat( - [self._data, addition], ignore_index=True, join='inner' - ) - - if debug: - mrich.out(addition) - - def _get_ingredient( - self, - series, - ) -> 'Ingredient': - """Get ingredient from one of the DataFrame rows""" - - q_id = series['quote_id'] - - if isinstance(q_id, float) and isna(q_id): - q_id = None - - return Ingredient( - compound=CompoundModel.objects.get(pk=series['compound_id']), - amount=series['amount'], - quote=q_id, - supplier=series['supplier'], - max_lead_time=series['max_lead_time'], - ) - - def copy(self) -> 'IngredientSet': - """Return a copy of this :class:`.IngredientSet`""" - return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) - - def draw(self) -> None: - """Wrapper for :meth:`.CompoundSet.draw`""" - self.compounds.draw() - - def set_amounts( - self, - amount: float | list[float], - ) -> None: - """Set the amount(s) for all ingredients in this set, and update quotes - - :param amount: amount in ``mg`` - - """ - - self.df['amount'] = amount - - # if amounts are modified the quotes should be cleared - self.df['quote_id'] = None - - assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) - - # # update quotes - # pairs = self.db.execute( - # f""" - # WITH matching_quotes AS ( - # SELECT quote_id, quote_compound, MIN(quote_price) - # FROM {self.db.SQL_SCHEMA_PREFIX}quote - # WHERE quote_compound IN {self.str_compound_ids} - # AND quote_amount >= {amount} - # GROUP BY quote_compound - # ) - # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound - # LEFT JOIN matching_quotes ON quote_compound = compound_id - # WHERE compound_id IN {self.str_compound_ids} - # """ - # ).fetchall() - - qs = CataloguePriceModel.objects.filter( - compound__pk__in=self.compound_ids, - quote_amount__gte=amount, - ) - - for k in qs: - match = self.df.index[self.df['compound_id'] == k.compound.pk][0] - self.df.loc[match, 'quote_id'] = k.quote.pk - - def get_dict(self, data_orient: str = 'list') -> dict: - """Get serialisable dictionary - - :param data_orient: passed to ``pandas.DataFrame.to_dict`` - (Default value = 'list') - - """ - return dict( - supplier=self.supplier, - data=self.df.to_dict(orient=data_orient), - ) - - def pop(self) -> Ingredient: - """Pop the last compound in this set""" - item = self[self.df.index[-1]] - self.df.drop(self.df.index[-1], inplace=True) - return item - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - self._data = self.df.sample(frac=1).reset_index(drop=True) - - ### PROPERTIES - - @property - def df(self) -> 'DataFrame': - """Access the raw DataFrame""" - return self._data - - @property - def price_df(self) -> 'DataFrame': - """DataFrame including prices""" - df = self.df.copy() - tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] - df['price'] = [t[0] for t in tuples] - df['lead_time'] = [t[1] for t in tuples] - df['quote_supplier'] = [t[2] for t in tuples] - return df - - @property - def price(self) -> 'Price': - """Total price of these ingredients""" - return self.get_price() - - @property - def supplier(self) -> str | list[str]: - """Supplier(s)""" - return self._supplier - - @supplier.setter - def supplier(self, s): - if isinstance(s, list) or isinstance(s, tuple): - for x in s: - assert isinstance(x, str) - else: - assert isinstance(s, str) - - self._supplier = s - self.df['supplier'] = [s] * len(self) - - @property - def smiles(self) -> list[str]: - """SMILES for all ingredients""" - compound_ids = list(self.df['compound_id']) - return CompoundModel.objects.filter( - pk__in=compound_ids, - ).values_list('compound_smiles', flat=True) - - @property - def inchikeys(self) -> list[str]: - """InChI-keys for all ingredients""" - compound_ids = list(self.df['compound_id']) - return CompoundModel.objects.filter( - pk__in=compound_ids, - ).values_list('compound_inchikeys', flat=True) - - @property - def compound_ids(self) -> list[int]: - """CompoundModel IDs for all ingredients""" - return list(self.df['compound_id'].values) - - @property - def ids(self) -> list[int]: - """CompoundModel IDs for all ingredients""" - return self.compound_ids - - @property - def id_amount_pairs(self) -> list[tuple]: - """Get a list of compound ID and amount pairs""" - return [ - (id, amount) for id, amount in self.df[['compound_id', 'amount']].values - ] - - @property - def str_compound_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" - return str(tuple(self.df['compound_id'].values)).replace(',)', ')') - - @property - def compounds(self) -> 'CompoundSet': - """:class:`.CompoundSet` of all compounds in this set""" - return CompoundSet(self.compound_ids) - - @property - def quote_ids(self) -> list[int]: - """Get a list of quote ID's""" - - return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/hippo/designdb/sets/ingredient.py b/hippo/designdb/sets/ingredient.py new file mode 100644 index 0000000..ba97e6a --- /dev/null +++ b/hippo/designdb/sets/ingredient.py @@ -0,0 +1,659 @@ +"""IngredientSet: a set of :class:`.Ingredient`\\ s (compounds with amounts/quotes). + +Builds on :class:`.CompoundSet` (the dependency runs ``ingredient -> compound``; +``CompoundSet``'s few uses of ``IngredientSet`` are deferred local imports). +""" + +import json + +import mcol +import mrich +from designdb.components.compound import Ingredient +from designdb.components.price import Price +from designdb.models import CataloguePriceModel, CompoundModel +from designdb.sets.compound import CompoundSet +from pandas import DataFrame, concat, isna + + +class IngredientSet: + """An :class:`.Ingredient` is a :class:`.CompoundModel` with a fixed quanitity and + an attached quote, the :class:`.IngredientSet` is a object representing multiple + ingredients. + + .. attention:: + + :class:`.IngredientSet` objects should not be created directly. Instead they + are returned by several methods when working with :doc:`quoting` and + :doc:`rgen`. + + Selecting ingredients in the set + ================================ + + The :class:`.IngredientSet` can be indexed like a Python list: + + :: + + ingredient = ingredient_set[0] # first ingredient + + To get the ingredient for a specific :class:`.CompoundModel` ID: + + :: + + ingredient = ingredient_set(compound_id=13) + + """ + + _columns = [ + 'compound_id', + 'amount', + 'quote_id', + 'supplier', + 'max_lead_time', + 'quoted_amount', + ] + + def __init__( + self, + ingredients: 'None | list[Ingredient]' = None, + supplier: str | list | None = None, + debug: bool = False, + ) -> None: + """IngredientSet initialisation""" + + ingredients = ingredients or [] + + self._data = DataFrame(columns=self._columns, dtype=object) + + if debug: + mrich.debug(self._data) + + self._supplier = supplier + + for ingredient in ingredients: + self.add(ingredient) + + for col in self._columns: + assert col in self._data.columns, f'{col} not in df.columns' + + if debug: + mrich.debug(self._data) + + ### DUNDERS + + def __len__(self): + """The number of ingredients in this set""" + return len(self._data) + + def __str__(self) -> str: + """Unformatted string representation""" + return f'{{Ingredient × {len(self)}}}' + + def __repr__(self) -> str: + """ANSI ormatted string representation""" + return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' + + def __rich__(self) -> str: + """Representation for mrich""" + return f'[bold underline]{self}' + + def __add__(self, other): + """Add another :class:`.IngredientSet` this set""" + + for i, row in other._data.iterrows(): + self.add( + compound_id=row.compound_id, + amount=row.amount, + quote_id=row.quote_id, + supplier=row.supplier, + max_lead_time=row.max_lead_time, + quoted_amount=row.quoted_amount, + ) + + return self + + def __getitem__(self, key: int) -> 'Ingredient': + """Get a member by it's index""" + match key: + case int(): + series = self.df.loc[key] + return self._get_ingredient(series) + + case _: + raise NotImplementedError + + def __iter__(self): + """Iterate through the ingredients""" + return iter(self._get_ingredient(s) for i, s in self.df.iterrows()) + + def __call__( + self, + *, + compound_id: int | None = None, + tag: str | None = None, + ) -> 'IngredientSet | Ingredient | CompoundSet': + """Get members based on a compound_id or tag""" + + if compound_id: + # get the ingredient with the matching compound ID + matches = self.df[self.df['compound_id'] == compound_id] + + if len(matches) == 0: + return None + + elif len(matches) != 1: + mrich.warning(f'Multiple ingredients in set with {compound_id=}') + # print(matches) + + return IngredientSet( + self.db, [self._get_ingredient(s) for i, s in matches.iterrows()] + ) + + return self._get_ingredient(matches.iloc[0]) + + # elif tag: + # return self.compounds(tag=tag) + + else: + raise NotImplementedError + + def __getattr__(self, key: str): + """For missing attributes try getting from associated :class:`.CompoundSet`""" + return getattr(self.compounds, key) + + def __contains__(self, other: CompoundModel | Ingredient | int): + """Check if compound or ingredient is a member of this set""" + match other: + case CompoundModel(): + id = other.id + case Ingredient(): + id = other.compound_id + case int(): + id = other + + return id in set(self.compound_ids) + + @classmethod + def from_ingredient_df( + cls, + df: 'DataFrame', + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a DataFrame + + :param db: HIPPO Database + :param df: DataFrame of Ingredients + :param supplier: supplier to use for all quoting, (Default value = None) + + """ + # from numpy import nan + self = cls.__new__(cls) + + for col in cls._columns: + if col not in df.columns: + raise Exception(f'{col} not in df.columns') + df[col] = None + + self._data = df.copy() + self._supplier = supplier + + return self + + @classmethod + def from_json( + cls, + path: None | str, + supplier: str | list | None = None, + data: None | dict = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from JSON data or a JSON file + + :param db: HIPPO Database + :param path: path to JSON data (can be ``None`` if ``data`` provided) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + :param data: optional JSON data to parse, (Default value = ``None``) + + """ + + if not data: + data = json.load(open(path)) + + df = DataFrame(columns=cls._columns, dtype=object) + + for col in cls._columns: + df[col] = data[col] + + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_ingredient_dicts( + cls, + dicts: list[dict], + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from :class:`.Ingredient` dictionaries + + :param db: HIPPO Database + :param dicts: List of individual ingredient dictionaries + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + df = DataFrame(dicts, dtype=object) + return cls.from_ingredient_df(df=df, supplier=supplier) + + @classmethod + def from_compounds( + cls, + *, + compounds: 'CompoundSet | None' = None, + ids: list[int] | None = None, + amount: float | list[float] = 1, + supplier: str | list | None = None, + ) -> 'IngredientSet': + """Create an :class:`.IngredientSet` from a :class:`.CompoundSet` or IDs + + :param compounds: :class:`.CompoundSet` to use, if ``None`` must provide + ``ids`` and ``db`` (Default value = None) + :param ids: CompoundModel IDs (Default value = None) + :param db: HIPPO Database (Default value = None) + :param amount: Amount(s) in ``mg`` (Default value = 1) + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + if not ids: + ids = compounds.ids + + df = DataFrame( + dict( + compound_id=ids, + amount=amount, + quote_id=None, + supplier=supplier, + max_lead_time=None, + quoted_amount=None, + ), + dtype=object, + ) + + return cls.from_ingredient_df(df) + + ### METHODS + + def get_price( + self, supplier: str | list[str] = None, none: str = 'error', debug: bool = False + ) -> 'Price': + """Calculate the price with a given supplier + + :param supplier: supplier to use for all quoting, (Default value = ``None``) + + """ + + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + # coerce to int: df values may be stored as float/object (pandas) + quote_ids = [int(q) for q in pairs.values() if q is not None and not isna(q)] + + if debug: + mrich.debug('quote_ids', quote_ids) + + if quote_ids: + qs = CataloguePriceModel.objects.filter(pk__in=set(quote_ids)) + + if supplier: + qs = qs.filter(supplier=supplier) + + if qs.exists(): + # map pk -> Price, then sum over quote_ids so that ingredients + # sharing the same catalogue row are counted with multiplicity + # (filter(pk__in=...) collapses duplicates to one row each) + price_by_id = { + k.pk: Price(amount=k.price, currency=k.currency) for k in qs + } + quoted = Price.null() + for q in quote_ids: + price = price_by_id.get(q) + if price is not None: + quoted += price + else: + quoted = Price.null() + self.df['quote_id'] = None + pairs = {i: q for i, q in enumerate(self.df['quote_id'])} + + else: + quoted = Price.null() + + if debug: + mrich.debug('quoted', quoted) + + unquoted = [i for i, q in pairs.items() if q is None or isna(q)] + + unquoted_price = Price.null() + + for i in unquoted: + ingredient = self[i] + + if debug: + mrich.debug('unquoted', i, ingredient) + + p = ingredient.price + + unquoted_price += p + + if debug: + mrich.debug(unquoted_price) + + quote = ingredient.quote + + if not quote: + mrich.warning('NULL Quote:', ingredient) + continue + + self.df.loc[i, 'quote_id'] = quote.id + + assert quote.amount + + self.df.loc[i, 'quoted_amount'] = quote.amount + + if debug: + mrich.debug('quoted', quoted) + mrich.debug('unquoted_price', unquoted_price) + mrich.error('end of IngredientSet.get_price()') + + return quoted + unquoted_price + + def interactive(self, **kwargs) -> None: + """Wrapper for :meth:`.CompoundSet.interactive`""" + self.compounds.interactive(**kwargs) + + def add( + self, + ingredient: 'Ingredient | None' = None, + *, + compound_id: int | None = None, + amount: float | None = None, + quote_id: int | None = None, + supplier: str | list[str] | None = None, + max_lead_time: float | None = None, + quoted_amount: float | None = None, + debug: bool = False, + ) -> None: + """Add an :class:`.Ingredient` to this set + + :param ingredient: :class:`.Ingredient` to be added, if ``None`` must specify + other parameters, (Default value = None) + :param compound_id: :class:`.CompoundModel` ID (Default value = None) + :param amount: amount in ``mg`` (Default value = None) + :param quote_id: :class:`.Quote` ID (Default value = None) + :param supplier: supplier name string or list (Default value = None) + :param max_lead_time: maximum lead-time for quoting (in days) + (Default value = None) + :param quoted_amount: amount of associated :class:`.Quote` + (Default value = None) + :param debug: increase verbosity for debugging (Default value = False) + + """ + + if ingredient: + compound_id = ingredient.compound.pk + amount = ingredient.amount + + q = ingredient.quote + + supplier = ingredient.supplier + max_lead_time = ingredient.max_lead_time + + if q is None: + quote_id = None + quoted_amount = None + else: + quote_id = q.id + quoted_amount = q.amount + + else: + assert compound_id + assert amount + + if quote_id: + # if not quoted_amount: + # mrich.warning(f'Requoting C{compound_id}...') + + assert quoted_amount + + supplier = self.supplier + + if self._data.empty: + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + self._data = addition + + else: + if compound_id in self._data['compound_id'].values: + index = self._data.index[ + self._data['compound_id'] == compound_id + ].tolist()[0] + self._data.loc[index, 'amount'] += amount + + # discard if the quote is no longer valid + if (a := self.df.loc[index, 'quoted_amount']) and a < self.df.loc[ + index, 'amount' + ]: + self._data.loc[index, 'quote_id'] = None + self._data.loc[index, 'quoted_amount'] = None + + if debug and supplier: + mrich.debug('Adding to existing ingredient') + mrich.debug(f'{self._data.loc[index, "supplier"]=}') + mrich.debug(f'{supplier=}') + + else: + # from numpy import nan + addition = DataFrame( + [ + dict( + compound_id=compound_id, + amount=amount, + quote_id=quote_id, + supplier=supplier, + max_lead_time=max_lead_time, + quoted_amount=quoted_amount, + ) + ], + dtype=object, + ) + + self._data = concat( + [self._data, addition], ignore_index=True, join='inner' + ) + + if debug: + mrich.out(addition) + + def _get_ingredient( + self, + series, + ) -> 'Ingredient': + """Get ingredient from one of the DataFrame rows""" + + q_id = series['quote_id'] + + if isinstance(q_id, float) and isna(q_id): + q_id = None + + return Ingredient( + compound=CompoundModel.objects.get(pk=series['compound_id']), + amount=series['amount'], + quote=q_id, + supplier=series['supplier'], + max_lead_time=series['max_lead_time'], + ) + + def copy(self) -> 'IngredientSet': + """Return a copy of this :class:`.IngredientSet`""" + return IngredientSet.from_ingredient_df(self.df, supplier=self.supplier) + + def draw(self) -> None: + """Wrapper for :meth:`.CompoundSet.draw`""" + self.compounds.draw() + + def set_amounts( + self, + amount: float | list[float], + ) -> None: + """Set the amount(s) for all ingredients in this set, and update quotes + + :param amount: amount in ``mg`` + + """ + + self.df['amount'] = amount + + # if amounts are modified the quotes should be cleared + self.df['quote_id'] = None + + assert all(self.df['supplier'].isna()) and all(self.df['max_lead_time'].isna()) + + # # update quotes + # pairs = self.db.execute( + # f""" + # WITH matching_quotes AS ( + # SELECT quote_id, quote_compound, MIN(quote_price) + # FROM {self.db.SQL_SCHEMA_PREFIX}quote + # WHERE quote_compound IN {self.str_compound_ids} + # AND quote_amount >= {amount} + # GROUP BY quote_compound + # ) + # SELECT compound_id, quote_id FROM {self.db.SQL_SCHEMA_PREFIX}compound + # LEFT JOIN matching_quotes ON quote_compound = compound_id + # WHERE compound_id IN {self.str_compound_ids} + # """ + # ).fetchall() + + qs = CataloguePriceModel.objects.filter( + compound__pk__in=self.compound_ids, + quote_amount__gte=amount, + ) + + for k in qs: + match = self.df.index[self.df['compound_id'] == k.compound.pk][0] + self.df.loc[match, 'quote_id'] = k.quote.pk + + def get_dict(self, data_orient: str = 'list') -> dict: + """Get serialisable dictionary + + :param data_orient: passed to ``pandas.DataFrame.to_dict`` + (Default value = 'list') + + """ + return dict( + supplier=self.supplier, + data=self.df.to_dict(orient=data_orient), + ) + + def pop(self) -> Ingredient: + """Pop the last compound in this set""" + item = self[self.df.index[-1]] + self.df.drop(self.df.index[-1], inplace=True) + return item + + def shuffle(self) -> None: + """Randomises the order of compounds in this set""" + self._data = self.df.sample(frac=1).reset_index(drop=True) + + ### PROPERTIES + + @property + def df(self) -> 'DataFrame': + """Access the raw DataFrame""" + return self._data + + @property + def price_df(self) -> 'DataFrame': + """DataFrame including prices""" + df = self.df.copy() + tuples = [(i.price, i.lead_time, i.quote.supplier) for i in self] + df['price'] = [t[0] for t in tuples] + df['lead_time'] = [t[1] for t in tuples] + df['quote_supplier'] = [t[2] for t in tuples] + return df + + @property + def price(self) -> 'Price': + """Total price of these ingredients""" + return self.get_price() + + @property + def supplier(self) -> str | list[str]: + """Supplier(s)""" + return self._supplier + + @supplier.setter + def supplier(self, s): + if isinstance(s, list) or isinstance(s, tuple): + for x in s: + assert isinstance(x, str) + else: + assert isinstance(s, str) + + self._supplier = s + self.df['supplier'] = [s] * len(self) + + @property + def smiles(self) -> list[str]: + """SMILES for all ingredients""" + compound_ids = list(self.df['compound_id']) + return CompoundModel.objects.filter( + pk__in=compound_ids, + ).values_list('compound_smiles', flat=True) + + @property + def inchikeys(self) -> list[str]: + """InChI-keys for all ingredients""" + compound_ids = list(self.df['compound_id']) + return CompoundModel.objects.filter( + pk__in=compound_ids, + ).values_list('compound_inchikeys', flat=True) + + @property + def compound_ids(self) -> list[int]: + """CompoundModel IDs for all ingredients""" + return list(self.df['compound_id'].values) + + @property + def ids(self) -> list[int]: + """CompoundModel IDs for all ingredients""" + return self.compound_ids + + @property + def id_amount_pairs(self) -> list[tuple]: + """Get a list of compound ID and amount pairs""" + return [ + (id, amount) for id, amount in self.df[['compound_id', 'amount']].values + ] + + @property + def str_compound_ids(self) -> str: + """Return an SQL formatted tuple string of the :class:`.CompoundModel` IDs""" + return str(tuple(self.df['compound_id'].values)).replace(',)', ')') + + @property + def compounds(self) -> 'CompoundSet': + """:class:`.CompoundSet` of all compounds in this set""" + return CompoundSet(self.compound_ids) + + @property + def quote_ids(self) -> list[int]: + """Get a list of quote ID's""" + + return [q for q in self.df['quote_id'].values if not isna(q) and q is not None] diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index dc5e729..634681f 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -1,691 +1,269 @@ -"""Classes for working with sets of interactions""" +"""Sets of protein-ligand interactions (ORM-backed). + +An :class:`.InteractionSet` wraps a set of :class:`.InteractionModel` rows (via a +Django queryset). Construct it through :meth:`.PoseModel.interactions` / +:meth:`.PoseSet.interactions` or the factories here. + +Interaction *detection* and duplicate *resolution* live in +:class:`.InteractionService` (``services/interaction.py``); this module is just the +read/aggregate surface over already-stored interactions. +""" import mcol import mrich from designdb.models import InteractionModel - - -class InteractionTable: - """Class representing all :class:`.InteractionModel` objects in the 'interaction' - table of the :class:`.Database`. - - .. attention:: - - :class:`.InteractionTable` objects should not be created directly. Instead - use the :meth:`.HIPPO.interactions` property. - - """ - - def __init__(self, db: 'Database', table: str = 'interaction') -> None: - """InteractionTable initialisation""" - - self._db = db - self._df = None - self._table = table - - ### PROPERTIES - - @property - def db(self) -> 'Database': - """Returns the associated :class:`.Database`""" - return self._db - - @property - def table(self) -> str: - """Returns the name of the :class:`.Database` table""" - return self._table - - @property - def df(self) -> 'pandas.DataFrame': - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table='interaction', key='interaction_id > 0', multiple=True - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - - ### DUNDERS - - def __len__(self) -> int: - """The total number of interactions""" - return self.db.count(self.table) - - def __str__(self) -> str: - """Unformatted command-line representation""" - return f'{{I × {len(self)}}}' - - def __repr__(self) -> str: - """ANSI formatted command-line representation""" - return f'{mcol.bold}{mcol.underline}{self}{mcol.unbold}{mcol.ununderline}' - - def __rich__(self) -> str: - """Rich formatted command-line representation""" - return f'[bold underline]{self}' +from django.db.models import Count + +# `df` columns: ORM field (model / joined feature) -> output column name +_DF_COLUMNS = { + 'id': 'id', + 'feature_id': 'feature_id', + 'pose_id': 'pose_id', + 'feature__target_id': 'target_id', + 'interaction_type': 'type', + 'feature__feature_family': 'prot_family', + 'interaction_family': 'lig_family', + 'feature__feature_residue_name': 'residue_name', + 'feature__feature_residue_number': 'residue_number', + 'feature__feature_chain_name': 'chain_name', + 'interaction_distance': 'distance', + 'interaction_angle': 'angle', + 'interaction_energy': 'energy', + 'interaction_prot_coord': 'prot_coord', + 'interaction_lig_coord': 'lig_coord', + 'feature__feature_atom_name': 'prot_atoms', + 'interaction_atom_id': 'lig_atoms', +} class InteractionSet: - """Class representing a subset of the :class:`.InteractionModel` objects in the - 'interaction' table of the :class:`.Database`. + """A set of :class:`.InteractionModel` rows. .. attention:: - :class:`.InteractionSet` objects should not be created directly. Instead - use :meth:`.PoseModel.interactions`, or :meth:`.PoseSet.interactions` - methods. - + Not constructed directly -- use :meth:`.PoseModel.interactions` / + :meth:`.PoseSet.interactions`, or the factory classmethods here. """ - def __init__( - self, - indices: list = None, - ) -> None: - """InteractionSet initialisation""" - + def __init__(self, indices: list | None = None) -> None: + """InteractionSet initialisation from a list of :class:`.InteractionModel` IDs""" indices = indices or [] - if not isinstance(indices, list): indices = list(indices) - - indices = [int(i) for i in indices] - - self._indices = sorted(list(set(indices))) + self._indices = sorted({int(i) for i in indices}) self._df = None - self._qs = InteractionModel.objects.filter(pk__in=indices) + self._qs = InteractionModel.objects.filter(pk__in=self._indices) ### FACTORIES @classmethod - def from_pose( - cls, - pose: 'PoseModel | PoseSet', - table: str = 'interaction', - db: 'Database | None' = None, - ) -> 'InteractionSet': - """Construct a :class:`.InteractionSet` from one or more poses. + def from_pose(cls, pose: 'PoseModel | PoseSet') -> 'InteractionSet': + """Construct from one or more poses. - :param pose: a :class:`.PoseModel` or :class:`.PoseSet` object - :param table: Database table name - :param db: Use this instead of PoseModel's Database - :returns: an :class:`.InteractionSet` + :param pose: a :class:`.PoseSet` (has ``.ids``) or a single + :class:`.Pose`/:class:`.PoseModel` (has ``.id``) """ - - # ``pose`` may be a PoseSet (has ``.ids``) or a single Pose/PoseModel if hasattr(pose, 'ids'): qs = InteractionModel.objects.filter(pose_id__in=list(pose.ids)) else: qs = InteractionModel.objects.filter(pose_id=pose.id) - return cls(list(qs.values_list('id', flat=True))) @classmethod - def all( - cls, - ) -> 'InteractionSet': - """Construct a :class:`.InteractionSet` for all interactions in the table. - - :returns: an :class:`.InteractionSet` - - """ - - # bit of a round-trip - ids = InteractionModel.objects.values_list('pk', flat=True) - self = cls.__new__(cls) - self.__init__(ids) - - return self + def all(cls) -> 'InteractionSet': + """Construct an :class:`.InteractionSet` for every interaction in the table.""" + return cls(list(InteractionModel.objects.values_list('pk', flat=True))) @classmethod def from_residue( cls, - db: 'Database', residue_number: int, - chain: None | str = None, + chain: str | None = None, target: 'TargetModel | int' = 1, ) -> 'InteractionSet': - """Get the set of interactions for a given residue number (and chain) + """Interactions formed with a given protein residue (and optionally chain). - :param db: HIPPO :class:`.Database` :param residue_number: the residue number - :param chain: the chain name / letter, defaults to any chain - :param target: the protein :class:`.TargetModel` object or ID, defaults to - first target in database - :returns: a :class:`.InteractionSet` object + :param chain: the chain name, or ``None`` for any chain + :param target: the :class:`.TargetModel` or its ID (defaults to ``1``) """ - from designdb.models import TargetModel - self = cls.__new__(cls) - if isinstance(target, TargetModel): target = target.id - sql = f""" - SELECT interaction_id FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE feature_target = {target} - AND feature_residue_number = {residue_number} - """ - + qs = InteractionModel.objects.filter( + feature__target_id=target, + feature__feature_residue_number=residue_number, + ) if chain: - sql += f' AND feature_chain_name = "{chain}"' - - ids = db.execute(sql).fetchall() - - ids = [i for (i,) in ids] + qs = qs.filter(feature__feature_chain_name=chain) - self.__init__(db, ids) - - return self + return cls(list(qs.values_list('id', flat=True))) ### PROPERTIES + @property + def queryset(self): + """The underlying :class:`.InteractionModel` queryset""" + return self._qs + @property def indices(self) -> list[int]: - """Returns the ids of interactions in this set""" + """:class:`.InteractionModel` IDs in this set""" return self._indices @property def ids(self) -> list[int]: - """Returns the ids of interactions in this set""" + """:class:`.InteractionModel` IDs in this set""" return self._indices @property def types(self) -> list[str]: - """Returns the ids of interactions in this set""" - records = self.db.select_where( - query='interaction_type', - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - return [r for (r,) in records] + """Distinct interaction types in this set""" + return list(self._qs.values_list('interaction_type', flat=True).distinct()) @property - def db(self) -> 'Database': - """The associated HIPPO :class:`.Database`""" - return self._db + def feature_ids(self) -> list[int]: + """Distinct :class:`.FeatureModel` IDs interacted with""" + return list(self._qs.values_list('feature_id', flat=True).distinct()) @property - def table(self) -> str: - """Get the name of the database table""" - return self._table + def df(self) -> 'pandas.DataFrame': + """DataFrame of the interactions, one row each.""" + if self._df is None: + from pandas import DataFrame - @property - def str_ids(self) -> str: - """Return an SQL formatted tuple string of the :class:`.InteractionModel` IDs""" - return str(tuple(self.ids)).replace(',)', ')') + rows = list(self._qs.values(*_DF_COLUMNS)) + df = DataFrame(rows) + if not df.empty: + df = df.rename(columns=_DF_COLUMNS) + self._df = df + return self._df @property - def feature_ids(self) -> list[int]: - """Return a list of :class:`.FeatureModel` ID's""" - records = self.db.select_where( - query='DISTINCT interaction_feature', - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - return [r for (r,) in records] + def _feature_counts(self) -> dict[int, int]: + """Map of :class:`.FeatureModel` ID -> number of interactions with it""" + return { + row['feature']: row['n'] + for row in self._qs.values('feature').annotate(n=Count('id')) + } @property def classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein - :class:`.FeatureModel` ID's to the number of corresponding ligand features - (from any :class:`.PoseModel`)""" + """Classic HIPPO fingerprint: :class:`.FeatureModel` ID -> interaction count.""" return self.get_classic_fingerprint() - @property - def df(self) -> 'pandas.DataFrame': - """DataFrame representation of the interactions - - :returns: a ``pandas.Dataframe`` of the interactions - - """ - - if self._df is None: - records = self.db.select_all_where( - table=self.table, - key=f'interaction_id IN {self.str_ids}', - multiple=True, - ) - df = df_from_interaction_records(self.db, records) - self._df = df - - return self._df - @property def residue_number_chain_pairs(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT feature_residue_number, feature_chain_name - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() + """Distinct ``(residue_number, chain_name)`` pairs""" + return list( + self._qs.values_list( + 'feature__feature_residue_number', 'feature__feature_chain_name' + ).distinct() + ) @property - def avg_num_residues_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_number, feature_chain_name - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() + def type_residue_number_chain_triples(self) -> list[tuple]: + """Distinct ``(interaction_type, residue_number, chain_name)`` triples""" + return list( + self._qs.values_list( + 'interaction_type', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ).distinct() + ) + @property + def avg_num_residues_per_pose(self) -> float: + """Mean number of distinct ``(residue, chain)`` contacts per pose""" from collections import defaultdict from numpy import mean - d = defaultdict(set) - - for pose_id, res_num, chain_name in records: - d[pose_id].add((res_num, chain_name)) - - return mean(list(len(v) for v in d.values())) + d: dict[int, set] = defaultdict(set) + for pose_id, res_num, chain in self._qs.values_list( + 'pose_id', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ): + d[pose_id].add((res_num, chain)) + return mean([len(v) for v in d.values()]) if d else 0 @property - def avg_num_interactions_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT interaction_pose FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - + def avg_num_interactions_per_pose(self) -> float: + """Mean number of interactions per pose""" from collections import defaultdict from numpy import mean - d = defaultdict(int) - - for (pose_id,) in records: + d: dict[int, int] = defaultdict(int) + for (pose_id,) in self._qs.values_list('pose_id'): d[pose_id] += 1 - - return mean(list(d.values())) + return mean(list(d.values())) if d else 0 @property - def avg_num_interaction_type_residue_pairs_per_pose(self) -> list[tuple]: - """Get a list of ``(residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_pose, interaction_type, - feature_residue_number, feature_chain_name - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() - + def avg_num_interaction_type_residue_pairs_per_pose(self) -> float: + """Mean number of distinct ``(residue, type, chain)`` contacts per pose""" from collections import defaultdict from numpy import mean - d = defaultdict(set) - - for pose_id, type, res_num, chain_name in records: - d[pose_id].add((res_num, type, chain_name)) - - return mean(list(len(v) for v in d.values())) - - @property - def type_residue_number_chain_triples(self) -> list[tuple]: - """Get a list of ``(interaction_type, residue_number, chain_name)`` tuples""" - - sql = f""" - SELECT DISTINCT interaction_type, feature_residue_number, feature_chain_name - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON feature_id = interaction_feature - WHERE interaction_id IN {self.str_ids} - """ - - return self.db.execute(sql).fetchall() + d: dict[int, set] = defaultdict(set) + for pose_id, itype, res_num, chain in self._qs.values_list( + 'pose_id', + 'interaction_type', + 'feature__feature_residue_number', + 'feature__feature_chain_name', + ): + d[pose_id].add((res_num, itype, chain)) + return mean([len(v) for v in d.values()]) if d else 0 @property def num_features(self) -> int: - """Count the number of protein :class:`.FeatureModel`\\ s with which - interactions are formed""" + """Number of distinct protein :class:`.FeatureModel`\\ s interacted with""" return self._qs.values('feature').distinct().count() @property def avg_num_interactions_per_feature(self) -> float: - """Average number of interactions formed with each protein - :class:`.FeatureModel`""" - - (count,) = self.db.execute( - f""" - WITH counts AS - ( - SELECT interaction_feature, COUNT(1) AS count - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - ) - - SELECT AVG(count) FROM counts - """ - ).fetchone() + """Mean number of interactions formed with each protein feature""" + from numpy import mean - return count + counts = list(self._feature_counts.values()) + return mean(counts) if counts else 0 @property def per_feature_count_hirsch(self) -> float: - """A measure for how evenly protein :class:`.FeatureModel`s are being - interacted with""" - - counts = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) AS count - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - counts = [count for f_id, count in counts] - - # return -std(counts) - + """h-index-like measure of how evenly features are interacted with""" from hirsch import hirsch - if not counts: - return 0 - - return hirsch(counts) + counts = list(self._feature_counts.values()) + return hirsch(counts) if counts else 0 ### METHODS - def summary( - self, - families: bool = False, - ) -> None: - """Print a summary of this :class:`.InteractionSet`""" + def get_classic_fingerprint(self) -> dict: + """Classic HIPPO fingerprint: :class:`.FeatureModel` ID -> interaction count.""" + return dict(self._feature_counts) + def summary(self, families: bool = False) -> None: + """Print a summary of this :class:`.InteractionSet`""" mrich.header(self) - - for interaction in self: - # print(interaction) - - # mrich.var(f'{interaction.family_str}', f'{interaction.distance:.1f}') - s = f'{interaction.description}' - + for i in self._qs.select_related('feature'): + feature = i.feature + s = ( + f'{i.interaction_type} ' + f'{feature.feature_residue_name}{feature.feature_residue_number}' + ) if families: - s += f' {interaction.feature.family} ~ {interaction.family}' - - mrich.var(s, f'{interaction.distance:.1f}', 'Å') - - def get_classic_fingerprint(self) -> dict: - """Classic HIPPO fingerprint dictionary, mapping protein - :class:`.FeatureModel` ID's to the number of corresponding ligand features - (from any :class:`.PoseModel`)""" - - pairs = self.db.execute( - f""" - SELECT interaction_feature, COUNT(1) - FROM {self.db.SQL_SCHEMA_PREFIX}{self.table} - WHERE interaction_id IN {self.str_ids} - GROUP BY interaction_feature - """ - ).fetchall() - - return {f: c for f, c in pairs} - - def resolve( - self, - debug: bool = False, - commit: bool = True, - feature_cache: dict | None = None, - # table: str = 'interaction', - ) -> 'InteractionSet': - """Resolve into predicted key interactions. In place modification. - - :param debug: Increased verbosity for debugging (Default value = False) - :param commit: commit the changes (Default value = True) - :param feature_cache: lookup dictionary for feature data - :returns: a filtered :class:`.InteractionSet` - """ - - keep_list = [] - - table = self.table - - # get feature cache - - feature_cache = feature_cache or { - i: self.db.get_feature(id=i) for i in self.feature_ids - } - - ### H-Bonds (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrogen Bond" - GROUP BY interaction_atom_ids - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-stacking (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-stacking" - GROUP BY interaction_feature - """ - # INNER JOIN feature - # ON feature_id = interaction_feature - # GROUP BY feature_atom_names - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### pi-cation (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "π-cation" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### electrostatic (closest) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Electrostatic" - GROUP BY interaction_atom_ids - """ - # GROUP BY interaction_atom_ids - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - keep_list += ids - - ### sulfur-sulfur (all) - - sql = f""" - SELECT interaction_id - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Sulfur-Sulfur" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for (a,) in records] - keep_list += ids - - ### hydrophobic - - sql = f""" - SELECT interaction_id, interaction_distance - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {self.str_ids} - AND interaction_type = "Hydrophobic" - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - subset = InteractionSet(self.db, ids, table=table) - - # aggregate lumped - - hydrophobic_interactions_in_lumped = {} - lumped_hydrophobic_in_lumped_lumped = {} - - for interaction in subset: - feature = feature_cache[interaction.feature_id] - - families = (feature.family, interaction.family) - - if families == ('LumpedHydrophobe', 'Hydrophobe'): - for name in feature.atom_names.split(): - key = (name, interaction.atom_ids[0]) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ('Hydrophobe', 'LumpedHydrophobe'): - for atom_id in interaction.atom_ids: - key = (feature.atom_names, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): - for name in feature.atom_names.split(): - for atom_id in interaction.atom_ids: - key = (name, atom_id) - if key not in hydrophobic_interactions_in_lumped: - hydrophobic_interactions_in_lumped[key] = [] - hydrophobic_interactions_in_lumped[key].append(interaction.id) - - key = feature.atom_names - lumped_hydrophobic_in_lumped_lumped[key] = tuple(interaction.atom_ids) - - keep_hydrophobic_ids = set(subset.ids) - rev_hydrophobic_in_lumped_lumped = { - v: k for k, v in lumped_hydrophobic_in_lumped_lumped.items() - } - - # modify keep list by those covered in lumped - - for interaction in subset: - feature = feature_cache[interaction.feature_id] - - families = (feature.family, interaction.family) - - if families == ('Hydrophobe', 'Hydrophobe'): - key = (feature.atom_names, interaction.atom_ids[0]) - - if key in hydrophobic_interactions_in_lumped: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ('LumpedHydrophobe', 'Hydrophobe'): - key = feature.atom_names - - if key in lumped_hydrophobic_in_lumped_lumped: - atom_id = interaction.atom_ids[0] - value = lumped_hydrophobic_in_lumped_lumped[key] - if atom_id in value: - keep_hydrophobic_ids -= set([interaction.id]) - - elif families == ('Hydrophobe', 'LumpedHydrophobe'): - key = tuple(interaction.atom_ids) - - if key in rev_hydrophobic_in_lumped_lumped: - atom_name = feature.atom_names - value = rev_hydrophobic_in_lumped_lumped[key] - - if atom_name in value: - keep_hydrophobic_ids -= set([interaction.id]) - - keep_list += list(keep_hydrophobic_ids) - - ### cull non-keepers - - cull_list = set(self.ids) - set(keep_list) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f'interaction_id IN {cull_iset.str_ids}', - commit=commit, - ) - self._indices = sorted(list(set(keep_list))) - - ### revisit hydrophobes - - # for a given protein feature, choose the closest interaction - - cull_list = [] - - hydrophobic_keeper_iset = InteractionSet(self.db, keep_hydrophobic_ids) - - sql = f""" - SELECT interaction_id, MIN(interaction_distance) - FROM {self.db.SQL_SCHEMA_PREFIX}{table} - WHERE interaction_id IN {hydrophobic_keeper_iset.str_ids} - GROUP BY interaction_feature - """ - - records = self.db.execute(sql).fetchall() - ids = [a for a, b in records] - - cull_list = set(hydrophobic_keeper_iset.ids) - set(ids) - cull_iset = InteractionSet(self.db, cull_list) - self.db.delete_where( - table=table, - key=f'interaction_id IN {cull_iset.str_ids}', - commit=commit, - ) - self._indices = sorted(list(set(keep_list) - cull_list)) - - ### Summary - - # if debug: - # self.summary() + s += f' {feature.feature_family} ~ {i.interaction_family}' + mrich.var(s, f'{i.interaction_distance:.1f}', 'Å') ### DUNDERS def __len__(self) -> int: """The number of interactions in this set""" - return len(self.indices) + return len(self._indices) def __str__(self) -> str: """Unformatted command-line representation""" @@ -700,85 +278,15 @@ def __rich__(self) -> str: return f'[bold underline]{self}' def __iter__(self): - """Iterate through interactions in this set""" - return iter( - self.db.get_interaction(id=i, table=self.table) for i in self.indices - ) + """Iterate through the :class:`.InteractionModel` rows in this set""" + return iter(self._qs) def __getitem__(self, key) -> 'InteractionModel | InteractionSet': - """Get interaction or subsets thereof from this set""" + """Index by position (int) or slice""" match key: case int(): - index = self.indices[key] - return self.db.get_interaction(id=index, table=self.table) - + return InteractionModel.objects.get(pk=self._indices[key]) case slice(): - indices = self.indices[key] - return InteractionSet(self.db, indices, table=self.table) - + return InteractionSet(self._indices[key]) case _: raise NotImplementedError - - -def df_from_interaction_records( - db: 'Database', - records: list[tuple], -) -> 'pandas.DataFrame': - """Construct a dataframe from the 'interaction' table records""" - - import json - - from pandas import DataFrame - - data = [] - for record in records: - ( - id, - feature_id, - pose_id, - type, - family, - atom_ids, - prot_coord, - lig_coord, - distance, - angle, - energy, - ) = record - - feature = db.get_feature(id=feature_id) - - d = dict(id=id) - - d['feature_id'] = feature_id - d['pose_id'] = pose_id - d['target_id'] = feature.target - - # d['type'] = INTERACTION_TYPES[(feature.family, family)] - d['type'] = type - - d['prot_family'] = feature.family - d['lig_family'] = family - - d['residue_name'] = feature.residue_name - d['residue_number'] = feature.residue_number - d['chain_name'] = feature.chain_name - - d['distance'] = distance - d['angle'] = angle - d['energy'] = energy - - d['prot_coord'] = json.loads(prot_coord) - d['lig_coord'] = json.loads(lig_coord) - - d['prot_atoms'] = feature.atom_names - d['lig_atoms'] = atom_ids - - d['backbone'] = feature.backbone - d['sidechain'] = feature.sidechain - - data.append(d) - - df = DataFrame.from_records(data=data) - - return df diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index 0bd3483..a1c2065 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -30,25 +30,13 @@ SubsiteTagModel, TargetModel, ) -from designdb.services.subsite import SubsiteService from designdb.sets.interaction import InteractionSet from designdb.settings import DEFAULT_POSE_METHODS from designdb.utils import ScoreSubquery, normalize_string_list from designdb.utils_frag import generate_header from django.conf import settings from django.db import IntegrityError -from django.db.models import ( - Exists, - F, - FloatField, - Max, - Min, - OuterRef, - Q, - QuerySet, - Subquery, - Window, -) +from django.db.models import Exists, FloatField, OuterRef, Q, QuerySet, Subquery from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast from IPython.display import display @@ -500,8 +488,6 @@ def get_df( # need id in output flags['id'] = True - print('input flags', flags) - # alias and name both point to same thing. prefer 'name' if flags.get('name', False): flags['alias'] = True @@ -611,17 +597,10 @@ def get_df( values.append(method_name) columns[method_name] = method_name - print('df values', values) - print('df columns', columns) qs = self._queryset.annotate(**annotations).values(*values) - print('queryset', self._queryset.count(), self._queryset) - df = pd.DataFrame(qs) - print(df) - print('df columns from df before', df.columns) df = df.rename(columns=columns) - print('df columns from df after', df.columns) df = df.set_index('id') if alias: @@ -746,6 +725,9 @@ def get_by_subsite( def set_subsites_from_metadata_field(self, field: str = 'CanonSites alias') -> None: """Create and assign subsite entries from a pose metadata field.""" + # local import: keeps this upward set -> service call off the module graph + from designdb.services.subsite import SubsiteService + SubsiteService.set_subsites_from_metadata_field(self._queryset, field) def get_best_scoring_poses_per_compound( @@ -762,27 +744,30 @@ def get_best_scoring_poses_per_compound( :param inverse: if ``True``, higher score is better (default: lower is better) """ score_num = Cast(KeyTextTransform('score', 'score'), output_field=FloatField()) - agg = Max('score_num') if inverse else Min('score_num') filters = {'scoring_method__method_name': scoring_method} if version is not None: filters['scoring_method__method_version'] = version - best_pose_ids = ( - ScoreValueModel.objects - .filter(pose__in=self._queryset, **filters) + rows = ( + ScoreValueModel.objects.filter(pose__in=self._queryset, **filters) .annotate(score_num=score_num) - .annotate( - compound_best=Window( - expression=agg, - partition_by=['compound_id'], - ) - ) - .filter(score_num=F('compound_best')) - .values_list('pose_id', flat=True) - .distinct() + .values_list('compound_id', 'pose_id', 'score_num') ) + # keep exactly one pose per compound (the best score; ties broken by + # first-seen), rather than every pose tied for the best + best: dict[int, tuple[int, float]] = {} + for compound_id, pose_id, score in rows: + if score is None: + continue + current = best.get(compound_id) + if current is None or ( + score > current[1] if inverse else score < current[1] + ): + best[compound_id] = (pose_id, score) + + best_pose_ids = [pose_id for pose_id, _ in best.values()] return PoseSet(PoseModel.objects.filter(pk__in=best_pose_ids)) @@ -859,27 +844,27 @@ def add_tag( # problem. with every evaluation and refretch some attributes # may be lost. how can this be kept clean? - # unused? the original method didn't save object def append_to_metadata( self, key, value, ) -> None: - """Append a specific item to list-like values associated with a given key for - all member's metadata dictionaries + """Append ``value`` to the list at ``key`` in every member's metadata dict. - :param key: the :class:`.Metadata` key to match + :param key: the metadata key to match (created as a new list if absent) :param value: the value to append to the list - """ for pose in self._queryset: - # metadata = json.loads(pose.payload) - metadata = pose.pose_metadata - try: - metadata.append(key, value) - except AttributeError: - mrich.error(f'Could not append to metadata {key=}. Not a list?') - + metadata = pose.pose_metadata or {} + existing = metadata.get(key) + if existing is None: + metadata[key] = [value] + elif isinstance(existing, list): + existing.append(value) + else: + mrich.error(f'Could not append to metadata {key=}: not a list') + continue + pose.pose_metadata = metadata pose.save() self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) @@ -1031,9 +1016,6 @@ def write_sdf( **kwargs, ) - print('what do I have for name col', name_col) - print(df.columns) - if name_col not in ['name', 'alias', 'inchikey', 'id']: # try getting name from metadata records = self._queryset.values('id', 'pose_metadata') @@ -1161,14 +1143,8 @@ def to_fragalysis( poses = PoseSet(self._queryset) mrich.var('#poses', len(poses)) - logger.debug('about to create df') - # get the dataframe of poses # TODO: this should not go through the df - - # Scope issue - this code expect access to all poses in the db - self._queryset = PoseModel.objects.all() - pose_df = poses.get_df( mol=True, inspiration_ids=True, @@ -1192,9 +1168,13 @@ def to_fragalysis( pose_df = pose_df.reset_index() - # fix inspirations and reference column (comma separated aliases) - - lookup = {k.pk: k.pose_alias for k in self._queryset} + # fix inspirations and reference column (comma separated aliases). + # reference poses may lie outside this set, so look up exactly the + # referenced pose IDs rather than scanning the whole table. + ref_ids = {r for r in pose_df['reference_id'].tolist() if r is not None} + lookup = dict( + PoseModel.objects.filter(pk__in=ref_ids).values_list('pk', 'pose_alias') + ) inspiration_strs = [] # for i, row in pose_df.iterrows(): @@ -1224,7 +1204,7 @@ def fix_subsites(subsite_list: list[str]): # pose_df['ref_mols'] = inspiration_strs pose_df['ref_mols'] = 'inspiration_strs' - pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup[x]) + pose_df['ref_pdb'] = pose_df['reference_id'].apply(lambda x: lookup.get(x)) # add compound identifier column (inchikey?) @@ -1356,13 +1336,22 @@ def fix_subsites(subsite_list: list[str]): pdb_dir.mkdir(exist_ok=True) zip_path = Path(out_path).parent / f'{out_key}_refs.zip' - references = self.references - # lookup = self.db.get_pose_alias_path_dict(references) - lookup = {k.pose_alias: k.protein_link for k in self._queryset} + # ref_pdb holds reference pose aliases that may lie outside this set, + # so look them up directly (alias -> protein PDB path) + ref_aliases = {a for a in pose_df['ref_pdb'].tolist() if a is not None} + lookup = dict( + PoseModel.objects.filter(pose_alias__in=ref_aliases).values_list( + 'pose_alias', 'protein_link' + ) + ) zips = set() for ref_alias in pose_df['ref_pdb'].values: - source_path = Path(lookup[ref_alias]) + source = lookup.get(ref_alias) + if not source: + mrich.warning(f'No protein file for reference {ref_alias!r}; skipping') + continue + source_path = Path(source) stem = source_path.name.replace('_hippo.pdb', '.pdb') # current Fragalysis protein-file naming @@ -1669,8 +1658,6 @@ def to_syndirella( shutil.copy(ref.apo_path, template) ### Inspirations - print('all inspirations', all_inspirations) - # records = self._queryset.filter(pose_alias__in=all_inspirations) # isn't this overwriting the one few lines above?? records = PoseModel.objects.filter( target__in=self.targets, pose_alias__in=all_inspirations @@ -1938,7 +1925,7 @@ def get_interaction_overlaps(self, return_pairs: bool = False) -> int: pairs.add((pose_j, pose_k)) if return_pairs: - return [PoseSet(PoseModel.objects.filter(pk__in[a, b])) for a, b in pairs] + return [PoseSet(PoseModel.objects.filter(pk__in=[a, b])) for a, b in pairs] return count @@ -2080,7 +2067,7 @@ def inchikeys(self) -> list[str]: @property def id_name_dict(self) -> dict: """Return a dictionary mapping pose ID's to their name""" - return {p.pk: p.pose_alias for p in PoseModel.objects.all()} + return dict(self._queryset.values_list('pk', 'pose_alias')) @property def smiles(self) -> list[str]: diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index f576f20..4dee0fd 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -39,7 +39,7 @@ def from_ids(cls, ids: list | set, progress: bool = True): # avoiding circular reference # avoiding name conflict - from designdb.components.recipe import Route + from designdb.recipe import Route routes = [Route.get_route(id=r) for r in ids] @@ -80,7 +80,7 @@ def from_json(cls, path: 'str | Path', data: dict = None) -> 'RouteSet': """ - from designdb.components.recipe import Route + from designdb.recipe import Route self = cls.__new__(cls) From 42783981a16241abd973ba0cb319ac28369cf6e4 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 23 Jun 2026 08:28:48 +0100 Subject: [PATCH 157/163] fix: linting --- .pre-commit-config.yaml | 6 -- Makefile | 4 -- hippo/bootstrap.py | 4 +- hippo/designdb/animal.py | 28 ++++++-- hippo/designdb/components/compound.py | 10 ++- hippo/designdb/components/quote.py | 3 +- hippo/designdb/components/reaction.py | 12 +++- hippo/designdb/interactions.py | 6 +- hippo/designdb/models.py | 3 +- hippo/designdb/plotting.py | 8 ++- hippo/designdb/recipe.py | 18 +++-- hippo/designdb/services/compound.py | 7 +- hippo/designdb/services/download.py | 2 - hippo/designdb/services/generation.py | 20 ++++-- hippo/designdb/services/ingestion.py | 45 +++++++++---- hippo/designdb/services/interaction.py | 17 ++--- hippo/designdb/services/method.py | 4 +- hippo/designdb/services/pose.py | 13 +++- hippo/designdb/services/reaction.py | 3 +- hippo/designdb/services/recipe.py | 56 +++++++++------ hippo/designdb/services/recipe_score.py | 26 ++++--- hippo/designdb/services/route.py | 4 +- hippo/designdb/services/subsite.py | 4 +- hippo/designdb/sets/compound.py | 37 +++++----- hippo/designdb/sets/ingredient.py | 2 +- hippo/designdb/sets/interaction.py | 10 ++- hippo/designdb/sets/pose.py | 90 +++++++++++++++---------- hippo/designdb/sets/reaction.py | 13 ++-- hippo/designdb/sets/route.py | 6 ++ hippo/designdb/utils.py | 4 ++ hippo/designdb/utils_chem.py | 6 +- pyproject.toml | 10 +-- uv.lock | 11 --- 33 files changed, 313 insertions(+), 179 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 86a31f0..4666b1b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,12 +31,6 @@ repos: language: system types: [python] - - id: isort - name: isort - entry: uv run isort src - language: system - types: [python] - # - id: mypy # name: mypy # entry: uv run mypy diff --git a/Makefile b/Makefile index bd61834..f0038ab 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,6 @@ help: @echo " make lint Run ruff lint" @echo " make format Run ruff format" @echo " make typecheck Run mypy" - @echo " make isort Run isort" @echo " make check Run all checks" @echo " make test Run tests" @echo " make ci Simulate CI run" @@ -30,9 +29,6 @@ format: typecheck: uv run pre-commit run mypy --all-files -isort: - uv run pre-commit run isort --all-files - check: uv run pre-commit run --all-files diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index 0f650dc..c6385bd 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -83,9 +83,9 @@ def load_hippo( tas_list = get_auth_target_access(username) # mock response until auth pod is externally accessible - tas_list = ('lb18145-1') + tas_list = 'lb18145-1' - if not target_access_string in tas_list: + if target_access_string not in tas_list: mrich.error(f'User {username} does not have access to {target_access_string}') return diff --git a/hippo/designdb/animal.py b/hippo/designdb/animal.py index f0f6555..7f2ad87 100644 --- a/hippo/designdb/animal.py +++ b/hippo/designdb/animal.py @@ -10,13 +10,20 @@ import pandas as pd from django.db import transaction -from .client import GeneratorManager, IngredientManager, RecipeManager, RouteManager, ScorerManager +from .client import ( + GeneratorManager, + IngredientManager, + RecipeManager, + RouteManager, + ScorerManager, +) from .models import ( CompoundModel, EnumerationMethodModel, PoseMethodModel, PoseModel, Project, + RouteModel, ScoringMethodModel, TargetModel, ) @@ -491,7 +498,7 @@ def __str__(self) -> str: if obj is None: raise ValueError( f"Pose method '{name}' not found. " - "Call register_pose_method() first." + 'Call register_pose_method() first.' ) pose_method_objs.append(obj) @@ -595,7 +602,8 @@ def load_sdf( if name_col is None: raise ValueError( - "name_col cannot be None. Provide the SDF column name that contains pose identifiers." + 'name_col cannot be None. Provide the SDF column name that ' + 'contains pose identifiers.' ) skip_equal_dict = skip_equal_dict or {} @@ -651,8 +659,12 @@ def load_sdf( score_method_map = {} if score_cols and scoring_methods: if len(score_cols) != len(scoring_methods): - raise ValueError('score_cols and scoring_methods must be the same length') - for col, (method_name, method_version) in zip(score_cols, scoring_methods): + raise ValueError( + 'score_cols and scoring_methods must be the same length' + ) + for col, (method_name, method_version) in zip( + score_cols, scoring_methods, strict=False + ): try: obj = ScoringMethodModel.objects.get( method_name=method_name, method_version=method_version @@ -740,6 +752,8 @@ def add_syndirella_routes( # TODO: handle gracefully raise Exception from exc + return result + def add_enamine_real_routes( self, csv_path: str | Path, @@ -845,7 +859,9 @@ def set_derivative_subsites(self) -> None: """Propagate subsite assignments from inspiration poses to their derivatives.""" SubsiteService.set_derivative_subsites() - def register_enumeration_method(self, name: str, version: str, description: str = ''): + def register_enumeration_method( + self, name: str, version: str, description: str = '' + ): """Register an enumeration method, or retrieve it if already registered.""" return MethodService.register_enumeration_method(name, version, description) diff --git a/hippo/designdb/components/compound.py b/hippo/designdb/components/compound.py index f0acbf1..a8f1155 100644 --- a/hippo/designdb/components/compound.py +++ b/hippo/designdb/components/compound.py @@ -2,6 +2,7 @@ import logging from pathlib import Path +from typing import TYPE_CHECKING import mcol import mrich @@ -18,7 +19,8 @@ ReactionModel, ScaffoldModel, ) -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, OuterRef +from IPython.display import display from molparse.atomtypes import formula_to_atomtype_dict from molparse.rdkit import draw_highlighted_mol, draw_mcs from molparse.rdkit.classify import classify_mol @@ -30,6 +32,12 @@ from .price import Price from .quote import Quote +if TYPE_CHECKING: + from designdb.animal import HIPPO + from designdb.sets.compound import CompoundSet + from designdb.sets.pose import PoseSet + from designdb.sets.reaction import ReactionSet + class Compound: """A :class:`.Compound` represents a ligand/small molecule with stereochemistry diff --git a/hippo/designdb/components/quote.py b/hippo/designdb/components/quote.py index c4153c3..5228324 100644 --- a/hippo/designdb/components/quote.py +++ b/hippo/designdb/components/quote.py @@ -30,7 +30,8 @@ def estimate(cls, required_amount: float, quotes: 'list[Quote]') -> 'Quote | Non """Estimate a quote for ``required_amount`` when no single pack is big enough. Scales the biggest available pack's unit price to the required amount; the - returned quote wraps an *unsaved* :class:`.CataloguePriceModel` (``id is None``). + returned quote wraps an *unsaved* :class:`.CataloguePriceModel` + (``id is None``). :param required_amount: amount in ``mg`` :param quotes: available :class:`.Quote` packs to scale from diff --git a/hippo/designdb/components/reaction.py b/hippo/designdb/components/reaction.py index 57b292e..32aa54d 100644 --- a/hippo/designdb/components/reaction.py +++ b/hippo/designdb/components/reaction.py @@ -1,11 +1,21 @@ """Reaction component.""" +from typing import TYPE_CHECKING + import mcol import mrich -from designdb.models import CataloguePriceCompoundJunctionModel, CompoundModel, ReactionModel +from designdb.models import ( + CataloguePriceCompoundJunctionModel, + CompoundModel, + ReactionModel, +) from .compound import Compound +if TYPE_CHECKING: + from designdb.recipe import Recipe + from designdb.sets.reaction import ReactionSet + DEFAULT_REACTANT_AMOUNT = 1.0 DEFAULT_PRODUCT_YIELD = 1.0 diff --git a/hippo/designdb/interactions.py b/hippo/designdb/interactions.py index 139f436..45f00cd 100644 --- a/hippo/designdb/interactions.py +++ b/hippo/designdb/interactions.py @@ -4,7 +4,11 @@ re-exported from ``molparse``; the distance/angle cutoffs are defined here. """ -from molparse.rdkit.features import COMPLEMENTARY_FEATURES, FEATURE_FAMILIES, INTERACTION_TYPES +from molparse.rdkit.features import ( + COMPLEMENTARY_FEATURES, + FEATURE_FAMILIES, + INTERACTION_TYPES, +) # maximum centroid-centroid distance (Angstrom) for each interaction type INTERACTION_CUTOFF = { diff --git a/hippo/designdb/models.py b/hippo/designdb/models.py index 8a0e882..b9fdc04 100644 --- a/hippo/designdb/models.py +++ b/hippo/designdb/models.py @@ -1,6 +1,7 @@ from pathlib import Path import mrich + # from django.db.models import indexes from django.conf import settings from django.db import models @@ -105,7 +106,7 @@ class Meta(BaseModel.Meta): ] def __str__(self) -> str: - return f"{self.project_name}" + return f'{self.project_name}' class TargetModel(BaseModel): diff --git a/hippo/designdb/plotting.py b/hippo/designdb/plotting.py index 13452da..e4402fb 100644 --- a/hippo/designdb/plotting.py +++ b/hippo/designdb/plotting.py @@ -63,13 +63,17 @@ def plot_interaction_punchcard( plot_data[x] = plot_data[['residue_name', 'residue_number']].agg( lambda r: ' '.join(str(i) for i in r), axis=1 ) - sort_key = lambda v: v[1] + + def sort_key(v): + return v[1] else: x = 'chain_res_name_number_str' plot_data[x] = plot_data[['chain_name', 'residue_name', 'residue_number']].agg( lambda r: ' '.join(str(i) for i in r), axis=1 ) - sort_key = lambda v: (v[2], v[1]) + + def sort_key(v): + return (v[2], v[1]) title = 'Interaction Punch-Card' if title_prefix: diff --git a/hippo/designdb/recipe.py b/hippo/designdb/recipe.py index 86b7201..850b039 100644 --- a/hippo/designdb/recipe.py +++ b/hippo/designdb/recipe.py @@ -9,6 +9,8 @@ delegate to it (see the ``DEPRECATED`` banner below) via a local import. """ +from typing import TYPE_CHECKING + import mcol import mrich from designdb.components.compound import Ingredient @@ -18,6 +20,16 @@ from designdb.sets.ingredient import IngredientSet from designdb.sets.reaction import ReactionSet +if TYPE_CHECKING: + from pathlib import Path + + import pandas + from designdb.components.price import Price + from designdb.sets.interaction import InteractionSet + from designdb.sets.pose import PoseSet + from designdb.sets.route import RouteSet + from plotly import graph_objects + class Recipe: """A Recipe stores data corresponding to a specific synthetic recipe involving @@ -1010,7 +1022,7 @@ def _ingredients(ids, amounts, quote=False): priced; otherwise ingredients are left unquoted. """ iset = IngredientSet() - for cid, amount in zip(ids, amounts): + for cid, amount in zip(ids, amounts, strict=False): iset.add( Ingredient.from_compound( compound=CompoundModel.objects.get(pk=cid), @@ -1150,9 +1162,7 @@ def get_df(self, **kwargs) -> 'pandas.DataFrame': """Get a dataframe of recipe dictionaries. See :meth:`.Recipe.get_dict`.""" from pandas import DataFrame - data = [ - recipe.get_dict(timestamp=False, **kwargs) for recipe in self - ] + data = [recipe.get_dict(timestamp=False, **kwargs) for recipe in self] return DataFrame(data) def items(self) -> 'list[tuple[str, Recipe]]': diff --git a/hippo/designdb/services/compound.py b/hippo/designdb/services/compound.py index 7a6baf2..b206bd1 100644 --- a/hippo/designdb/services/compound.py +++ b/hippo/designdb/services/compound.py @@ -4,7 +4,12 @@ import mrich import rdkit from designdb.models import CompoundModel, CompoundTagModel -from designdb.utils import registration_hash_tautomer_insensitive, sanitise_smiles, superparent +from designdb.utils import ( + registration_hash_tautomer_insensitive, + sanitise_smiles, + superparent, +) + # from mypackage.services.compound import CompoundService from rdkit import Chem diff --git a/hippo/designdb/services/download.py b/hippo/designdb/services/download.py index 9ef520e..384325f 100644 --- a/hippo/designdb/services/download.py +++ b/hippo/designdb/services/download.py @@ -16,7 +16,6 @@ Adapted and hardened from the ``downloader.py`` prototype. """ -import os import tarfile import time import zipfile @@ -184,7 +183,6 @@ def download_target( 'or pass an explicit url' ) - destination = Path(destination) if destination else Path.cwd() destination.mkdir(parents=True, exist_ok=True) diff --git a/hippo/designdb/services/generation.py b/hippo/designdb/services/generation.py index fbf65d8..16d94ce 100644 --- a/hippo/designdb/services/generation.py +++ b/hippo/designdb/services/generation.py @@ -179,9 +179,11 @@ def _dump_data(self, data: dict) -> None: data['suppliers'] = self._suppliers data['starting_recipe'] = self._starting_recipe.get_dict(serialise_price=True) mrich.writing(self._data_path) - json.dump(data, open(self._data_path, 'wt'), indent=4) + json.dump(data, open(self._data_path, 'w'), indent=4) - def _write_recipe(self, recipe: 'Recipe', budget: 'Price', stats: dict, params: dict): + def _write_recipe( + self, recipe: 'Recipe', budget: 'Price', stats: dict, params: dict + ): """Write a generated recipe to ``{recipe_dir}/Recipe_.json``.""" out_file = self._recipe_dir / f'Recipe_{dt_hash()}.json' metadict = { @@ -273,7 +275,8 @@ def generate( permitted_clusters=None, debug: bool = False, ) -> 'Recipe': - """Generate a random recipe of routes within ``budget`` (also written to disk).""" + """Generate a random recipe of routes within ``budget`` + (also written to disk).""" if balance_clusters: raise NotImplementedError( 'balance_clusters requires route clustering, which is not implemented' @@ -346,7 +349,8 @@ def generate( shuffle: bool = True, debug: bool = False, ) -> 'Recipe': - """Generate a random compound selection within ``budget`` (also written to disk).""" + """Generate a random compound selection within ``budget`` + (also written to disk).""" if max_iter is None: max_iter = max_compounds * 3 budget = Price(budget, currency) @@ -386,7 +390,10 @@ def __init__( self._starting_recipe = start_with or Recipe() self._setup_io( - out_key, '_rsgen.json', '_recipes_and_selections', skip_directory_creation=False + out_key, + '_rsgen.json', + '_recipes_and_selections', + skip_directory_creation=False, ) # inner generators build the pools and dump their own state files; they do @@ -428,7 +435,8 @@ def generate( shuffle: bool = True, debug: bool = False, ) -> 'Recipe': - """Generate a random recipe of routes + compound selections (also written to disk).""" + """Generate a random recipe of routes + compound selections + (also written to disk).""" budget = Price(budget, currency) recipe, stats = _generate_recipe( self._starting_recipe, diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index b1a7ab0..58b9059 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -33,13 +33,22 @@ remove_other_ligands, sanitise_smiles, ) -from designdb.utils_chem import InvalidChemistryError, UnsupportedChemistryError, check_chemistry -from designdb.utils_frag import UnsupportedFragalysisLongcodeError, parse_observation_longcode +from designdb.utils_chem import ( + InvalidChemistryError, + UnsupportedChemistryError, + check_chemistry, +) +from designdb.utils_frag import ( + UnsupportedFragalysisLongcodeError, + parse_observation_longcode, +) from django.db import connection from numpy import isnan from pandas import read_pickle + # from mypackage.services.compound import CompoundService from rdkit import Chem + # from rdkit.Chem import inchi from rdkit.Chem import PandasTools @@ -479,7 +488,7 @@ def ingest_sdf( if not smiles: smiles = mp.rdkit.mol_to_smiles(r[mol_col]) try: - sane_smiles = sanitise_smiles( + sanitise_smiles( smiles, sanitisation_failed='error', radical='warning', @@ -547,7 +556,9 @@ def ingest_sdf( pose.inspirations.add(*PoseModel.objects.filter(pk__in=pose_inspirations)) if score_method_map: - scorer.add_scores_from_record(pose=pose, record=r, score_method_map=score_method_map) + scorer.add_scores_from_record( + pose=pose, record=r, score_method_map=score_method_map + ) else: scorer.add_scores_from_record(pose=pose, record=r) @@ -725,7 +736,9 @@ def ingest_enamine_real_routes( try: for step_id in range(1, steps + 1): r1_smiles = d.get(f'reactant_step{step_id}') - if not r1_smiles or (isinstance(r1_smiles, float) and isnan(r1_smiles)): + if not r1_smiles or ( + isinstance(r1_smiles, float) and isnan(r1_smiles) + ): continue reaction_type = d[f'reaction_name_step{step_id}'] @@ -735,7 +748,9 @@ def ingest_enamine_real_routes( reactant_smiles = [r1_smiles] r2_smiles = d.get(f'reactant2_step{step_id}') - if r2_smiles and not (isinstance(r2_smiles, float) and isnan(r2_smiles)): + if r2_smiles and not ( + isinstance(r2_smiles, float) and isnan(r2_smiles) + ): reactant_smiles.append(r2_smiles) reaction, _ = ReactionModel.objects.get_or_create( @@ -752,7 +767,9 @@ def ingest_enamine_real_routes( ) rs.append(reactant.pk) - if do_check_chemistry and not check_chemistry(reaction_type, rs, product): + if do_check_chemistry and not check_chemistry( + reaction_type, rs, product + ): raise InvalidChemistryError( f'{reaction_type=}, {rs=}, {product.id=}', ) @@ -838,7 +855,7 @@ def ingest_syndirella_elabs( for step in range(num_steps): step += 1 matches = df[f'{step}_flag'].apply( - lambda x: flag in x if x is not None else False + lambda x, flag=flag: flag in x if x is not None else False ) mrich.print( 'Filtering out', @@ -888,7 +905,9 @@ def ingest_syndirella_elabs( (template_path,) = template_paths template_path = Path(template_path) mrich.var('template_path', template_path) - base_name = template_path.name.removesuffix('.pdb').removesuffix('_delig-desolv') + base_name = template_path.name.removesuffix('.pdb').removesuffix( + '_delig-desolv' + ) # DEPRECATED(apo-naming): pre-'delig' Fragalysis naming, remove once all # data uses 'delig' base_name = base_name.removesuffix('_apo-desolv') @@ -994,7 +1013,7 @@ def ingest_syndirella_elabs( } df[inchikey_col] = df[smiles_col].apply( - lambda x: orig_smiles_to_inchikey.get(x) + lambda x, m=orig_smiles_to_inchikey: m.get(x) ) # get associated IDs @@ -1003,7 +1022,7 @@ def ingest_syndirella_elabs( for k in CompoundModel.objects.filter(compound_smiles__in=unique_smiles) } df[compound_id_col] = df[inchikey_col].apply( - lambda x: compound_inchikey_id_dict.get(x) + lambda x, m=compound_inchikey_id_dict: m.get(x) ) # bulk register reactions @@ -1121,9 +1140,9 @@ def ingest_syndirella_elabs( if require_intra_geometry_pass: mrich.var( '#poses !intra_geometry_pass', - len(df[df['intra_geometry_pass'] == False]), + len(df[df['intra_geometry_pass'] == False]), # noqa: E712 ) - ok = ok[ok['intra_geometry_pass'] == True] + ok = ok[ok['intra_geometry_pass'] == True] # noqa: E712 if max_energy_score is not None: mrich.var( diff --git a/hippo/designdb/services/interaction.py b/hippo/designdb/services/interaction.py index f3dcf55..d940a5a 100644 --- a/hippo/designdb/services/interaction.py +++ b/hippo/designdb/services/interaction.py @@ -159,7 +159,6 @@ def _detect( candidates: list[dict] = [] for prot_feature in protein_system.get_protein_features(): - prot_family = prot_feature.family if prot_family not in COMPLEMENTARY_FEATURES: @@ -173,7 +172,6 @@ def _detect( feature_id = InteractionService._protein_feature_id(target, prot_feature) for complementary_family in COMPLEMENTARY_FEATURES[prot_family]: - interaction_type = INTERACTION_TYPES[ (prot_family, complementary_family) ] @@ -181,7 +179,6 @@ def _detect( for lig_feature in comp_features_by_family.get( complementary_family, [] ): - lig_pos = np.asarray(lig_feature.position) distance = float(np.linalg.norm(lig_pos - prot_coord)) angle = None @@ -285,17 +282,13 @@ def keep_min_per(predicate, key) -> None: lambda c: c['type'] == 'Hydrogen Bond', lambda c: tuple(c['atom_ids']) ) keep_min_per(lambda c: c['type'] == 'π-stacking', lambda c: c['feature_id']) - keep_min_per( - lambda c: c['type'] == 'π-cation', lambda c: tuple(c['atom_ids']) - ) + keep_min_per(lambda c: c['type'] == 'π-cation', lambda c: tuple(c['atom_ids'])) keep_min_per( lambda c: c['type'] == 'Electrostatic', lambda c: tuple(c['atom_ids']) ) # Sulfur-Sulfur: keep all - keep.update( - c['_idx'] for c in candidates if c['type'] == 'Sulfur-Sulfur' - ) + keep.update(c['_idx'] for c in candidates if c['type'] == 'Sulfur-Sulfur') # Hydrophobic: de-duplicate lumped vs. single hydrophobes hydrophobic = [c for c in candidates if c['type'] == 'Hydrophobic'] @@ -310,9 +303,9 @@ def keep_min_per(predicate, key) -> None: covered.setdefault((name, c['atom_ids'][0]), []).append(c['_idx']) elif families == ('Hydrophobe', 'LumpedHydrophobe'): for atom_id in c['atom_ids']: - covered.setdefault( - (c['feature_atom_name'], atom_id), [] - ).append(c['_idx']) + covered.setdefault((c['feature_atom_name'], atom_id), []).append( + c['_idx'] + ) elif families == ('LumpedHydrophobe', 'LumpedHydrophobe'): for name in names: for atom_id in c['atom_ids']: diff --git a/hippo/designdb/services/method.py b/hippo/designdb/services/method.py index 3687b95..9b1c379 100644 --- a/hippo/designdb/services/method.py +++ b/hippo/designdb/services/method.py @@ -7,7 +7,9 @@ class MethodService: @classmethod - def register_enumeration_method(cls, name: str, version: str, description: str = ''): + def register_enumeration_method( + cls, name: str, version: str, description: str = '' + ): obj, created = EnumerationMethodModel.objects.get_or_create( enum_name=name, enum_version=version, diff --git a/hippo/designdb/services/pose.py b/hippo/designdb/services/pose.py index fd54954..5c4e808 100644 --- a/hippo/designdb/services/pose.py +++ b/hippo/designdb/services/pose.py @@ -7,12 +7,20 @@ import mrich import pandas as pd import rdkit + # from rdkit.Chem import inchi -from designdb.models import CompoundModel, PoseMethodModel, PoseModel, PoseTagModel, TargetModel +from designdb.models import ( + CompoundModel, + PoseMethodModel, + PoseModel, + PoseTagModel, + TargetModel, +) from designdb.utils import normalize_string_list from designdb.utils_chem import get_rmsd from designdb.utils_frag import GENERATED_TAG_COLS, META_IGNORE_COLS from django.db.models import Q + # from mypackage.services.compound import CompoundService from rdkit import Chem @@ -117,7 +125,8 @@ def find_rmsd_duplicate( rmsd = get_rmsd(mol, existing.pose_mol) if rmsd < rmsd_threshold: logger.warning( - 'Pose RMSD %.3f Å below threshold %.3f Å, skipping duplicate (alias=%s)', + 'Pose RMSD %.3f Å below threshold %.3f Å, ' + 'skipping duplicate (alias=%s)', rmsd, rmsd_threshold, existing.pose_alias, diff --git a/hippo/designdb/services/reaction.py b/hippo/designdb/services/reaction.py index f432e91..4ef76aa 100644 --- a/hippo/designdb/services/reaction.py +++ b/hippo/designdb/services/reaction.py @@ -1,6 +1,7 @@ import logging import mrich + # from mypackage.services.compound import CompoundService # from rdkit.Chem import inchi from designdb.models import CompoundModel, ReactantModel, ReactionModel @@ -101,7 +102,7 @@ def create_from_lists( reaction_ids.append(reaction.pk) payload = [] - for reaction_id, ((reaction_type, product_id), reactant_ids) in zip( + for reaction_id, ((_reaction_type, _product_id), reactant_ids) in zip( reaction_ids, non_duplicates.items(), strict=False ): for reactant_id in reactant_ids: diff --git a/hippo/designdb/services/recipe.py b/hippo/designdb/services/recipe.py index 8814eb0..168486d 100644 --- a/hippo/designdb/services/recipe.py +++ b/hippo/designdb/services/recipe.py @@ -6,16 +6,30 @@ """ from itertools import product +from typing import TYPE_CHECKING import mrich from designdb.components.compound import Compound from designdb.components.reaction import DEFAULT_PRODUCT_YIELD, Reaction -from designdb.models import CompoundModel, InspirationModel, PoseModel, ReactionModel, RouteModel +from designdb.models import ( + CompoundModel, + InspirationModel, + PoseModel, + ReactionModel, + RouteModel, +) from designdb.sets.compound import CompoundSet from designdb.sets.ingredient import IngredientSet from designdb.sets.pose import PoseSet from designdb.sets.reaction import ReactionSet +if TYPE_CHECKING: + from pathlib import Path + + from designdb.recipe import Recipe + from designdb.sets.route import RouteSet + from pandas import DataFrame + class RecipeService: """Construction and traversal logic for :class:`.Recipe` objects.""" @@ -186,7 +200,9 @@ def get_reactant_amount_pairs( if not priced: mrich.error("0 recipes with prices, can't choose cheapest") return recipes - sorted_recipes = sorted(priced, key=lambda r: r.get_price(supplier=supplier)) + sorted_recipes = sorted( + priced, key=lambda r: r.get_price(supplier=supplier) + ) if debug: for recipe in recipes: mrich.debug(f'{recipe}, {recipe.price}') @@ -319,9 +335,9 @@ def from_compounds( if use_routes: route_ids = list( - RouteModel.objects.filter( - product_compound__id=comp.id - ).values_list('id', flat=True) + RouteModel.objects.filter(product_compound__id=comp.id).values_list( + 'id', flat=True + ) ) if not route_ids: mrich.error('No routes to', comp) @@ -480,9 +496,9 @@ def from_reactants( possible_reactions |= set(reaction_ids) product_ids = list( - ReactionModel.objects.filter( - pk__in=reaction_ids - ).values_list('product_compound_id', flat=True) + ReactionModel.objects.filter(pk__in=reaction_ids).values_list( + 'product_compound_id', flat=True + ) ) n_prev = len(all_reactants) @@ -636,10 +652,10 @@ def write_product_csv( # compound_id -> set of inspiration (original) pose IDs, scoped to the # product compounds and their scaffolds (the inspiration fallback needs both) scaffold_ids = set() - for product in recipe.products: + for prod in recipe.products: # Ingredient.__getattr__ delegates to the CompoundModel (ORM), so wrap # in the Compound component to reach component-level properties - if scaffolds := Compound(product.compound).scaffolds: + if scaffolds := Compound(prod.compound).scaffolds: scaffold_ids.update(scaffolds.ids) needed_ids = set(product_ids) | scaffold_ids @@ -651,34 +667,34 @@ def write_product_csv( data = [] - for product in mrich.track( + for prod in mrich.track( recipe.products, prefix='Constructing product DataFrame' ): # wrap in the Compound component for component-level properties # (Ingredient.__getattr__ delegates to the CompoundModel ORM instead) - comp = Compound(product.compound) + comp = Compound(prod.compound) d = dict( - hippo_id=product.compound_id, + hippo_id=prod.compound_id, smiles=comp.smiles, inchikey=comp.inchikey, - required_amount_mg=product.amount, + required_amount_mg=prod.amount, ) upstream_routes = [] upstream_reaction_ids = [] for route in routes: - if route.product_compound.id == product.compound_id: + if route.product_compound.id == prod.compound_id: upstream_routes.append(route) upstream_reaction_ids += route.reactions.ids if not upstream_routes: - mrich.error('No upstream routes for', product) + mrich.error('No upstream routes for', prod) continue if not upstream_reaction_ids: - mrich.error('No upstream reactions for', product) + mrich.error('No upstream reactions for', prod) continue upstream_reactions = ReactionSet(list(set(upstream_reaction_ids))) @@ -687,9 +703,9 @@ def write_product_csv( if scaffolds := comp.scaffolds: scaffold_series, is_scaffold = scaffolds.ids, False else: - scaffold_series, is_scaffold = [product.compound_id], True + scaffold_series, is_scaffold = [prod.compound_id], True - poses = pose_map.get(product.compound_id, set()) + poses = pose_map.get(prod.compound_id, set()) d['num_poses'] = len(poses) d['poses'] = poses @@ -706,7 +722,7 @@ def write_product_csv( d['scaffold_series'] = scaffold_series # inspiration pose IDs, with fallback to the scaffold / metadata - inspirations = inspiration_map.get(product.compound_id, None) + inspirations = inspiration_map.get(prod.compound_id, None) if not inspirations and not is_scaffold: scaffold = Compound(comp.scaffolds[0]) diff --git a/hippo/designdb/services/recipe_score.py b/hippo/designdb/services/recipe_score.py index 8c04cba..941062f 100644 --- a/hippo/designdb/services/recipe_score.py +++ b/hippo/designdb/services/recipe_score.py @@ -6,7 +6,6 @@ weight. The score cache is written to ``{out_key}.json``. """ -import json from pathlib import Path import mrich @@ -149,7 +148,10 @@ def __call__(self, recipe: 'Recipe') -> float: return self.weight * self.unweighted(recipe) def __str__(self) -> str: - return f'{self._type}("{self.key}", weight={self.weight:.2f}, inverse={self.inverse})' + return ( + f'{self._type}("{self.key}", weight={self.weight:.2f}, ' + f'inverse={self.inverse})' + ) def __repr__(self) -> str: import mcol @@ -243,9 +245,7 @@ def default( self = cls.__new__(cls) - standard = [ - k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard' - ] + standard = [k for k, v in DEFAULT_ATTRIBUTES.items() if v['type'] == 'standard'] self.__init__( directory=directory, @@ -331,7 +331,7 @@ def weights(self, ws) -> None: ws = [ws] * self.num_attributes ws = list(ws) wsum = sum(abs(w) for w in ws) or 1.0 - for a, w in zip(self.attributes, ws): + for a, w in zip(self.attributes, ws, strict=False): a.weight = w / wsum @property @@ -385,7 +385,7 @@ def score(self, recipe: 'Recipe', *, debug: bool = False) -> float: def get_sorted_df(self) -> 'pd.DataFrame': """Score cache sorted by descending score.""" - self.scores + _ = self.scores return self._data.sort_values(by='score', ascending=False) def top_keys(self, n: int) -> list[str]: @@ -405,7 +405,7 @@ def plot(self, keys: list[str], budget: float | None = None): mrich.error('Only two keys supported') return None - self.scores + _ = self.scores df = self._data.drop( columns=['compound_ids', 'pose_ids', 'interaction_ids', 'pose_metadata'] @@ -443,9 +443,7 @@ def _populate_query_cache(self) -> None: df.at[recipe.hash, 'compound_ids'] = recipe.combined_compound_ids # compound -> pose IDs - all_compound_ids = set().union( - *(set(ids) for ids in df['compound_ids'] if ids) - ) + all_compound_ids = set().union(*(set(ids) for ids in df['compound_ids'] if ids)) mrich.debug(f'Getting poses for {len(all_compound_ids)} compounds') compound_pose_map: dict[int, set] = {} for c_id, p_id in PoseModel.objects.filter( @@ -484,9 +482,9 @@ def _populate_query_cache(self) -> None: mrich.debug(f'Getting metadata for {len(all_pose_ids)} poses') metadata_map: dict[int, dict] = {} if all_pose_ids: - for p_id, meta in PoseModel.objects.filter( - pk__in=all_pose_ids - ).values_list('id', 'pose_metadata'): + for p_id, meta in PoseModel.objects.filter(pk__in=all_pose_ids).values_list( + 'id', 'pose_metadata' + ): metadata_map[p_id] = meta or {} mrich.debug('Populating _data["pose_metadata"]...') diff --git a/hippo/designdb/services/route.py b/hippo/designdb/services/route.py index 4b4bb43..7184486 100644 --- a/hippo/designdb/services/route.py +++ b/hippo/designdb/services/route.py @@ -88,7 +88,9 @@ def prune_duplicate_routes(cls) -> int: route_fingerprints[route_id][1].add((comp_ref, comp_type)) # freeze the sets so they're hashable - frozen = {rid: (fp[0], frozenset(fp[1])) for rid, fp in route_fingerprints.items()} + frozen = { + rid: (fp[0], frozenset(fp[1])) for rid, fp in route_fingerprints.items() + } mrich.var('#routes', len(frozen)) diff --git a/hippo/designdb/services/subsite.py b/hippo/designdb/services/subsite.py index ab8b308..c9365ca 100644 --- a/hippo/designdb/services/subsite.py +++ b/hippo/designdb/services/subsite.py @@ -43,7 +43,9 @@ def set_subsites_from_metadata_field( metadata = pose.pose_metadata or {} name = metadata.get(field) if not name: - logger.warning('Field "%s" not in metadata for pose pk=%s', field, pose.pk) + logger.warning( + 'Field "%s" not in metadata for pose pk=%s', field, pose.pk + ) continue subsite, _ = SubsiteModel.objects.get_or_create( target=pose.target, diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index f2dd3e3..1197fec 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -1,14 +1,14 @@ import json from collections.abc import Callable from pathlib import Path +from statistics import mean +from typing import TYPE_CHECKING import mcol import mrich import pandas as pd from designdb.components.compound import Ingredient -from designdb.components.price import Price from designdb.models import ( - CataloguePriceModel, CompoundModel, CompoundTagJunctionModel, CompoundTagModel, @@ -19,13 +19,21 @@ ScaffoldModel, ) from django.db.models import Exists, OuterRef, Q -from pandas import DataFrame, concat, isna +from pandas import DataFrame from rdkit import Chem + # from rdkit.Chem import inchi from rdkit.Chem import Mol from ..utils import registration_hash_tautomer_insensitive, superparent +if TYPE_CHECKING: + import plotly.graph_objects as go + from designdb.sets.ingredient import IngredientSet + from designdb.sets.pose import PoseSet + from designdb.sets.reaction import ReactionSet + from designdb.sets.route import RouteSet + class CompoundSet: """Object representing a subset of the 'compound' table in the :class:`.Database`. @@ -129,7 +137,7 @@ def __getitem__( index = self.indices[key] try: return CompoundModel.objects.get(id=index) - except CompoundModel.DoesNotExist: + except CompoundModel.DoesNotExist as exc: raise CompoundModel.DoesNotExist from exc case slice(): @@ -526,6 +534,7 @@ def draw(self) -> None: """ + from IPython.display import display from molparse.rdkit import draw_grid data = [(str(c), c.mol) for c in self] @@ -964,8 +973,7 @@ def get_df( mrich.debug('querying...') rows = { - r['id']: r - for r in CompoundModel.objects.filter(pk__in=ids).values(*fields) + r['id']: r for r in CompoundModel.objects.filter(pk__in=ids).values(*fields) } data = [] @@ -1007,9 +1015,9 @@ def get_df( mrich.debug('adding pose column') lookup: dict[int, set] = {} - for cid, pid in PoseModel.objects.filter( - compound_id__in=ids - ).values_list('compound_id', 'id'): + for cid, pid in PoseModel.objects.filter(compound_id__in=ids).values_list( + 'compound_id', 'id' + ): lookup.setdefault(cid, set()).add(pid) if poses: @@ -1021,9 +1029,9 @@ def get_df( if debug: mrich.debug('adding num_reactant column') counts: dict[int, int] = {} - for cid in ReactantModel.objects.filter( - compound_id__in=ids - ).values_list('compound_id', flat=True): + for cid in ReactantModel.objects.filter(compound_id__in=ids).values_list( + 'compound_id', flat=True + ): counts[cid] = counts.get(cid, 0) + 1 df['num_reactant'] = df['id'].apply(lambda x: counts.get(x, 0)) @@ -1511,7 +1519,7 @@ def register_missing_routes( mrich.var('#compounds', len(self)) - for i, c in mrich.track(enumerate(self), total=len(self)): + for c in mrich.track(self, total=len(self)): try: reactions = c.reactions except Exception as e: @@ -1579,7 +1587,6 @@ def smiles(self) -> list[str]: @property def mols(self) -> 'list[Chem.Mol]': """Returns the molecules of child compounds""" - from rdkit.Chem import Mol result = self.db.select_where( query='mol_to_binary_mol(compound_mol)', @@ -1730,7 +1737,7 @@ def avg_num_atoms_added(self) -> float: INNER JOIN nums ON comp_id = compound_id WHERE compound_id IN {self.str_ids} - """ + """ # noqa: F841 # TODO(legacy-self.db): port to ORM (avg,) = self.db.execute().fetchone() diff --git a/hippo/designdb/sets/ingredient.py b/hippo/designdb/sets/ingredient.py index ba97e6a..91d4bb9 100644 --- a/hippo/designdb/sets/ingredient.py +++ b/hippo/designdb/sets/ingredient.py @@ -99,7 +99,7 @@ def __rich__(self) -> str: def __add__(self, other): """Add another :class:`.IngredientSet` this set""" - for i, row in other._data.iterrows(): + for _, row in other._data.iterrows(): self.add( compound_id=row.compound_id, amount=row.amount, diff --git a/hippo/designdb/sets/interaction.py b/hippo/designdb/sets/interaction.py index 634681f..c15f322 100644 --- a/hippo/designdb/sets/interaction.py +++ b/hippo/designdb/sets/interaction.py @@ -9,11 +9,18 @@ read/aggregate surface over already-stored interactions. """ +from typing import TYPE_CHECKING + import mcol import mrich from designdb.models import InteractionModel from django.db.models import Count +if TYPE_CHECKING: + import pandas + from designdb.models import PoseModel, TargetModel + from designdb.sets.pose import PoseSet + # `df` columns: ORM field (model / joined feature) -> output column name _DF_COLUMNS = { 'id': 'id', @@ -46,7 +53,8 @@ class InteractionSet: """ def __init__(self, indices: list | None = None) -> None: - """InteractionSet initialisation from a list of :class:`.InteractionModel` IDs""" + """InteractionSet initialisation from a list of + :class:`.InteractionModel` IDs""" indices = indices or [] if not isinstance(indices, list): indices = list(indices) diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index a1c2065..c3755fe 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -20,8 +20,6 @@ CompoundModel, InspirationModel, InteractionModel, - PoseMethodJunctionModel, - PoseMethodModel, PoseModel, PoseTagJunctionModel, PoseTagModel, @@ -35,7 +33,7 @@ from designdb.utils import ScoreSubquery, normalize_string_list from designdb.utils_frag import generate_header from django.conf import settings -from django.db import IntegrityError +from django.db import IntegrityError, transaction from django.db.models import Exists, FloatField, OuterRef, Q, QuerySet, Subquery from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast @@ -51,8 +49,10 @@ ) from molparse.rdkit import draw_grid, draw_mols from pandas import DataFrame + # from mypackage.services.compound import CompoundService from rdkit import Chem + # from rdkit.Chem import inchi from rdkit.Chem import PandasTools, SDWriter @@ -61,6 +61,13 @@ else: from django.contrib.postgres.aggregates import ArrayAgg +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pandas + from designdb.components.pose import Pose + from designdb.sets.compound import CompoundSet + # from .validation.compound import ValidationError, validate_compound_data @@ -303,7 +310,8 @@ def __call__( target: int = None, subsite: int = None, ) -> 'PoseSet': - """Filter poses by a given tag, pose method name, SubsiteModel ID, or target ID. See + """Filter poses by a given tag, pose method name, SubsiteModel ID, or + target ID. See :meth:`.PoseSet.get_by_tag`, :meth:`.PoseSet.get_by_method`, :meth:`.PoseSet.get_by_target`, and :meth:`.PoseSet.get_by_subsite`""" @@ -330,7 +338,7 @@ def get_by_inspirations(cls, poseset: 'PoseSet') -> 'PoseSet': return PoseSet( PoseModel.objects.filter( pk__in=InspirationModel.objects.filter( - derivative_pose__in=self._queryset, + derivative_pose__in=poseset._queryset, ).values( 'original_pose', ), @@ -383,7 +391,7 @@ def get_by_metadata( regardless of value (Default value = None) """ - results = self.db.select_where( + results = self.db.select_where( # noqa: F841 # TODO(legacy-self.db): port to ORM query='pose_id, pose_metadata', key=f'pose_id IN {self.str_ids}', table='pose', @@ -483,7 +491,14 @@ def get_df( flags = { name: locals()[name] for name in sig.parameters - if name not in ('self', 'debug', 'expand_tags', 'expand_metadata', 'scoring_methods') + if name + not in ( + 'self', + 'debug', + 'expand_tags', + 'expand_metadata', + 'scoring_methods', + ) } # need id in output flags['id'] = True @@ -581,16 +596,20 @@ def get_df( values = [v[1] for k, v in fields.items() if flags.get(k, False)] columns = {v[1]: v[0] for k, v in fields.items() if flags.get(k, False)} - for method_name, version in (scoring_methods or []): + for method_name, version in scoring_methods or []: score_sq = Subquery( ScoreValueModel.objects.filter( pose=OuterRef('pk'), compound=OuterRef('compound'), scoring_method__method_name=method_name, scoring_method__method_version=version, - ).annotate( - score_val=Cast(KeyTextTransform('score', 'score'), output_field=FloatField()) - ).values('score_val')[:1], + ) + .annotate( + score_val=Cast( + KeyTextTransform('score', 'score'), output_field=FloatField() + ) + ) + .values('score_val')[:1], output_field=FloatField(), ) annotations[method_name] = score_sq @@ -631,7 +650,7 @@ def get_df( # build boolean columns for tag in all_tags: - df[tag] = df['tags'].apply(lambda tags: tag in tags) + df[tag] = df['tags'].apply(lambda tags, tag=tag: tag in tags) df = df.drop(columns=['tags']) @@ -736,7 +755,8 @@ def get_best_scoring_poses_per_compound( version: str | None = None, inverse: bool = False, ) -> 'PoseSet': - """Return one pose per compound with the best score for the given scoring method. + """Return one pose per compound with the best score for the given + scoring method. :param scoring_method: ``ScoringMethodModel.method_name`` to rank by :param version: ``ScoringMethodModel.method_version`` — required when multiple @@ -770,7 +790,6 @@ def get_best_scoring_poses_per_compound( best_pose_ids = [pose_id for pose_id, _ in best.values()] return PoseSet(PoseModel.objects.filter(pk__in=best_pose_ids)) - # def filter( # self, # function=None, @@ -868,7 +887,6 @@ def append_to_metadata( pose.save() self._queryset = PoseModel.objects.filter(pk__in=self._queryset.values('pk')) - # TODO: implement scores # def calculate_inspiration_scores( # self, @@ -1030,7 +1048,7 @@ def write_sdf( longcode_lookup[i] = metadata.get(name_col, None) values = [] - for i, row in df.iterrows(): + for _, row in df.iterrows(): values.append(longcode_lookup[row['id']]) df[name_col] = values @@ -1136,7 +1154,10 @@ def to_fragalysis( poses = PoseSet(PoseModel.objects.filter(pk__in=values)) mrich.debug(len(poses), 'remaining after skipping null inspirations') else: - logger.warning('no inspirations found; per-pose fallback will set inspiration to self') + logger.warning( + 'no inspirations found; per-pose fallback will set ' + 'inspiration to self' + ) if not poses: # huh? @@ -1176,7 +1197,6 @@ def to_fragalysis( PoseModel.objects.filter(pk__in=ref_ids).values_list('pk', 'pose_alias') ) - inspiration_strs = [] # for i, row in pose_df.iterrows(): # strs = [] # for i in normalize_string_list(row['inspiration_ids']): @@ -1200,7 +1220,9 @@ def fix_subsites(subsite_list: list[str]): pose_df['subsites'] = pose_df['subsites'].apply(fix_subsites) if tags: - pose_df['tags'] = pose_df['tags'].apply(lambda x: ','.join(v for v in x if v is not None)) + pose_df['tags'] = pose_df['tags'].apply( + lambda x: ','.join(v for v in x if v is not None) + ) # pose_df['ref_mols'] = inspiration_strs pose_df['ref_mols'] = 'inspiration_strs' @@ -1349,13 +1371,17 @@ def fix_subsites(subsite_list: list[str]): for ref_alias in pose_df['ref_pdb'].values: source = lookup.get(ref_alias) if not source: - mrich.warning(f'No protein file for reference {ref_alias!r}; skipping') + mrich.warning( + f'No protein file for reference {ref_alias!r}; skipping' + ) continue source_path = Path(source) stem = source_path.name.replace('_hippo.pdb', '.pdb') # current Fragalysis protein-file naming - apo_path = source_path.parent / stem.replace('.pdb', '_delig-desolv.pdb') + apo_path = source_path.parent / stem.replace( + '.pdb', '_delig-desolv.pdb' + ) # DEPRECATED(apo-naming): fall back to pre-'delig' naming if present, # remove once all data uses 'delig' legacy_path = source_path.parent / stem.replace( @@ -1385,8 +1411,6 @@ def fix_subsites(subsite_list: list[str]): # create the header molecule - df_cols = set(pose_df.columns) - header = generate_header( # self[0], # <- what does that do?? self._queryset.first(), @@ -1399,8 +1423,6 @@ def fix_subsites(subsite_list: list[str]): metadata=metadata, ) - header_cols = set(header.GetPropNames()) - # # empty properties # pose_df["generation_date"] = [None] * len(pose_df) # pose_df["submitter_name"] = [None] * len(pose_df) @@ -1422,8 +1444,6 @@ def fix_subsites(subsite_list: list[str]): if sort_by: pose_df = pose_df.sort_values(by=sort_by, ascending=not sort_reverse) - fields = [] - mrich.writing(out_path) with open(out_path, 'w') as sdfh: @@ -1459,7 +1479,7 @@ def to_pymol(self, prefix: str | None = None) -> None: from pathlib import Path - for i, (ref_id, poses) in enumerate(self.split_by_reference().items()): + for ref_id, poses in self.split_by_reference().items(): ref_pose = PoseModel.objects.get(id=ref_id) ref_name = ref_pose.pose_alias or ref_id @@ -1481,7 +1501,7 @@ def to_pymol(self, prefix: str | None = None) -> None: commands.append('set surface_color, white') commands.append('set transparency, 0.4') - for j, (insp_ids, poses) in enumerate( + for j, (insp_ids, poses) in enumerate( # noqa: B020 # TODO(legacy-self.db): port to ORM poses.split_by_inspirations().items() ): inspirations = PoseSet(self.db, insp_ids) @@ -1542,7 +1562,9 @@ def to_knitwork( for pose in self._queryset: assert pose.pose_alias - assert pose.methods.filter(pose_method_name__in=DEFAULT_POSE_METHODS).exists() + assert pose.methods.filter( + pose_method_name__in=DEFAULT_POSE_METHODS + ).exists() if aligned_files_dir: mol = str(pose.mol_path) @@ -1668,7 +1690,7 @@ def to_syndirella( ### Write CSV if separate: - for i, row in df.iterrows(): + for _, row in df.iterrows(): csv_name = out_dir / f'{row["compound_set"]}_syndirella_input.csv' mrich.writing(csv_name) row.to_frame().T.to_csv(csv_name, index=False) @@ -1844,7 +1866,7 @@ def draw(self) -> None: mols = [p.mol for p in self] drawing = draw_mols(mols) - # display(drawing) + display(drawing) def grid(self) -> None: """Draw a grid of all contained molecules""" @@ -2000,7 +2022,7 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': # calculate modal interactions - for i, cluster in psets.items(): + for cluster in psets.values(): mrich.var(cluster.name, len(cluster), unit='poses') df = cluster.interactions.df @@ -2263,7 +2285,7 @@ def subsite_ids(self) -> set[int]: return SubsiteModel.objects.filter( pk__in=SubsiteTagModel.objects.filter( pose__in=self._queryset, - ).values(subsite), + ).values('subsite'), ).values_list('pk', flat=True) @property diff --git a/hippo/designdb/sets/reaction.py b/hippo/designdb/sets/reaction.py index ac2e0f2..9e54b7d 100644 --- a/hippo/designdb/sets/reaction.py +++ b/hippo/designdb/sets/reaction.py @@ -7,7 +7,14 @@ from designdb.sets.compound import CompoundSet from django.db.models import Q from IPython.display import display -from ipywidgets import BoundedIntText, Checkbox, GridBox, Layout, VBox, interactive_output +from ipywidgets import ( + BoundedIntText, + Checkbox, + GridBox, + Layout, + VBox, + interactive_output, +) class ReactionSet: @@ -318,9 +325,7 @@ def ids(self) -> list[int]: @property def types(self) -> list[str]: """Returns the unique reaction types in this set""" - return list( - self._queryset.values_list('reaction_type', flat=True).distinct() - ) + return list(self._queryset.values_list('reaction_type', flat=True).distinct()) @property def num_types(self) -> int: diff --git a/hippo/designdb/sets/route.py b/hippo/designdb/sets/route.py index 4dee0fd..8ff1cb6 100644 --- a/hippo/designdb/sets/route.py +++ b/hippo/designdb/sets/route.py @@ -1,10 +1,16 @@ import json +from typing import TYPE_CHECKING import mcol import mrich from designdb.models import ComponentModel, RouteModel from designdb.sets.compound import CompoundSet +if TYPE_CHECKING: + from pathlib import Path + + from designdb.recipe import Route + class RouteSet: """A set of Route objects""" diff --git a/hippo/designdb/utils.py b/hippo/designdb/utils.py index 91ce289..78afec5 100644 --- a/hippo/designdb/utils.py +++ b/hippo/designdb/utils.py @@ -5,6 +5,7 @@ import re from datetime import datetime from string import ascii_uppercase +from typing import TYPE_CHECKING import mcol import molparse as mp @@ -17,6 +18,9 @@ from rdkit.Chem.inchi import MolToInchiKey from rdkit.Chem.MolStandardize import rdMolStandardize +if TYPE_CHECKING: + from designdb.models import PoseModel + def strip_sql(sql) -> str: """Reduce unecessary whitespace in SQL""" diff --git a/hippo/designdb/utils_chem.py b/hippo/designdb/utils_chem.py index 4da66bd..c8e1f43 100644 --- a/hippo/designdb/utils_chem.py +++ b/hippo/designdb/utils_chem.py @@ -1,12 +1,16 @@ """functions for validating chemistry""" import logging +from typing import TYPE_CHECKING import mrich from designdb.models import CompoundModel from rdkit import Chem from rdkit.Chem import rdMolAlign +if TYPE_CHECKING: + from designdb.sets.compound import CompoundSet + """ Checks @@ -272,8 +276,6 @@ def check_atomtype_diff( ) -> bool: """check atomtypes""" - check_type = 'atomtype' - # get values reac = reactants.atomtype_dict prod = product.atomtype_dict diff --git a/pyproject.toml b/pyproject.toml index 2dfd630..587439a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,6 @@ dev = [ "mypy>=1.19", "commitizen>=4.13.5,<5", "pre-commit>=4.5.1", - "isort>=8.0.1", ] [tool.commitizen] @@ -95,7 +94,7 @@ target-version = "py313" exclude = [ "tests", "migrations", - "hippo", + # "hippo", "hippo_legacy", ] force-exclude = true @@ -119,15 +118,10 @@ quote-style = "single" exclude = [ "migrations", "tests", - "hippo", + # "hippo", "hippo_legacy", ] -[tool.isort] -profile = "hug" -src_paths = ["src", "tests"] - - [tool.uv.sources] django-rdkit = { git = "https://github.com/rdkit/django-rdkit" } diff --git a/uv.lock b/uv.lock index 143024b..2c48a96 100644 --- a/uv.lock +++ b/uv.lock @@ -1341,15 +1341,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, ] -[[package]] -name = "isort" -version = "8.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, -] - [[package]] name = "itsdangerous" version = "2.2.0" @@ -4326,7 +4317,6 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "commitizen" }, - { name = "isort" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -4368,7 +4358,6 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "commitizen", specifier = ">=4.13.5,<5" }, - { name = "isort", specifier = ">=8.0.1" }, { name = "mypy", specifier = ">=1.19" }, { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2,<10" }, From 80d1042bd7cc17faed84526edcaa895531c216ac Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 23 Jun 2026 08:35:55 +0100 Subject: [PATCH 158/163] fix: github actions --- .github/workflows/lint.yaml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 5b97787..4b553de 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -15,13 +15,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 - - name: Set up Python - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v6 with: - python-version: "3.10" - - name: Configure pre-commit - run: | - pip install --upgrade pip - pip install pre-commit==4.5.1 + enable-cache: true + - name: Install dependencies + run: uv sync --frozen - name: Repeat pre-commit - run: pre-commit run --all-files + run: uv run pre-commit run --all-files From 6a3dd81d6f26b2c0c33dbdbe959e9a073cebedbb Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Tue, 23 Jun 2026 15:46:08 +0100 Subject: [PATCH 159/163] fix: allow access to host machine Necessary for testing with ssh tunnel to production database --- docker-compose.yaml | 2 ++ hippo/bootstrap.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 0da0b5d..4c59c97 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -46,6 +46,8 @@ services: start_period: 90s networks: - app_network + extra_hosts: + - "host.docker.internal:host-gateway" backend: diff --git a/hippo/bootstrap.py b/hippo/bootstrap.py index c6385bd..e8aa107 100644 --- a/hippo/bootstrap.py +++ b/hippo/bootstrap.py @@ -82,9 +82,6 @@ def load_hippo( tas_list = get_auth_target_access(username) - # mock response until auth pod is externally accessible - tas_list = 'lb18145-1' - if target_access_string not in tas_list: mrich.error(f'User {username} does not have access to {target_access_string}') return From f309d7edfc4385c81bdc8ec4a98e0189cae4f13d Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Thu, 25 Jun 2026 15:18:24 +0100 Subject: [PATCH 160/163] fix: new built-in functions to disable and enable triggers on load_sdf --- hippo/designdb/services/ingestion.py | 16 +++---------- images/xchem-designdb/init-db/01_schema.sql | 26 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index 58b9059..d92b42c 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -469,13 +469,10 @@ def ingest_sdf( name_col=name_col, ) - # temp hack: disable a trigger that runs on every score + # temp(?) hack: disable a trigger that runs on every score # insertion and later enable it cursor = connection.cursor() - cursor.execute( - 'ALTER TABLE designdb.score_values ' - 'DISABLE TRIGGER trg_score_values_refresh_pivoted_mv;' - ) + cursor.execute('SELECT designdb.begin_score_values_load();') for r in records: result.attempts += 1 @@ -563,14 +560,7 @@ def ingest_sdf( scorer.add_scores_from_record(pose=pose, record=r) # re-enable trigger and populate matview - cursor.execute( - 'ALTER TABLE designdb.score_values ' - 'ENABLE TRIGGER trg_score_values_refresh_pivoted_mv;' - ) - cursor.execute( - 'REFRESH MATERIALIZED VIEW CONCURRENTLY ' - 'designdb.scores_per_pose_pivoted_mv;' - ) + cursor.execute('SELECT designdb.begin_score_values_load();') return result diff --git a/images/xchem-designdb/init-db/01_schema.sql b/images/xchem-designdb/init-db/01_schema.sql index a3aa669..7bff0ea 100644 --- a/images/xchem-designdb/init-db/01_schema.sql +++ b/images/xchem-designdb/init-db/01_schema.sql @@ -1003,6 +1003,7 @@ BEGIN END; $$; + -- ========================================================= -- AUDIT FUNCTIONS -- ========================================================= @@ -1296,3 +1297,28 @@ CREATE TRIGGER trg_has_enumeration_methods_updated_on BEFORE UPDATE ON designdb. -- Pivoted materialized view once at schema load, this will be mapped in Scarab to do the filtering based on any type of scores/methods SELECT designdb.create_scores_per_pose_pivoted_mv(); + + +-- functions to disable and enable triggers in pose table (Lucas' later addition) +CREATE OR REPLACE FUNCTION designdb.begin_score_values_load() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = designdb, pg_temp +AS $$ +BEGIN + EXECUTE 'ALTER TABLE designdb.score_values DISABLE TRIGGER trg_score_values_refresh_pivoted_mv'; +END; +$$; + +CREATE OR REPLACE FUNCTION designdb.end_score_values_load() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = designdb, pg_temp +AS $$ +BEGIN + EXECUTE 'ALTER TABLE designdb.score_values ENABLE TRIGGER trg_score_values_refresh_pivoted_mv'; +  EXECUTE 'REFRESH MATERIALIZED VIEW designdb.scores_per_pose_pivoted_mv'; +END; +$$; From 736bea1b8cefe6f4c5ac3f02fd8e732f985372b9 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Mon, 29 Jun 2026 14:08:47 +0100 Subject: [PATCH 161/163] fix: attempt to fix dat loading bug attempt, because can't reproduce --- hippo/designdb/services/ingestion.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hippo/designdb/services/ingestion.py b/hippo/designdb/services/ingestion.py index d92b42c..111e5aa 100644 --- a/hippo/designdb/services/ingestion.py +++ b/hippo/designdb/services/ingestion.py @@ -25,6 +25,7 @@ from designdb.services.pose_score import ScoreService from designdb.services.reaction import ReactionService from designdb.services.route import RouteService +from designdb.sets.compound import CompoundSet from designdb.sets.ingredient import IngredientSet from designdb.sets.reaction import ReactionSet from designdb.utils import ( @@ -653,10 +654,10 @@ def ingest_syndirella_routes( compound=reactant_comp, reaction=reaction, ) - rs.append(reactant.pk) + rs.append(reactant_comp.pk) if do_check_chemistry and not check_chemistry( - reaction_type, rs, product + reaction_type, CompoundSet(rs), product ): raise InvalidChemistryError( f'{type=}, {rs=}, {product.id=}', @@ -755,10 +756,10 @@ def ingest_enamine_real_routes( compound=reactant_comp, reaction=reaction, ) - rs.append(reactant.pk) + rs.append(reactant_comp.pk) if do_check_chemistry and not check_chemistry( - reaction_type, rs, product + reaction_type, CompoundSet(rs), product ): raise InvalidChemistryError( f'{reaction_type=}, {rs=}, {product.id=}', From 731aed1800d1cfb0b4d557e393ba499028acb356 Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 1 Jul 2026 11:02:00 +0100 Subject: [PATCH 162/163] feat: port over remaining methods from CompoundSet --- hippo/designdb/sets/compound.py | 640 ++++++++++++-------------------- 1 file changed, 233 insertions(+), 407 deletions(-) diff --git a/hippo/designdb/sets/compound.py b/hippo/designdb/sets/compound.py index 1197fec..b7becd6 100644 --- a/hippo/designdb/sets/compound.py +++ b/hippo/designdb/sets/compound.py @@ -1,30 +1,30 @@ import json from collections.abc import Callable from pathlib import Path -from statistics import mean +from statistics import mean, pvariance from typing import TYPE_CHECKING import mcol import mrich import pandas as pd -from designdb.components.compound import Ingredient +from designdb.components.compound import Compound, Ingredient +from designdb.components.reaction import Reaction from designdb.models import ( CompoundModel, CompoundTagJunctionModel, CompoundTagModel, PoseModel, + PoseTagJunctionModel, ReactantModel, ReactionModel, RouteModel, ScaffoldModel, ) -from django.db.models import Exists, OuterRef, Q +from django.db.models import Count, Exists, OuterRef, Q from pandas import DataFrame from rdkit import Chem # from rdkit.Chem import inchi -from rdkit.Chem import Mol - from ..utils import registration_hash_tautomer_insensitive, superparent if TYPE_CHECKING: @@ -350,25 +350,17 @@ def get_by_scaffold( """ - if not isinstance(scaffold, int): - assert scaffold._table == 'compound' - scaffold = scaffold.id + scaffold_id = scaffold if isinstance(scaffold, int) else scaffold.id - values = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=( - f'scaffold_base = {scaffold}' - f' AND scaffold_superstructure IN {self.str_ids}' - ), - multiple=True, - none=none, + ids = list( + ScaffoldModel.objects.filter( + base_compound_id=scaffold_id, + superstructure_compound_id__in=self.ids, + ) + .values_list('superstructure_compound_id', flat=True) + .distinct() ) - ids = [v for (v,) in values if v] - - if not ids: - return None - return CompoundSet(self.db, ids) + return CompoundSet(ids) def get_by_smiles(self, smiles: str) -> CompoundModel: """Get a compound in this set by SMILES, using tautomer-insensitive matching. @@ -468,37 +460,31 @@ def get_risk_diversity(self, debug: bool = False) -> float: """ - variances = self.db.execute( - f""" - WITH nums AS ( - SELECT scaffold_base AS base, scaffold_superstructure AS elab, - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c2.compound_mol) - - {self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms']}(c1.compound_mol) - AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c1 - ON scaffold_base = c1.compound_id - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}compound AS c2 - ON scaffold_superstructure = c2.compound_id - WHERE scaffold_superstructure IN {self.str_ids} - ), - - means AS ( - SELECT base, AVG(diff) AS mean FROM nums - GROUP BY base - ) - - SELECT AVG((nums.diff - mean)*(nums.diff - mean)) var FROM nums - LEFT JOIN means - ON nums.base = means.base - GROUP BY nums.base - """ - ).fetchall() + # heavy-atom count per compound, cached across scaffold edges + nha_cache: dict[int, int | None] = {} + + def nha(compound_id: int) -> int | None: + if compound_id not in nha_cache: + nha_cache[compound_id] = Compound( + CompoundModel.objects.get(pk=compound_id) + ).num_heavy_atoms + return nha_cache[compound_id] + + # group the #atoms-added of each elaboration by its scaffold base + diffs_by_base: dict[int, list[int]] = {} + for base_id, elab_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + base_nha, elab_nha = nha(base_id), nha(elab_id) + if base_nha is None or elab_nha is None: + continue + diffs_by_base.setdefault(base_id, []).append(elab_nha - base_nha) - if not variances: + if not diffs_by_base: return None - variances = [v for (v,) in variances] + # population variance of #atoms-added within each scaffold (0 for singletons) + variances = [pvariance(diffs) for diffs in diffs_by_base.values()] if debug: mrich.debug(f'{variances=}') @@ -537,10 +523,9 @@ def draw(self) -> None: from IPython.display import display from molparse.rdkit import draw_grid - data = [(str(c), c.mol) for c in self] - - mols = [d[1] for d in data] - labels = [d[0] for d in data] + compounds = [Compound(c) for c in self._queryset] + mols = [c.mol for c in compounds] + labels = [str(c) for c in compounds] display(draw_grid(mols, labels=labels)) @@ -560,62 +545,44 @@ def summary(self, return_df: bool = False) -> None: mrich.header(self) - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] - - df = DataFrame(data) - df = df.set_index('tag') - - # poses - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_pose) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON pose_id = tag_pose - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) - - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_poses'] = count - - # compounds with poses - - sql = f""" - SELECT tag_name, COUNT(DISTINCT pose_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}pose - ON tag_pose = pose_id - WHERE pose_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name - """ - - cursor = self.db.execute(sql) + ids = list(self.ids) - for tag, count in cursor.fetchall(): - df.loc[tag, 'num_posed_compounds'] = count + # compound-tag counts: distinct compounds per compound tag + comp_rows = [ + dict(tag=name, num_compounds=n) + for name, n in CompoundTagJunctionModel.objects.filter(compound_id__in=ids) + .values_list('compound_tag__compound_tag_name') + .annotate(n=Count('compound', distinct=True)) + .order_by('compound_tag__compound_tag_name') + ] + df = DataFrame(comp_rows) + if len(df): + df = df.set_index('tag') + else: + df = DataFrame(columns=['num_compounds']) + df.index.name = 'tag' + + # pose-tag counts over the poses of these compounds + for name, num_poses, num_posed in ( + PoseTagJunctionModel.objects.filter(pose__compound_id__in=ids) + .values_list('pose_tag__pose_tag_name') + .annotate( + num_poses=Count('pose', distinct=True), + num_posed=Count('pose__compound', distinct=True), + ) + .order_by('pose_tag__pose_tag_name') + ): + df.loc[name, 'num_poses'] = num_poses + df.loc[name, 'num_posed_compounds'] = num_posed df.loc['TOTAL', 'num_compounds'] = len(self) df.loc['TOTAL', 'num_poses'] = self.num_poses - df.loc['TOTAL', 'num_posed_compounds'] = len(self.poses.compounds) + df.loc['TOTAL', 'num_posed_compounds'] = ( + PoseModel.objects.filter(compound_id__in=ids) + .values('compound_id') + .distinct() + .count() + ) df = df.fillna(0) df = df.astype(int) @@ -775,25 +742,19 @@ def widget( def tag_summary(self) -> 'pd.DataFrame': """Print a summary table of tags with compound counts""" - from pandas import DataFrame - - sql = f""" - SELECT tag_name, - COUNT(DISTINCT tag_compound) - FROM {self.db.SQL_SCHEMA_PREFIX}tag - WHERE tag_compound IN {self.str_ids} - GROUP BY tag_name - ORDER BY tag_name; - """ - - cursor = self.db.execute(sql) - - data = [dict(tag=a, num_compounds=b) for a, b in cursor.fetchall()] + data = [ + dict(tag=name, num_compounds=n) + for name, n in CompoundTagJunctionModel.objects.filter( + compound_id__in=list(self.ids) + ) + .values_list('compound_tag__compound_tag_name') + .annotate(n=Count('compound', distinct=True)) + .order_by('compound_tag__compound_tag_name') + ] df = DataFrame(data) - df = df.set_index('tag') - - df = df.astype(int) + if len(df): + df = df.set_index('tag').astype(int) mrich.print(df) @@ -890,27 +851,6 @@ def copy(self) -> 'CompoundSet': """Returns a copy of this set""" return CompoundSet(self.ids) - def shuffled(self) -> 'CompoundSet': - """Returns a randomised copy of this set""" - copy = self.copy() - copy.shuffle() - return copy - - def pop(self) -> CompoundModel: - """Pop the last compound in this set""" - c_id = self.pop_id() - return self.db.get_compound(id=c_id) - - def pop_id(self) -> int: - """Pop the last compound id in this set""" - return self._indices.pop() - - def shuffle(self) -> None: - """Randomises the order of compounds in this set""" - from random import shuffle - - shuffle(self._indices) - def get_df( self, smiles: bool = True, @@ -1130,7 +1070,7 @@ def get_unquoted( def get_dict(self) -> dict: """Get a dictionary object with all serialisable data needed to reconstruct this set""" - return dict(db=str(self.db.path.resolve()), indices=self.indices) + return dict(indices=list(self.indices)) def write_smiles_csv( self, file: str, tags: bool = True, split_tags: bool = True @@ -1142,43 +1082,15 @@ def write_smiles_csv( :param split_tags: split tags into separate columns """ - from pandas import DataFrame - - if tags: - records = self.db.select_where( - table='tag', - query='tag_compound, tag_name', - key=f'tag_compound IN {self.str_ids}', - multiple=True, - none='quiet', - ) - TAGS = {} - if records: - for compound_id, tag_name in records: - if compound_id not in TAGS: - TAGS[compound_id] = set() - TAGS[compound_id].add(tag_name) - - records = self.db.select_where( - table=self.table, - query='compound_id, compound_smiles', - key=f'compound_id IN {self.str_ids}', - multiple=True, - ) - - data = [dict(id=id, smiles=smiles) for id, smiles in records] - - if tags: - for d in data: - tagset = TAGS.get(d['id'], set()) + df = self.get_df(smiles=True, alias=False, tags=tags).reset_index() - if split_tags: - for tag in tagset: - d[tag] = True - else: - d['tags'] = tagset + # explode the per-compound tag sets into a boolean column per tag + if tags and split_tags and 'tags' in df.columns: + all_tags: set[str] = set().union(*df['tags']) if len(df) else set() + for tag in sorted(all_tags): + df[tag] = df['tags'].apply(lambda s, t=tag: t in s) + df = df.drop(columns=['tags']) - df = DataFrame(data) mrich.writing(file) df.to_csv(file, index=False) @@ -1323,7 +1235,7 @@ def write_CAR_csv( rows = [] for r_id in mrich.track(self.reaction_ids, prefix='Solving compound recipes'): - reaction = self.db.get_reaction(id=r_id) + reaction = ReactionModel.objects.get(pk=r_id) recipes = Recipe.from_reaction( reaction, @@ -1345,7 +1257,8 @@ def write_CAR_csv( 'batch-tag': None, } - for i, reaction in enumerate(sub_recipe.reactions): + for i, reaction_model in enumerate(sub_recipe.reactions): + reaction = Reaction(reaction_model) i = i + 1 row['no-steps'] += 1 @@ -1395,12 +1308,17 @@ def add_tag( assert isinstance(tag, str) - for i in self.indices: - self.db.insert_tag(name=tag, compound=i, commit=False) + compound_tag, _ = CompoundTagModel.objects.get_or_create(compound_tag_name=tag) - mrich.print(f'Tagged {self} w/ "{tag}"') + CompoundTagJunctionModel.objects.bulk_create( + [ + CompoundTagJunctionModel(compound=compound, compound_tag=compound_tag) + for compound in self._queryset + ], + ignore_conflicts=True, + ) - self.db.commit() + mrich.print(f'Tagged {self} w/ "{tag}"') def plot_tsnee(self, **kwargs) -> 'go.Figure': """Plot a tanimoto similarity plot of these compounds""" @@ -1421,16 +1339,21 @@ def as_ingredientset( ) def split_by_scaffolds(self) -> 'dict[CompoundSet, CompoundSet]': - """Split this set into subsets clustered by scaffold compound""" - - cluster_dict = self.db.get_compound_cluster_dict(cset=self) + """Split this set into subsets clustered by scaffold compound - subsets = {} - for cluster, elabs in cluster_dict.items(): - cluster = CompoundSet(self.db, list(cluster)) - subsets[cluster] = CompoundSet(self.db, list(elabs)) - - return subsets + Maps a single-compound :class:`.CompoundSet` for each scaffold base to a + :class:`.CompoundSet` of its elaborations within this set. + """ + clusters: dict[int, set] = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + clusters.setdefault(base_id, set()).add(sup_id) + + return { + CompoundSet([base_id]): CompoundSet(list(elab_ids)) + for base_id, elab_ids in clusters.items() + } def despaghettify( self, @@ -1565,60 +1488,45 @@ def name(self) -> str | None: @property def names(self) -> list[str]: """Returns the aliases of compounds in this set""" - result = self.db.select_where( - query='compound_alias', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] + return list(self._queryset.values_list('compound_alias', flat=True)) @property def smiles(self) -> list[str]: """Returns the smiles of child compounds""" - result = self.db.select_where( - query='compound_smiles', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] + return list(self._queryset.values_list('compound_smiles', flat=True)) @property def mols(self) -> 'list[Chem.Mol]': - """Returns the molecules of child compounds""" + """Returns the RDKit molecules of child compounds - result = self.db.select_where( - query='mol_to_binary_mol(compound_mol)', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [Mol(q) for (q,) in result] + Uses each compound's stored MolBlock (``compound_mol``) where available, + falling back to parsing ``compound_smiles``. + """ + mols = [] + for molblock, smiles in self._queryset.values_list( + 'compound_mol', 'compound_smiles' + ): + if molblock: + mols.append(Chem.MolFromMolBlock(molblock)) + elif smiles: + mols.append(Chem.MolFromSmiles(smiles)) + else: + mols.append(None) + return mols @property def inchikeys(self) -> list[str]: """Returns the inchikeys of compounds in this set""" - result = self.db.select_where( - query='compound_inchikey', - table='compound', - key=f'compound_id in {self.str_ids}', - multiple=True, - ) - return [q for (q,) in result] + return list(self._queryset.values_list('compound_inchikey', flat=True)) @property def tags(self) -> set[str]: """Returns the set of unique tags present in this compound set""" - values = self.db.select_where( - table='tag', - query='DISTINCT tag_name', - key=f'tag_compound in {self.str_ids}', - multiple=True, + return set( + CompoundTagJunctionModel.objects.filter(compound_id__in=self.ids) + .values_list('compound_tag__compound_tag_name', flat=True) + .distinct() ) - if not values: - return set() - return set(v for (v,) in values) @property def num_poses(self) -> int: @@ -1632,19 +1540,24 @@ def poses(self) -> 'PoseSet': return PoseSet(PoseModel.objects.filter(compound_id__in=self.ids)) - @property - def best_placed_poses(self) -> 'PoseSet': - """Get the best placed pose for each compound in this set""" - from .pose import PoseSet + def best_placed_poses( + self, + scoring_method: str, + version: str | None = None, + inverse: bool = False, + ) -> 'PoseSet': + """Get the best-scoring pose for each compound in this set. + + Delegates to :meth:`.PoseSet.get_best_scoring_poses_per_compound`. - query = self.db.select_where( - table='pose', - query='pose_id, MIN(pose_distance_score)', - key=f'pose_compound in {self.str_ids} GROUP BY pose_compound', - multiple=True, + :param scoring_method: ``ScoringMethodModel.method_name`` to rank by + :param version: ``ScoringMethodModel.method_version`` — required when + multiple versions of the same method exist + :param inverse: if ``True``, higher score is better (default: lower is better) + """ + return self.poses.get_best_scoring_poses_per_compound( + scoring_method, version=version, inverse=inverse ) - ids = [i for i, s in query] - return PoseSet(self.db, ids) @property def str_ids(self) -> str: @@ -1654,12 +1567,16 @@ def str_ids(self) -> str: @property def num_heavy_atoms(self) -> int: """Get the total number of heavy atoms""" - return sum([c.num_heavy_atoms for c in self]) + return sum( + n for c in self._queryset if (n := Compound(c).num_heavy_atoms) is not None + ) @property def num_rings(self): """Get the total number of molecular rings""" - return sum([c.num_rings for c in self]) + return sum( + n for c in self._queryset if (n := Compound(c).num_rings) is not None + ) @property def formula(self) -> str: @@ -1674,74 +1591,35 @@ def atomtype_dict(self) -> dict[str, int]: quantities/counts as values""" from molparse.atomtypes import combine_atomtype_dicts - atomtype_dicts = [c.atomtype_dict for c in self] + atomtype_dicts = [Compound(c).atomtype_dict for c in self._queryset] return combine_atomtype_dicts(atomtype_dicts) @property - def num_atoms_added(self) -> list[int]: - """Calculate the number of atoms added w.r.t the scaffold - - :returns: list of number of atoms added values - - """ - - nha = self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms'] - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {nha}(A.compound_mol) - - {nha}(B.compound_mol) - AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, - {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) + def num_atoms_added(self) -> list: + """Calculate the number of heavy atoms added w.r.t the scaffold(s) - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - LEFT JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} + Delegates to :attr:`.Compound.num_atoms_added` per member, so each entry + is an ``int`` (single scaffold), a list of ints (multiple scaffolds), or + ``None`` (no scaffold), aligned with this set's compounds. """ - - query = self.db.execute(sql).fetchall() - - lookup = {k: v for k, v in query} - - return [lookup[i] for i in self.indices] + return [Compound(c).num_atoms_added for c in self._queryset] @property def avg_num_atoms_added(self) -> float: - """Calculate the average number of atoms added w.r.t the scaffold - - :returns: average number of atoms added values for compounds which have a - scaffold + """Calculate the average number of heavy atoms added w.r.t the scaffold + :returns: mean number of atoms added across members which have a scaffold + (``0.0`` if none do) """ - nha = self.db.COMPOUND_PROPERTY_FUNCTIONS['num_heavy_atoms'] - sql = f""" - WITH nums AS ( - SELECT - A.compound_id AS comp_id, - {nha}(A.compound_mol) - - {nha}(B.compound_mol) - AS diff - FROM {self.db.SQL_SCHEMA_PREFIX}compound A, - {self.db.SQL_SCHEMA_PREFIX}compound B - WHERE A.compound_base = B.compound_id - AND A.compound_id IN {self.str_ids} - ) - - SELECT compound_id, diff FROM {self.db.SQL_SCHEMA_PREFIX}compound - INNER JOIN nums - ON comp_id = compound_id - WHERE compound_id IN {self.str_ids} - """ # noqa: F841 # TODO(legacy-self.db): port to ORM - - (avg,) = self.db.execute().fetchone() - - return avg + values: list[int] = [] + for v in self.num_atoms_added: + if v is None: + continue + if isinstance(v, list): + values.extend(v) + else: + values.append(v) + return mean(values) if values else 0.0 @property def risk_diversity(self) -> float: @@ -1760,21 +1638,17 @@ def elaboration_balance(self) -> float: """Measure of how evenly elaborations are distributed across scaffolds in this set""" - sql = f""" - SELECT COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - GROUP BY scaffold_base - """ - - counts = self.db.execute(sql).fetchall() - - counts = [c for (c,) in counts] # + [0 for _ in range(len(self)-len(counts))] + # number of elaborations (in this set) per scaffold base compound + counts_by_base: dict[int, int] = {} + for base_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', flat=True): + counts_by_base[base_id] = counts_by_base.get(base_id, 0) + 1 + # optional dependency, isolated so module import never depends on it from hirsch import hirsch - return hirsch(counts) - - # return -std(counts) + return hirsch(list(counts_by_base.values())) @property def num_scaffolds_elaborated(self) -> int: @@ -1784,16 +1658,7 @@ def num_scaffolds_elaborated(self) -> int: :returns: number of scaffold compounds """ - - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - - return count + return len(self.scaffold_ids) @property def scaffolds(self) -> 'CompoundSet': @@ -1802,80 +1667,63 @@ def scaffolds(self) -> 'CompoundSet': :returns: :class:`.CompoundSet` """ - return CompoundSet(self.db, self.scaffold_ids) + return CompoundSet(self.scaffold_ids) @property def scaffold_ids(self) -> list[int]: """Return a list of :class:`.CompoundModel` ID's for scaffolds of this set""" - scaffold_ids = self.db.execute( - f""" - SELECT DISTINCT scaffold_base FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchall() - return [i for (i,) in scaffold_ids] + return list( + ScaffoldModel.objects.filter(superstructure_compound_id__in=self.ids) + .values_list('base_compound_id', flat=True) + .distinct() + ) @property def num_scaffolds(self) -> int: """Return a count of scaffolds of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_base) - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_superstructure IN {self.str_ids} - """ - ).fetchone() - return count + return len(self.scaffold_ids) @property def elabs(self) -> 'CompoundSet': - """Returns a :class:`.CompoundSet` of all compounds that are a an elaboration - of an existing scaffold""" - - ids = self.db.select_where( - query='scaffold_superstructure', - table='scaffold', - key=( - f'scaffold_superstructure IS NOT NULL' - f' and scaffold_base IN {self.str_ids}' - ), - multiple=True, - none='quiet', - ) - - if not ids: - return None + """Returns a :class:`.CompoundSet` of all compounds that are an elaboration + of a scaffold in this set""" - ids = [q for (q,) in ids] - return CompoundSet(self.db, ids) + ids = list( + ScaffoldModel.objects.filter( + base_compound_id__in=self.ids, + superstructure_compound_id__isnull=False, + ) + .values_list('superstructure_compound_id', flat=True) + .distinct() + ) + return CompoundSet(ids) @property def num_elabs(self) -> int: """Return a count of elaborations of this set""" - (count,) = self.db.execute( - f""" - SELECT COUNT(DISTINCT scaffold_superstructure) - FROM {self.db.SQL_SCHEMA_PREFIX}scaffold - WHERE scaffold_base IN {self.str_ids} - """ - ).fetchone() - return count + return len(self.elabs) @property def elab_df(self) -> 'pd.DataFrame': - """Get a DataFrame summarising the elaborations in this CompoundSet""" - from pandas import DataFrame + """Get a DataFrame summarising the elaborations in this CompoundSet. - cluster_dict = self.db.get_compound_cluster_dict(max_scaffolds=1) + Groups this set's compounds by the scaffold(s) they elaborate; a compound + with multiple scaffolds is listed under each. + """ + # base scaffold compound -> elaboration compound ids within this set + clusters: dict[int, set] = {} + for base_id, sup_id in ScaffoldModel.objects.filter( + superstructure_compound_id__in=self.ids + ).values_list('base_compound_id', 'superstructure_compound_id'): + clusters.setdefault(base_id, set()).add(sup_id) data = [] - for scaffold, elabs in cluster_dict.items(): - scaffold = self.db.get_compound(id=scaffold[0]) - elabs = CompoundSet(self.db, indices=elabs) + for base_id, elab_ids in clusters.items(): + elabs = CompoundSet(list(elab_ids)) data.append( dict( - scaffold_id=scaffold.id, - scaffold_compound=scaffold, + scaffold_id=base_id, + scaffold_compound=Compound(CompoundModel.objects.get(pk=base_id)), elabs=elabs, num_elabs=len(elabs), ) @@ -1887,42 +1735,20 @@ def elab_df(self) -> 'pd.DataFrame': def id_num_poses_dict(self) -> dict[int, int]: """Get a dictionary mapping compound ids to the number of poses""" - sql = f""" - SELECT pose_compound, COUNT(1) FROM {self.db.SQL_SCHEMA_PREFIX}pose - WHERE pose_compound IN {self.str_ids} - GROUP BY pose_compound - """ - - records = self.db.execute(sql) - - assert records - - lookup = {k: v for k, v in records} - - for id in self.ids: - if id not in lookup: - lookup[id] = 0 - - return lookup + counts = dict( + PoseModel.objects.filter(compound_id__in=self.ids) + .values_list('compound_id') + .annotate(n=Count('id')) + ) - @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 + return {cid: counts.get(cid, 0) for cid in self.ids} @property def reaction_ids(self) -> list[int]: """Returns a list of :class:`.ReactionModel` IDs that result in members of this set""" - records = self.db.select_where( - table='reaction', - query='reaction_id', - key=f'reaction_product IN {self.str_ids}', - multiple=True, + return list( + ReactionModel.objects.filter(product_compound_id__in=self.ids) + .values_list('id', flat=True) + .distinct() ) - if not records: - return None - return [r for (r,) in records] From 76658740d08e261ae736bc34ad789e5e14ad831a Mon Sep 17 00:00:00 2001 From: Kalev Takkis Date: Wed, 1 Jul 2026 12:11:47 +0100 Subject: [PATCH 163/163] feat: more functions ported over (poseset) --- hippo/designdb/sets/pose.py | 101 +++++++++++++++--------------------- 1 file changed, 42 insertions(+), 59 deletions(-) diff --git a/hippo/designdb/sets/pose.py b/hippo/designdb/sets/pose.py index c3755fe..e1e2154 100644 --- a/hippo/designdb/sets/pose.py +++ b/hippo/designdb/sets/pose.py @@ -34,7 +34,15 @@ from designdb.utils_frag import generate_header from django.conf import settings from django.db import IntegrityError, transaction -from django.db.models import Exists, FloatField, OuterRef, Q, QuerySet, Subquery +from django.db.models import ( + Count, + Exists, + FloatField, + OuterRef, + Q, + QuerySet, + Subquery, +) from django.db.models.fields.json import KeyTextTransform from django.db.models.functions import Cast from IPython.display import display @@ -391,13 +399,6 @@ def get_by_metadata( regardless of value (Default value = None) """ - results = self.db.select_where( # noqa: F841 # TODO(legacy-self.db): port to ORM - query='pose_id, pose_metadata', - key=f'pose_id IN {self.str_ids}', - table='pose', - multiple=True, - ) - if value is None: # metadata stored as string return PoseSet( @@ -1501,10 +1502,10 @@ def to_pymol(self, prefix: str | None = None) -> None: commands.append('set surface_color, white') commands.append('set transparency, 0.4') - for j, (insp_ids, poses) in enumerate( # noqa: B020 # TODO(legacy-self.db): port to ORM + for j, (inspirations, poses) in enumerate( # noqa: B020 poses.split_by_inspirations().items() ): - inspirations = PoseSet(self.db, insp_ids) + # split_by_inspirations() keys are already inspiration PoseSets insp_names = '-'.join(inspirations.names) # create the subdirectory @@ -1879,33 +1880,32 @@ def grid(self) -> None: drawing = draw_grid(mols, labels=labels) display(drawing) - # TODO: disabled, the field subsite_tag_ref doesn't exist anymore, - # don't know what the query is doing - # def subsite_summary(self) -> 'pd.DataFrame': - # """Print a table counting poses by subsite""" - - # sql = f""" - # SELECT subsite_id, subsite_name, COUNT(DISTINCT subsite_tag_pose) - # FROM {self.db.SQL_SCHEMA_PREFIX}subsite - # INNER JOIN {self.db.SQL_SCHEMA_PREFIX}subsite_tag - # ON subsite_id = subsite_tag_ref - # WHERE subsite_tag_pose IN {self.str_ids} - # GROUP BY subsite_name - # """ - - # cursor = self.db.execute(sql) + def subsite_summary(self) -> 'pd.DataFrame': + """Print a table counting poses by subsite""" - # df = DataFrame( - # [dict(id=i, subsite=name, num_poses=count) for i, name, count in cursor] - # ) + rows = ( + SubsiteTagModel.objects.filter(pose__in=self._queryset) + .values('subsite_id', 'subsite__subsite_name') + .annotate(num_poses=Count('pose', distinct=True)) + ) - # df = df.set_index('id') + df = DataFrame( + [ + dict( + id=r['subsite_id'], + subsite=r['subsite__subsite_name'], + num_poses=r['num_poses'], + ) + for r in rows + ] + ) - # df = df.sort_values(by='num_poses', ascending=False) + if len(df): + df = df.set_index('id').sort_values(by='num_poses', ascending=False) - # mrich.print(df) + mrich.print(df) - # return df + return df def get_interaction_overlaps(self, return_pairs: bool = False) -> int: """Count the number of member pose pairs which share at least one but not all @@ -1955,17 +1955,6 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': """Cluster poses based on shared interactions.""" # get interaction records - - sql = f""" - SELECT DISTINCT interaction_pose, feature_residue_name, - feature_residue_number, interaction_type - FROM {self.db.SQL_SCHEMA_PREFIX}interaction - INNER JOIN {self.db.SQL_SCHEMA_PREFIX}feature - ON interaction_feature = feature_id - WHERE interaction_pose IN {self.str_ids} - """ - - records = self.db.execute(sql).fetchall() records = InteractionModel.objects.filter( pose__in=self._queryset, ).values( @@ -1978,8 +1967,8 @@ def get_interaction_clusters(self) -> 'dict[int, PoseSet]': ISETS = {} for r in records: pose_id = r['pose'] - feature_residue_name = r['feature_residue_name'] - feature_residue_number = r['feature_residue_number'] + feature_residue_name = r['feature__feature_residue_name'] + feature_residue_number = r['feature__feature_residue_number'] interaction_type = r['interaction_type'] values = ISETS.get(pose_id, set()) values.add((interaction_type, feature_residue_name, feature_residue_number)) @@ -2107,20 +2096,14 @@ def num_fingerprinted(self) -> int: # that's one field suspect not in use return self._queryset.filter(pose_fingerprint=1).count() - # seems unused and causes circular dependency - # @property - # def compounds(self) -> 'CompoundSet': - # """Get the compounds associated to this set of poses""" - # from .cset import CompoundSet - - # ids = self.db.select_where( - # table='pose', - # query='DISTINCT pose_compound', - # key=f'pose_id in {self.str_ids}', - # multiple=True, - # ) - # ids = [v for (v,) in ids] - # return CompoundSet(self.db, ids) + @property + def compounds(self) -> 'CompoundSet': + """Get the compounds associated to this set of poses""" + # local import to avoid the PoseSet <-> CompoundSet cycle + from designdb.sets.compound import CompoundSet + + ids = list(self._queryset.values_list('compound_id', flat=True).distinct()) + return CompoundSet(ids) @property def mols(self) -> list[Chem.rdchem.Mol]: