From ae0bf2344d65c0768fbd619e9a061ab9afba5a82 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 12:24:47 +0100 Subject: [PATCH 01/35] Return metabolite ID with msn annotation results --- metaboblend/build_structures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 013143c..871aa6d 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -426,7 +426,7 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], [k + "," + str(i) + "\n" for k, i in zip(structure_frequency.keys(), structure_frequency.values())]) if yield_smi_dict: - yield structure_frequency + yield {ms_id: structure_frequency} db.close() From de6eab2d2014675405475e9dbd250d13c9ce55c4 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 12:29:42 +0100 Subject: [PATCH 02/35] Rewrite MSn method for limiting connectivity to fragment edges only --- metaboblend/build_structures.py | 67 +++++++++++++++++++++++++-------- metaboblend/databases.py | 32 +++------------- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 871aa6d..b32b3f5 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -185,8 +185,10 @@ def reindex_atoms(records): if atom.GetIdx() in record["degree_atoms"]: atoms_available.append(new_idx) + if atom.GetIdx() in record["dummies"]: atoms_to_remove.append(new_idx) + if atom.GetIdx() in record["bond_types"]: bond_types[new_idx] = record["bond_types"][atom.GetIdx()] all_bond_types[i] += record["bond_types"][atom.GetIdx()] @@ -606,17 +608,26 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d db = SubstructureDb(path_substructure_db, path_connectivity_db) tolerance = 0.001 - if prescribed_mass is None: # standard build method + if prescribed_mass is None: exact_mass__1 = round(exact_mass) - exact_mass__0_0001 = round(exact_mass, 4) else: # prescribed substructure build method + if ((prescribed_mass / 1000000) * ppm) > 0.001: + fragment_tolerance = round((prescribed_mass / 1000000) * ppm, 4) + else: + fragment_tolerance = 0.001 + + prescribed_subset = db.select_mass_values("0_0001", [round(prescribed_mass)], table_name) + prescribed_subset = [m for m in prescribed_subset[0] if abs(m - prescribed_mass) <= fragment_tolerance] + + if len(prescribed_subset) == 0: + return set() + loss = exact_mass - prescribed_mass exact_mass__1 = round(loss) - exact_mass__0_0001 = round(loss, 4) - if ((loss / 1000000) * ppm) > 0.001: - tolerance = round((loss / 1000000) * ppm, 4) + if ((exact_mass / 1000000) * ppm) > 0.001: + tolerance = round((exact_mass / 1000000) * ppm, 4) max_n_substructures -= 1 # we find sets of mols that add up to the loss, not the precursor mass @@ -630,7 +641,7 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d integer_subsets = list(subset_sum(integer_mass_values, exact_mass__1, max_n_substructures)) - configs_iso = db.k_configs(prescribed_mass is not None) + configs_iso = db.k_configs() if path_smi_out is not None: smi_out = open(path_smi_out, out_mode) @@ -641,25 +652,28 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d # refine groups of masses to 4dp mass resolution exact_mass_values = db.select_mass_values("0_0001", integer_subset, table_name) - exact_subsets = [] + + if prescribed_mass is not None: + exact_mass_values = [prescribed_subset] + exact_mass_values # use combinations to get second group of masses instead of subset sum - subset sum is integer mass only + exact_subsets = [] for mass_combo in itertools.product(*exact_mass_values): - if abs(sum(mass_combo) - exact_mass__0_0001) <= tolerance: + if abs(sum(mass_combo) - exact_mass) <= tolerance: exact_subsets.append(mass_combo) if len(exact_subsets) == 0: continue - if prescribed_mass is not None: # add fragments mass to to loss group - exact_subsets = [subset + (round(exact_mass - loss, 4),) for subset in exact_subsets] - # refines groups based on ecs and gets substructures from db (appends to substructure_subsets) for exact_subset in exact_subsets: substructure_subsets += build_from_subsets(exact_subset, mf=mf, table_name=table_name, db=db) with multiprocessing.Pool(processes=ncpus) as pool: # send sets of substructures for building - smi_lists = pool.map(partial(substructure_combination_build, configs_iso=configs_iso), substructure_subsets) + smi_lists = pool.map( + partial(substructure_combination_build, configs_iso=configs_iso, prescribed_structure=prescribed_mass is not None), + substructure_subsets + ) smis = set([val for sublist in smi_lists for val in sublist]) @@ -795,7 +809,7 @@ def build_from_subsets(exact_subset, mf, table_name, db): return substructure_subsets -def substructure_combination_build(substructure_subset, configs_iso): +def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure): """ Final stage for building molecules; takes a combination of substructures (substructure_combination) and builds them according to graphs in the substructure database. May be run in parallel. @@ -811,12 +825,25 @@ def substructure_combination_build(substructure_subset, configs_iso): smis = [] for substructure_combination in itertools.product(*substructure_subset): + substructure_combination[0]["fragment"] = True substructure_combination = sorted(substructure_combination, key=itemgetter('atoms_available', 'valence')) v_a = () - for d in substructure_combination: + if prescribed_structure is not None: + fragment_indexes = [] + j = -1 + for i, d in enumerate(substructure_combination): v_a += (tuple(d["degree_atoms"].values()),) # obtain valence configuration of the set of substructures + for atom_available in tuple(d["degree_atoms"].values()): + j += 1 + + try: + if prescribed_structure is not None and d["fragment"]: + fragment_indexes.append(j) + except KeyError: + continue + if str(v_a) not in configs_iso: # check mols "fit" together according to the connectivity database continue @@ -826,6 +853,16 @@ def substructure_combination_build(substructure_subset, configs_iso): continue # check that bond types are compatible (imperfect check) for edges in configs_iso[str(v_a)]: # build mols for each graph in connectivity db + if prescribed_structure is not None: + non_fragment_edges = False + + for edge in edges: # check that edges only connect to fragment ion + if edge[0] not in fragment_indexes and edge[1] not in fragment_indexes: + non_fragment_edges = True + + if non_fragment_edges: + continue + mol_e = add_bonds(mol_comb, edges, atoms_available, bond_types) # add bonds between substructures if mol_e is None: @@ -842,7 +879,7 @@ def substructure_combination_build(substructure_subset, configs_iso): continue try: # append the canonical smiles of the final structure - smis.append(Chem.MolToSmiles(mol_out)) + smis.append(Chem.MolToSmiles(mol_out, isomericSmiles=False)) except RuntimeError: continue # bad bond type violation diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 0f2d449..b2aa168 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -395,14 +395,12 @@ def select_mfs(self, exact_mass, table_name, accuracy): return self.cursor.fetchall() - def k_configs(self, fragment_edges_only=False): + def k_configs(self): """ Obtains strings detailing the valences for each substructure in a connectivity graph and the ID of the related PKL file. Used to match a set of substructures to the correct set of non-isomorphic graphs in the connectivity database. - :param fragment_edges_only: If true, only include sets of edges that connect to the fragment ion. - :return: Dictionary containing the valences as keys and PKL IDs as values. """ @@ -414,28 +412,10 @@ def k_configs(self, fragment_edges_only=False): for record in records: configs[str(record[1])] = [] - fragment_atoms = [i for i in range(len(eval(record[1])[0]))] for path in self.paths(pickle.loads(record[0])): - if fragment_edges_only: - all_bonds_connect_to_fragment = True - - for edge in path: - if edge[0] not in fragment_atoms and edge[1] not in fragment_atoms: - all_bonds_connect_to_fragment = False - - # check that all bonds connect to fragment ion - if all_bonds_connect_to_fragment: - configs[str(record[1])].append(path) - - else: - # for normal building, there is no fragment ion configs[str(record[1])].append(path) - # delete keys for which there are no valid edge sets - if len(configs[str(record[1])]) == 0: - del configs[str(record[1])] - return configs def paths(self, tree, cur=()): @@ -708,7 +688,7 @@ def get_substructure(mol, idxs_edges_subgraph): except: return - return {"smiles": Chem.MolToSmiles(mol_out), # REORDERED ATOM INDEXES + return {"smiles": Chem.MolToSmiles(mol_out, isomericSmiles=False), # REORDERED ATOM INDEXES "mol": mol_out, "bond_types": bond_types, "degree_atoms": degree_atoms, @@ -811,8 +791,8 @@ def filter_records(records): if len(atom_check) > 0: continue - smiles_rdkit = Chem.MolToSmiles(mol) - smiles_rdkit_kek = Chem.MolToSmiles(mol, kekuleSmiles=True) + smiles_rdkit = Chem.MolToSmiles(mol, isomericSmiles=False) + smiles_rdkit_kek = Chem.MolToSmiles(mol, isomericSmiles=False, kekuleSmiles=True) if "+" in smiles_rdkit_kek or "-" in smiles_rdkit_kek or "+" in smiles_rdkit or "-" in smiles_rdkit: continue # only neutral molecules are compatible @@ -1029,7 +1009,7 @@ def create_substructure_database(hmdb_paths: Union[str, bytes, os.PathLike], db.close() -def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike], +def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None], path_substructure_db: Union[str, bytes, os.PathLike], ha_min: Union[int, None] = None, ha_max: Union[int, None] = None, @@ -1191,7 +1171,7 @@ def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_ if lib["valence"] > max_degree: return - smiles_rdkit = Chem.MolToSmiles(lib["mol"]) # canonical rdkit smiles + smiles_rdkit = Chem.MolToSmiles(lib["mol"], isomericSmiles=False) # canonical rdkit smiles exact_mass = calculate_exact_mass(lib["mol"]) els = get_elements(lib["mol"]) From cf2378ea38839f08a2362ee740b696f675e9d48f Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 13:00:17 +0100 Subject: [PATCH 03/35] Add option for use of smiles without non-structural isomeric information --- metaboblend/build_structures.py | 27 +++++++++++++------ metaboblend/databases.py | 48 +++++++++++++++++++++------------ tests/test_databases.py | 14 +++++----- 3 files changed, 58 insertions(+), 31 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index b32b3f5..f3707a3 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -291,7 +291,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], write_fragment_smis: bool = False, minimum_frequency: Union[int, None] = None, hydrogenation_allowance: int = 2, - yield_smi_dict: bool = True) -> Union[Sequence[Dict[str, int]], None]: + yield_smi_dict: bool = True, + isomeric_smiles: bool = False) -> Union[Sequence[Dict[str, int]], None]: """ Generate molecules of a given mass using chemical substructures, connectivity graphs and spectral trees or fragmentation spectra. Final structures and rankings are yielded by the function as a dictionary and/or written in @@ -357,6 +358,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param yield_smi_dict: If True, for each input molecule the function yields a dictionary whose keys are SMILEs strings and values are the number of `fragment_masses` by which the structure was generated. Else, returns None. + :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. + :return: For each input molecule yields a dictionary whose keys are SMILEs strings for the generated structures and values are the number of `fragment_masses` by which the structure was built (unless `yield_smi_dict = False`). @@ -415,7 +418,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], out_mode="a", table_name=table_name, ncpus=ncpus, - clean=False + clean=False, + isomeric_smiles=isomeric_smiles )) for smi in fragment_smis: @@ -444,7 +448,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], ncpus: Union[int, None] = None, path_connectivity_db: Union[str, bytes, os.PathLike, None] = None, minimum_frequency: Union[int, None] = None, - yield_smi_set: bool = True + yield_smi_set: bool = True, + isomeric_smiles: bool = False ) -> Union[Sequence[list], None]: """ Generate molecules of a given mass using chemical substructures and connectivity graphs. Can optionally take a @@ -501,6 +506,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], :param yield_smi_set: If True, yields a set of unique SMILEs string for each input molecule, else returns None. + :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. + :return: For each input molecule, yields a set of unique SMILEs strings (unless `yield_smi_set = False`). """ @@ -547,7 +554,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], out_mode="w", table_name=table_name, ncpus=ncpus, - clean=False + clean=False, + isomeric_smiles=isomeric_smiles ) if yield_smi_set: @@ -557,7 +565,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_db, path_substructure_db, - prescribed_mass, ppm, out_mode, ncpus, table_name, clean): + prescribed_mass, ppm, out_mode, ncpus, table_name, clean, isomeric_smiles): """ Core function for generating molecules of a given mass using substructures and connectivity graphs. Can optionally take a "prescribed" fragment mass to further filter results; this can be used to incorporate MSn data. Final @@ -602,6 +610,8 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d :param clean: Whether to remove the temporary table of substructures, table_name`, after the method is complete. + :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. + :return: Returns a set of unique SMILEs strings. """ @@ -671,7 +681,8 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d with multiprocessing.Pool(processes=ncpus) as pool: # send sets of substructures for building smi_lists = pool.map( - partial(substructure_combination_build, configs_iso=configs_iso, prescribed_structure=prescribed_mass is not None), + partial(substructure_combination_build, configs_iso=configs_iso, + prescribed_structure=prescribed_mass is not None, isomeric_smiles=isomeric_smiles), substructure_subsets ) @@ -809,7 +820,7 @@ def build_from_subsets(exact_subset, mf, table_name, db): return substructure_subsets -def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure): +def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles): """ Final stage for building molecules; takes a combination of substructures (substructure_combination) and builds them according to graphs in the substructure database. May be run in parallel. @@ -879,7 +890,7 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ continue try: # append the canonical smiles of the final structure - smis.append(Chem.MolToSmiles(mol_out, isomericSmiles=False)) + smis.append(Chem.MolToSmiles(mol_out, isomericSmiles=isomeric_smiles)) except RuntimeError: continue # bad bond type violation diff --git a/metaboblend/databases.py b/metaboblend/databases.py index b2aa168..2436ecf 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -573,7 +573,7 @@ def close(self, clean=True): self.conn.close() -def get_substructure(mol, idxs_edges_subgraph): +def get_substructure(mol, idxs_edges_subgraph, isomeric_smiles=False): """ Generates information for the substructure database from a reference molecule and the bond IDs of a substructure. @@ -583,6 +583,8 @@ def get_substructure(mol, idxs_edges_subgraph): substructure (as returned by :py:meth:`metaboblend.databases.get_sgs`) or an integer representing the index of a single atom. + :param isomeric_smiles: If True, returns smiles with non-structural isomeric information. + :return: A list of lists containing the libs of the substructures obtained by the query; the lib is a dictionary containing details about the substructure, in the format: @@ -688,7 +690,7 @@ def get_substructure(mol, idxs_edges_subgraph): except: return - return {"smiles": Chem.MolToSmiles(mol_out, isomericSmiles=False), # REORDERED ATOM INDEXES + return {"smiles": Chem.MolToSmiles(mol_out, isomericSmiles=isomeric_smiles), # REORDERED ATOM INDEXES "mol": mol_out, "bond_types": bond_types, "degree_atoms": degree_atoms, @@ -746,12 +748,14 @@ def calculate_exact_mass(mol, exact_mass_elements=None): return exact_mass -def filter_records(records): +def filter_records(records, isomeric_smiles=False): """ Filters records generated by :py:meth:`parse_xml` to ensure they are compatible with the MetaboBlend workflow. :param records: A dictionary containing information about the molecule, as generated by :py:meth:`parse_xml`. + :param isomeric_smiles: If True, returns smiles with non-structural isomeric information. + :return: Generates a dictionary containing key information extracted from the record. * "**HMDB_ID**": The HMDB ID of the molecule, in the format "HMDBXXXXXXX". @@ -791,10 +795,9 @@ def filter_records(records): if len(atom_check) > 0: continue - smiles_rdkit = Chem.MolToSmiles(mol, isomericSmiles=False) - smiles_rdkit_kek = Chem.MolToSmiles(mol, isomericSmiles=False, kekuleSmiles=True) + smiles_rdkit = Chem.MolToSmiles(mol, isomericSmiles=isomeric_smiles) - if "+" in smiles_rdkit_kek or "-" in smiles_rdkit_kek or "+" in smiles_rdkit or "-" in smiles_rdkit: + if "+" in smiles_rdkit or "-" in smiles_rdkit: continue # only neutral molecules are compatible els = get_elements(mol) @@ -805,7 +808,7 @@ def filter_records(records): 'exact_mass': round(exact_mass, 6), 'smiles': record['smiles'], 'smiles_rdkit': smiles_rdkit, - 'smiles_rdkit_kek': smiles_rdkit_kek, + 'smiles_rdkit_kek': Chem.MolToSmiles(mol, isomericSmiles=isomeric_smiles, kekuleSmiles=True), 'C': els['C'], 'H': els['H'], 'N': els['N'], @@ -950,7 +953,8 @@ def create_substructure_database(hmdb_paths: Union[str, bytes, os.PathLike], max_degree: Union[int, None] = None, max_atoms_available: Union[int, None] = None, method: str = "exhaustive", - substructures_only: bool = False) -> None: + substructures_only: bool = False, + isomeric_smiles: bool = False) -> None: """ Creates a substructure database by fragmenting one or more input molecules. Combinations of substructures in this database are used to build new molecules. Fragmentation is carried out by selecting @@ -993,6 +997,8 @@ def create_substructure_database(hmdb_paths: Union[str, bytes, os.PathLike], :param substructures_only: Whether to generate all tables or only the substructures table. Retains necessary information for building and reduces database size. + + :param isomeric_smiles: If True, generates a database using smiles with non-structural isomeric information. """ db = SubstructureDb(path_substructure_db) @@ -1002,7 +1008,8 @@ def create_substructure_database(hmdb_paths: Union[str, bytes, os.PathLike], for hmdb_path in hmdb_paths: update_substructure_database(hmdb_path=hmdb_path, path_substructure_db=path_substructure_db, ha_min=ha_min, ha_max=ha_max, method=method, max_atoms_available=max_atoms_available, - max_degree=max_degree, substructures_only=substructures_only) + max_degree=max_degree, substructures_only=substructures_only, + isomeric_smiles=isomeric_smiles) db = SubstructureDb(path_substructure_db) db.create_indexes() @@ -1017,7 +1024,8 @@ def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None] max_degree: Union[int, None] = None, method: str = "exhaustive", substructures_only: bool = False, - records: Union[Sequence[Dict], None] = None) -> None: + records: Union[Sequence[Dict], None] = None, + isomeric_smiles: bool = False) -> None: """ Add entries to the substructure database by fragmenting a molecule or set of molecules. Combinations of substructures in this database are used to build new molecules. Fragmentation is carried out by selecting @@ -1064,6 +1072,8 @@ def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None] :param records: Records of molecules to be fragmented. Must be a list containing dictionaries containing key information about the molecules, as generated by :py:meth:`metaboblend.databases.parse_xml`; if records is not supplied, the records will be obtained from the XML at `hmdb_path`. + + :param isomeric_smiles: If True, generates a database using smiles with non-structural isomeric information. """ conn = sqlite3.connect(path_substructure_db) @@ -1075,7 +1085,7 @@ def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None] if ha_min is None: ha_min = 0 - for record_dict in filter_records(records): + for record_dict in filter_records(records, isomeric_smiles=isomeric_smiles): if not substructures_only: cursor.execute("""INSERT OR IGNORE INTO compounds ( hmdbid, @@ -1093,23 +1103,25 @@ def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None] # Returns a tuple of 2-tuples with bond IDs for sgs in get_sgs(record_dict=record_dict, n_min=ha_min-1, n_max=ha_max-1, method=method): for edge_idxs in sgs: - lib = get_substructure(record_dict["mol"], edge_idxs) # convert bond IDs to substructure mol + lib = get_substructure(record_dict["mol"], edge_idxs, isomeric_smiles=isomeric_smiles) # convert bond IDs to substructure mol # insert substructure obtained from get_sgs - insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree) + insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree, + isomeric_smiles) if ha_min <= 1: for atom in record_dict["mol"].GetAtoms(): - lib = get_substructure(record_dict["mol"], atom.GetIdx()) + lib = get_substructure(record_dict["mol"], atom.GetIdx(), isomeric_smiles=isomeric_smiles) # insert single atom substructures - insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree) + insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree, + isomeric_smiles) conn.commit() conn.close() -def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree): +def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_available, max_degree, isomeric_smiles): """ Converts the details of a single substructure into an entry in a substructure database. See :py:meth:`update_substructure_database`. @@ -1158,6 +1170,8 @@ def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_ 2 = double, etc.). Maximum degree is also limited by the extensivity of the supplied connectivity database. For instance, a substructure that has 3 `atoms_available`, each of their bond types being single bonds, would have a total degree of 3. + + :param isomeric_smiles: If True, generates database entries using smiles with non-structural isomeric information. """ if lib is None: @@ -1171,7 +1185,7 @@ def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_ if lib["valence"] > max_degree: return - smiles_rdkit = Chem.MolToSmiles(lib["mol"], isomericSmiles=False) # canonical rdkit smiles + smiles_rdkit = Chem.MolToSmiles(lib["mol"], isomericSmiles=isomeric_smiles) # canonical rdkit smiles exact_mass = calculate_exact_mass(lib["mol"]) els = get_elements(lib["mol"]) diff --git a/tests/test_databases.py b/tests/test_databases.py index 1cd59c6..a997642 100644 --- a/tests/test_databases.py +++ b/tests/test_databases.py @@ -83,7 +83,7 @@ def test_filter_records(self): with open(self.to_test_data("test_hmdbs.dictionary"), "rb") as test_hmdbs: filtered_records = pickle.load(test_hmdbs) - record_gen = filter_records(parsed_records.values()) + record_gen = filter_records(parsed_records.values(), isomeric_smiles=True) test_filtered_records = {} for record in record_gen: del record["mol"] @@ -150,7 +150,7 @@ def test_get_substructure(self): for edges in [[(0, 1, 2, 22, 3, 16, 17, 18, 19, 20, 21), (0, 1, 2, 22, 3, 4, 16, 17, 18, 19, 20)]]: for i, edge_idx in enumerate(edges): - lib = get_substructure(mol, edge_idx) + lib = get_substructure(mol, edge_idx, isomeric_smiles=True) del lib["mol"] self.assertEqual(lib, libs[i]) @@ -188,8 +188,8 @@ def test_calculate_exact_mass(self): def test_create_substructure_database(self): records = [self.to_test_data(r + ".xml") for r in ["HMDB0000073", "HMDB0000122", "HMDB0000158", "HMDB0000186"]] - create_substructure_database(records, - self.to_test_results("test_db.sqlite"), 4, 8, method="exhaustive") + create_substructure_database(records, self.to_test_results("test_db.sqlite"), 4, 8, method="exhaustive", + isomeric_smiles=True) test_db = sqlite3.connect(self.to_test_results("test_db.sqlite")) test_db_cursor = test_db.cursor() @@ -259,7 +259,8 @@ def test_update_substructure_database(self): # requires create_compound_databas record = self.to_test_data(record + ".xml") update_substructure_database(self.to_test_data(record), - self.to_test_results("test_db.sqlite"), 4, 8, method="exhaustive") + self.to_test_results("test_db.sqlite"), 4, 8, + method="exhaustive", isomeric_smiles=True) test_db = sqlite3.connect(self.to_test_results("test_db.sqlite")) test_db_cursor = test_db.cursor() @@ -328,7 +329,8 @@ def test_update_substructure_database(self): # requires create_compound_databas record = self.to_test_data(record + ".xml") update_substructure_database(self.to_test_data(record), - self.to_test_results("test_db.sqlite"), 1, 1, method="exhaustive") + self.to_test_results("test_db.sqlite"), 1, 1, + method="exhaustive", isomeric_smiles=True) test_db = sqlite3.connect(self.to_test_results("test_db.sqlite")) test_db_cursor = test_db.cursor() From 4726b3aa285f12984538c55e84b204c28b4d10a0 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 14:07:17 +0100 Subject: [PATCH 04/35] Update tests --- metaboblend/build_structures.py | 2 +- tests/test_build_structures.py | 45 +++++++++++++++++++---------- tests/test_substructure_database.py | 12 -------- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index f3707a3..c579c1f 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -682,7 +682,7 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d with multiprocessing.Pool(processes=ncpus) as pool: # send sets of substructures for building smi_lists = pool.map( partial(substructure_combination_build, configs_iso=configs_iso, - prescribed_structure=prescribed_mass is not None, isomeric_smiles=isomeric_smiles), + prescribed_structure=prescribed_mass, isomeric_smiles=isomeric_smiles), substructure_subsets ) diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 5b4ad77..1cb02f0 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -71,7 +71,8 @@ def test_build(self): # core - all other build functions rely on path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + ".smi"), max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures" + prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", + isomeric_smiles=True ) j = 0 @@ -98,7 +99,8 @@ def test_build(self): # core - all other build functions rely on prescribed_mass=fragments[i], ppm=15, clean=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - out_mode="w", ncpus=None, table_name="substructures" + out_mode="w", ncpus=None, table_name="substructures", + isomeric_smiles=True ) j = 0 @@ -124,12 +126,13 @@ def test_substructure_combination_build(self): ec_products = [((4, 5, 0, 0, 0, 0), (4, 6, 1, 2, 0, 0)), ((5, 5, 0, 2, 0, 0), (3, 6, 1, 0, 0, 0)), ((2, 4, 0, 2, 0, 0), (4, 8, 0, 4, 0, 0))] - configs_iso = db.k_configs(True) + configs_iso = db.k_configs() lens = [0, 1, 60] for i, ec_product in enumerate(ec_products): substructure_subset = db.select_substructures(ec_product, "substructures") - smis = substructure_combination_build(substructure_subset, configs_iso) + smis = substructure_combination_build(substructure_subset, configs_iso, + prescribed_structure=False, isomeric_smiles=True) self.assertEqual(len(smis), lens[i]) @@ -187,7 +190,8 @@ def test_generate_structures(self): # tests vs build path_smi_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True + minimum_frequency=None, yield_smi_set=True, + isomeric_smiles=True )) build_smis = build( @@ -197,7 +201,8 @@ def test_generate_structures(self): # tests vs build path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + "_build.smi"), max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures" + prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", + isomeric_smiles=True ) unique_smis = set() @@ -219,7 +224,8 @@ def test_generate_structures(self): # tests vs build path_smi_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True + minimum_frequency=None, yield_smi_set=True, + isomeric_smiles=True )) build_smis = build( @@ -230,7 +236,8 @@ def test_generate_structures(self): # tests vs build prescribed_mass=fragments[i], ppm=0, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - out_mode="w", ncpus=None, table_name="substructures", clean=True + out_mode="w", ncpus=None, table_name="substructures", clean=True, + isomeric_smiles=True ) unique_smis = set() @@ -254,7 +261,8 @@ def test_generate_structures(self): # tests vs build path_smi_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True + minimum_frequency=None, yield_smi_set=True, + isomeric_smiles=True )) for i, record_dict in enumerate(record_dicts.values()): @@ -265,7 +273,8 @@ def test_generate_structures(self): # tests vs build path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + "_build.smi"), max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures" + prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", + isomeric_smiles=True ) unique_smis = set() @@ -308,9 +317,12 @@ def test_annotate_msn(self): # tests vs build_msn path_smi_out=self.to_test_results("annotate"), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True, write_fragment_smis=True + minimum_frequency=None, yield_smi_dict=True, write_fragment_smis=True, + isomeric_smiles=True )) + returned_smis = returned_smis[0][record_dict["HMDB_ID"]] + peak_smis = set() for prescribed_mass in fragments: with open(self.to_test_results("annotate", "1_" + record_dict["HMDB_ID"], @@ -326,17 +338,17 @@ def test_annotate_msn(self): # tests vs build_msn csv_output_smis.add(line.split()[0]) self.assertEqual(peak_smis, csv_output_smis) - self.assertEqual(peak_smis, set(returned_smis[0].keys())) + self.assertEqual(peak_smis, set(returned_smis.keys())) self.assertEqual(len(peak_smis), overall_lens[i]) - self.assertEqual(len([freq for freq in set(returned_smis[0].values()) if freq > 1]), freqs[i]) + self.assertEqual(len([freq for freq in set(returned_smis.values()) if freq > 1]), freqs[i]) if smis[i] is not None: self.assertEqual(peak_smis, smis[i]) if i == 0: - self.assertEqual(returned_smis[0]['NCCc1ccc(O)c(O)c1'], 2) + self.assertEqual(returned_smis['NCCc1ccc(O)c(O)c1'], 3) ms_data = {} for i, record_dict in enumerate(record_dicts.values()): @@ -354,7 +366,8 @@ def test_annotate_msn(self): # tests vs build_msn path_smi_out=self.to_test_results("annotate_multi"), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True + minimum_frequency=None, yield_smi_dict=True, + isomeric_smiles=True )) for i, record_dict in enumerate(record_dicts.values()): @@ -365,7 +378,7 @@ def test_annotate_msn(self): # tests vs build_msn for line in smi_out: unique_smis.add(line.split()[0].split(",")[0]) - self.assertEqual(unique_smis, set(returned_smi_list[i].keys())) + self.assertEqual(unique_smis, set(returned_smi_list[i][record_dict["HMDB_ID"]].keys())) self.assertEqual(len(unique_smis), overall_lens[i]) db.close() diff --git a/tests/test_substructure_database.py b/tests/test_substructure_database.py index 908e201..a950e1d 100644 --- a/tests/test_substructure_database.py +++ b/tests/test_substructure_database.py @@ -193,18 +193,6 @@ def test_k_configs(self): ((0, 3), (0, 5), (1, 2), (1, 4), (2, 4), (3, 5)), ((0, 3), (0, 4), (1, 2), (1, 5), (2, 5), (3, 4))]) - k_configs = db.k_configs(True) - self.assertEqual(len(k_configs), 10) - self.assertEqual(k_configs['((1,), (1,))'], [((0, 1),)]) - self.assertTrue('((2, 2), (2, 2), (2, 2))' not in k_configs.keys()) - self.assertEqual(k_configs['((2, 2), (1, 1), (1, 1))'], - [((0, 2), (0, 4), (1, 3), (1, 5)), - ((0, 2), (0, 3), (1, 4), (1, 5)), - ((0, 2), (0, 5), (1, 3), (1, 4)), - ((0, 4), (0, 5), (1, 2), (1, 3)), - ((0, 3), (0, 5), (1, 2), (1, 4)), - ((0, 3), (0, 4), (1, 2), (1, 5))]) - db.close() def test_select_substructures(self): From dd2013b1ee07a535231bb6c1469116a49d909b04 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 23:41:18 +0100 Subject: [PATCH 05/35] Correct return type hints --- metaboblend/build_structures.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index c579c1f..decc5e0 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -292,7 +292,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], minimum_frequency: Union[int, None] = None, hydrogenation_allowance: int = 2, yield_smi_dict: bool = True, - isomeric_smiles: bool = False) -> Union[Sequence[Dict[str, int]], None]: + isomeric_smiles: bool = False + ) -> Dict[str, Sequence[Dict[str, int]]]: """ Generate molecules of a given mass using chemical substructures, connectivity graphs and spectral trees or fragmentation spectra. Final structures and rankings are yielded by the function as a dictionary and/or written in @@ -450,7 +451,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], minimum_frequency: Union[int, None] = None, yield_smi_set: bool = True, isomeric_smiles: bool = False - ) -> Union[Sequence[list], None]: + ) -> Dict[str, Sequence[list]]: """ Generate molecules of a given mass using chemical substructures and connectivity graphs. Can optionally take a "prescribed" fragment mass to further filter results. Final structures are returned as a list and/or written in @@ -542,7 +543,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], else: id_path_smi_out = None - smi_list = build( + smi_set = build( mf=ms_data[ms_id]["mf"], exact_mass=ms_data[ms_id]["exact_mass"], max_n_substructures=max_n_substructures, @@ -559,7 +560,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], ) if yield_smi_set: - yield smi_list + yield {ms_id: smi_set} db.close() @@ -743,7 +744,7 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass FROM hmdbid_substructures GROUP BY smiles HAVING COUNT(*) >= {}) - """.format(minimum_frequency,) + """.format(minimum_frequency,) if ha_min is None: ha_min_statement = "" @@ -759,7 +760,6 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass db.cursor.execute("""CREATE TABLE {} AS SELECT * FROM substructures WHERE - atoms_available <= {} AND valence <= {} AND exact_mass__1 < {}{}{}{} From 071e9a2f47ccb335d7e7d30b4984cde13c9b05c4 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 19 Sep 2020 23:41:42 +0100 Subject: [PATCH 06/35] Implement SQLITE3 annotate_msn results database --- metaboblend/build_structures.py | 207 ++++++++++++++++++++++++++------ tests/test_build_structures.py | 49 ++------ 2 files changed, 184 insertions(+), 72 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index decc5e0..360f3da 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -26,6 +26,7 @@ from functools import partial import networkx as nx import numpy +import sqlite3 from operator import itemgetter from typing import Sequence, Dict, Union @@ -277,18 +278,173 @@ def add_bonds(mols, edges, atoms_available, bond_types): return mol_edit +class ResultsDb: + """ + Methods for interacting with the SQLITE3 results database, as created by + :py:meth:`metaboblend.build_structures.annotate_msn`. + + :param path_results_db: Path to the results database. + """ + + def __init__(self, path_results_db): + """Constructor method.""" + + self.path_results_db = path_results_db + + self.conn = None + self.cursor = None + + def connect(self): + """Connects to the results database.""" + + self.conn = sqlite3.connect(self.path_results_db) + self.cursor = self.conn.cursor() + + def create_results_table(self): + """Generates a new results database.""" + + if os.path.exists(self.path_results_db): + os.remove(self.path_results_db) + + self.connect() + + self.cursor.execute("""CREATE TABLE results ( + ms_id TEXT PRIMARY KEY, + exact_mass NUMERIC, + mc TEXT, + ppm INTEGER, + ha_min INTEGER, + ha_max INTEGER, + max_atoms_available INTEGER, + max_degree INTEGER, + max_n_substructures INTEGER, + hydrogenation_allowance INTEGER, + isomeric_smiles INTEGER, + neutral_fragments TEXT)""") + + self.conn.commit() + + def add_ms(self, msn_data, parameters): + """ + Add entries to the results table and set up test-specific tables. + + :param msn_data: Dictionary in the form + `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses=[]}`. id represents a unique + identifier for a given spectral tree or fragmentation spectrum, mf is a list of integers referring to the + molecular formula of the structure of interest, exact_mass is the mass of this molecular formula to >=4d.p. + and fragment_masses are neutral fragment masses generated by this structure used to inform candidate + scoring. See :py:meth:`metaboblend.build_structures.annotate_msn`. + + :param parameters: List of parameters, in the form: [ppm, ha_min, ha_max, max_atoms_available, max_degree, + max_n_substructures, hydrogenation_allowance, isomeric_smiles]. See + :py:meth:`metaboblend.build_structures.annotate_msn`. + """ + + for i, parameter in enumerate(parameters): + if parameter is None: + parameters[i] = "NULL" + elif isinstance(parameter, bool): + parameters[i] = int(parameter) + + for ms_id in msn_data.keys(): + + self.cursor.execute("""INSERT INTO results ( + ms_id, + exact_mass, + mc, + ppm, + ha_min, + ha_max, + max_atoms_available, + max_degree, + max_n_substructures, + hydrogenation_allowance, + isomeric_smiles, + neutral_fragments + ) VALUES ('{}', {}, '{}', {}, '{}')""".format( + ms_id, + msn_data[ms_id]["exact_mass"], + msn_data[ms_id]["mf"], + ", ".join([str(p) for p in parameters]), + str(msn_data[ms_id]["fragment_masses"]) + )) + + fragment_statement = "'" + ",\n'".join([str(mass) + "' INTEGER DEFAULT 0" for mass in msn_data[ms_id]["fragment_masses"]]) + self.cursor.execute("""CREATE TABLE {} ( + smiles TEXT PRIMARY KEY, + {}, + peak_sum INTEGER DEFAULT 0)""".format(ms_id, fragment_statement)) + + self.conn.commit() + + def add_smis(self, ms_id, smis, fragment_mass): + """ + Record which smiles were generated for a given fragment mass. + + :param ms_id: Unique identifer for the annotation of a single metabolite. + + :param smis: The smiles generated by the annotation of a single peak for a single metabolite. + + :param fragment_mass: The neutral fragment mass that has been annotated. + """ + + self.cursor.executemany("INSERT OR IGNORE INTO {} (smiles) VALUES (?)".format(ms_id), [[smi] for smi in smis]) + + self.cursor.execute("UPDATE {} SET '{}' = 1 WHERE smiles IN ('{}')".format(ms_id, + str(fragment_mass), + "', '".join(smis))) + + self.conn.commit() + + def calc_scores(self, ms_id, fragment_masses): + """ + Calculate number of peaks for which a given structure was generated. + + :param ms_id: Unique identifer for the annotation of a single metabolite. + + :param fragment_masses: The neutral fragment masses that have been annotated. + """ + + self.cursor.execute("UPDATE {} SET peak_sum = [{}]".format( + ms_id, + "] + [".join([str(fragment_mass) for fragment_mass in fragment_masses]) + )) + + self.conn.commit() + + def structure_frequency(self, ms_id): + """ + Generate a dictionary of results with structure frequencies. + + :param ms_id: Unique identifer for the annotation of a single metabolite. + + :return: A dictionary with smiles as keys and the number of peaks for which the smiles were generated as + values + """ + + structure_frequencies = {} + for smiles, peak_sum in self.cursor.execute("""SELECT smiles, peak_sum FROM {}""".format(ms_id)): + structure_frequencies[smiles] = peak_sum + + return structure_frequencies + + def close(self): + """Close the connection to the SQLITE3 database""" + + self.conn.close() + + def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], path_substructure_db: Union[str, bytes, os.PathLike], + path_sql_out: Union[str, bytes, os.PathLike] = "metaboblend_results.sqlite", ppm: int = 5, ha_min: Union[int, None] = None, ha_max: Union[int, None] = None, max_atoms_available: int = 2, max_degree: int = 6, max_n_substructures: int = 3, - path_smi_out: Union[str, bytes, os.PathLike, None] = None, path_connectivity_db: Union[str, bytes, os.PathLike, None] = None, ncpus: Union[int, None] = None, - write_fragment_smis: bool = False, minimum_frequency: Union[int, None] = None, hydrogenation_allowance: int = 2, yield_smi_dict: bool = True, @@ -309,6 +465,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. + :param path_sql_out: The path at which an SQLite database will be generated to store results. + :param ppm: The maximal tolerated m/z deviation (in parts per million) of the mass of substructures from the supplied `fragment_masses`. @@ -332,11 +490,6 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param max_n_substructures: The maximum number of substructures to be used for building molecules. The max number of substructures is also limited by the extensivity of the supplied connectivity database. - :param path_smi_out: The directory to which unique smile strings should be written representing the final - structures generated and their respective scores; this is a csv file unique SMILEs strings of structures built - and the number of `prescribed_masses` by which each structure was generated. If None, no files are written. - See `write_fragment_smis` should you wish to inspect the SMILEs generated by individual peaks. - :param path_connectivity_db: The path to the SQLite 3 connectivity database, as generated by :py:meth:`metaboblend.databases.create_isomorphism_database`. If the path is None, the default connectivity database bundled with MetaboBlend will be used. @@ -346,10 +499,6 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param minimum_frequency: The minimum frequency of substructures in table_name; e.g. substructures have a frequency of 1 if they are unique. Defaults to None, in which case this filtering method is not applied. - :param write_fragment_smis: Whether to write smiles to a file for each fragment. Requires `path_smi_out` to be - specified. If `True`, a new directory is generated for each input spectral tree and the results for each - fragment are stored as a text file that contains unique smiles strings. - :param hydrogenation_allowance: In order to represent re-arrangement events (the movement of hydrogens), in addition to attempting to build from substructures in prescribed_masses, we also attempt to build from `fragment_masses +- hydrogenation_allowance`. E.g. if `prescribed_masses = [141.5938]` and @@ -373,6 +522,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], path_connectivity_db = os.path.join(os.path.realpath(os.path.dirname(__file__)), "data", "connectivity.sqlite") db = SubstructureDb(path_substructure_db, path_connectivity_db) + results_db = ResultsDb(path_sql_out) + results_db.create_results_table() # prepare temporary table here - will only be generated once in case of multiple input table_name = gen_subs_table( @@ -385,57 +536,41 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], max_mass=round(max([msn_data[ms_id]["exact_mass"] for ms_id in msn_data.keys()])) ) - for i, ms_id in enumerate(msn_data.keys()): - if path_smi_out is not None and write_fragment_smis: - path_smi_out_subdir = os.path.join(path_smi_out, str(i+1) + "_" + ms_id) - os.mkdir(path_smi_out_subdir) - else: - path_smi_out_subdir = None + results_db.add_ms(msn_data, [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, + hydrogenation_allowance, isomeric_smiles]) - structure_frequency = {} # map smiles to how many separate masses generated them - msn_data[ms_id]["fragment_masses"].sort(reverse=True) + for i, ms_id in enumerate(msn_data.keys()): for prescribed_mass in msn_data[ms_id]["fragment_masses"]: - if path_smi_out_subdir is not None and write_fragment_smis: - id_path_smi_out = os.path.join(path_smi_out_subdir, str(round(prescribed_mass, 4)) + ".smi") - open(id_path_smi_out, "w").close() - else: - id_path_smi_out = None - fragment_smis = set() - for j in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): hydrogenated_prescribed_mass = prescribed_mass + (j * 1.007825) # consider re-arrangements fragment_smis.update(build( mf=msn_data[ms_id]["mf"], exact_mass=msn_data[ms_id]["exact_mass"], - path_smi_out=id_path_smi_out, max_n_substructures=max_n_substructures, + path_smi_out=None, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, prescribed_mass=hydrogenated_prescribed_mass, ppm=ppm, - out_mode="a", + out_mode=None, table_name=table_name, ncpus=ncpus, clean=False, isomeric_smiles=isomeric_smiles )) - for smi in fragment_smis: - structure_frequency[smi] = structure_frequency.get(smi, 0) + 1 + results_db.add_smis(ms_id, fragment_smis, prescribed_mass) - # write structure_frequency dict as csv - if path_smi_out is not None: - with open(os.path.join(path_smi_out, ms_id + "_frequency.csv"), "w") as freq_out: - freq_out.writelines( - [k + "," + str(i) + "\n" for k, i in zip(structure_frequency.keys(), structure_frequency.values())]) + results_db.calc_scores(ms_id, msn_data[ms_id]["fragment_masses"]) if yield_smi_dict: - yield {ms_id: structure_frequency} + yield {ms_id: results_db.structure_frequency(ms_id)} db.close() + results_db.close() def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 1cb02f0..87cc2ab 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -194,6 +194,8 @@ def test_generate_structures(self): # tests vs build isomeric_smiles=True )) + returned_smis = returned_smis[0][record_dict["HMDB_ID"]] + build_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], @@ -210,7 +212,7 @@ def test_generate_structures(self): # tests vs build for line in smi_out: unique_smis.add(line.split()[0]) - self.assertEqual(unique_smis, returned_smis[0]) + self.assertEqual(unique_smis, returned_smis) self.assertEqual(unique_smis, build_smis) ms_data = {record_dict["HMDB_ID"]: {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], @@ -228,6 +230,8 @@ def test_generate_structures(self): # tests vs build isomeric_smiles=True )) + returned_smis = returned_smis[0][record_dict["HMDB_ID"]] + build_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], @@ -245,7 +249,7 @@ def test_generate_structures(self): # tests vs build for line in smi_out: unique_smis.add(line.split()[0]) - self.assertEqual(unique_smis, returned_smis[0]) + self.assertEqual(unique_smis, returned_smis) self.assertEqual(unique_smis, build_smis) ms_data = {} @@ -255,6 +259,7 @@ def test_generate_structures(self): # tests vs build record_dict["O"], record_dict["P"], record_dict["S"]], "exact_mass": record_dict["exact_mass"], "prescribed_masses": None} + # test building with multiple inputs returned_smi_list = list(generate_structures( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, @@ -282,7 +287,7 @@ def test_generate_structures(self): # tests vs build for line in smi_out: unique_smis.add(line.split()[0]) - self.assertEqual(unique_smis, returned_smi_list[i]) + self.assertEqual(unique_smis, returned_smi_list[i][record_dict["HMDB_ID"]]) self.assertEqual(unique_smis, build_smis) db.close() @@ -314,38 +319,18 @@ def test_annotate_msn(self): # tests vs build_msn # test standard building returned_smis = list(annotate_msn( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_smi_out=self.to_test_results("annotate"), + path_sql_out=self.to_test_results("results.sqlite"), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True, write_fragment_smis=True, - isomeric_smiles=True + minimum_frequency=None, yield_smi_dict=True, isomeric_smiles=True )) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] - peak_smis = set() - for prescribed_mass in fragments: - with open(self.to_test_results("annotate", "1_" + record_dict["HMDB_ID"], - str(round(prescribed_mass, 4))) + ".smi", "r") as smi_out: - for line in smi_out: - peak_smis.add(line.split()[0]) - - csv_output_smis = set() - for prescribed_mass in fragments: - with open(self.to_test_results("annotate", "1_" + record_dict["HMDB_ID"], - str(round(prescribed_mass, 4))) + ".smi", "r") as smi_out: - for line in smi_out: - csv_output_smis.add(line.split()[0]) - - self.assertEqual(peak_smis, csv_output_smis) - self.assertEqual(peak_smis, set(returned_smis.keys())) - - self.assertEqual(len(peak_smis), overall_lens[i]) - self.assertEqual(len([freq for freq in set(returned_smis.values()) if freq > 1]), freqs[i]) if smis[i] is not None: - self.assertEqual(peak_smis, smis[i]) + self.assertEqual(set(returned_smis.keys()), smis[i]) if i == 0: self.assertEqual(returned_smis['NCCc1ccc(O)c(O)c1'], 3) @@ -363,7 +348,7 @@ def test_annotate_msn(self): # tests vs build_msn # test building with multiple inputs returned_smi_list = list(annotate_msn( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_smi_out=self.to_test_results("annotate_multi"), + path_sql_out=self.to_test_results("annotate_multi", "results.sqlite"), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smi_dict=True, @@ -371,15 +356,7 @@ def test_annotate_msn(self): # tests vs build_msn )) for i, record_dict in enumerate(record_dicts.values()): - unique_smis = set() - - with open(self.to_test_results("annotate_multi", - record_dict["HMDB_ID"] + "_frequency.csv"), "r") as smi_out: - for line in smi_out: - unique_smis.add(line.split()[0].split(",")[0]) - - self.assertEqual(unique_smis, set(returned_smi_list[i][record_dict["HMDB_ID"]].keys())) - self.assertEqual(len(unique_smis), overall_lens[i]) + self.assertEqual(len(set(returned_smi_list[i][record_dict["HMDB_ID"]].keys())), overall_lens[i]) db.close() From 4d52c4002de94c9557c0851b78c715ac9a566d34 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Sat, 21 Nov 2020 22:25:14 +0000 Subject: [PATCH 07/35] Add msn option to ResultsDb --- metaboblend/build_structures.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 360f3da..5ccdf74 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -32,7 +32,7 @@ from rdkit import Chem -from .databases import SubstructureDb +from .databases import SubstructureDb, get_elements, calculate_exact_mass def find_path(mass_list, sum_matrix, n, mass, max_subset_length, path=[]): @@ -79,9 +79,9 @@ def subset_sum(mass_list, mass, max_subset_length=3): """ Dynamic programming implementation of subset sum. Note that, whilst this algorithm is pseudo-polynomial, the backtracking algorithm for obtaining all possible subsets has exponential complexity and so remains unsuitable - for large input values. This does, however, tend to perform a lot better than non-sum_matrix implementations, as we're - no longer doing sums multiple times and we've cut down the operations performed during the exponential portion of - the method. + for large input values. This does, however, tend to perform a lot better than non-sum_matrix implementations, as + we're no longer doing sums multiple times and we've cut down the operations performed during the exponential portion + of the method. :param mass_list: A list of masses from which to identify subsets. @@ -121,8 +121,8 @@ def subset_sum(mass_list, mass, max_subset_length=3): def combine_mfs(precise_mass_grp, db, table_name, accuracy): """ A wrapper for :py:meth:`metaboblend.databases.select_ecs` that instead takes a group of subsets, as generated by - the second stage of :py:meth:`metaboblend.build_structres.subset_sum` in - :py:meth:`metaboblend.build_structres.build`. + the second stage of :py:meth:`metaboblend.build_structures.subset_sum` in + :py:meth:`metaboblend.build_structures.build`. :param precise_mass_grp: A list containing the masses of substructures identified by subset_sum. @@ -286,10 +286,11 @@ class ResultsDb: :param path_results_db: Path to the results database. """ - def __init__(self, path_results_db): + def __init__(self, path_results_db, msn=True): """Constructor method.""" self.path_results_db = path_results_db + self.msn = msn self.conn = None self.cursor = None From 918b7b0d6389efaa824dc47c660db38f1e2f0477 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Sat, 21 Nov 2020 22:25:46 +0000 Subject: [PATCH 08/35] Re-structure results db tables --- metaboblend/build_structures.py | 45 +++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 5ccdf74..50b1a15 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -301,7 +301,7 @@ def connect(self): self.conn = sqlite3.connect(self.path_results_db) self.cursor = self.conn.cursor() - def create_results_table(self): + def create_results_db(self): """Generates a new results database.""" if os.path.exists(self.path_results_db): @@ -309,10 +309,15 @@ def create_results_table(self): self.connect() - self.cursor.execute("""CREATE TABLE results ( + self.cursor.execute("""CREATE TABLE queries ( ms_id TEXT PRIMARY KEY, exact_mass NUMERIC, - mc TEXT, + C INTEGER, + H INTEGER, + N INTEGER, + O INTEGER, + P INTEGER, + S INTEGER, ppm INTEGER, ha_min INTEGER, ha_max INTEGER, @@ -320,8 +325,38 @@ def create_results_table(self): max_degree INTEGER, max_n_substructures INTEGER, hydrogenation_allowance INTEGER, - isomeric_smiles INTEGER, - neutral_fragments TEXT)""") + isomeric_smiles INTEGER)""") + + if self.msn: + self.cursor.execute("""CREATE TABLE spectra ( + ms_id TEXT, + fragment_id NUMERIC, + neutral_mass NUMERIC, + PRIMARY KEY (ms_id, fragment_id))""") + + self.cursor.execute("""CREATE TABLE structures ( + ms_id TEXT, + smiles TEXT, + frequency NUMERIC, + exact_mass NUMERIC, + C INTEGER, + H INTEGER, + N INTEGER, + O INTEGER, + P INTEGER, + S INTEGER, + PRIMARY KEY (ms_id, smiles))""") + + self.cursor.execute("""CREATE TABLE substructures ( + structure_smiles TEXT , + substructure_smiles TEXT, + PRIMARY KEY (structure_smiles, substructure_smiles))""") + + self.cursor.execute("""CREATE TABLE results ( + ms_id TEXT, + fragment_id NUMERIC, + structure_smiles TEXT, + PRIMARY KEY(ms_id, fragment_id, structure_smiles))""") self.conn.commit() From 030083b5c7bb2bf6c32987543531339721cbf297 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:52:32 +0000 Subject: [PATCH 09/35] Update add_ms to add ms information to the queries table --- metaboblend/build_structures.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 50b1a15..b11dbfc 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -362,10 +362,10 @@ def create_results_db(self): def add_ms(self, msn_data, parameters): """ - Add entries to the results table and set up test-specific tables. + Add entries to the `queries` and `spectra` tables. :param msn_data: Dictionary in the form - `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses=[]}`. id represents a unique + `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses: []}`. id represents a unique identifier for a given spectral tree or fragmentation spectrum, mf is a list of integers referring to the molecular formula of the structure of interest, exact_mass is the mass of this molecular formula to >=4d.p. and fragment_masses are neutral fragment masses generated by this structure used to inform candidate @@ -384,10 +384,10 @@ def add_ms(self, msn_data, parameters): for ms_id in msn_data.keys(): - self.cursor.execute("""INSERT INTO results ( + self.cursor.execute("""INSERT INTO queries ( ms_id, exact_mass, - mc, + C, H, N, O, P, S, ppm, ha_min, ha_max, @@ -395,22 +395,16 @@ def add_ms(self, msn_data, parameters): max_degree, max_n_substructures, hydrogenation_allowance, - isomeric_smiles, - neutral_fragments - ) VALUES ('{}', {}, '{}', {}, '{}')""".format( + isomeric_smiles + ) VALUES ('{}', {}, '{}', '{}', '{}', '{}', '{}', '{}', {})""".format( ms_id, msn_data[ms_id]["exact_mass"], - msn_data[ms_id]["mf"], - ", ".join([str(p) for p in parameters]), - str(msn_data[ms_id]["fragment_masses"]) + msn_data[ms_id]["mf"][0], msn_data[ms_id]["mf"][1], + msn_data[ms_id]["mf"][2], msn_data[ms_id]["mf"][3], + msn_data[ms_id]["mf"][4], msn_data[ms_id]["mf"][5], + ", ".join([str(p) for p in parameters]) )) - fragment_statement = "'" + ",\n'".join([str(mass) + "' INTEGER DEFAULT 0" for mass in msn_data[ms_id]["fragment_masses"]]) - self.cursor.execute("""CREATE TABLE {} ( - smiles TEXT PRIMARY KEY, - {}, - peak_sum INTEGER DEFAULT 0)""".format(ms_id, fragment_statement)) - self.conn.commit() def add_smis(self, ms_id, smis, fragment_mass): From b91c1ae6d95e092c2a4595d86e1a3d777b816496 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:53:12 +0000 Subject: [PATCH 10/35] Add function to insert entries into the results and substructures tables --- metaboblend/build_structures.py | 57 +++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index b11dbfc..d6f14d2 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -407,38 +407,53 @@ def add_ms(self, msn_data, parameters): self.conn.commit() - def add_smis(self, ms_id, smis, fragment_mass): + def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): """ Record which smiles were generated for a given fragment mass. - :param ms_id: Unique identifer for the annotation of a single metabolite. + :param ms_id: Unique identifier for the annotation of a single metabolite. - :param smis: The smiles generated by the annotation of a single peak for a single metabolite. + :param smi_dict: The fragment and substructure smiles generated by the annotation of a single peak for a single + metabolite. :param fragment_mass: The neutral fragment mass that has been annotated. - """ - - self.cursor.executemany("INSERT OR IGNORE INTO {} (smiles) VALUES (?)".format(ms_id), [[smi] for smi in smis]) - - self.cursor.execute("UPDATE {} SET '{}' = 1 WHERE smiles IN ('{}')".format(ms_id, - str(fragment_mass), - "', '".join(smis))) - self.conn.commit() - - def calc_scores(self, ms_id, fragment_masses): + :param fragment_id: The unique identifier for the fragment mass that has been annotated. """ - Calculate number of peaks for which a given structure was generated. - :param ms_id: Unique identifer for the annotation of a single metabolite. + if self.msn: + self.cursor.execute("""INSERT OR IGNORE INTO spectra ( + ms_id, + fragment_id, + neutral_mass + ) VALUES ('{}', {}, {})""".format( + ms_id, + fragment_id, + fragment_mass + )) + else: + fragment_id = "NULL" - :param fragment_masses: The neutral fragment masses that have been annotated. - """ + for structure_smiles in smi_dict.keys(): + self.cursor.execute("""INSERT INTO results ( + ms_id, + fragment_id, + structure_smiles + ) VALUES ('{}', '{}', '{}')""".format( + ms_id, + fragment_id, + structure_smiles + )) + + for substructure_smiles in smi_dict[structure_smiles]: - self.cursor.execute("UPDATE {} SET peak_sum = [{}]".format( - ms_id, - "] + [".join([str(fragment_mass) for fragment_mass in fragment_masses]) - )) + self.cursor.execute("""INSERT INTO substructures ( + structure_smiles, + substructure_smiles + ) VALUES ('{}', '{}')""".format( + structure_smiles, + substructure_smiles + )) self.conn.commit() From 41253070711f8cdfa4869936c6bd3798604ddc54 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:53:47 +0000 Subject: [PATCH 11/35] Add function to get structure frequencies and/or SMILEs --- metaboblend/build_structures.py | 51 +++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index d6f14d2..aa9fe9e 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -457,21 +457,56 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): self.conn.commit() - def structure_frequency(self, ms_id): + def get_structures(self, ms_id): """ - Generate a dictionary of results with structure frequencies. + Gets smiles of generated structures. In the case of the MSn annotation workflow, also gets structure + frequencies. - :param ms_id: Unique identifer for the annotation of a single metabolite. + :param ms_id: Unique identifier for the annotation of a single metabolite. - :return: A dictionary with smiles as keys and the number of peaks for which the smiles were generated as - values + :return: In the case of simple structure generation, returns a set of smiles strings for output structures. + For the MSn annotation workflow, returns a dictionary with smiles as keys and the number of peaks for which + the smiles were generated as values. """ structure_frequencies = {} - for smiles, peak_sum in self.cursor.execute("""SELECT smiles, peak_sum FROM {}""".format(ms_id)): - structure_frequencies[smiles] = peak_sum + self.cursor.execute("SELECT DISTINCT structure_smiles FROM results WHERE ms_id = '%s'" % ms_id) + + for structure_smiles in self.cursor.fetchall(): + structure_mol = Chem.MolFromSmiles(structure_smiles[0]) + structure_mass = calculate_exact_mass(structure_mol) + structure_mc = get_elements(structure_mol) + + if self.msn: + self.cursor.execute("""SELECT DISTINCT fragment_id FROM results + WHERE ms_id = '{}' + AND structure_smiles = '{}'""".format(ms_id, structure_smiles[0])) + + structure_frequency = len(self.cursor.fetchall()) + else: + structure_frequency = "NULL" + + self.cursor.execute("""INSERT INTO structures ( + ms_id, + smiles, + exact_mass, + C, H, N, O, P, S, + frequency + ) VALUES ('{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}')""".format( + ms_id, + structure_smiles[0], + structure_mass, + structure_mc["C"], structure_mc["H"], structure_mc["N"], + structure_mc["O"], structure_mc["P"], structure_mc["S"], + structure_frequency + )) - return structure_frequencies + structure_frequencies[structure_smiles[0]] = structure_frequency + + if self.msn: + return structure_frequencies + else: + return set(structure_frequencies.keys()) def close(self): """Close the connection to the SQLITE3 database""" From 6017bda2de902de12bac7c8a5d4882916f084ef5 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:54:44 +0000 Subject: [PATCH 12/35] Update user-facing functions for compatibility with ResultsDb --- metaboblend/build_structures.py | 52 +++++++++++++++------------------ 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index aa9fe9e..dda3a1c 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -603,7 +603,7 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], db = SubstructureDb(path_substructure_db, path_connectivity_db) results_db = ResultsDb(path_sql_out) - results_db.create_results_table() + results_db.create_results_db() # prepare temporary table here - will only be generated once in case of multiple input table_name = gen_subs_table( @@ -621,33 +621,30 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], for i, ms_id in enumerate(msn_data.keys()): - for prescribed_mass in msn_data[ms_id]["fragment_masses"]: - fragment_smis = set() - for j in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): - hydrogenated_prescribed_mass = prescribed_mass + (j * 1.007825) # consider re-arrangements + for j, fragment_mass in enumerate(msn_data[ms_id]["fragment_masses"]): - fragment_smis.update(build( + for k in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): + hydrogenated_fragment_mass = fragment_mass + (k * 1.007825) # consider re-arrangements + + smi_dict = build( mf=msn_data[ms_id]["mf"], exact_mass=msn_data[ms_id]["exact_mass"], max_n_substructures=max_n_substructures, - path_smi_out=None, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, - prescribed_mass=hydrogenated_prescribed_mass, + prescribed_mass=hydrogenated_fragment_mass, ppm=ppm, - out_mode=None, table_name=table_name, ncpus=ncpus, clean=False, isomeric_smiles=isomeric_smiles - )) - - results_db.add_smis(ms_id, fragment_smis, prescribed_mass) + ) - results_db.calc_scores(ms_id, msn_data[ms_id]["fragment_masses"]) + results_db.add_results(ms_id, smi_dict, fragment_mass, j) + fragment_smis = None if yield_smi_dict: - yield {ms_id: results_db.structure_frequency(ms_id)} + yield {ms_id: results_db.get_structures(ms_id)} db.close() results_db.close() @@ -655,18 +652,18 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], path_substructure_db: Union[str, bytes, os.PathLike], + path_sql_out: Union[str, bytes, os.PathLike] = "metaboblend_results.sqlite", ha_min: Union[int, None] = 2, ha_max: Union[int, None] = 9, max_degree: int = 6, max_atoms_available: int = 2, max_n_substructures: int = 3, - path_smi_out: Union[str, bytes, os.PathLike, None] = None, ncpus: Union[int, None] = None, path_connectivity_db: Union[str, bytes, os.PathLike, None] = None, minimum_frequency: Union[int, None] = None, yield_smi_set: bool = True, isomeric_smiles: bool = False - ) -> Dict[str, Sequence[list]]: + ) -> Dict[str, Sequence[set]]: """ Generate molecules of a given mass using chemical substructures and connectivity graphs. Can optionally take a "prescribed" fragment mass to further filter results. Final structures are returned as a list and/or written in @@ -682,6 +679,9 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. + :param path_sql_out: The path to the SQLite 3 substructure database, as generated by + :py:meth:`metaboblend.databases.SubstructureDb`. + :param ha_min: The minimum size (number of heavy atoms) of substructures to be used to build final structures. If None, no limit is applied. @@ -708,9 +708,6 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], :param max_n_substructures: The maximum number of substructures to be used for building molecules. The max number of substructures is also limited by the extensivity of the supplied connectivity database. - :param path_smi_out: The directory to which unique smile strings should be written representing the final - structures generated. If None, no files are written. - :param path_connectivity_db: The path to the SQLite 3 connectivity database, as generated by :py:meth:`metaboblend.databases.create_isomorphism_database`. If the path is None, the default connectivity database bundled with MetaboBlend will be used. @@ -729,6 +726,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], """ db = SubstructureDb(path_substructure_db, path_connectivity_db) + results_db = ResultsDb(path_sql_out, False) + results_db.create_results_db() if path_connectivity_db is None: path_connectivity_db = os.path.join(os.path.realpath(os.path.dirname(__file__)), "data", "connectivity.sqlite") @@ -744,6 +743,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], max_mass=round(max([ms_data[ms_id]["exact_mass"] for ms_id in ms_data.keys()])) ) + results_db.add_ms(ms_data, [None, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, + None, isomeric_smiles]) for ms_id in ms_data.keys(): ppm = None @@ -753,29 +754,24 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], except KeyError: ms_data[ms_id]["prescribed_masses"] = None - if path_smi_out is not None: - id_path_smi_out = os.path.join(path_smi_out, ms_id + ".smi") - else: - id_path_smi_out = None - - smi_set = build( + smi_dict = build( mf=ms_data[ms_id]["mf"], exact_mass=ms_data[ms_id]["exact_mass"], max_n_substructures=max_n_substructures, - path_smi_out=id_path_smi_out, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, prescribed_mass=ms_data[ms_id]["prescribed_masses"], ppm=ppm, - out_mode="w", table_name=table_name, ncpus=ncpus, clean=False, isomeric_smiles=isomeric_smiles ) + results_db.add_results(ms_id, smi_dict, ms_data[ms_id]["prescribed_masses"]) + if yield_smi_set: - yield {ms_id: smi_set} + yield {ms_id: results_db.get_structures(ms_id)} db.close() From b7f405b29fa5e26e4d37907076b4a58943bf7970 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:55:26 +0000 Subject: [PATCH 13/35] Remove text-based output and return substructure smiles from build functions in addition to final structures --- metaboblend/build_structures.py | 56 ++++++++++++++++----------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index dda3a1c..275270d 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -776,8 +776,8 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], db.close() -def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_db, path_substructure_db, - prescribed_mass, ppm, out_mode, ncpus, table_name, clean, isomeric_smiles): +def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substructure_db, + prescribed_mass, ppm, ncpus, table_name, clean, isomeric_smiles): """ Core function for generating molecules of a given mass using substructures and connectivity graphs. Can optionally take a "prescribed" fragment mass to further filter results; this can be used to incorporate MSn data. Final @@ -792,9 +792,6 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d :param exact_mass: The exact mass (float) of the target metabolite. - :param path_smi_out: The path of the file to which unique smile strings should be written representing the final - structures generated. If None, no file is written. - :param max_n_substructures: The maximum number of substructures to be used for building molecules. :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by @@ -809,12 +806,6 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d :param ppm: The maximal tolerated m/z deviation (in parts per million) of the mass of substructures from the supplied `fragment_masses`. - :param out_mode: The mode in which to write to the output file. - - * **"w"** Create a new file, overwriting any existing file. - - * **"w"** Append results to an existing file. - :param ncpus: How many CPUs to utilise; if left as None, :py:meth:`os.cpu_count` is used. :param table_name: The table specified within the substructure database will be used to generate @@ -843,7 +834,7 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d prescribed_subset = [m for m in prescribed_subset[0] if abs(m - prescribed_mass) <= fragment_tolerance] if len(prescribed_subset) == 0: - return set() + return {} loss = exact_mass - prescribed_mass exact_mass__1 = round(loss) @@ -859,13 +850,11 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d # select groups of masses at low mass resolution integer_mass_values = [m for m in db.select_mass_values("1", [], table_name) if m <= exact_mass__1] if len(integer_mass_values) == 0: - return set() + return {} integer_subsets = list(subset_sum(integer_mass_values, exact_mass__1, max_n_substructures)) configs_iso = db.k_configs() - if path_smi_out is not None: - smi_out = open(path_smi_out, out_mode) substructure_subsets = [] for integer_subset in integer_subsets: @@ -892,23 +881,23 @@ def build(mf, exact_mass, max_n_substructures, path_smi_out, path_connectivity_d substructure_subsets += build_from_subsets(exact_subset, mf=mf, table_name=table_name, db=db) with multiprocessing.Pool(processes=ncpus) as pool: # send sets of substructures for building - smi_lists = pool.map( + smi_dicts = pool.map( partial(substructure_combination_build, configs_iso=configs_iso, prescribed_structure=prescribed_mass, isomeric_smiles=isomeric_smiles), substructure_subsets ) - smis = set([val for sublist in smi_lists for val in sublist]) - - if path_smi_out is not None: - if len(smis) != 0: - smi_out.writelines("\n".join(smis)) - - smi_out.close() + smi_dict = {} + for d in smi_dicts: + for k in d.keys(): + try: + smi_dict[k].update(d[k]) + except KeyError: + smi_dict[k] = d[k] db.close(clean) - return smis + return smi_dict def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass, table_name="subset_substructures", @@ -938,10 +927,10 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass :param table_name: Defaults to "subset_substructures", which is cleaned up upon database closure. The name of the table to be generated - :return: The name of the temporary secondary substructure table. - :param minimum_frequency: The minimum frequency of substructures in table_name; e.g. substructures have a frequency of 1 if they are unique. + + :return: The name of the temporary secondary substructure table. """ db.cursor.execute("DROP TABLE IF EXISTS %s" % table_name) @@ -1041,10 +1030,14 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ :param configs_iso: Possible substructure combinations extracted from the connectivity database. A tuple containing tuples for each substructure; these tuples specify how many bonds each substructure can make. + :param prescribed_structure: Prescribed fragment mass for building. + + :param isomeric_smiles: True/False, should output smiles be written with isomeric information? + :return: List of smiles representing molecules generated (and the substructures used to generate them). """ - smis = [] + smis = {} for substructure_combination in itertools.product(*substructure_subset): substructure_combination[0]["fragment"] = True @@ -1101,8 +1094,15 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ continue try: # append the canonical smiles of the final structure - smis.append(Chem.MolToSmiles(mol_out, isomericSmiles=isomeric_smiles)) + final_structure = Chem.MolToSmiles(mol_out, isomericSmiles=isomeric_smiles) except RuntimeError: continue # bad bond type violation + final_substructures = set(subs["smiles"] for subs in substructure_combination) + + try: + smis[final_structure].update(final_substructures) + except KeyError: + smis[final_structure] = final_substructures + return smis From 3c04cf04bb18eb99e81292e5f1f75d6d1a171f70 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sat, 21 Nov 2020 22:55:43 +0000 Subject: [PATCH 14/35] Update build unit tests for ResultsDb --- tests/test_build_structures.py | 123 +++++++++++---------------------- 1 file changed, 41 insertions(+), 82 deletions(-) diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 87cc2ab..5fa0e44 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -67,55 +67,44 @@ def test_build(self): # core - all other build functions rely on built_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], - exact_mass=record_dict["exact_mass"], - path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + ".smi"), max_n_substructures=3, + exact_mass=record_dict["exact_mass"], max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", + prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True ) j = 0 - unique_smis = set() - with open(self.to_test_results(record_dict["HMDB_ID"] + ".smi"), "r") as smi_out: - for line in smi_out: - j += 1 - unique_smis.add(line.split()[0]) - self.assertEqual(unique_smis, built_smis) + for smi in built_smis: + j += 1 + self.assertEqual(j, std_lens[i]) if smis[i] is not None: - self.assertEqual(unique_smis, smis[i]) + self.assertEqual(set(built_smis.keys()), smis[i]) else: - self.assertTrue(len(unique_smis) == 51 or len(unique_smis) == 1892) + self.assertTrue(len(built_smis.keys()) == 51 or len(built_smis.keys()) == 1892) # test prescribed substructure building built_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], - exact_mass=record_dict["exact_mass"], - path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + ".smi"), max_n_substructures=3, + exact_mass=record_dict["exact_mass"], max_n_substructures=3, prescribed_mass=fragments[i], ppm=15, clean=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - out_mode="w", ncpus=None, table_name="substructures", - isomeric_smiles=True + ncpus=None, table_name="substructures", isomeric_smiles=True ) j = 0 - unique_smis = set() - with open(self.to_test_results(record_dict["HMDB_ID"] + ".smi"), "r") as smi_out: - for line in smi_out: - j += 1 - unique_smis.add(line.split()[0]) - - self.assertEqual(unique_smis, built_smis) + for smi in built_smis: + j += 1 if i == 2: - self.assertEqual(unique_smis, {'N[C@@H](Cc1ccc(O)cc1)C(=O)O', 'N[C@@H](Cc1cccc(O)c1)C(=O)O'}) + self.assertEqual(set(built_smis.keys()), {'N[C@@H](Cc1ccc(O)cc1)C(=O)O', 'N[C@@H](Cc1cccc(O)c1)C(=O)O'}) - self.assertEqual(len(unique_smis), exp_lens[i]) + self.assertEqual(len(built_smis.keys()), exp_lens[i]) db.close() @@ -127,17 +116,17 @@ def test_substructure_combination_build(self): ((5, 5, 0, 2, 0, 0), (3, 6, 1, 0, 0, 0)), ((2, 4, 0, 2, 0, 0), (4, 8, 0, 4, 0, 0))] configs_iso = db.k_configs() - lens = [0, 1, 60] + lens = [0, 1, 41] for i, ec_product in enumerate(ec_products): substructure_subset = db.select_substructures(ec_product, "substructures") smis = substructure_combination_build(substructure_subset, configs_iso, prescribed_structure=False, isomeric_smiles=True) - self.assertEqual(len(smis), lens[i]) + self.assertEqual(len(smis.keys()), lens[i]) if i == 1: - self.assertEqual(smis, ['NCCc1ccc(O)c(O)c1']) + self.assertEqual(list(smis.keys()), ['NCCc1ccc(O)c(O)c1']) db.close() @@ -185,14 +174,12 @@ def test_generate_structures(self): # tests vs build "exact_mass": record_dict["exact_mass"]}} # test standard building - returned_smis = list(generate_structures( - ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_smi_out=self.to_test_results(), - path_connectivity_db=self.to_test_data("connectivity.sqlite"), - path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True, - isomeric_smiles=True - )) + returned_smis = list( + generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), + path_sql_out=self.to_test_results("results.sqlite"), + max_degree=6, max_atoms_available=2, max_n_substructures=3, + path_connectivity_db=self.to_test_data("connectivity.sqlite"), + minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] @@ -200,20 +187,12 @@ def test_generate_structures(self): # tests vs build mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], exact_mass=record_dict["exact_mass"], - path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + "_build.smi"), max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", - isomeric_smiles=True + prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True ) - unique_smis = set() - with open(self.to_test_results(record_dict["HMDB_ID"] + ".smi"), "r") as smi_out: - for line in smi_out: - unique_smis.add(line.split()[0]) - - self.assertEqual(unique_smis, returned_smis) - self.assertEqual(unique_smis, build_smis) + self.assertEqual(set(build_smis.keys()), returned_smis) ms_data = {record_dict["HMDB_ID"]: {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], @@ -221,36 +200,26 @@ def test_generate_structures(self): # tests vs build "prescribed_masses": fragments[i]}} # test prescribed building - returned_smis = list(generate_structures( - ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_smi_out=self.to_test_results(), - path_connectivity_db=self.to_test_data("connectivity.sqlite"), - path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True, - isomeric_smiles=True - )) + returned_smis = list( + generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), + path_sql_out=self.to_test_results("results.sqlite"), + max_degree=6, max_atoms_available=2, max_n_substructures=3, + path_connectivity_db=self.to_test_data("connectivity.sqlite"), + minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] build_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], - exact_mass=record_dict["exact_mass"], - path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + "_build.smi"), max_n_substructures=3, + exact_mass=record_dict["exact_mass"], max_n_substructures=3, prescribed_mass=fragments[i], ppm=0, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - out_mode="w", ncpus=None, table_name="substructures", clean=True, - isomeric_smiles=True + ncpus=None, table_name="substructures", clean=True, isomeric_smiles=True ) - unique_smis = set() - with open(self.to_test_results(record_dict["HMDB_ID"] + ".smi"), "r") as smi_out: - for line in smi_out: - unique_smis.add(line.split()[0]) - - self.assertEqual(unique_smis, returned_smis) - self.assertEqual(unique_smis, build_smis) + self.assertEqual(set(build_smis.keys()), returned_smis) ms_data = {} for i, record_dict in enumerate(record_dicts.values()): @@ -261,34 +230,24 @@ def test_generate_structures(self): # tests vs build "prescribed_masses": None} # test building with multiple inputs - returned_smi_list = list(generate_structures( - ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_smi_out=self.to_test_results(), - path_connectivity_db=self.to_test_data("connectivity.sqlite"), - path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_set=True, - isomeric_smiles=True - )) + returned_smi_list = list( + generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), + path_sql_out=self.to_test_results("results.sqlite"), + max_degree=6, max_atoms_available=2, max_n_substructures=3, + path_connectivity_db=self.to_test_data("connectivity.sqlite"), + minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) for i, record_dict in enumerate(record_dicts.values()): build_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], exact_mass=record_dict["exact_mass"], - path_smi_out=self.to_test_results(record_dict["HMDB_ID"] + "_build.smi"), max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, out_mode="w", ncpus=None, table_name="substructures", - isomeric_smiles=True + prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True ) - unique_smis = set() - with open(self.to_test_results(record_dict["HMDB_ID"] + ".smi"), "r") as smi_out: - for line in smi_out: - unique_smis.add(line.split()[0]) - - self.assertEqual(unique_smis, returned_smi_list[i][record_dict["HMDB_ID"]]) - self.assertEqual(unique_smis, build_smis) + self.assertEqual(set(build_smis.keys()), returned_smi_list[i][record_dict["HMDB_ID"]]) db.close() From 8a9dd0e2a7b7a7e87cd94c3641258f514d919ba6 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sun, 22 Nov 2020 01:19:32 +0000 Subject: [PATCH 15/35] Add CSV output for build functions --- metaboblend/build_structures.py | 57 +++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 275270d..8fc9239 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -27,6 +27,7 @@ import networkx as nx import numpy import sqlite3 +import csv from operator import itemgetter from typing import Sequence, Dict, Union @@ -283,13 +284,14 @@ class ResultsDb: Methods for interacting with the SQLITE3 results database, as created by :py:meth:`metaboblend.build_structures.annotate_msn`. - :param path_results_db: Path to the results database. + :param path_results: Directory to which results will be written. """ - def __init__(self, path_results_db, msn=True): + def __init__(self, path_results, msn=True): """Constructor method.""" - self.path_results_db = path_results_db + self.path_results = path_results + self.path_results_db = os.path.join(self.path_results, "metaboblend_results.sqlite") self.msn = msn self.conn = None @@ -508,15 +510,43 @@ def get_structures(self, ms_id): else: return set(structure_frequencies.keys()) + def generate_csv_output(self): + """ + Generate CSV file output for i) queries and tool parameters and ii) structures generated. + """ + + with open(os.path.join(self.path_results, "metaboblend_queries.csv"), "w", newline="") as results_file, \ + open(os.path.join(self.path_results, "metaboblend_structures.csv"), "w", newline="") as ms_file: + + results_writer = csv.writer(results_file, delimiter=",") + ms_writer = csv.writer(ms_file, delimiter=",") + + results_writer.writerow(["ms_id", "exact_mass", "C", "H", "N", "O", "P", "S", "ppm", "ha_min", "ha_max", + "max_atoms_available", "max_degree", "max_n_substructures", + "hydrogenation_allowance", "isomeric_smiles"]) + + self.cursor.execute("SELECT * FROM queries") + + for query in self.cursor.fetchall(): + results_writer.writerow(query) + + ms_writer.writerow(["ms_id", "smiles", "frequency", "exact_mass", "C", "H", "N", "O", "P", "S"]) + + self.cursor.execute("SELECT * FROM structures") + + for structure in self.cursor.fetchall(): + ms_writer.writerow(structure) + def close(self): - """Close the connection to the SQLITE3 database""" + """Close the connection to the SQLITE3 database.""" self.conn.close() def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], - path_substructure_db: Union[str, bytes, os.PathLike], - path_sql_out: Union[str, bytes, os.PathLike] = "metaboblend_results.sqlite", + path_substructure_db: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), + path_out: Union[str, bytes, os.PathLike] = "", + write_csv_output: bool = False, ppm: int = 5, ha_min: Union[int, None] = None, ha_max: Union[int, None] = None, @@ -545,7 +575,7 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. - :param path_sql_out: The path at which an SQLite database will be generated to store results. + :param path_out: Folder to which the SQLite 3 results database and CSV outputs should be written. :param ppm: The maximal tolerated m/z deviation (in parts per million) of the mass of substructures from the supplied `fragment_masses`. @@ -602,7 +632,7 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], path_connectivity_db = os.path.join(os.path.realpath(os.path.dirname(__file__)), "data", "connectivity.sqlite") db = SubstructureDb(path_substructure_db, path_connectivity_db) - results_db = ResultsDb(path_sql_out) + results_db = ResultsDb(path_out) results_db.create_results_db() # prepare temporary table here - will only be generated once in case of multiple input @@ -646,13 +676,17 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], if yield_smi_dict: yield {ms_id: results_db.get_structures(ms_id)} + if write_csv_output: + results_db.generate_csv_output() + db.close() results_db.close() def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], path_substructure_db: Union[str, bytes, os.PathLike], - path_sql_out: Union[str, bytes, os.PathLike] = "metaboblend_results.sqlite", + path_out: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), + write_csv_output: bool = False, ha_min: Union[int, None] = 2, ha_max: Union[int, None] = 9, max_degree: int = 6, @@ -726,7 +760,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], """ db = SubstructureDb(path_substructure_db, path_connectivity_db) - results_db = ResultsDb(path_sql_out, False) + results_db = ResultsDb(path_out, False) results_db.create_results_db() if path_connectivity_db is None: @@ -773,6 +807,9 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], if yield_smi_set: yield {ms_id: results_db.get_structures(ms_id)} + if write_csv_output: + results_db.generate_csv_output() + db.close() From c75ac3864cf84d6a1b75d5a9177c21d8fa286f25 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sun, 22 Nov 2020 01:21:20 +0000 Subject: [PATCH 16/35] Check if ResultsDb output matches reference files --- tests/test_build_structures.py | 50 +++++++++++++++++++--- tests/test_data/metaboblend_queries.csv | 5 +++ tests/test_data/metaboblend_structures.csv | 47 ++++++++++++++++++++ tests/test_suite_build_structures.py | 2 +- 4 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 tests/test_data/metaboblend_queries.csv create mode 100644 tests/test_data/metaboblend_structures.csv diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 5fa0e44..c1c9c21 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -22,6 +22,7 @@ import unittest import shutil +import filecmp import tempfile from metaboblend.build_structures import * from metaboblend.databases import * @@ -176,7 +177,7 @@ def test_generate_structures(self): # tests vs build # test standard building returned_smis = list( generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), - path_sql_out=self.to_test_results("results.sqlite"), + write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) @@ -202,7 +203,7 @@ def test_generate_structures(self): # tests vs build # test prescribed building returned_smis = list( generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), - path_sql_out=self.to_test_results("results.sqlite"), + write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) @@ -232,7 +233,7 @@ def test_generate_structures(self): # tests vs build # test building with multiple inputs returned_smi_list = list( generate_structures(ms_data, path_substructure_db=self.to_test_data("substructures.sqlite"), - path_sql_out=self.to_test_results("results.sqlite"), + write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) @@ -278,7 +279,7 @@ def test_annotate_msn(self): # tests vs build_msn # test standard building returned_smis = list(annotate_msn( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_sql_out=self.to_test_results("results.sqlite"), + write_csv_output=True, path_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smi_dict=True, isomeric_smiles=True @@ -307,7 +308,7 @@ def test_annotate_msn(self): # tests vs build_msn # test building with multiple inputs returned_smi_list = list(annotate_msn( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - path_sql_out=self.to_test_results("annotate_multi", "results.sqlite"), + path_out=self.to_test_results("annotate_multi"), write_csv_output=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smi_dict=True, @@ -316,9 +317,46 @@ def test_annotate_msn(self): # tests vs build_msn for i, record_dict in enumerate(record_dicts.values()): self.assertEqual(len(set(returned_smi_list[i][record_dict["HMDB_ID"]].keys())), overall_lens[i]) - + db.close() + def test_results_db(self): + fragments = [56.05, 60.0211, 68.0262, 56.0262] + + with open(self.to_test_data("test_hmdbs.dictionary"), "rb") as test_hmdbs: + record_dicts = pickle.load(test_hmdbs) + + ms_data = {} + for i, record_dict in enumerate(record_dicts.values()): + record_dict["mol"] = Chem.MolFromSmiles(record_dict["smiles"]) + ms_data[record_dict["HMDB_ID"]] = {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], + record_dict["O"], record_dict["P"], record_dict["S"]], + "exact_mass": record_dict["exact_mass"], + "fragment_masses": fragments} + + os.mkdir(self.to_test_results("test_results_db")) + + list(annotate_msn( + ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, + path_out=self.to_test_results("test_results_db"), write_csv_output=True, + path_connectivity_db=self.to_test_data("connectivity.sqlite"), + path_substructure_db=self.to_test_data("substructures.sqlite"), + minimum_frequency=None, yield_smi_dict=True, + isomeric_smiles=True + )) + + # is the sqlite database the size we expect? + self.assertEqual(os.path.getsize(self.to_test_results("test_results_db", "metaboblend_results.sqlite")), 61440) + + # do the files generated by ResultsDb match what we expect? if not, find differences + self.assertTrue(filecmp.cmp(self.to_test_results("test_results_db", "metaboblend_queries.csv"), + self.to_test_data("metaboblend_queries.csv"), + shallow=False)) + + self.assertTrue(filecmp.cmp(self.to_test_results("test_results_db", "metaboblend_structures.csv"), + self.to_test_data("metaboblend_structures.csv"), + shallow=False)) + def test_gen_subs_table(self): db = SubstructureDb(self.to_test_data("substructures.sqlite"), "") table_name = gen_subs_table(db, 5, 6, 4, 2, 500) diff --git a/tests/test_data/metaboblend_queries.csv b/tests/test_data/metaboblend_queries.csv new file mode 100644 index 0000000..5f6dd89 --- /dev/null +++ b/tests/test_data/metaboblend_queries.csv @@ -0,0 +1,5 @@ +ms_id,exact_mass,C,H,N,O,P,S,ppm,ha_min,ha_max,max_atoms_available,max_degree,max_n_substructures,hydrogenation_allowance,isomeric_smiles +HMDB0000073,153.078979,8,11,1,2,0,0,5,,,2,6,3,2,1 +HMDB0000122,180.06339,6,12,0,6,0,0,5,,,2,6,3,2,1 +HMDB0000158,181.073894,9,11,1,3,0,0,5,,,2,6,3,2,1 +HMDB0000186,342.116215,12,22,0,11,0,0,5,,,2,6,3,2,1 diff --git a/tests/test_data/metaboblend_structures.csv b/tests/test_data/metaboblend_structures.csv new file mode 100644 index 0000000..2958015 --- /dev/null +++ b/tests/test_data/metaboblend_structures.csv @@ -0,0 +1,47 @@ +ms_id,smiles,frequency,exact_mass,C,H,N,O,P,S +HMDB0000073,NCCc1ccc(O)c(O)c1,3,153.07897899999998,8,11,1,2,0,0 +HMDB0000073,NCCc1cc(O)cc(O)c1,1,153.07897899999998,8,11,1,2,0,0 +HMDB0000073,NCCc1cc(O)ccc1O,1,153.07897899999998,8,11,1,2,0,0 +HMDB0000122,OC1C(O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC1[C@H](O)C(O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC1[C@H](O)[C@@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC1[C@H](O)[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC1[C@H](O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC1[C@H](O)[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)C(O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)O[C@H](CO)C1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)O[C@@H]1CO,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OO[C@H](CO)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1OO[C@H](CO)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](CO)OC1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)O[C@@H]1CO,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 +HMDB0000158,N[C@@H](Cc1ccc(O)cc1)C(=O)O,1,181.07389399999997,9,11,1,3,0,0 +HMDB0000158,N[C@@H](Cc1cccc(O)c1)C(=O)O,1,181.07389399999997,9,11,1,3,0,0 diff --git a/tests/test_suite_build_structures.py b/tests/test_suite_build_structures.py index 48176ba..0cc6ee1 100644 --- a/tests/test_suite_build_structures.py +++ b/tests/test_suite_build_structures.py @@ -36,4 +36,4 @@ suite.addTest(unittest.findTestCases(test_build_structures)) report = os.path.join(os.path.abspath(os.path.join(__file__, os.pardir)), 'results', 'results_test_suite_build_structures') - runTestSuite(suite, report, title='Process Test Suite Report',verbosity=2) + runTestSuite(suite, report, title='Process Test Suite Report', verbosity=2) From 94e31cd150e15e9b48f82f421a0a10968b0f672f Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Sun, 22 Nov 2020 01:43:01 +0000 Subject: [PATCH 17/35] Check ResultsDb CSV files line by line vs reference --- tests/test_build_structures.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index c1c9c21..167c118 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -22,7 +22,6 @@ import unittest import shutil -import filecmp import tempfile from metaboblend.build_structures import * from metaboblend.databases import * @@ -348,14 +347,18 @@ def test_results_db(self): # is the sqlite database the size we expect? self.assertEqual(os.path.getsize(self.to_test_results("test_results_db", "metaboblend_results.sqlite")), 61440) - # do the files generated by ResultsDb match what we expect? if not, find differences - self.assertTrue(filecmp.cmp(self.to_test_results("test_results_db", "metaboblend_queries.csv"), - self.to_test_data("metaboblend_queries.csv"), - shallow=False)) + # are the csv files the same as the reference? + with open(self.to_test_results("test_results_db", "metaboblend_queries.csv"), "r") as results_file, \ + open(self.to_test_data("metaboblend_queries.csv"), "r") as test_file: - self.assertTrue(filecmp.cmp(self.to_test_results("test_results_db", "metaboblend_structures.csv"), - self.to_test_data("metaboblend_structures.csv"), - shallow=False)) + for results_line, test_line in zip(results_file, test_file): + self.assertEqual(results_line, test_line) + + with open(self.to_test_results("test_results_db", "metaboblend_structures.csv"), "r") as results_file, \ + open(self.to_test_data("metaboblend_structures.csv"), "r") as test_file: + + for results_line, test_line in zip(results_file, test_file): + self.assertEqual(results_line, test_line) def test_gen_subs_table(self): db = SubstructureDb(self.to_test_data("substructures.sqlite"), "") From 38c8ee0ab15bed436522b723b5ff543b21af3927 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Sun, 22 Nov 2020 14:28:00 +0000 Subject: [PATCH 18/35] Implement simple bond dissociation energy calculations --- metaboblend/build_structures.py | 82 ++++++++++++++++++++++++--------- tests/test_build_structures.py | 5 +- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 8fc9239..ea450ec 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -214,7 +214,7 @@ def reindex_atoms(records): return mol_comb, atoms_available, atoms_to_remove, bond_types, bond_mismatch -def add_bonds(mols, edges, atoms_available, bond_types): +def add_bonds(mols, edges, atoms_available, bond_types, bond_enthalpies): """ Takes a set of substructures and attempts to combine them together to generate a final structure. One of the last steps in the :py:meth:`metaboblend.build_structures.build` workflow. @@ -244,28 +244,32 @@ def add_bonds(mols, edges, atoms_available, bond_types): 1.5: Chem.rdchem.BondType.AROMATIC, 2: Chem.rdchem.BondType.DOUBLE} + bond_types_copy = copy.deepcopy(bond_types) # deep copy as we modify items within the dict + g = nx.Graph() g.add_edges_from(edges) g = nx.relabel_nodes(g, dict(zip(sorted(g.nodes()), atoms_available))) + total_bde = 0 + mol_edit = Chem.EditableMol(mols) for edge in g.edges(): - if edge[0] in bond_types: - bt_start = copy.copy(bond_types[edge[0]]) + if edge[0] in bond_types_copy: + bt_start = bond_types_copy[edge[0]] else: - return None # nested dummy + return None, None # nested dummy - if edge[1] in bond_types: - bt_end = copy.copy(bond_types[edge[1]]) + if edge[1] in bond_types_copy: + bt_end = bond_types_copy[edge[1]] else: - return None # nested dummy + return None, None # nested dummy bond_matches = list(set(bt_start).intersection(bt_end)) if len(bond_matches) == 0: - return None + return None, None else: bt_start.remove(bond_matches[0]) @@ -274,9 +278,14 @@ def add_bonds(mols, edges, atoms_available, bond_types): try: mol_edit.AddBond(edge[0], edge[1], rdkit_bond_types[bond_matches[0]]) except KeyError: - return None # unknown bond type + return None, None # unknown bond type + + try: + total_bde += bond_enthalpies[bond_matches[0]][mols.GetAtomWithIdx(edge[0]).GetSymbol()][mols.GetAtomWithIdx(edge[1]).GetSymbol()] + except SyntaxError: + total_bde = None - return mol_edit + return mol_edit, total_bde class ResultsDb: @@ -358,6 +367,7 @@ def create_results_db(self): ms_id TEXT, fragment_id NUMERIC, structure_smiles TEXT, + bde NUMERIC, PRIMARY KEY(ms_id, fragment_id, structure_smiles))""") self.conn.commit() @@ -440,14 +450,16 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): self.cursor.execute("""INSERT INTO results ( ms_id, fragment_id, - structure_smiles - ) VALUES ('{}', '{}', '{}')""".format( + structure_smiles, + bde + ) VALUES ('{}', '{}', '{}', '{}')""".format( ms_id, fragment_id, - structure_smiles + structure_smiles, + smi_dict[structure_smiles][0] )) - for substructure_smiles in smi_dict[structure_smiles]: + for substructure_smiles in smi_dict[structure_smiles][1]: self.cursor.execute("""INSERT INTO substructures ( structure_smiles, @@ -920,7 +932,8 @@ def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substr with multiprocessing.Pool(processes=ncpus) as pool: # send sets of substructures for building smi_dicts = pool.map( partial(substructure_combination_build, configs_iso=configs_iso, - prescribed_structure=prescribed_mass, isomeric_smiles=isomeric_smiles), + prescribed_structure=prescribed_mass, isomeric_smiles=isomeric_smiles, + bond_enthalpies=get_bond_enthalpies()), substructure_subsets ) @@ -928,7 +941,11 @@ def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substr for d in smi_dicts: for k in d.keys(): try: - smi_dict[k].update(d[k]) + smi_dict[k][1].update(d[k][1]) + + if d[k][0] < smi_dict[k][0]: + smi_dict[k][0] = d[k][0] + except KeyError: smi_dict[k] = d[k] @@ -1057,7 +1074,26 @@ def build_from_subsets(exact_subset, mf, table_name, db): return substructure_subsets -def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles): +def get_bond_enthalpies(): + + return {1.0: {'C': {'C': 348, 'N': 305, 'O': 360, 'P': 264, 'S': 272}, + 'N': {'C': 305, 'N': 163, 'O': 222, 'P': None, 'S': None}, + 'O': {'C': 360, 'N': 222, 'O': 146, 'P': 335, 'S': None}, + 'P': {'C': 264, 'N': None, 'O': 335, 'P': 201, 'S': None}, + 'S': {'C': 272, 'N': None, 'O': None, 'P': None, 'S': 226}}, + 1.5: {'C': {'C': 837, 'N': 890, 'O': None, 'P': None, 'S': None}, + 'N': {'C': 890, 'N': 944, 'O': None, 'P': None, 'S': None}, + 'O': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, + 'P': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, + 'S': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}}, + 2.0: {'C': {'C': 612, 'N': 613, 'O': 743, 'P': None, 'S': 573}, + 'N': {'C': 613, 'N': 409, 'O': 607, 'P': None, 'S': None}, + 'O': {'C': 743, 'N': 607, 'O': 496, 'P': 544, 'S': 522}, + 'P': {'C': None, 'N': None, 'O': 544, 'P': None, 'S': 335}, + 'S': {'C': 573, 'N': None, 'O': 522, 'P': 335, 'S': 425}}} + + +def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles, bond_enthalpies): """ Final stage for building molecules; takes a combination of substructures (substructure_combination) and builds them according to graphs in the substructure database. May be run in parallel. @@ -1115,9 +1151,9 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ if non_fragment_edges: continue - mol_e = add_bonds(mol_comb, edges, atoms_available, bond_types) # add bonds between substructures + mol_e, total_bde = add_bonds(mol_comb, edges, atoms_available, bond_types, bond_enthalpies) # add bonds between substructures - if mol_e is None: + if mol_e is None or total_bde is None: continue atoms_to_remove.sort(reverse=True) @@ -1138,8 +1174,12 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ final_substructures = set(subs["smiles"] for subs in substructure_combination) try: - smis[final_structure].update(final_substructures) + smis[final_structure][1].update(final_substructures) + + if total_bde < smis[final_structure][0]: + smis[final_structure][0] = total_bde + except KeyError: - smis[final_structure] = final_substructures + smis[final_structure] = [total_bde, final_substructures] return smis diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 167c118..dcbab3f 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -121,7 +121,8 @@ def test_substructure_combination_build(self): for i, ec_product in enumerate(ec_products): substructure_subset = db.select_substructures(ec_product, "substructures") smis = substructure_combination_build(substructure_subset, configs_iso, - prescribed_structure=False, isomeric_smiles=True) + prescribed_structure=False, isomeric_smiles=True, + bond_enthalpies=get_bond_enthalpies()) self.assertEqual(len(smis.keys()), lens[i]) @@ -440,7 +441,7 @@ def test_add_bonds(self): mol_out = [None, "*[CH]1(O)OC2**[CH](O)(C(O)C2O)[CH]1(*)O", "*C[CH](N)(C(=O)O)c1(*)ccc(O)cc1"] for i in range(len(atoms_available)): - mol_e = add_bonds(mol_comb[i], edges[i], atoms_available[i], bond_types[i]) + mol_e, total_bde = add_bonds(mol_comb[i], edges[i], atoms_available[i], bond_types[i], get_bond_enthalpies()) if i == 0: self.assertTrue(mol_e is None) From be844fcc94bd2b3cf9b67036bd81b4bafdc48ff4 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Wed, 25 Nov 2020 19:45:38 +0000 Subject: [PATCH 19/35] Add integer MS integer IDs and implement calculate_frequencies to more efficiently calculate structure frequencies --- metaboblend/build_structures.py | 209 ++++++++++----------- tests/test_build_structures.py | 26 +-- tests/test_data/metaboblend_queries.csv | 8 +- tests/test_data/metaboblend_structures.csv | 92 ++++----- 4 files changed, 164 insertions(+), 171 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index ea450ec..6709df3 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -282,7 +282,7 @@ def add_bonds(mols, edges, atoms_available, bond_types, bond_enthalpies): try: total_bde += bond_enthalpies[bond_matches[0]][mols.GetAtomWithIdx(edge[0]).GetSymbol()][mols.GetAtomWithIdx(edge[1]).GetSymbol()] - except SyntaxError: + except (SyntaxError, TypeError): total_bde = None return mol_edit, total_bde @@ -321,7 +321,8 @@ def create_results_db(self): self.connect() self.cursor.execute("""CREATE TABLE queries ( - ms_id TEXT PRIMARY KEY, + ms_id_num NUMERIC PRIMARY KEY, + ms_id TEXT, exact_mass NUMERIC, C INTEGER, H INTEGER, @@ -340,23 +341,16 @@ def create_results_db(self): if self.msn: self.cursor.execute("""CREATE TABLE spectra ( - ms_id TEXT, + ms_id_num NUMERIC, fragment_id NUMERIC, neutral_mass NUMERIC, - PRIMARY KEY (ms_id, fragment_id))""") + PRIMARY KEY (ms_id_num, fragment_id))""") self.cursor.execute("""CREATE TABLE structures ( - ms_id TEXT, - smiles TEXT, + ms_id_num NUMERIC, + structure_smiles TEXT, frequency NUMERIC, - exact_mass NUMERIC, - C INTEGER, - H INTEGER, - N INTEGER, - O INTEGER, - P INTEGER, - S INTEGER, - PRIMARY KEY (ms_id, smiles))""") + PRIMARY KEY (ms_id_num, structure_smiles))""") self.cursor.execute("""CREATE TABLE substructures ( structure_smiles TEXT , @@ -364,15 +358,15 @@ def create_results_db(self): PRIMARY KEY (structure_smiles, substructure_smiles))""") self.cursor.execute("""CREATE TABLE results ( - ms_id TEXT, + ms_id_num NUMERIC, fragment_id NUMERIC, structure_smiles TEXT, bde NUMERIC, - PRIMARY KEY(ms_id, fragment_id, structure_smiles))""") + PRIMARY KEY(ms_id_num, fragment_id, structure_smiles))""") self.conn.commit() - def add_ms(self, msn_data, parameters): + def add_ms(self, msn_data, ms_id, ms_id_num, parameters): """ Add entries to the `queries` and `spectra` tables. @@ -383,6 +377,10 @@ def add_ms(self, msn_data, parameters): and fragment_masses are neutral fragment masses generated by this structure used to inform candidate scoring. See :py:meth:`metaboblend.build_structures.annotate_msn`. + :param ms_id: Unique identifier for the annotation of a single metabolite. + + :param ms_id_num: Unique numeric identifier for the annotation of a single metaoblite. + :param parameters: List of parameters, in the form: [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, hydrogenation_allowance, isomeric_smiles]. See :py:meth:`metaboblend.build_structures.annotate_msn`. @@ -394,36 +392,36 @@ def add_ms(self, msn_data, parameters): elif isinstance(parameter, bool): parameters[i] = int(parameter) - for ms_id in msn_data.keys(): - - self.cursor.execute("""INSERT INTO queries ( - ms_id, - exact_mass, - C, H, N, O, P, S, - ppm, - ha_min, - ha_max, - max_atoms_available, - max_degree, - max_n_substructures, - hydrogenation_allowance, - isomeric_smiles - ) VALUES ('{}', {}, '{}', '{}', '{}', '{}', '{}', '{}', {})""".format( - ms_id, - msn_data[ms_id]["exact_mass"], - msn_data[ms_id]["mf"][0], msn_data[ms_id]["mf"][1], - msn_data[ms_id]["mf"][2], msn_data[ms_id]["mf"][3], - msn_data[ms_id]["mf"][4], msn_data[ms_id]["mf"][5], - ", ".join([str(p) for p in parameters]) - )) + self.cursor.execute("""INSERT INTO queries ( + ms_id, + ms_id_num, + exact_mass, + C, H, N, O, P, S, + ppm, + ha_min, + ha_max, + max_atoms_available, + max_degree, + max_n_substructures, + hydrogenation_allowance, + isomeric_smiles + ) VALUES ('{}', {}, {}, '{}', '{}', '{}', '{}', '{}', '{}', {})""".format( + ms_id, + ms_id_num, + msn_data[ms_id]["exact_mass"], + msn_data[ms_id]["mf"][0], msn_data[ms_id]["mf"][1], + msn_data[ms_id]["mf"][2], msn_data[ms_id]["mf"][3], + msn_data[ms_id]["mf"][4], msn_data[ms_id]["mf"][5], + ", ".join([str(p) for p in parameters]) + )) self.conn.commit() - def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): + def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None): """ Record which smiles were generated for a given fragment mass. - :param ms_id: Unique identifier for the annotation of a single metabolite. + :param ms_id_num: Unique identifier for the annotation of a single metabolite. :param smi_dict: The fragment and substructure smiles generated by the annotation of a single peak for a single metabolite. @@ -435,11 +433,11 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): if self.msn: self.cursor.execute("""INSERT OR IGNORE INTO spectra ( - ms_id, + ms_id_num, fragment_id, neutral_mass ) VALUES ('{}', {}, {})""".format( - ms_id, + ms_id_num, fragment_id, fragment_mass )) @@ -447,13 +445,13 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): fragment_id = "NULL" for structure_smiles in smi_dict.keys(): - self.cursor.execute("""INSERT INTO results ( - ms_id, + self.cursor.execute("""INSERT OR IGNORE INTO results ( + ms_id_num, fragment_id, structure_smiles, bde ) VALUES ('{}', '{}', '{}', '{}')""".format( - ms_id, + ms_id_num, fragment_id, structure_smiles, smi_dict[structure_smiles][0] @@ -461,7 +459,7 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): for substructure_smiles in smi_dict[structure_smiles][1]: - self.cursor.execute("""INSERT INTO substructures ( + self.cursor.execute("""INSERT OR IGNORE INTO substructures ( structure_smiles, substructure_smiles ) VALUES ('{}', '{}')""".format( @@ -471,56 +469,44 @@ def add_results(self, ms_id, smi_dict, fragment_mass=None, fragment_id=None): self.conn.commit() - def get_structures(self, ms_id): + def calculate_frequencies(self, ms_id_num): + """ + Calculates structure frequencies in the SQLite DB. + + :param ms_id_num: Unique identifier for the annotation of a single metabolite. + """ + + self.cursor.execute("""INSERT INTO structures (ms_id_num, structure_smiles, frequency) + SELECT ms_id_num, structure_smiles, COUNT(*) + FROM results + WHERE ms_id_num = {} + GROUP BY structure_smiles""".format(ms_id_num)) + + def get_structures(self, ms_id_num): """ Gets smiles of generated structures. In the case of the MSn annotation workflow, also gets structure frequencies. - :param ms_id: Unique identifier for the annotation of a single metabolite. + :param ms_id_num: Unique identifier for the annotation of a single metabolite. :return: In the case of simple structure generation, returns a set of smiles strings for output structures. For the MSn annotation workflow, returns a dictionary with smiles as keys and the number of peaks for which the smiles were generated as values. """ - structure_frequencies = {} - self.cursor.execute("SELECT DISTINCT structure_smiles FROM results WHERE ms_id = '%s'" % ms_id) - - for structure_smiles in self.cursor.fetchall(): - structure_mol = Chem.MolFromSmiles(structure_smiles[0]) - structure_mass = calculate_exact_mass(structure_mol) - structure_mc = get_elements(structure_mol) - - if self.msn: - self.cursor.execute("""SELECT DISTINCT fragment_id FROM results - WHERE ms_id = '{}' - AND structure_smiles = '{}'""".format(ms_id, structure_smiles[0])) - - structure_frequency = len(self.cursor.fetchall()) - else: - structure_frequency = "NULL" - - self.cursor.execute("""INSERT INTO structures ( - ms_id, - smiles, - exact_mass, - C, H, N, O, P, S, - frequency - ) VALUES ('{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}', '{}')""".format( - ms_id, - structure_smiles[0], - structure_mass, - structure_mc["C"], structure_mc["H"], structure_mc["N"], - structure_mc["O"], structure_mc["P"], structure_mc["S"], - structure_frequency - )) + if self.msn: + msn_str = ", frequency" + else: + msn_str = "" - structure_frequencies[structure_smiles[0]] = structure_frequency + self.cursor.execute("""SELECT structure_smiles{} FROM structures + WHERE ms_id_num = {} + """.format(msn_str, ms_id_num)) if self.msn: - return structure_frequencies + return [t for t in self.cursor.fetchall()] else: - return set(structure_frequencies.keys()) + return [item for t in self.cursor.fetchall() for item in t] def generate_csv_output(self): """ @@ -558,7 +544,6 @@ def close(self): def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], path_substructure_db: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), path_out: Union[str, bytes, os.PathLike] = "", - write_csv_output: bool = False, ppm: int = 5, ha_min: Union[int, None] = None, ha_max: Union[int, None] = None, @@ -569,8 +554,9 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], ncpus: Union[int, None] = None, minimum_frequency: Union[int, None] = None, hydrogenation_allowance: int = 2, - yield_smi_dict: bool = True, - isomeric_smiles: bool = False + yield_smis: bool = True, + isomeric_smiles: bool = False, + write_csv_output: bool = True ) -> Dict[str, Sequence[Dict[str, int]]]: """ Generate molecules of a given mass using chemical substructures, connectivity graphs and spectral trees or @@ -627,11 +613,13 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], `hydrogenation_allowance = 1` then, to find candidate fragment substructures, we use as query the masses `[141.5938 - 1.007825, 141.5938, 141.5938 + 1.007825]`. - :param yield_smi_dict: If True, for each input molecule the function yields a dictionary whose keys are SMILEs - strings and values are the number of `fragment_masses` by which the structure was generated. Else, returns None. + :param yield_smis: If True, for each input molecule the function yields SMILEs the number of `fragment_masses` by + which the structure was generated. Else, returns None. :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. + :param write_csv_output: Whether to extract results from the SQLite3 database for deposition in CSV files. + :return: For each input molecule yields a dictionary whose keys are SMILEs strings for the generated structures and values are the number of `fragment_masses` by which the structure was built (unless `yield_smi_dict = False`). @@ -658,11 +646,11 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], max_mass=round(max([msn_data[ms_id]["exact_mass"] for ms_id in msn_data.keys()])) ) - results_db.add_ms(msn_data, [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, - hydrogenation_allowance, isomeric_smiles]) - for i, ms_id in enumerate(msn_data.keys()): + results_db.add_ms(msn_data, ms_id, i, + [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, hydrogenation_allowance, isomeric_smiles]) + for j, fragment_mass in enumerate(msn_data[ms_id]["fragment_masses"]): for k in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): @@ -682,11 +670,13 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], isomeric_smiles=isomeric_smiles ) - results_db.add_results(ms_id, smi_dict, fragment_mass, j) + results_db.add_results(i, smi_dict, fragment_mass, j) fragment_smis = None - if yield_smi_dict: - yield {ms_id: results_db.get_structures(ms_id)} + results_db.calculate_frequencies(i) + + if yield_smis: + yield {ms_id: results_db.get_structures(i)} if write_csv_output: results_db.generate_csv_output() @@ -698,7 +688,6 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], path_substructure_db: Union[str, bytes, os.PathLike], path_out: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), - write_csv_output: bool = False, ha_min: Union[int, None] = 2, ha_max: Union[int, None] = 9, max_degree: int = 6, @@ -707,8 +696,9 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], ncpus: Union[int, None] = None, path_connectivity_db: Union[str, bytes, os.PathLike, None] = None, minimum_frequency: Union[int, None] = None, - yield_smi_set: bool = True, - isomeric_smiles: bool = False + yield_smis: bool = True, + isomeric_smiles: bool = False, + write_csv_output: bool = True, ) -> Dict[str, Sequence[set]]: """ Generate molecules of a given mass using chemical substructures and connectivity graphs. Can optionally take a @@ -725,8 +715,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. - :param path_sql_out: The path to the SQLite 3 substructure database, as generated by - :py:meth:`metaboblend.databases.SubstructureDb`. + :param path_out: Folder to which the SQLite 3 results database and CSV outputs should be written. :param ha_min: The minimum size (number of heavy atoms) of substructures to be used to build final structures. If None, no limit is applied. @@ -763,12 +752,13 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], :param minimum_frequency: The minimum frequency of substructures in table_name; e.g. substructures have a frequency of 1 if they are unique. Defaults to None, in which case this filtering method is not applied. - :param yield_smi_set: If True, yields a set of unique SMILEs string for each input molecule, else returns None. + :param yield_smis: If True, yields a set of unique SMILEs string for each input molecule, else returns None. :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. - :return: For each input molecule, yields a set of unique SMILEs strings (unless - `yield_smi_set = False`). + :param write_csv_output: Whether to extract results from the SQLite3 database for deposition in CSV files. + + :return: For each input molecule, yields unique SMILEs strings (unless `yield_smis = False`). """ db = SubstructureDb(path_substructure_db, path_connectivity_db) @@ -789,11 +779,13 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], max_mass=round(max([ms_data[ms_id]["exact_mass"] for ms_id in ms_data.keys()])) ) - results_db.add_ms(ms_data, [None, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, - None, isomeric_smiles]) - for ms_id in ms_data.keys(): + for i, ms_id in enumerate(ms_data.keys()): + + results_db.add_ms(ms_data, ms_id, i, + [None, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, None, isomeric_smiles]) ppm = None + try: if ms_data[ms_id]["prescribed_masses"] is not None: ppm = 0 @@ -814,10 +806,11 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], isomeric_smiles=isomeric_smiles ) - results_db.add_results(ms_id, smi_dict, ms_data[ms_id]["prescribed_masses"]) + results_db.add_results(i, smi_dict, ms_data[ms_id]["prescribed_masses"]) + results_db.calculate_frequencies(i) - if yield_smi_set: - yield {ms_id: results_db.get_structures(ms_id)} + if yield_smis: + yield {ms_id: results_db.get_structures(i)} if write_csv_output: results_db.generate_csv_output() diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index dcbab3f..6706abc 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -180,7 +180,7 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] @@ -193,7 +193,7 @@ def test_generate_structures(self): # tests vs build prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True ) - self.assertEqual(set(build_smis.keys()), returned_smis) + self.assertEqual(set(build_smis.keys()), set(returned_smis)) ms_data = {record_dict["HMDB_ID"]: {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], @@ -206,7 +206,7 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] @@ -220,7 +220,7 @@ def test_generate_structures(self): # tests vs build ncpus=None, table_name="substructures", clean=True, isomeric_smiles=True ) - self.assertEqual(set(build_smis.keys()), returned_smis) + self.assertEqual(set(build_smis.keys()), set(returned_smis)) ms_data = {} for i, record_dict in enumerate(record_dicts.values()): @@ -236,7 +236,7 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smi_set=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) for i, record_dict in enumerate(record_dicts.values()): build_smis = build( @@ -248,7 +248,7 @@ def test_generate_structures(self): # tests vs build prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True ) - self.assertEqual(set(build_smis.keys()), returned_smi_list[i][record_dict["HMDB_ID"]]) + self.assertEqual(set(build_smis.keys()), set(returned_smi_list[i][record_dict["HMDB_ID"]])) db.close() @@ -282,18 +282,18 @@ def test_annotate_msn(self): # tests vs build_msn write_csv_output=True, path_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True, isomeric_smiles=True + minimum_frequency=None, yield_smis=True, isomeric_smiles=True )) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] - self.assertEqual(len([freq for freq in set(returned_smis.values()) if freq > 1]), freqs[i]) + self.assertEqual(len([t[1] for t in returned_smis if t[1] > 1]), freqs[i]) if smis[i] is not None: - self.assertEqual(set(returned_smis.keys()), smis[i]) + self.assertEqual(set(t[0] for t in returned_smis), smis[i]) if i == 0: - self.assertEqual(returned_smis['NCCc1ccc(O)c(O)c1'], 3) + self.assertEqual(returned_smis[2][1], 3) ms_data = {} for i, record_dict in enumerate(record_dicts.values()): @@ -311,12 +311,12 @@ def test_annotate_msn(self): # tests vs build_msn path_out=self.to_test_results("annotate_multi"), write_csv_output=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True, + minimum_frequency=None, yield_smis=True, isomeric_smiles=True )) for i, record_dict in enumerate(record_dicts.values()): - self.assertEqual(len(set(returned_smi_list[i][record_dict["HMDB_ID"]].keys())), overall_lens[i]) + self.assertEqual(len(returned_smi_list[i][record_dict["HMDB_ID"]]), overall_lens[i]) db.close() @@ -341,7 +341,7 @@ def test_results_db(self): path_out=self.to_test_results("test_results_db"), write_csv_output=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - minimum_frequency=None, yield_smi_dict=True, + minimum_frequency=None, yield_smis=True, isomeric_smiles=True )) diff --git a/tests/test_data/metaboblend_queries.csv b/tests/test_data/metaboblend_queries.csv index 5f6dd89..25e3afa 100644 --- a/tests/test_data/metaboblend_queries.csv +++ b/tests/test_data/metaboblend_queries.csv @@ -1,5 +1,5 @@ ms_id,exact_mass,C,H,N,O,P,S,ppm,ha_min,ha_max,max_atoms_available,max_degree,max_n_substructures,hydrogenation_allowance,isomeric_smiles -HMDB0000073,153.078979,8,11,1,2,0,0,5,,,2,6,3,2,1 -HMDB0000122,180.06339,6,12,0,6,0,0,5,,,2,6,3,2,1 -HMDB0000158,181.073894,9,11,1,3,0,0,5,,,2,6,3,2,1 -HMDB0000186,342.116215,12,22,0,11,0,0,5,,,2,6,3,2,1 +0,HMDB0000073,153.078979,8,11,1,2,0,0,5,,,2,6,3,2,1 +1,HMDB0000122,180.06339,6,12,0,6,0,0,5,,,2,6,3,2,1 +2,HMDB0000158,181.073894,9,11,1,3,0,0,5,,,2,6,3,2,1 +3,HMDB0000186,342.116215,12,22,0,11,0,0,5,,,2,6,3,2,1 diff --git a/tests/test_data/metaboblend_structures.csv b/tests/test_data/metaboblend_structures.csv index 2958015..73cbf77 100644 --- a/tests/test_data/metaboblend_structures.csv +++ b/tests/test_data/metaboblend_structures.csv @@ -1,47 +1,47 @@ ms_id,smiles,frequency,exact_mass,C,H,N,O,P,S -HMDB0000073,NCCc1ccc(O)c(O)c1,3,153.07897899999998,8,11,1,2,0,0 -HMDB0000073,NCCc1cc(O)cc(O)c1,1,153.07897899999998,8,11,1,2,0,0 -HMDB0000073,NCCc1cc(O)ccc1O,1,153.07897899999998,8,11,1,2,0,0 -HMDB0000122,OC1C(O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC1[C@H](O)C(O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC1[C@H](O)[C@@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC1[C@H](O)[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC1[C@H](O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC1[C@H](O)[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)C(O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)O[C@H](CO)C1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)O[C@@H]1CO,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OO[C@H](CO)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1OO[C@H](CO)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](CO)OC1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)C(O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)O[C@@H]1CO,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](CO)O1,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)C1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000122,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@H]1O,1,180.06338999999997,6,12,0,6,0,0 -HMDB0000158,N[C@@H](Cc1ccc(O)cc1)C(=O)O,1,181.07389399999997,9,11,1,3,0,0 -HMDB0000158,N[C@@H](Cc1cccc(O)c1)C(=O)O,1,181.07389399999997,9,11,1,3,0,0 +0,NCCc1cc(O)cc(O)c1,1 +0,NCCc1cc(O)ccc1O,1 +0,NCCc1ccc(O)c(O)c1,3 +1,OC1C(O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1 +1,OC1[C@H](O)C(O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC1[C@H](O)[C@@H](O)[C@@H](O)[C@H](O)[C@H]1O,1 +1,OC1[C@H](O)[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC1[C@H](O)[C@H](O)[C@@H](O)[C@H](O)[C@H]1O,1 +1,OC1[C@H](O)[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)C(O)[C@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)O[C@H](CO)C1O,1 +1,OC[C@H]1OC(O)[C@@H](O)C(O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@@H](O)[C@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@H](O)C(O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@H](O)O[C@@H]1CO,1 +1,OC[C@H]1OC(O)[C@H](O)[C@@H](CO)O1,1 +1,OC[C@H]1OC(O)[C@H](O)[C@@H](O)C1O,1 +1,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@H](O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@@H]1O,1 +1,OC[C@H]1OC(O)[C@H](O)[C@H](O)[C@H]1O,1 +1,OC[C@H]1OO[C@H](CO)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1OO[C@H](CO)[C@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@@H](O)C(O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](CO)O1,1 +1,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](CO)O1,1 +1,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)C1O,1 +1,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@@H](O)[C@H](O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@@H](CO)OC1O,1 +1,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@@H](O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)C(O)[C@@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)O[C@@H]1CO,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](CO)O1,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)C1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@@H](O)[C@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@@H]1O,1 +1,OC[C@H]1O[C@H](O)[C@H](O)[C@H](O)[C@H]1O,1 +2,N[C@@H](Cc1ccc(O)cc1)C(=O)O,1 +2,N[C@@H](Cc1cccc(O)c1)C(=O)O,1 From a5d7dcb53a3e1ec1c0d4dd0caa46154a327364a1 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Wed, 25 Nov 2020 20:55:34 +0000 Subject: [PATCH 20/35] Re-format get_bond_enthalpies --- metaboblend/build_structures.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 6709df3..7a3921e 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -1069,21 +1069,21 @@ def build_from_subsets(exact_subset, mf, table_name, db): def get_bond_enthalpies(): - return {1.0: {'C': {'C': 348, 'N': 305, 'O': 360, 'P': 264, 'S': 272}, - 'N': {'C': 305, 'N': 163, 'O': 222, 'P': None, 'S': None}, - 'O': {'C': 360, 'N': 222, 'O': 146, 'P': 335, 'S': None}, - 'P': {'C': 264, 'N': None, 'O': 335, 'P': 201, 'S': None}, - 'S': {'C': 272, 'N': None, 'O': None, 'P': None, 'S': 226}}, - 1.5: {'C': {'C': 837, 'N': 890, 'O': None, 'P': None, 'S': None}, - 'N': {'C': 890, 'N': 944, 'O': None, 'P': None, 'S': None}, - 'O': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, - 'P': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, - 'S': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}}, - 2.0: {'C': {'C': 612, 'N': 613, 'O': 743, 'P': None, 'S': 573}, - 'N': {'C': 613, 'N': 409, 'O': 607, 'P': None, 'S': None}, - 'O': {'C': 743, 'N': 607, 'O': 496, 'P': 544, 'S': 522}, - 'P': {'C': None, 'N': None, 'O': 544, 'P': None, 'S': 335}, - 'S': {'C': 573, 'N': None, 'O': 522, 'P': 335, 'S': 425}}} + return {1.0: {'C': {'C': 348, 'N': 305, 'O': 360, 'P': 264, 'S': 272}, + 'N': {'C': 305, 'N': 163, 'O': 222, 'P': None, 'S': None}, + 'O': {'C': 360, 'N': 222, 'O': 146, 'P': 335, 'S': None}, + 'P': {'C': 264, 'N': None, 'O': 335, 'P': 201, 'S': None}, + 'S': {'C': 272, 'N': None, 'O': None, 'P': None, 'S': 226}}, + 1.5: {'C': {'C': 837, 'N': 890, 'O': None, 'P': None, 'S': None}, + 'N': {'C': 890, 'N': 944, 'O': None, 'P': None, 'S': None}, + 'O': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, + 'P': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}, + 'S': {'C': None, 'N': None, 'O': None, 'P': None, 'S': None}}, + 2.0: {'C': {'C': 612, 'N': 613, 'O': 743, 'P': None, 'S': 573}, + 'N': {'C': 613, 'N': 409, 'O': 607, 'P': None, 'S': None}, + 'O': {'C': 743, 'N': 607, 'O': 496, 'P': 544, 'S': 522}, + 'P': {'C': None, 'N': None, 'O': 544, 'P': None, 'S': 335}, + 'S': {'C': 573, 'N': None, 'O': 522, 'P': 335, 'S': 425}}} def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles, bond_enthalpies): From f36e1741ccbd308932f78267be1808f1022a3f6c Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:22:35 +0000 Subject: [PATCH 21/35] Use integer IDs for results DB --- metaboblend/build_structures.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 7a3921e..e58626a 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -305,6 +305,8 @@ def __init__(self, path_results, msn=True): self.conn = None self.cursor = None + + self.substructure_combo_id = 0 def connect(self): """Connects to the results database.""" @@ -321,7 +323,7 @@ def create_results_db(self): self.connect() self.cursor.execute("""CREATE TABLE queries ( - ms_id_num NUMERIC PRIMARY KEY, + ms_id_num INTEGER PRIMARY KEY, ms_id TEXT, exact_mass NUMERIC, C INTEGER, @@ -341,27 +343,32 @@ def create_results_db(self): if self.msn: self.cursor.execute("""CREATE TABLE spectra ( - ms_id_num NUMERIC, - fragment_id NUMERIC, + ms_id_num INTEGER, + fragment_id INTEGER, neutral_mass NUMERIC, PRIMARY KEY (ms_id_num, fragment_id))""") self.cursor.execute("""CREATE TABLE structures ( - ms_id_num NUMERIC, + ms_id_num INTEGER, structure_smiles TEXT, - frequency NUMERIC, + frequency INTEGER, PRIMARY KEY (ms_id_num, structure_smiles))""") self.cursor.execute("""CREATE TABLE substructures ( - structure_smiles TEXT , + substructure_combo_id INTEGER, + substructure_position_id INTEGER, + ms_id_num INTEGER, + structure_smiles TEXT, + fragment_id INTEGER, substructure_smiles TEXT, - PRIMARY KEY (structure_smiles, substructure_smiles))""") + bde INTEGER, + PRIMARY KEY (substructure_combo_id, substructure_position_id))""") self.cursor.execute("""CREATE TABLE results ( - ms_id_num NUMERIC, - fragment_id NUMERIC, + ms_id_num INTEGER, + fragment_id INTEGER, structure_smiles TEXT, - bde NUMERIC, + bde INTEGER, PRIMARY KEY(ms_id_num, fragment_id, structure_smiles))""") self.conn.commit() From 580219d62a8c39bc379d3cc1144a2cd40f084be7 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:22:47 +0000 Subject: [PATCH 22/35] Add retain_substructures option --- metaboblend/build_structures.py | 88 ++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index e58626a..1fa4539 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -424,7 +424,7 @@ def add_ms(self, msn_data, ms_id, ms_id_num, parameters): self.conn.commit() - def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None): + def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None, retain_substructures=False): """ Record which smiles were generated for a given fragment mass. @@ -436,6 +436,8 @@ def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None) :param fragment_mass: The neutral fragment mass that has been annotated. :param fragment_id: The unique identifier for the fragment mass that has been annotated. + + :param retain_substructures: If True, record substructures in the results DB. """ if self.msn: @@ -452,27 +454,43 @@ def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None) fragment_id = "NULL" for structure_smiles in smi_dict.keys(): + self.cursor.execute("""INSERT OR IGNORE INTO results ( ms_id_num, fragment_id, structure_smiles, bde - ) VALUES ('{}', '{}', '{}', '{}')""".format( + ) VALUES ({}, {}, '{}', {})""".format( ms_id_num, fragment_id, structure_smiles, - smi_dict[structure_smiles][0] + min(smi_dict[structure_smiles]["bdes"]) )) - for substructure_smiles in smi_dict[structure_smiles][1]: - - self.cursor.execute("""INSERT OR IGNORE INTO substructures ( - structure_smiles, - substructure_smiles - ) VALUES ('{}', '{}')""".format( - structure_smiles, - substructure_smiles - )) + if retain_substructures: + for i in range(len(smi_dict[structure_smiles]["substructures"])): # for each combination + + for j, substructure in enumerate(smi_dict[structure_smiles]["substructures"][i]): + + self.cursor.execute("""INSERT INTO substructures ( + substructure_combo_id, + substructure_position_id, + ms_id_num, + fragment_id, + structure_smiles, + substructure_smiles, + bde + ) VALUES ({}, {}, {}, {}, '{}', '{}', {})""".format( + self.substructure_combo_id, + j, + ms_id_num, + fragment_id, + structure_smiles, + substructure, + smi_dict[structure_smiles]["bdes"][i] + )) + + self.substructure_combo_id += 1 self.conn.commit() @@ -563,7 +581,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], hydrogenation_allowance: int = 2, yield_smis: bool = True, isomeric_smiles: bool = False, - write_csv_output: bool = True + write_csv_output: bool = True, + retain_substructures: bool = False ) -> Dict[str, Sequence[Dict[str, int]]]: """ Generate molecules of a given mass using chemical substructures, connectivity graphs and spectral trees or @@ -627,6 +646,8 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], :param write_csv_output: Whether to extract results from the SQLite3 database for deposition in CSV files. + :param retain_substructures: Whether to record the substructures used to generate final structures. + :return: For each input molecule yields a dictionary whose keys are SMILEs strings for the generated structures and values are the number of `fragment_masses` by which the structure was built (unless `yield_smi_dict = False`). @@ -674,11 +695,12 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], table_name=table_name, ncpus=ncpus, clean=False, - isomeric_smiles=isomeric_smiles + isomeric_smiles=isomeric_smiles, + retain_substructures=retain_substructures ) - results_db.add_results(i, smi_dict, fragment_mass, j) - fragment_smis = None + results_db.add_results(i, smi_dict, fragment_mass, j, retain_substructures) + smi_dict = None results_db.calculate_frequencies(i) @@ -706,6 +728,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], yield_smis: bool = True, isomeric_smiles: bool = False, write_csv_output: bool = True, + retain_substructures: bool = False ) -> Dict[str, Sequence[set]]: """ Generate molecules of a given mass using chemical substructures and connectivity graphs. Can optionally take a @@ -810,10 +833,13 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], table_name=table_name, ncpus=ncpus, clean=False, - isomeric_smiles=isomeric_smiles + isomeric_smiles=isomeric_smiles, + retain_substructures=retain_substructures ) results_db.add_results(i, smi_dict, ms_data[ms_id]["prescribed_masses"]) + smi_dict = None + results_db.calculate_frequencies(i) if yield_smis: @@ -826,7 +852,7 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substructure_db, - prescribed_mass, ppm, ncpus, table_name, clean, isomeric_smiles): + prescribed_mass, ppm, ncpus, table_name, clean, isomeric_smiles, retain_substructures): """ Core function for generating molecules of a given mass using substructures and connectivity graphs. Can optionally take a "prescribed" fragment mass to further filter results; this can be used to incorporate MSn data. Final @@ -864,6 +890,8 @@ def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substr :param isomeric_smiles: If True, writes smiles with non-structural isomeric information. + :param retain_substructures: Whether to record the substructures used to generate final structures. + :return: Returns a set of unique SMILEs strings. """ @@ -933,7 +961,7 @@ def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substr smi_dicts = pool.map( partial(substructure_combination_build, configs_iso=configs_iso, prescribed_structure=prescribed_mass, isomeric_smiles=isomeric_smiles, - bond_enthalpies=get_bond_enthalpies()), + bond_enthalpies=get_bond_enthalpies(), retain_substructures=retain_substructures), substructure_subsets ) @@ -941,10 +969,10 @@ def build(mf, exact_mass, max_n_substructures, path_connectivity_db, path_substr for d in smi_dicts: for k in d.keys(): try: - smi_dict[k][1].update(d[k][1]) + smi_dict[k]["bdes"] += d[k]["bdes"] - if d[k][0] < smi_dict[k][0]: - smi_dict[k][0] = d[k][0] + if retain_substructures: + smi_dict[k]["substructures"] += d[k]["substructures"] except KeyError: smi_dict[k] = d[k] @@ -1093,7 +1121,8 @@ def get_bond_enthalpies(): 'S': {'C': 573, 'N': None, 'O': 522, 'P': 335, 'S': 425}}} -def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles, bond_enthalpies): +def substructure_combination_build(substructure_subset, configs_iso, prescribed_structure, isomeric_smiles, + bond_enthalpies, retain_substructures): """ Final stage for building molecules; takes a combination of substructures (substructure_combination) and builds them according to graphs in the substructure database. May be run in parallel. @@ -1171,15 +1200,18 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ except RuntimeError: continue # bad bond type violation - final_substructures = set(subs["smiles"] for subs in substructure_combination) + final_substructures = [subs["smiles"] for subs in substructure_combination] try: - smis[final_structure][1].update(final_substructures) + smis[final_structure]["bdes"].append(total_bde) - if total_bde < smis[final_structure][0]: - smis[final_structure][0] = total_bde + if retain_substructures: + smis[final_structure]["substructures"].append(final_substructures) except KeyError: - smis[final_structure] = [total_bde, final_substructures] + smis[final_structure] = {"bdes": [total_bde]} + + if retain_substructures: + smis[final_structure]["substructures"] = [final_substructures] return smis From a9b2cfd43ecbaa1764ec526447e32dd40acfb371 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:24:36 +0000 Subject: [PATCH 23/35] Make filter_hmdbid_substructures a filtered version of the hmdbid_substructures table --- metaboblend/databases.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 2436ecf..36d7ca3 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -184,14 +184,15 @@ def filter_hmdbid_substructures(self, min_node_weight=2): :param min_node_weight: Minimal count of the substructure within 'hmdbid_substructures'. """ - self.cursor.execute("DROP TABLE IF EXISTS unique_hmdbid") self.cursor.execute("DROP TABLE IF EXISTS filtered_hmdbid_substructures") - self.cursor.execute("CREATE TABLE unique_hmdbid AS SELECT DISTINCT hmdbid FROM compounds") - self.cursor.execute("""CREATE TABLE filtered_hmdbid_substructures AS - SELECT smiles, COUNT(*) FROM hmdbid_substructures - GROUP BY smiles HAVING COUNT(*) >=%s""" % min_node_weight) + SELECT * FROM hmdbid_substructures + WHERE substructure_id IN ( + SELECT substructure_id FROM hmdbid_substructures + GROUP BY substructure_id HAVING COUNT(*) >=%s + ) + """ % min_node_weight) def generate_substructure_network(self, method="default", min_node_weight=2, remove_isolated=False): """ From 26e2c89d53633cddd1aac31cf8f755d395192284 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:25:18 +0000 Subject: [PATCH 24/35] Implement the substructure network generation algorithm in SQLite instead of networkx --- metaboblend/databases.py | 153 +++++++++++++-------------------------- 1 file changed, 49 insertions(+), 104 deletions(-) diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 36d7ca3..b691a15 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -194,123 +194,68 @@ def filter_hmdbid_substructures(self, min_node_weight=2): ) """ % min_node_weight) - def generate_substructure_network(self, method="default", min_node_weight=2, remove_isolated=False): + def generate_substructure_network(self, min_node_weight=2, return_networkx=False): """ Generate networks to explore the co-occurence of substructures in the substructure database. - :param method: Which method to use for the generation of a substructure co-occurence network. - - * **default** Generate a standard substructure network using the most time-efficient method. Node weights - represent the frequency of substructures in the substructure database whilst edge weights represent the - co-occurence of these substructures. See - :py:meth:`metaboblend.databases.default_substructure_network`. - - * **extended** Alternative method for generating substructure networks that generates an intermediate - metabolite-substructure linkage network; returns the same network as the **default** method. See - :py:meth:`metaboblend.databases.extended_substructure_network`. - - * **parent_structure_linkage** Uses the **extended** method and returns the intermediate - metabolite-substructure linkage network; instead of weighted edges between substructures being present, - the substructures are connected to the metabolites by which they were generated. - :param min_node_weight: Minimum frequency of substructures to be included in the network; a minimum node weight of 2 means that all non-unique substructures will be present. - :param remove_isolated: Isolated nodes are substructures that do not co-occur with any others. - - * **True** Remove isolated nodes from the final network. - - * **False** Allow isolated nodes to remain in the final network. - - :return: A :py:meth:`networkx.Graph` object containing the specified network. - """ - - substructure_graph = nx.Graph() - self.filter_hmdbid_substructures(min_node_weight) - - self.cursor.execute("SELECT * FROM unique_hmdbid") - unique_hmdb_ids = self.cursor.fetchall() - - self.cursor.execute("SELECT * FROM filtered_hmdbid_substructures") - # add node for each unique substructure, weighted by count - for unique_substructure in self.cursor.fetchall(): - substructure_graph.add_node(unique_substructure[0], weight=unique_substructure[1]) - - # generate different flavours of network - if method == "default": - substructure_graph = self.default_substructure_network(substructure_graph, unique_hmdb_ids) - elif method == "extended": - substructure_graph = self.extended_substructure_network(substructure_graph, unique_hmdb_ids, - include_parents=False) - elif method == "parent_structure_linkage": - substructure_graph = self.extended_substructure_network(substructure_graph, unique_hmdb_ids, - include_parents=True) - - if remove_isolated: - substructure_graph.remove_nodes_from(list(nx.isolates(substructure_graph))) - - return substructure_graph + :param return_networkx: If True, returns a :py:meth:`networkx.Graph` containing the generated network. - def extended_substructure_network(self, substructure_graph, unique_hmdb_ids, include_parents=False): + :return: If return_networkx, A :py:meth:`networkx.Graph` object containing the generated network. """ - Extended method for substructure network generation - ie, an intermediate substructure network is generated - which involves substructure nodes, weighted by frequency, and metabolite nodes; unweighted edges are found - between substructures and metabolites, representing the structures from which substructures were generated. - This intermediate network can be returned, or it may be transformed into the standard substructure network - generated by :py:meth:`metaboblend.databases.default_substructure_network`. - - :param substructure_graph: A :py:meth:`networkx.Graph` containing substructure nodes, weighted by their - frequencies in the substructure database. Can be passed by - :py:meth:`metaboblend.databases.generate_substructure_network`. - - :param unique_hmdb_ids: A list containing distinct HMDB IDs from the substructure database. - - :param include_parents: Which type of substructure network to return. - * **True** Return the intermediate network containing metabolite nodes. + self.cursor.execute("DROP TABLE IF EXISTS substructure_graph") - * **False** Return the standard substructure network. + self.cursor.execute("""CREATE TABLE substructure_graph ( + substructure_id_1 INTEGER, + substructure_id_2 INTEGER, + weight INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (substructure_id_1, substructure_id_2) + ) + """) - :return: A :py:meth:`networkx.Graph` object containing the specified network. - """ - - # add node for each parent structure - for unique_hmdb_id in unique_hmdb_ids: - substructure_graph.add_node(unique_hmdb_id[0]) + self.filter_hmdbid_substructures(min_node_weight) # add edge for each linked parent structure and substructure - self.cursor.execute("""SELECT * FROM hmdbid_substructures WHERE smiles IN - (SELECT smiles FROM filtered_hmdbid_substructures)""") - for hmdbid_substructures in self.cursor.fetchall(): - substructure_graph.add_edge(hmdbid_substructures[0], hmdbid_substructures[1]) - - if not include_parents: - # remove parent structures and replace with linked, weighted substructures - for unique_hmdb_id in unique_hmdb_ids: - for adj1 in substructure_graph.adj[unique_hmdb_id[0]]: - for adj2 in substructure_graph.adj[unique_hmdb_id[0]]: - if substructure_graph.has_edge(adj1, adj2): - substructure_graph[adj1][adj2]['weight'] += 1 - else: - substructure_graph.add_edge(adj1, adj2, weight=1) - substructure_graph.remove_node(unique_hmdb_id[0]) - - # remove self-loops - substructure_graph.remove_edges_from(nx.selfloop_edges(substructure_graph)) - - return substructure_graph - - def default_substructure_network(self, substructure_graph, unique_hmdb_ids): - """ - Standard method for generating substructure co-occurence networks. - - :param substructure_graph: A :py:meth:`networkx.Graph` containing substructure nodes, weighted by their - frequencies in the substructure database. Can be passed by - :py:meth:`metaboblend.databases.generate_substructure_network`. - - :param unique_hmdb_ids: A list containing distinct HMDB IDs from the substructure database. - - :return: A :py:meth:`networkx.Graph` object containing the specified network. + self.cursor.execute("SELECT DISTINCT hmdbid FROM filtered_hmdbid_substructures") + + for hmdb_id in self.cursor.fetchall(): + self.cursor.execute("SELECT substructure_id FROM filtered_hmdbid_substructures WHERE hmdbid = '%s'" % hmdb_id) + substructures = self.cursor.fetchall() + + seen_substructures = [] + for adj1 in substructures: + for adj2 in seen_substructures: + if adj1 == adj2: + continue + + self.cursor.execute("""INSERT OR IGNORE INTO substructure_graph ( + substructure_id_1, + substructure_id_2 + ) VALUES ({}, {})""".format( + min(adj1[0], adj2[0]), + max(adj1[0], adj2[0]) + )) + + self.cursor.execute("""UPDATE substructure_graph + SET weight = weight + 1 + WHERE substructure_id_1 = '{}' + AND substructure_id_2 = '{}' + """.format( + min(adj1[0], adj2[0]), + max(adj1[0], adj2[0]) + )) + + seen_substructures.append(adj1) + + self.conn.commit() + + if return_networkx: + return self.get_substructure_network() + + def get_substructure_network(self): """ # add edges by walking through hmdbid_substructures From 9eaea12f2d6ea195a5ecea1ee3648291dc9ca352 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:25:58 +0000 Subject: [PATCH 25/35] Add get_substructure_network function to convert SQLite3 substructure network to a networkx graph --- metaboblend/databases.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/metaboblend/databases.py b/metaboblend/databases.py index b691a15..165704f 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -257,21 +257,27 @@ def generate_substructure_network(self, min_node_weight=2, return_networkx=False def get_substructure_network(self): """ + Converts the SQLite 3 network representation to a :py:meth:`networkx.Graph` object. - # add edges by walking through hmdbid_substructures - for unique_hmdb_id in unique_hmdb_ids: - self.cursor.execute("""SELECT * FROM hmdbid_substructures - WHERE smiles IN (SELECT smiles FROM filtered_hmdbid_substructures) - AND hmdbid = '%s'""" % unique_hmdb_id) - nodes = [] - for substructure in self.cursor.fetchall(): - for node in nodes: - if substructure_graph.has_edge(substructure[1], node): - substructure_graph[substructure[1]][node]['weight'] += 1 - else: - substructure_graph.add_edge(substructure[1], node, weight=1) + :return: A :py:meth:`networkx.Graph` object containing the generated network. + """ + + substructure_graph = nx.Graph() + + self.cursor.execute("""SELECT substructure_id, COUNT(*) + FROM filtered_hmdbid_substructures + GROUP BY substructure_id + """) + + for substructure in self.cursor.fetchall(): + substructure_graph.add_node(substructure[0], weight=substructure[1]) + + self.cursor.execute("SELECT * FROM substructure_graph") + + for edge in self.cursor.fetchall(): + substructure_graph.add_edge(edge[0], edge[1], weight=edge[2]) - nodes.append(substructure[1]) + substructure_graph.remove_nodes_from(list(nx.isolates(substructure_graph))) return substructure_graph From 8a19a0e7f2124f2dcd2c71ebf40e65adeb6c0fa6 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:26:25 +0000 Subject: [PATCH 26/35] Implement get_single_edge to get substructure edge weights without the generation of a substructure network --- metaboblend/databases.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 165704f..152c820 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -281,6 +281,44 @@ def get_substructure_network(self): return substructure_graph + def get_single_edge(self, substructure_ids): + """ + Get the edge weight corresponding to a subset of substructures + + :param substructure_ids: A list of substructure IDs; weights will be obtained for the combinations. + """ + + substructure_weights = {} + + for substructure_id_1 in substructure_ids: + for substructure_id_2 in substructure_ids: + + small_id = min(substructure_id_1, substructure_id_2) + large_id = max(substructure_id_1, substructure_id_2) + + if substructure_id_1 == substructure_id_2: + edge_weight = None + + else: + self.cursor.execute("""SELECT COUNT(*) + FROM hmdbid_substructures + WHERE substructure_id = {} + AND hmdbid IN ( + SELECT hmdbid + FROM hmdbid_substructures + WHERE substructure_id = {} + ) + """.format(substructure_id_1, substructure_id_2)) + + edge_weight = self.cursor.fetchall()[0][0] + + try: + substructure_weights[small_id][large_id] = edge_weight + except KeyError: + substructure_weights[small_id] = {large_id: edge_weight} + + return substructure_weights + def select_mass_values(self, accuracy, masses, table_name): """ Gets mass values from a table of substructures. Can limit results based on mass at integer level. Used by From 3e434324a86d82f8ba59e377bf112d5dc879eb8f Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:27:02 +0000 Subject: [PATCH 27/35] Add integer substructure key --- metaboblend/databases.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 152c820..03d24a8 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -506,7 +506,8 @@ def create_compound_database(self): smiles TEXT)""") self.cursor.execute("""CREATE TABLE substructures ( - smiles TEXT PRIMARY KEY, + substructure_id INTEGER PRIMARY KEY, + smiles TEXT NOT NULL UNIQUE, heavy_atoms INTEGER, length INTEGER, exact_mass__1 INTEGER, @@ -527,8 +528,9 @@ def create_compound_database(self): self.cursor.execute("""CREATE TABLE hmdbid_substructures ( hmdbid TEXT, - smiles, - PRIMARY KEY (hmdbid, smiles))""") + substructure_id INTEGER, + PRIMARY KEY (hmdbid, substructure_id), + FOREIGN KEY (substructure_id) REFERENCES substructures(substructure_id))""") def create_indexes(self, table="substructures", selection="all"): """Creates indexes for the `substructures` table for use by the build method.""" @@ -1075,6 +1077,8 @@ def update_substructure_database(hmdb_path: Union[str, bytes, os.PathLike, None] if ha_min is None: ha_min = 0 + substructure_id = 0 + for record_dict in filter_records(records, isomeric_smiles=isomeric_smiles): if not substructures_only: cursor.execute("""INSERT OR IGNORE INTO compounds ( @@ -1236,10 +1240,12 @@ def insert_substructure(lib, cursor, record_dict, substructures_only, max_atoms_ :mol)""", sub_smi_dict) if not substructures_only: + cursor.execute("SELECT substructure_id FROM substructures WHERE smiles = '%s'" % sub_smi_dict["smiles"]) + cursor.execute("""INSERT OR IGNORE INTO hmdbid_substructures ( hmdbid, - smiles) - VALUES ("%s", "%s")""" % (record_dict['HMDB_ID'], smiles_rdkit)) + substructure_id) + VALUES ('{}', {})""".format(record_dict['HMDB_ID'], cursor.fetchall()[0][0])) def create_connectivity_database(path_connectivity_db: Union[str, bytes, os.PathLike], max_n_substructures: int = 3, From 151a189be7880e4dc91f3469258e64f9dbba4688 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Fri, 27 Nov 2020 01:49:15 +0000 Subject: [PATCH 28/35] Update unit tests --- tests/test_build_structures.py | 32 ++++++++----- tests/test_data/substructures.sqlite | Bin 647168 -> 569344 bytes tests/test_databases.py | 6 ++- tests/test_substructure_database.py | 68 +++++++++++++++------------ 4 files changed, 60 insertions(+), 46 deletions(-) diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index 6706abc..e74f81d 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -71,7 +71,7 @@ def test_build(self): # core - all other build functions rely on path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", - isomeric_smiles=True + isomeric_smiles=True, retain_substructures=True ) j = 0 @@ -94,7 +94,8 @@ def test_build(self): # core - all other build functions rely on prescribed_mass=fragments[i], ppm=15, clean=True, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), - ncpus=None, table_name="substructures", isomeric_smiles=True + ncpus=None, table_name="substructures", isomeric_smiles=True, + retain_substructures=False ) j = 0 @@ -122,7 +123,8 @@ def test_substructure_combination_build(self): substructure_subset = db.select_substructures(ec_product, "substructures") smis = substructure_combination_build(substructure_subset, configs_iso, prescribed_structure=False, isomeric_smiles=True, - bond_enthalpies=get_bond_enthalpies()) + bond_enthalpies=get_bond_enthalpies(), + retain_substructures=False) self.assertEqual(len(smis.keys()), lens[i]) @@ -180,7 +182,8 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True, + retain_substructures=True)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] @@ -190,7 +193,8 @@ def test_generate_structures(self): # tests vs build exact_mass=record_dict["exact_mass"], max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, - prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True + prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True, + retain_substructures=True ) self.assertEqual(set(build_smis.keys()), set(returned_smis)) @@ -206,7 +210,8 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True, + retain_substructures=False)) returned_smis = returned_smis[0][record_dict["HMDB_ID"]] @@ -214,7 +219,7 @@ def test_generate_structures(self): # tests vs build mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], exact_mass=record_dict["exact_mass"], max_n_substructures=3, - prescribed_mass=fragments[i], ppm=0, + prescribed_mass=fragments[i], ppm=0, retain_substructures=False, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), ncpus=None, table_name="substructures", clean=True, isomeric_smiles=True @@ -236,13 +241,14 @@ def test_generate_structures(self): # tests vs build write_csv_output=True, path_out=self.to_test_results(), max_degree=6, max_atoms_available=2, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), - minimum_frequency=None, yield_smis=True, isomeric_smiles=True)) + minimum_frequency=None, yield_smis=True, isomeric_smiles=True, + retain_substructures=False)) for i, record_dict in enumerate(record_dicts.values()): build_smis = build( mf=[record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], - exact_mass=record_dict["exact_mass"], + exact_mass=record_dict["exact_mass"], retain_substructures=False, max_n_substructures=3, path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), clean=True, prescribed_mass=None, ppm=None, ncpus=None, table_name="substructures", isomeric_smiles=True @@ -279,7 +285,7 @@ def test_annotate_msn(self): # tests vs build_msn # test standard building returned_smis = list(annotate_msn( ms_data, max_degree=6, max_atoms_available=2, max_n_substructures=3, - write_csv_output=True, path_out=self.to_test_results(), + write_csv_output=True, retain_substructures=False, path_out=self.to_test_results(), path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smis=True, isomeric_smiles=True @@ -312,7 +318,7 @@ def test_annotate_msn(self): # tests vs build_msn path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smis=True, - isomeric_smiles=True + isomeric_smiles=True, retain_substructures=False )) for i, record_dict in enumerate(record_dicts.values()): @@ -342,11 +348,11 @@ def test_results_db(self): path_connectivity_db=self.to_test_data("connectivity.sqlite"), path_substructure_db=self.to_test_data("substructures.sqlite"), minimum_frequency=None, yield_smis=True, - isomeric_smiles=True + isomeric_smiles=True, retain_substructures=True )) # is the sqlite database the size we expect? - self.assertEqual(os.path.getsize(self.to_test_results("test_results_db", "metaboblend_results.sqlite")), 61440) + self.assertEqual(os.path.getsize(self.to_test_results("test_results_db", "metaboblend_results.sqlite")), 53248) # are the csv files the same as the reference? with open(self.to_test_results("test_results_db", "metaboblend_queries.csv"), "r") as results_file, \ diff --git a/tests/test_data/substructures.sqlite b/tests/test_data/substructures.sqlite index a7fae0a2ea3a9fcc0d22f19fceff7b6c686c69bf..612d839e5635c1368a14b6a7314e58566be79a0b 100644 GIT binary patch literal 569344 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 Y%=aN^8Pm!tW0j5>Vv>D~CF|aQ0UNfd;s5{u literal 647168 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%0DXa^A=M2BcA(|{xEMOl#dn^C&@l^sX3XqhUmej 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 Date: Sun, 29 Nov 2020 17:30:05 +0000 Subject: [PATCH 29/35] Add parse_ms_data function to convert user provided raw data into a list required for building --- metaboblend/build_structures.py | 72 ++++++++++++++++++++++----------- 1 file changed, 49 insertions(+), 23 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 1fa4539..5076491 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -566,7 +566,25 @@ def close(self): self.conn.close() -def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], +def parse_ms_data(ms_data): + """ + Parse raw data provided by user and yield formatted input data. + + :param ms_data: + + :param annotate_msn: + + :return: None + """ + + if isinstance(ms_data, dict): + for i, ms_id in enumerate(ms_data.keys()): + yield [i] + ms_data[ms_id] + + yield None + + +def annotate_msn(msn_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int, list]]]], path_substructure_db: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), path_out: Union[str, bytes, os.PathLike] = "", ppm: int = 5, @@ -671,22 +689,23 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], max_degree=max_degree, max_atoms_available=max_atoms_available, minimum_frequency=minimum_frequency, - max_mass=round(max([msn_data[ms_id]["exact_mass"] for ms_id in msn_data.keys()])) + max_mass=None ) - for i, ms_id in enumerate(msn_data.keys()): + # 0: i, 1: ms_id, 2: mc, 3: exact_mass, 4: fragment_masses + for ms in parse_ms_data(msn_data): - results_db.add_ms(msn_data, ms_id, i, + results_db.add_ms(msn_data, ms[1], ms[0], [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, hydrogenation_allowance, isomeric_smiles]) - for j, fragment_mass in enumerate(msn_data[ms_id]["fragment_masses"]): + for j, fragment_mass in enumerate(ms[4]): for k in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): hydrogenated_fragment_mass = fragment_mass + (k * 1.007825) # consider re-arrangements smi_dict = build( - mf=msn_data[ms_id]["mf"], - exact_mass=msn_data[ms_id]["exact_mass"], + mf=ms[2], + exact_mass=ms[3], max_n_substructures=max_n_substructures, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, @@ -699,13 +718,13 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], retain_substructures=retain_substructures ) - results_db.add_results(i, smi_dict, fragment_mass, j, retain_substructures) + results_db.add_results(ms[0], smi_dict, fragment_mass, j, retain_substructures) smi_dict = None - results_db.calculate_frequencies(i) + results_db.calculate_frequencies(ms[0]) if yield_smis: - yield {ms_id: results_db.get_structures(i)} + yield {ms[1]: results_db.get_structures(ms[0])} if write_csv_output: results_db.generate_csv_output() @@ -714,7 +733,7 @@ def annotate_msn(msn_data: Dict[str, Dict[str, Union[int, list]]], results_db.close() -def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], +def generate_structures(ms_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int, None]]]], path_substructure_db: Union[str, bytes, os.PathLike], path_out: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), ha_min: Union[int, None] = 2, @@ -809,26 +828,27 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], max_mass=round(max([ms_data[ms_id]["exact_mass"] for ms_id in ms_data.keys()])) ) - for i, ms_id in enumerate(ms_data.keys()): + # 0: i, 1: ms_id, 2: mc, 3: exact_mass, 4: prescribed_mass + for ms in parse_ms_data(ms_data): - results_db.add_ms(ms_data, ms_id, i, + results_db.add_ms(ms_data, ms[1], ms[0], [None, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, None, isomeric_smiles]) ppm = None try: - if ms_data[ms_id]["prescribed_masses"] is not None: + if ms[4] is not None: ppm = 0 - except KeyError: - ms_data[ms_id]["prescribed_masses"] = None + except IndexError: + ms.append(None) smi_dict = build( - mf=ms_data[ms_id]["mf"], - exact_mass=ms_data[ms_id]["exact_mass"], + mf=ms[2], + exact_mass=ms[3], max_n_substructures=max_n_substructures, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, - prescribed_mass=ms_data[ms_id]["prescribed_masses"], + prescribed_mass=ms[4], ppm=ppm, table_name=table_name, ncpus=ncpus, @@ -837,13 +857,13 @@ def generate_structures(ms_data: Dict[str, Dict[str, Union[int, None]]], retain_substructures=retain_substructures ) - results_db.add_results(i, smi_dict, ms_data[ms_id]["prescribed_masses"]) + results_db.add_results(ms[0], smi_dict, ms[4]) smi_dict = None - results_db.calculate_frequencies(i) + results_db.calculate_frequencies(ms[0]) if yield_smis: - yield {ms_id: results_db.get_structures(i)} + yield {ms[1]: results_db.get_structures(ms[0])} if write_csv_output: results_db.generate_csv_output() @@ -1040,6 +1060,12 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass ha_max_statement = """ AND heavy_atoms <= %s""" % str(ha_max) + if max_mass is None: + max_mass_statment = "" + else: + max_mass_statment = """ + exact_mass__1 < %s""" % str(max_mass) + db.cursor.execute("""CREATE TABLE {} AS SELECT * FROM substructures WHERE atoms_available <= {} AND @@ -1048,7 +1074,7 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass """.format(table_name, max_atoms_available, max_degree, - max_mass, + max_mass_statment, freq_statement, ha_min_statement, ha_max_statement)) From a10ca660489717a2610a63a884272c8efd0f1c5a Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Mon, 30 Nov 2020 02:37:16 +0000 Subject: [PATCH 30/35] Implement msp parsing, update existing tests and spread functions across additional files --- metaboblend/algorithms.py | 105 ++++++ metaboblend/auxiliary.py | 2 +- metaboblend/build_structures.py | 449 ++-------------------- metaboblend/databases.py | 96 +---- metaboblend/parse.py | 281 ++++++++++++++ metaboblend/results.py | 301 +++++++++++++++ tests/test_build_structures.py | 8 +- tests/test_data/massbank_msp.txt | 87 +++++ tests/test_data/mona_msp.msp | 580 +++++++++++++++++++++++++++++ tests/test_databases.py | 1 + tests/test_isomorphism_database.py | 1 + tests/test_parse.py | 60 +++ tests/test_suite_auxiliary.py | 2 + 13 files changed, 1464 insertions(+), 509 deletions(-) create mode 100644 metaboblend/algorithms.py create mode 100644 metaboblend/parse.py create mode 100644 metaboblend/results.py create mode 100644 tests/test_data/massbank_msp.txt create mode 100644 tests/test_data/mona_msp.msp create mode 100644 tests/test_parse.py diff --git a/metaboblend/algorithms.py b/metaboblend/algorithms.py new file mode 100644 index 0000000..624b00a --- /dev/null +++ b/metaboblend/algorithms.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © 2019-2020 Ralf Weber +# +# This file is part of MetaboBlend. +# +# MetaboBlend is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# MetaboBlend is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with MetaboBlend. If not, see . +# + +import numpy + + +def find_path(mass_list, sum_matrix, n, mass, max_subset_length, path=[]): + """ + Recursive solution for backtracking through the dynamic programming boolean matrix. All possible subsets are found + + :param mass_list: A list of masses from which to identify subsets. + + :param mass: The target mass of the sum of the substructures. + + :param sum_matrix: The dynamic programming boolean matrix. + + :param n: The size of mass_list. + + :param max_subset_length: The maximum length of subsets to return. Allows the recursive backtracking algorithm to + terminate early in many cases, significantly improving runtime. + + :param path: List for keeping track of the current subset. + + :return: Generates of lists containing the masses of valid subsets. + """ + + # base case - the path has generated a correct solution + if mass == 0: + yield sorted(path) + return + + # stop running when we overshoot the mass + elif mass < 0: + return + + # can we sum up to the target value using the remaining masses? recursive call + elif sum_matrix[n][mass]: + yield from find_path(mass_list, sum_matrix, n - 1, mass, max_subset_length, path) + + if len(path) < max_subset_length: + path.append(mass_list[n-1]) + + yield from find_path(mass_list, sum_matrix, n - 1, mass - mass_list[n - 1], max_subset_length, path) + path.pop() + + +def subset_sum(mass_list, mass, max_subset_length=3): + """ + Dynamic programming implementation of subset sum. Note that, whilst this algorithm is pseudo-polynomial, the + backtracking algorithm for obtaining all possible subsets has exponential complexity and so remains unsuitable + for large input values. This does, however, tend to perform a lot better than non-sum_matrix implementations, as + we're no longer doing sums multiple times and we've cut down the operations performed during the exponential portion + of the method. + + :param mass_list: A list of masses from which to identify subsets. + + :param mass: The target mass of the sum of the substructures. + + :param max_subset_length: The maximum length of subsets to return. Allows the recursive backtracking algorithm to + terminate early in many cases, significantly improving runtime. + + :return: Generates of lists containing the masses of valid subsets. + """ + + n = len(mass_list) + + # initialise dynamic programming array + sum_matrix = numpy.ndarray([n + 1, mass + 1], bool) + + # subsets can always equal 0 + for i in range(n+1): + sum_matrix[i][0] = True + + # empty subsets do not have non-zero sums + for i in range(mass): + sum_matrix[0][i + 1] = False + + # fill in the remaining boolean matrix + for i in range(n): + for j in range(mass+1): + if j >= mass_list[i]: + sum_matrix[i + 1][j] = sum_matrix[i][j] or sum_matrix[i][j - mass_list[i]] + else: + sum_matrix[i + 1][j] = sum_matrix[i][j] + + # backtrack through the matrix recursively to obtain all solutions + return find_path(mass_list, sum_matrix, n, mass, max_subset_length) diff --git a/metaboblend/auxiliary.py b/metaboblend/auxiliary.py index 52717b8..4408224 100644 --- a/metaboblend/auxiliary.py +++ b/metaboblend/auxiliary.py @@ -20,8 +20,8 @@ # import itertools -import networkx as nx import pylab as plt +import networkx as nx def calculate_complete_multipartite_graphs(max_atoms_available, max_n_substructures): diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 5076491..105a14a 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -20,103 +20,22 @@ # import os -import multiprocessing import copy +import numpy import itertools -from functools import partial +import multiprocessing import networkx as nx -import numpy -import sqlite3 -import csv +from functools import partial from operator import itemgetter from typing import Sequence, Dict, Union from rdkit import Chem -from .databases import SubstructureDb, get_elements, calculate_exact_mass - - -def find_path(mass_list, sum_matrix, n, mass, max_subset_length, path=[]): - """ - Recursive solution for backtracking through the dynamic programming boolean matrix. All possible subsets are found - - :param mass_list: A list of masses from which to identify subsets. - - :param mass: The target mass of the sum of the substructures. - - :param sum_matrix: The dynamic programming boolean matrix. - - :param n: The size of mass_list. - - :param max_subset_length: The maximum length of subsets to return. Allows the recursive backtracking algorithm to - terminate early in many cases, significantly improving runtime. - - :param path: List for keeping track of the current subset. - - :return: Generates of lists containing the masses of valid subsets. - """ - - # base case - the path has generated a correct solution - if mass == 0: - yield sorted(path) - return - - # stop running when we overshoot the mass - elif mass < 0: - return - # can we sum up to the target value using the remaining masses? recursive call - elif sum_matrix[n][mass]: - yield from find_path(mass_list, sum_matrix, n - 1, mass, max_subset_length, path) - - if len(path) < max_subset_length: - path.append(mass_list[n-1]) - - yield from find_path(mass_list, sum_matrix, n - 1, mass - mass_list[n - 1], max_subset_length, path) - path.pop() - - -def subset_sum(mass_list, mass, max_subset_length=3): - """ - Dynamic programming implementation of subset sum. Note that, whilst this algorithm is pseudo-polynomial, the - backtracking algorithm for obtaining all possible subsets has exponential complexity and so remains unsuitable - for large input values. This does, however, tend to perform a lot better than non-sum_matrix implementations, as - we're no longer doing sums multiple times and we've cut down the operations performed during the exponential portion - of the method. - - :param mass_list: A list of masses from which to identify subsets. - - :param mass: The target mass of the sum of the substructures. - - :param max_subset_length: The maximum length of subsets to return. Allows the recursive backtracking algorithm to - terminate early in many cases, significantly improving runtime. - - :return: Generates of lists containing the masses of valid subsets. - """ - - n = len(mass_list) - - # initialise dynamic programming array - sum_matrix = numpy.ndarray([n + 1, mass + 1], bool) - - # subsets can always equal 0 - for i in range(n+1): - sum_matrix[i][0] = True - - # empty subsets do not have non-zero sums - for i in range(mass): - sum_matrix[0][i + 1] = False - - # fill in the remaining boolean matrix - for i in range(n): - for j in range(mass+1): - if j >= mass_list[i]: - sum_matrix[i + 1][j] = sum_matrix[i][j] or sum_matrix[i][j - mass_list[i]] - else: - sum_matrix[i + 1][j] = sum_matrix[i][j] - - # backtrack through the matrix recursively to obtain all solutions - return find_path(mass_list, sum_matrix, n, mass, max_subset_length) +from .results import ResultsDb +from .parse import parse_ms_data +from .algorithms import subset_sum +from .databases import SubstructureDb def combine_mfs(precise_mass_grp, db, table_name, accuracy): @@ -275,11 +194,12 @@ def add_bonds(mols, edges, atoms_available, bond_types, bond_enthalpies): bt_start.remove(bond_matches[0]) bt_end.remove(bond_matches[0]) - try: + try: # try forming the specified bond mol_edit.AddBond(edge[0], edge[1], rdkit_bond_types[bond_matches[0]]) except KeyError: return None, None # unknown bond type + # calculate bond dissociation energy of "formed" bonds for the structure try: total_bde += bond_enthalpies[bond_matches[0]][mols.GetAtomWithIdx(edge[0]).GetSymbol()][mols.GetAtomWithIdx(edge[1]).GetSymbol()] except (SyntaxError, TypeError): @@ -288,302 +208,6 @@ def add_bonds(mols, edges, atoms_available, bond_types, bond_enthalpies): return mol_edit, total_bde -class ResultsDb: - """ - Methods for interacting with the SQLITE3 results database, as created by - :py:meth:`metaboblend.build_structures.annotate_msn`. - - :param path_results: Directory to which results will be written. - """ - - def __init__(self, path_results, msn=True): - """Constructor method.""" - - self.path_results = path_results - self.path_results_db = os.path.join(self.path_results, "metaboblend_results.sqlite") - self.msn = msn - - self.conn = None - self.cursor = None - - self.substructure_combo_id = 0 - - def connect(self): - """Connects to the results database.""" - - self.conn = sqlite3.connect(self.path_results_db) - self.cursor = self.conn.cursor() - - def create_results_db(self): - """Generates a new results database.""" - - if os.path.exists(self.path_results_db): - os.remove(self.path_results_db) - - self.connect() - - self.cursor.execute("""CREATE TABLE queries ( - ms_id_num INTEGER PRIMARY KEY, - ms_id TEXT, - exact_mass NUMERIC, - C INTEGER, - H INTEGER, - N INTEGER, - O INTEGER, - P INTEGER, - S INTEGER, - ppm INTEGER, - ha_min INTEGER, - ha_max INTEGER, - max_atoms_available INTEGER, - max_degree INTEGER, - max_n_substructures INTEGER, - hydrogenation_allowance INTEGER, - isomeric_smiles INTEGER)""") - - if self.msn: - self.cursor.execute("""CREATE TABLE spectra ( - ms_id_num INTEGER, - fragment_id INTEGER, - neutral_mass NUMERIC, - PRIMARY KEY (ms_id_num, fragment_id))""") - - self.cursor.execute("""CREATE TABLE structures ( - ms_id_num INTEGER, - structure_smiles TEXT, - frequency INTEGER, - PRIMARY KEY (ms_id_num, structure_smiles))""") - - self.cursor.execute("""CREATE TABLE substructures ( - substructure_combo_id INTEGER, - substructure_position_id INTEGER, - ms_id_num INTEGER, - structure_smiles TEXT, - fragment_id INTEGER, - substructure_smiles TEXT, - bde INTEGER, - PRIMARY KEY (substructure_combo_id, substructure_position_id))""") - - self.cursor.execute("""CREATE TABLE results ( - ms_id_num INTEGER, - fragment_id INTEGER, - structure_smiles TEXT, - bde INTEGER, - PRIMARY KEY(ms_id_num, fragment_id, structure_smiles))""") - - self.conn.commit() - - def add_ms(self, msn_data, ms_id, ms_id_num, parameters): - """ - Add entries to the `queries` and `spectra` tables. - - :param msn_data: Dictionary in the form - `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses: []}`. id represents a unique - identifier for a given spectral tree or fragmentation spectrum, mf is a list of integers referring to the - molecular formula of the structure of interest, exact_mass is the mass of this molecular formula to >=4d.p. - and fragment_masses are neutral fragment masses generated by this structure used to inform candidate - scoring. See :py:meth:`metaboblend.build_structures.annotate_msn`. - - :param ms_id: Unique identifier for the annotation of a single metabolite. - - :param ms_id_num: Unique numeric identifier for the annotation of a single metaoblite. - - :param parameters: List of parameters, in the form: [ppm, ha_min, ha_max, max_atoms_available, max_degree, - max_n_substructures, hydrogenation_allowance, isomeric_smiles]. See - :py:meth:`metaboblend.build_structures.annotate_msn`. - """ - - for i, parameter in enumerate(parameters): - if parameter is None: - parameters[i] = "NULL" - elif isinstance(parameter, bool): - parameters[i] = int(parameter) - - self.cursor.execute("""INSERT INTO queries ( - ms_id, - ms_id_num, - exact_mass, - C, H, N, O, P, S, - ppm, - ha_min, - ha_max, - max_atoms_available, - max_degree, - max_n_substructures, - hydrogenation_allowance, - isomeric_smiles - ) VALUES ('{}', {}, {}, '{}', '{}', '{}', '{}', '{}', '{}', {})""".format( - ms_id, - ms_id_num, - msn_data[ms_id]["exact_mass"], - msn_data[ms_id]["mf"][0], msn_data[ms_id]["mf"][1], - msn_data[ms_id]["mf"][2], msn_data[ms_id]["mf"][3], - msn_data[ms_id]["mf"][4], msn_data[ms_id]["mf"][5], - ", ".join([str(p) for p in parameters]) - )) - - self.conn.commit() - - def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None, retain_substructures=False): - """ - Record which smiles were generated for a given fragment mass. - - :param ms_id_num: Unique identifier for the annotation of a single metabolite. - - :param smi_dict: The fragment and substructure smiles generated by the annotation of a single peak for a single - metabolite. - - :param fragment_mass: The neutral fragment mass that has been annotated. - - :param fragment_id: The unique identifier for the fragment mass that has been annotated. - - :param retain_substructures: If True, record substructures in the results DB. - """ - - if self.msn: - self.cursor.execute("""INSERT OR IGNORE INTO spectra ( - ms_id_num, - fragment_id, - neutral_mass - ) VALUES ('{}', {}, {})""".format( - ms_id_num, - fragment_id, - fragment_mass - )) - else: - fragment_id = "NULL" - - for structure_smiles in smi_dict.keys(): - - self.cursor.execute("""INSERT OR IGNORE INTO results ( - ms_id_num, - fragment_id, - structure_smiles, - bde - ) VALUES ({}, {}, '{}', {})""".format( - ms_id_num, - fragment_id, - structure_smiles, - min(smi_dict[structure_smiles]["bdes"]) - )) - - if retain_substructures: - for i in range(len(smi_dict[structure_smiles]["substructures"])): # for each combination - - for j, substructure in enumerate(smi_dict[structure_smiles]["substructures"][i]): - - self.cursor.execute("""INSERT INTO substructures ( - substructure_combo_id, - substructure_position_id, - ms_id_num, - fragment_id, - structure_smiles, - substructure_smiles, - bde - ) VALUES ({}, {}, {}, {}, '{}', '{}', {})""".format( - self.substructure_combo_id, - j, - ms_id_num, - fragment_id, - structure_smiles, - substructure, - smi_dict[structure_smiles]["bdes"][i] - )) - - self.substructure_combo_id += 1 - - self.conn.commit() - - def calculate_frequencies(self, ms_id_num): - """ - Calculates structure frequencies in the SQLite DB. - - :param ms_id_num: Unique identifier for the annotation of a single metabolite. - """ - - self.cursor.execute("""INSERT INTO structures (ms_id_num, structure_smiles, frequency) - SELECT ms_id_num, structure_smiles, COUNT(*) - FROM results - WHERE ms_id_num = {} - GROUP BY structure_smiles""".format(ms_id_num)) - - def get_structures(self, ms_id_num): - """ - Gets smiles of generated structures. In the case of the MSn annotation workflow, also gets structure - frequencies. - - :param ms_id_num: Unique identifier for the annotation of a single metabolite. - - :return: In the case of simple structure generation, returns a set of smiles strings for output structures. - For the MSn annotation workflow, returns a dictionary with smiles as keys and the number of peaks for which - the smiles were generated as values. - """ - - if self.msn: - msn_str = ", frequency" - else: - msn_str = "" - - self.cursor.execute("""SELECT structure_smiles{} FROM structures - WHERE ms_id_num = {} - """.format(msn_str, ms_id_num)) - - if self.msn: - return [t for t in self.cursor.fetchall()] - else: - return [item for t in self.cursor.fetchall() for item in t] - - def generate_csv_output(self): - """ - Generate CSV file output for i) queries and tool parameters and ii) structures generated. - """ - - with open(os.path.join(self.path_results, "metaboblend_queries.csv"), "w", newline="") as results_file, \ - open(os.path.join(self.path_results, "metaboblend_structures.csv"), "w", newline="") as ms_file: - - results_writer = csv.writer(results_file, delimiter=",") - ms_writer = csv.writer(ms_file, delimiter=",") - - results_writer.writerow(["ms_id", "exact_mass", "C", "H", "N", "O", "P", "S", "ppm", "ha_min", "ha_max", - "max_atoms_available", "max_degree", "max_n_substructures", - "hydrogenation_allowance", "isomeric_smiles"]) - - self.cursor.execute("SELECT * FROM queries") - - for query in self.cursor.fetchall(): - results_writer.writerow(query) - - ms_writer.writerow(["ms_id", "smiles", "frequency", "exact_mass", "C", "H", "N", "O", "P", "S"]) - - self.cursor.execute("SELECT * FROM structures") - - for structure in self.cursor.fetchall(): - ms_writer.writerow(structure) - - def close(self): - """Close the connection to the SQLITE3 database.""" - - self.conn.close() - - -def parse_ms_data(ms_data): - """ - Parse raw data provided by user and yield formatted input data. - - :param ms_data: - - :param annotate_msn: - - :return: None - """ - - if isinstance(ms_data, dict): - for i, ms_id in enumerate(ms_data.keys()): - yield [i] + ms_data[ms_id] - - yield None - - def annotate_msn(msn_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int, list]]]], path_substructure_db: Union[str, bytes, os.PathLike] = os.path.realpath(os.getcwd()), path_out: Union[str, bytes, os.PathLike] = "", @@ -692,20 +316,22 @@ def annotate_msn(msn_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int max_mass=None ) - # 0: i, 1: ms_id, 2: mc, 3: exact_mass, 4: fragment_masses - for ms in parse_ms_data(msn_data): + for i, ms in enumerate(parse_ms_data(msn_data)): + + if ms is None: + continue - results_db.add_ms(msn_data, ms[1], ms[0], + results_db.add_ms(msn_data, ms["ms_id"], i, [ppm, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, hydrogenation_allowance, isomeric_smiles]) - for j, fragment_mass in enumerate(ms[4]): + for j, fragment_mass in enumerate(ms["neutral_fragment_masses"]): for k in range(0 - hydrogenation_allowance, hydrogenation_allowance + 1): hydrogenated_fragment_mass = fragment_mass + (k * 1.007825) # consider re-arrangements smi_dict = build( - mf=ms[2], - exact_mass=ms[3], + mf=ms["mf"], + exact_mass=ms["exact_mass"], max_n_substructures=max_n_substructures, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, @@ -718,13 +344,13 @@ def annotate_msn(msn_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int retain_substructures=retain_substructures ) - results_db.add_results(ms[0], smi_dict, fragment_mass, j, retain_substructures) + results_db.add_results(i, smi_dict, fragment_mass, j, retain_substructures) smi_dict = None - results_db.calculate_frequencies(ms[0]) + results_db.calculate_frequencies(i) if yield_smis: - yield {ms[1]: results_db.get_structures(ms[0])} + yield {ms["ms_id"]: results_db.get_structures(i)} if write_csv_output: results_db.generate_csv_output() @@ -807,6 +433,8 @@ def generate_structures(ms_data: Union[str, os.PathLike, Dict[str, Dict[str, Uni :param write_csv_output: Whether to extract results from the SQLite3 database for deposition in CSV files. + :param retain_substructures: Whether to record the substructures used to generate final structures. + :return: For each input molecule, yields unique SMILEs strings (unless `yield_smis = False`). """ @@ -828,27 +456,26 @@ def generate_structures(ms_data: Union[str, os.PathLike, Dict[str, Dict[str, Uni max_mass=round(max([ms_data[ms_id]["exact_mass"] for ms_id in ms_data.keys()])) ) - # 0: i, 1: ms_id, 2: mc, 3: exact_mass, 4: prescribed_mass - for ms in parse_ms_data(ms_data): + for i, ms in enumerate(parse_ms_data(ms_data, False)): - results_db.add_ms(ms_data, ms[1], ms[0], + results_db.add_ms(ms_data, ms["ms_id"], i, [None, ha_min, ha_max, max_atoms_available, max_degree, max_n_substructures, None, isomeric_smiles]) ppm = None try: - if ms[4] is not None: + if ms["prescribed_mass"] is not None: ppm = 0 - except IndexError: - ms.append(None) + except KeyError: + ms["prescribed_mass"] = None smi_dict = build( - mf=ms[2], - exact_mass=ms[3], + mf=ms["mf"], + exact_mass=ms["exact_mass"], max_n_substructures=max_n_substructures, path_connectivity_db=path_connectivity_db, path_substructure_db=path_substructure_db, - prescribed_mass=ms[4], + prescribed_mass=ms["prescribed_mass"], ppm=ppm, table_name=table_name, ncpus=ncpus, @@ -857,13 +484,13 @@ def generate_structures(ms_data: Union[str, os.PathLike, Dict[str, Dict[str, Uni retain_substructures=retain_substructures ) - results_db.add_results(ms[0], smi_dict, ms[4]) + results_db.add_results(i, smi_dict, ms["prescribed_mass"]) smi_dict = None - results_db.calculate_frequencies(ms[0]) + results_db.calculate_frequencies(i) if yield_smis: - yield {ms[1]: results_db.get_structures(ms[0])} + yield {ms["ms_id"]: results_db.get_structures(i)} if write_csv_output: results_db.generate_csv_output() @@ -1064,13 +691,13 @@ def gen_subs_table(db, ha_min, ha_max, max_degree, max_atoms_available, max_mass max_mass_statment = "" else: max_mass_statment = """ - exact_mass__1 < %s""" % str(max_mass) + AND exact_mass__1 < %s""" % str(max_mass) db.cursor.execute("""CREATE TABLE {} AS - SELECT * FROM substructures WHERE - atoms_available <= {} AND - valence <= {} AND - exact_mass__1 < {}{}{}{} + SELECT * + FROM substructures + WHERE atoms_available <= {} + AND valence <= {}{}{}{}{} """.format(table_name, max_atoms_available, max_degree, diff --git a/metaboblend/databases.py b/metaboblend/databases.py index 03d24a8..2c31d12 100644 --- a/metaboblend/databases.py +++ b/metaboblend/databases.py @@ -21,13 +21,10 @@ import io import os -import sys -import subprocess +import pickle import sqlite3 import tempfile -import pickle -from collections import OrderedDict -import xml.etree.ElementTree as ElementTree +import subprocess import networkx as nx from typing import Sequence, Dict, Union @@ -35,97 +32,10 @@ from rdkit.Chem import Recap from rdkit.Chem import BRICS +from .parse import parse_xml from .auxiliary import calculate_complete_multipartite_graphs, graph_to_ri, graph_info, sort_subgraphs -def reformat_xml(source, encoding="utf8"): - """ - Reformats HMDB xml files to be compatible with :py:meth:`metaboblend.databases.parse_xml`; some such files do not - contain a `` header. - - :param source: Path to file to be reformatted. - - :param encoding: Encoding of source file. - - :return: Source file destination. - """ - - with io.open(source, "r", encoding=encoding) as xml: - xml_contents = xml.readlines() - if "hmdb" in xml_contents[1]: - return source - - xml_contents.insert(1, " \n") - - with io.open(source, "w", encoding=encoding) as xml: - xml_contents = "".join(xml_contents) - xml.write(xml_contents) - xml.write("") - - return source - - -def parse_xml(source, encoding="utf8", reformat=False): - """ - Parses the contents of HMDB xml files to to extract information for the generation of substructures. - - :param source: Source file destination. - - :param encoding: Encoding of source file. - - :param reformat: Whether to apply :py:meth:`metaboblend.databases.reformat_xml` to the XML file. Is required for - XML files recording single metabolites. - - * **True** Add a `` header to the XML file before parsing. - - * **False** Parse the XML file as it is (recommended if header is present). - - :return: The XML file converted to a dictionary. - """ - - if reformat: - reformat_xml(source, encoding) - - with io.open(source, "r", encoding=encoding) as inp: - record_out = OrderedDict() - - inp.readline() - inp.readline() - - xml_record = "" - path = [] - - for line in inp: - xml_record += line - if line == "\n" or line == "\n": - - if sys.version_info[0] == 3: - inp = io.StringIO(xml_record) - else: - inp = io.BytesIO(xml_record.encode('utf-8').strip()) - - for event, elem in ElementTree.iterparse(inp, events=("start", "end")): - if event == 'end': - path.pop() - - if event == 'start': - path.append(elem.tag) - if elem.text is not None: - if elem.text.replace(" ", "") != "\n": - - path_elem = ".".join(map(str, path[1:])) - if path_elem in record_out: - if type(record_out[path_elem]) != list: - record_out[path_elem] = [record_out[path_elem]] - record_out[path_elem].append(elem.text) - else: - record_out[path_elem] = elem.text - - xml_record = "" - yield record_out - record_out = OrderedDict() - - class SubstructureDb: """ Methods for interacting with the SQLITE3 substructure and connectivity databases. Provides a connection to the diff --git a/metaboblend/parse.py b/metaboblend/parse.py new file mode 100644 index 0000000..28aa2f2 --- /dev/null +++ b/metaboblend/parse.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © 2019-2020 Ralf Weber +# +# This file is part of MetaboBlend. +# +# MetaboBlend is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# MetaboBlend is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with MetaboBlend. If not, see . +# + +import io +import re +import sys +import copy +import warnings +from collections import OrderedDict +import xml.etree.ElementTree as ElementTree + + +def parse_ms_data(ms_data, msn=True): + """ + Parse raw data provided by user and yield formatted input data. + + :param ms_data: Dictionary containing input data or path to an MSP file. + + :return: None + """ + + if isinstance(ms_data, dict): + for i, ms_id in enumerate(ms_data.keys()): + + ms_data[ms_id]["ms_id"] = ms_id + + # check if user has provided a neutralised mass or ionised mz values + if "neutral_fragment_masses" in ms_data[ms_id].keys() and "exact_mass" in ms_data[ms_id].keys(): + which = "none" + + elif "exact_mass" in ms_data[ms_id].keys(): + if msn: + which = "fragments" + else: + which = "none" + + elif "neutral_fragment_masses" in ms_data[ms_id].keys() or not msn: + which = "precursor" + + else: + which = "both" + + yield precursor_ions_to_neutral_masses(ms_data[ms_id], which) + + else: + yield from parse_msp(ms_data) + + +def precursor_ion_to_neutral_mass(mass, precursor_type): + """ Convert precursor ion to predicted neutral mass for substructure searching. """ + + # conversions + precursor_dict = {"[M+H]+": 1.007276} + + return mass - precursor_dict[precursor_type] + + +def precursor_ions_to_neutral_masses(ms_dict, which="both"): + """ Convert precursor ion and fragment ions to neutral. """ + + if which == "precursor" or which == "both": + ms_dict["exact_mass"] = precursor_ion_to_neutral_mass(ms_dict["precursor_mz"], + ms_dict["precursor_type"]) + + if which == "fragments" or which == "both": + + ms_dict["neutral_fragment_masses"] = [] + + for fragment_ion_mass in ms_dict["fragment_mzs"]: + ms_dict["neutral_fragment_masses"].append(precursor_ion_to_neutral_mass(fragment_ion_mass, + ms_dict["precursor_type"])) + + return ms_dict + + +def parse_msp(msp_path): + """ Parse msp files and yield data for each compound. """ + + meta_parse = get_msp_regex() + reached_spectra = False + + empty_dict = {"ms_id": None, "mf": None, "precursor_mz": None, "fragment_mzs": []} + entry_dict = copy.deepcopy(empty_dict) + + with open(msp_path, "r") as msp_file: + + for line in msp_file: + + line = re.sub('^(.{2}\\$)', "", line) # remove "XX$" from line start in massbank files + + if reached_spectra: + if line in ["\n", "\r\n", "//\n", "//\r\n", "", "//"]: # reached end of spectra + + yield reformat_msp_input(entry_dict) # completed entry ready for sending to build + + entry_dict = copy.deepcopy(empty_dict) + reached_spectra = False + + else: # add peak + entry_dict["fragment_mzs"].append(float(line.split()[0])) + + else: + for meta_type in meta_parse.keys(): + for meta_re in meta_parse[meta_type]: + + re_query = re.search(meta_re, line, re.IGNORECASE) + + if re_query: # TODO: walrus + entry_dict[meta_type] = re_query.group(1).strip() + + if re.match("^Num Peaks(.*)$", line, re.IGNORECASE) or re.match("^PEAK:(.*)", line, re.IGNORECASE): + reached_spectra = True # reached line prior to spectra + + if entry_dict != empty_dict: + yield reformat_msp_input(entry_dict) + + +def reformat_msp_input(entry_dict): + """ Reformat input for use by build functions. """ + + if entry_dict["mf"] is not None: # convert from C5H6... to [5, 6, ...] + entry_dict["mf"] = mc_to_list(entry_dict["mf"]) + + for key in ["ms_id", "mf", "precursor_mz", "precursor_type"]: # required for the tool to function + if entry_dict[key] is None: + if key == "ms_id": + warnings.warn("Entry ignored from MSP file due to lack of accession in MSP file") + else: + warnings.warn("Entry " + entry_dict["ms_id"] + " removed due to lack of valid " + key + " in MSP file") + return None + + entry_dict["precursor_mz"] = float(entry_dict["precursor_mz"]) + + if len(entry_dict["fragment_mzs"]) == 0: # require a spectra to annotate + warnings.warn("No fragments were identified for " + entry_dict["ms_id"] + " in MSP file") + return None + + return precursor_ions_to_neutral_masses(entry_dict) + + +def mc_to_list(mc): + """ Convert molecular formula to list format. """ + + if isinstance(mc, list): + return mc + + mc_list = [0, 0, 0, 0, 0, 0] + element_positions = {"C": 0, "H": 1, "N": 2, "O": 3, "P": 4, "S": 5} + + # seperates out the formula into [letter, number, letter, number, ...] + mc = re.findall(r"[A-Z][a-z]*|\d+", re.sub("[A-Z][a-z]*(?![\da-z])", r"\g<0>1", mc)) + + for i, substring in enumerate(mc): + + if i % 2 == 0: # in case of letter + try: + element_position = element_positions[substring] + except KeyError: # element not in C, H, N, O, P, S + return None + + else: # record number following the letter + mc_list[element_position] = int(substring) + + return mc_list + + +def get_msp_regex(): + """ Dictionary of regular expressions for parsing msp metadata. """ + + meta_parse = {"ms_id": ["^accession(?:=|:)(.*)$", "^DB#(?:=|:)(.*)$", "^ACCESSION:(.*)$"], # use accession as ms_id + "mf": ["^molecular formula(?:=|:)(.*)$", "^formula:(.*)$"], + "precursor_type": ["^precursor.*type(?:=|:)(.*)$", "^adduct(?:=|:)(.*)$", "^MS\$FOCUSED_ION:\s+PRECURSOR_TYPE\s+(.*)$"], + "precursor_mz": ["^precursor m/z(?:=|:)\s*(\d*[.,]?\d*)$", "^precursor.*mz(?:=|:)\s*(\d*[.,]?\d*)$", "^MS\$FOCUSED_ION:\s+PRECURSOR_M/Z\s+(\d*[.,]?\d*)$"]} + + return meta_parse + + +def reformat_xml(source, encoding="utf8"): + """ + Reformats HMDB xml files to be compatible with :py:meth:`metaboblend.databases.parse_xml`; some such files do not + contain a `` header. + + :param source: Path to file to be reformatted. + + :param encoding: Encoding of source file. + + :return: Source file destination. + """ + + with io.open(source, "r", encoding=encoding) as xml: + xml_contents = xml.readlines() + if "hmdb" in xml_contents[1]: + return source + + xml_contents.insert(1, " \n") + + with io.open(source, "w", encoding=encoding) as xml: + xml_contents = "".join(xml_contents) + xml.write(xml_contents) + xml.write("") + + return source + + +def parse_xml(source, encoding="utf8", reformat=False): + """ + Parses the contents of HMDB xml files to to extract information for the generation of substructures. + + :param source: Source file destination. + + :param encoding: Encoding of source file. + + :param reformat: Whether to apply :py:meth:`metaboblend.databases.reformat_xml` to the XML file. Is required for + XML files recording single metabolites. + + * **True** Add a `` header to the XML file before parsing. + + * **False** Parse the XML file as it is (recommended if header is present). + + :return: The XML file converted to a dictionary. + """ + + if reformat: + reformat_xml(source, encoding) + + with io.open(source, "r", encoding=encoding) as inp: + record_out = OrderedDict() + + inp.readline() + inp.readline() + + xml_record = "" + path = [] + + for line in inp: + xml_record += line + if line == "\n" or line == "\n": + + if sys.version_info[0] == 3: + inp = io.StringIO(xml_record) + else: + inp = io.BytesIO(xml_record.encode('utf-8').strip()) + + for event, elem in ElementTree.iterparse(inp, events=("start", "end")): + if event == 'end': + path.pop() + + if event == 'start': + path.append(elem.tag) + if elem.text is not None: + if elem.text.replace(" ", "") != "\n": + + path_elem = ".".join(map(str, path[1:])) + if path_elem in record_out: + if type(record_out[path_elem]) != list: + record_out[path_elem] = [record_out[path_elem]] + record_out[path_elem].append(elem.text) + else: + record_out[path_elem] = elem.text + + xml_record = "" + yield record_out + record_out = OrderedDict() diff --git a/metaboblend/results.py b/metaboblend/results.py new file mode 100644 index 0000000..573ca26 --- /dev/null +++ b/metaboblend/results.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © 2019-2020 Ralf Weber +# +# This file is part of MetaboBlend. +# +# MetaboBlend is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# MetaboBlend is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with MetaboBlend. If not, see . +# + +import os +import csv +import sqlite3 + + +class ResultsDb: + """ + Methods for interacting with the SQLITE3 results database, as created by + :py:meth:`metaboblend.build_structures.annotate_msn`. + + :param path_results: Directory to which results will be written. + """ + + def __init__(self, path_results, msn=True): + """Constructor method.""" + + self.path_results = path_results + self.path_results_db = os.path.join(self.path_results, "metaboblend_results.sqlite") + self.msn = msn + + self.conn = None + self.cursor = None + + self.substructure_combo_id = 0 + + def connect(self): + """Connects to the results database.""" + + self.conn = sqlite3.connect(self.path_results_db) + self.cursor = self.conn.cursor() + + def create_results_db(self): + """Generates a new results database.""" + + if os.path.exists(self.path_results_db): + os.remove(self.path_results_db) + + self.connect() + + self.cursor.execute("""CREATE TABLE queries ( + ms_id_num INTEGER PRIMARY KEY, + ms_id TEXT, + exact_mass NUMERIC, + C INTEGER, + H INTEGER, + N INTEGER, + O INTEGER, + P INTEGER, + S INTEGER, + ppm INTEGER, + ha_min INTEGER, + ha_max INTEGER, + max_atoms_available INTEGER, + max_degree INTEGER, + max_n_substructures INTEGER, + hydrogenation_allowance INTEGER, + isomeric_smiles INTEGER)""") + + if self.msn: + self.cursor.execute("""CREATE TABLE spectra ( + ms_id_num INTEGER, + fragment_id INTEGER, + neutral_mass NUMERIC, + PRIMARY KEY (ms_id_num, fragment_id))""") + + self.cursor.execute("""CREATE TABLE structures ( + ms_id_num INTEGER, + structure_smiles TEXT, + frequency INTEGER, + PRIMARY KEY (ms_id_num, structure_smiles))""") + + self.cursor.execute("""CREATE TABLE substructures ( + substructure_combo_id INTEGER, + substructure_position_id INTEGER, + ms_id_num INTEGER, + structure_smiles TEXT, + fragment_id INTEGER, + substructure_smiles TEXT, + bde INTEGER, + PRIMARY KEY (substructure_combo_id, substructure_position_id))""") + + self.cursor.execute("""CREATE TABLE results ( + ms_id_num INTEGER, + fragment_id INTEGER, + structure_smiles TEXT, + bde INTEGER, + PRIMARY KEY(ms_id_num, fragment_id, structure_smiles))""") + + self.conn.commit() + + def add_ms(self, msn_data, ms_id, ms_id_num, parameters): + """ + Add entries to the `queries` and `spectra` tables. + + :param msn_data: Dictionary in the form + `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses: []}`. id represents a unique + identifier for a given spectral tree or fragmentation spectrum, mf is a list of integers referring to the + molecular formula of the structure of interest, exact_mass is the mass of this molecular formula to >=4d.p. + and fragment_masses are neutral fragment masses generated by this structure used to inform candidate + scoring. See :py:meth:`metaboblend.build_structures.annotate_msn`. + + :param ms_id: Unique identifier for the annotation of a single metabolite. + + :param ms_id_num: Unique numeric identifier for the annotation of a single metaoblite. + + :param parameters: List of parameters, in the form: [ppm, ha_min, ha_max, max_atoms_available, max_degree, + max_n_substructures, hydrogenation_allowance, isomeric_smiles]. See + :py:meth:`metaboblend.build_structures.annotate_msn`. + """ + + for i, parameter in enumerate(parameters): + if parameter is None: + parameters[i] = "NULL" + elif isinstance(parameter, bool): + parameters[i] = int(parameter) + + self.cursor.execute("""INSERT INTO queries ( + ms_id, + ms_id_num, + exact_mass, + C, H, N, O, P, S, + ppm, + ha_min, + ha_max, + max_atoms_available, + max_degree, + max_n_substructures, + hydrogenation_allowance, + isomeric_smiles + ) VALUES ('{}', {}, {}, '{}', '{}', '{}', '{}', '{}', '{}', {})""".format( + ms_id, + ms_id_num, + msn_data[ms_id]["exact_mass"], + msn_data[ms_id]["mf"][0], msn_data[ms_id]["mf"][1], + msn_data[ms_id]["mf"][2], msn_data[ms_id]["mf"][3], + msn_data[ms_id]["mf"][4], msn_data[ms_id]["mf"][5], + ", ".join([str(p) for p in parameters]) + )) + + self.conn.commit() + + def add_results(self, ms_id_num, smi_dict, fragment_mass=None, fragment_id=None, retain_substructures=False): + """ + Record which smiles were generated for a given fragment mass. + + :param ms_id_num: Unique identifier for the annotation of a single metabolite. + + :param smi_dict: The fragment and substructure smiles generated by the annotation of a single peak for a single + metabolite. + + :param fragment_mass: The neutral fragment mass that has been annotated. + + :param fragment_id: The unique identifier for the fragment mass that has been annotated. + + :param retain_substructures: If True, record substructures in the results DB. + """ + + if self.msn: + self.cursor.execute("""INSERT OR IGNORE INTO spectra ( + ms_id_num, + fragment_id, + neutral_mass + ) VALUES ('{}', {}, {})""".format( + ms_id_num, + fragment_id, + fragment_mass + )) + else: + fragment_id = "NULL" + + for structure_smiles in smi_dict.keys(): + + self.cursor.execute("""INSERT OR IGNORE INTO results ( + ms_id_num, + fragment_id, + structure_smiles, + bde + ) VALUES ({}, {}, '{}', {})""".format( + ms_id_num, + fragment_id, + structure_smiles, + min(smi_dict[structure_smiles]["bdes"]) + )) + + if retain_substructures: + for i in range(len(smi_dict[structure_smiles]["substructures"])): # for each combination + + for j, substructure in enumerate(smi_dict[structure_smiles]["substructures"][i]): + self.cursor.execute("""INSERT INTO substructures ( + substructure_combo_id, + substructure_position_id, + ms_id_num, + fragment_id, + structure_smiles, + substructure_smiles, + bde + ) VALUES ({}, {}, {}, {}, '{}', '{}', {})""".format( + self.substructure_combo_id, + j, + ms_id_num, + fragment_id, + structure_smiles, + substructure, + smi_dict[structure_smiles]["bdes"][i] + )) + + self.substructure_combo_id += 1 + + self.conn.commit() + + def calculate_frequencies(self, ms_id_num): + """ + Calculates structure frequencies in the SQLite DB. + + :param ms_id_num: Unique identifier for the annotation of a single metabolite. + """ + + self.cursor.execute("""INSERT INTO structures (ms_id_num, structure_smiles, frequency) + SELECT ms_id_num, structure_smiles, COUNT(*) + FROM results + WHERE ms_id_num = {} + GROUP BY structure_smiles""".format(ms_id_num)) + + def get_structures(self, ms_id_num): + """ + Gets smiles of generated structures. In the case of the MSn annotation workflow, also gets structure + frequencies. + + :param ms_id_num: Unique identifier for the annotation of a single metabolite. + + :return: In the case of simple structure generation, returns a set of smiles strings for output structures. + For the MSn annotation workflow, returns a dictionary with smiles as keys and the number of peaks for which + the smiles were generated as values. + """ + + if self.msn: + msn_str = ", frequency" + else: + msn_str = "" + + self.cursor.execute("""SELECT structure_smiles{} FROM structures + WHERE ms_id_num = {} + """.format(msn_str, ms_id_num)) + + if self.msn: + return [t for t in self.cursor.fetchall()] + else: + return [item for t in self.cursor.fetchall() for item in t] + + def generate_csv_output(self): + """ + Generate CSV file output for i) queries and tool parameters and ii) structures generated. + """ + + with open(os.path.join(self.path_results, "metaboblend_queries.csv"), "w", newline="") as results_file, \ + open(os.path.join(self.path_results, "metaboblend_structures.csv"), "w", newline="") as ms_file: + + results_writer = csv.writer(results_file, delimiter=",") + ms_writer = csv.writer(ms_file, delimiter=",") + + results_writer.writerow(["ms_id", "exact_mass", "C", "H", "N", "O", "P", "S", "ppm", "ha_min", "ha_max", + "max_atoms_available", "max_degree", "max_n_substructures", + "hydrogenation_allowance", "isomeric_smiles"]) + + self.cursor.execute("SELECT * FROM queries") + + for query in self.cursor.fetchall(): + results_writer.writerow(query) + + ms_writer.writerow(["ms_id", "smiles", "frequency", "exact_mass", "C", "H", "N", "O", "P", "S"]) + + self.cursor.execute("SELECT * FROM structures") + + for structure in self.cursor.fetchall(): + ms_writer.writerow(structure) + + def close(self): + """Close the connection to the SQLITE3 database.""" + + self.conn.close() diff --git a/tests/test_build_structures.py b/tests/test_build_structures.py index e74f81d..32a67c7 100644 --- a/tests/test_build_structures.py +++ b/tests/test_build_structures.py @@ -202,7 +202,7 @@ def test_generate_structures(self): # tests vs build ms_data = {record_dict["HMDB_ID"]: {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], "exact_mass": record_dict["exact_mass"], - "prescribed_masses": fragments[i]}} + "prescribed_mass": fragments[i]}} # test prescribed building returned_smis = list( @@ -280,7 +280,7 @@ def test_annotate_msn(self): # tests vs build_msn ms_data = {record_dict["HMDB_ID"]: {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], "exact_mass": record_dict["exact_mass"], - "fragment_masses": fragments}} + "neutral_fragment_masses": fragments}} # test standard building returned_smis = list(annotate_msn( @@ -307,7 +307,7 @@ def test_annotate_msn(self): # tests vs build_msn ms_data[record_dict["HMDB_ID"]] = {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], "exact_mass": record_dict["exact_mass"], - "fragment_masses": fragments} + "neutral_fragment_masses": fragments} os.mkdir(self.to_test_results("annotate_multi")) @@ -338,7 +338,7 @@ def test_results_db(self): ms_data[record_dict["HMDB_ID"]] = {"mf": [record_dict["C"], record_dict["H"], record_dict["N"], record_dict["O"], record_dict["P"], record_dict["S"]], "exact_mass": record_dict["exact_mass"], - "fragment_masses": fragments} + "neutral_fragment_masses": fragments} os.mkdir(self.to_test_results("test_results_db")) diff --git a/tests/test_data/massbank_msp.txt b/tests/test_data/massbank_msp.txt new file mode 100644 index 0000000..cdfeb00 --- /dev/null +++ b/tests/test_data/massbank_msp.txt @@ -0,0 +1,87 @@ +ACCESSION: UO000002 +RECORD_TITLE: 2,3-di-O-Phytanyl-sn-glycerol-1-phosphoserine; EI-B; MS +DATE: 2016.01.19 (Created 2009.05.29, modified 2011.05.06) +AUTHORS: Hiroyuki Morii, Department of Chemistry, University of Occupational and Environmental Health +LICENSE: CC BY-SA +PUBLICATION: Morii, H., Nishihara, M., Ohga, M., and Koga, Y. 1986. A diphytanyl ether analog of phosphatidylserine from a methanogenic bacterium, Methanobrevibacter arboriphilus. J Lipid Res. 27: 724-730. +COMMENT: Ammonium salt of the compound was analyzed +COMMENT: [Analytical] Ionizing Curr 300 uA, Chamber Temp 250 C, Accel Volt 3KV, Ion Multi 1.0 KV, +CH$NAME: 2,3-di-O-Phytanyl-sn-glycerol-1-phosphoserine +CH$NAME: archaetidylserine +CH$COMPOUND_CLASS: Glycerophospholipids; Glycerophosphoserines; Dialkylglycerophosphoserines +CH$FORMULA: C46H94NO8P +CH$EXACT_MASS: 819.67171 +CH$SMILES: C(CCC(C)C)C(C)CCCC(CCCC(CCOCC(OCCC(CCCC(C)CCCC(C)CCCC(C)C)C)COP(O)(=O)OCC(C(O)=O)N)C)C +CH$IUPAC: InChI=1S/C46H94NO8P/c1-36(2)17-11-19-38(5)21-13-23-40(7)25-15-27-42(9)29-31-52-33-44(34-54-56(50,51)55-35-45(47)46(48)49)53-32-30-43(10)28-16-26-41(8)24-14-22-39(6)20-12-18-37(3)4/h36-45H,11-35,47H2,1-10H3,(H,48,49)(H,50,51)/t38-,39-,40-,41-,42-,43-,44-,45-/m1/s1 +CH$LINK: CAS 105662-26-8 +CH$LINK: LIPIDBANK EEL3026 +AC$INSTRUMENT: JMS DX-300/JMS-3500 data system, Japan Electron Optics Laboratory, Japan +AC$INSTRUMENT_TYPE: EI-B +AC$MASS_SPECTROMETRY: MS_TYPE MS +AC$MASS_SPECTROMETRY: ION_MODE POSITIVE +AC$MASS_SPECTROMETRY: IONIZATION_POTENTIAL 30 eV +AC$MASS_SPECTROMETRY: SCANNING 5 Sec +AC$MASS_SPECTROMETRY: SOURCE_TEMPERATURE 320 C +MS$FOCUSED_ION: ION_TYPE [M]+* +PK$SPLASH: splash10-05gi-9611001000-5e7663b41cf47681770e +PK$NUM_PEAK: 58 +PK$PEAK: m/z int. rel.int. + 36.0 48.935 69 + 43.0 155.642 221 + 55.0 174.853 248 + 56.0 241.397 343 + 57.0 685.069 973 + 69.0 429.724 610 + 70.0 442.816 629 + 71.0 703.562 999 + 74.0 153.368 218 + 81.0 183.718 261 + 82.0 116.090 165 + 83.0 432.501 614 + 84.0 189.394 269 + 85.0 524.513 745 + 95.0 102.128 145 + 96.0 179.720 255 + 97.0 449.439 638 + 98.0 118.287 168 + 99.0 376.928 535 + 111.0 429.907 610 + 112.0 168.627 239 + 113.0 308.186 438 + 123.0 298.191 423 + 124.0 233.188 331 + 125.0 504.097 716 + 126.0 362.310 514 + 127.0 300.450 427 + 139.0 123.338 175 + 140.0 233.340 331 + 141.0 235.767 335 + 153.0 107.469 153 + 155.0 163.241 232 + 169.0 130.189 185 + 182.0 94.773 135 + 183.0 160.234 228 + 196.0 132.555 188 + 197.0 163.622 232 + 211.0 75.654 107 + 278.0 326.649 464 + 279.0 220.386 313 + 280.0 227.649 323 + 281.0 143.572 204 + 296.0 158.358 225 + 297.0 60.502 86 + 309.0 35.370 50 + 325.0 279.819 397 + 326.0 71.000 101 + 340.0 133.760 190 + 341.0 60.486 86 + 343.0 243.579 346 + 344.0 60.028 85 + 354.0 51.468 73 + 373.0 132.555 188 + 374.0 42.069 60 + 383.0 63.767 91 + 634.0 450.446 640 + 635.0 212.497 302 + 636.0 49.622 70 +// diff --git a/tests/test_data/mona_msp.msp b/tests/test_data/mona_msp.msp new file mode 100644 index 0000000..b1f6d21 --- /dev/null +++ b/tests/test_data/mona_msp.msp @@ -0,0 +1,580 @@ +Name: Sulfaclozine +Synon: 4-amino-N-(6-chloropyrazin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100601 +InChIKey: QKLPUVXBJHRFQZ-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 285.0208 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: Ramp 21.1-31.6 eV +Formula: C10H9ClN4O2S +MW: 284 +ExactMass: 284.013474208 +Comments: "accession=AU100601" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=284.0135" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=Ramp 21.1-31.6 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.6 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=285.0208" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.17469602228006656" "mass error=4.9792000027082395E-5" "SMILES=c1cc(ccc1N)S(=O)(=O)Nc2cncc(n2)Cl" "cas=102-65-8" "pubchem cid=66890" "chemspider=60252" "InChI=InChI=1S/C10H9ClN4O2S/c11-9-5-13-6-10(14-9)15-18(16,17)8-3-1-7(12)2-4-8/h1-6H,12H2,(H,14,15)" "InChIKey=QKLPUVXBJHRFQZ-UHFFFAOYSA-N" "molecular formula=C10H9ClN4O2S" "total exact mass=284.013474208" "SMILES=C=1C=C(C=CC1N)S(N=C2C=NC=C(Cl)N2)(=O)=O" +Num Peaks: 27 +53.0389 0.594951 +54.0333 0.566811 +55.0178 0.522592 +60.0552 0.542692 +65.0382 3.822962 +66.0423 0.506512 +68.049 7.963499 +78.0333 0.727609 +79.0177 1.057244 +92.0498 7.702203 +93.0532 0.731629 +96.0443 0.623091 +108.0457 12.172375 +109.0483 1.181862 +110.0609 4.904325 +120.0562 3.095353 +130.0172 5.656054 +132.0138 1.515517 +156.0118 100.000000 +157.015 8.884065 +158.008 3.891301 +174.0228 0.751729 +184.0757 0.619071 +191.9647 0.590931 +219.0438 0.723589 +285.0221 3.694324 +287.0184 0.840167 + + +Name: Sulfachlorpyridazine +Synon: 4-amino-N-(6-chloropyridazin-3-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100701 +InChIKey: XOXHILFPRYWFOD-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 285.0208 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: Ramp 21.1-31.6 eV +Formula: C10H9ClN4O2S +MW: 284 +ExactMass: 284.013474208 +Comments: "accession=AU100701" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=284.0135" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=Ramp 21.1-31.6 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.6 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=285.0208" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.17469602228006656" "mass error=4.9792000027082395E-5" "SMILES=c1cc(ccc1N)S(=O)(=O)Nc2ccc(nn2)Cl" "cas=80-32-0" "pubchem cid=6634" "chemspider=6382" "InChI=InChI=1S/C10H9ClN4O2S/c11-9-5-6-10(14-13-9)15-18(16,17)8-3-1-7(12)2-4-8/h1-6H,12H2,(H,14,15)" "InChIKey=XOXHILFPRYWFOD-UHFFFAOYSA-N" "molecular formula=C10H9ClN4O2S" "total exact mass=284.013474208" "SMILES=C=1C=C(C=CC1N)S(NC=2C=CC(Cl)=NN2)(=O)=O" +Num Peaks: 27 +53.0389 0.594951 +54.0333 0.566811 +55.0178 0.522592 +60.0552 0.542692 +65.0382 3.822962 +66.0423 0.506512 +68.049 7.963499 +78.0333 0.727609 +79.0177 1.057244 +92.0498 7.702203 +93.0532 0.731629 +96.0443 0.623091 +108.0457 12.172375 +109.0483 1.181862 +110.0609 4.904325 +120.0562 3.095353 +130.0172 5.656054 +132.0138 1.515517 +156.0118 100.000000 +157.015 8.884065 +158.008 3.891301 +174.0228 0.751729 +184.0757 0.619071 +191.9647 0.590931 +219.0438 0.723589 +285.0221 3.694324 +287.0184 0.840167 + + +Name: Sulfadimidine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100801 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: Ramp 20.8-31.3 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.08374668799996 +Comments: "accession=AU100801" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=Ramp 20.8-31.3 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.4 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248146496051" "mass error=-2.2687999944537296E-5" "SMILES=Cc1cc(nc(n1)NS(=O)(=O)c2ccc(cc2)N)C" "cas=57-68-1" "kegg=C19530" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.08374668799996" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 46 +53.0379 0.894101 +54.0335 0.661867 +55.0176 0.598003 +65.0379 8.717487 +68.0491 13.013818 +69.0329 1.640153 +78.0334 1.477589 +79.0178 2.261379 +80.0489 1.431143 +81.0444 1.950766 +82.0284 0.606712 +92.0499 30.585230 +93.0558 2.844868 +94.0647 1.686600 +95.0608 3.027752 +96.0443 1.300511 +108.0461 33.946818 +109.0497 2.360079 +110.0616 6.107757 +111.0651 0.519624 +120.0565 1.962378 +122.0716 6.078727 +123.0794 2.246865 +124.0872 71.211681 +125.0905 6.398049 +126.0663 17.911054 +127.0697 0.595100 +156.0117 82.855318 +157.0148 5.739085 +158.0072 1.544357 +174.0224 1.106015 +186.0334 11.263353 +187.0368 0.775081 +188.0128 1.637250 +188.0291 0.534138 +204.0445 100.000000 +205.0473 6.972829 +206.0406 3.358686 +213.1141 18.259405 +214.1167 2.241059 +215.0927 3.071296 +215.1291 1.320831 +279.0925 61.483976 +280.0953 8.438806 +281.0725 7.837901 +282.0742 1.222132 + + +Name: Sulfamethazine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100802 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 20 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.08374668799996 +Comments: "accession=AU100802" "author=Nikiforos Alygizakis, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837467" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=20 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.1 min" "solvent a=90:10 water:methanol with 0.01% formic acid and 5mM ammonium formate" "solvent b=methanol with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248146496051" "mass error=-2.2687999944537296E-5" "SMILES=CC1=CC(C)=NC(NS(=O)(=O)C2=CC=C(N)C=C2)=N1" "cas=57-68-1" "chebi=102265" "kegg=D02436" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.08374668799996" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 16 +122.0703 0.766124 +124.0861 36.693459 +125.0892 1.930893 +149.0227 0.828453 +156.0104 53.249536 +157.0129 2.999571 +158.0061 1.778967 +174.0209 0.627183 +186.0321 22.621444 +187.0346 1.719235 +188.0285 0.646661 +204.0431 100.000000 +213.1128 8.749399 +214.1159 1.407591 +215.1281 0.658348 +279.0909 80.894937 + + +Name: Sulfamethazine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100803 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 30 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.083746688 +Comments: "accession=AU100803" "author=Nikiforos Alygizakis, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837467" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=30 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.2 min" "solvent a=90:10 water:methanol with 0.01% formic acid and 5mM ammonium formate" "solvent b=methanol with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248166863394" "mass error=-2.2688000001380715E-5" "SMILES=CC1=CC(C)=NC(NS(=O)(=O)C2=CC=C(N)C=C2)=N1" "cas=57-68-1" "chebi=102265" "kegg=D02436" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.083746688" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 17 +108.0441 1.285794 +122.0704 6.630847 +123.0781 2.170942 +124.0861 100.000000 +125.0889 6.093546 +149.0221 1.388285 +156.0106 50.043481 +158.0064 1.615007 +186.0323 15.118951 +187.0355 1.323064 +196.0858 1.220573 +204.0429 70.964035 +205.0455 4.931983 +213.1123 22.610100 +214.1155 3.003292 +215.1283 0.804398 +279.0903 3.580968 + + +Name: Sulfamethazine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100804 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 40 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.083746688 +Comments: "accession=AU100804" "author=Nikiforos Alygizakis, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837467" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=40 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.1 min" "solvent a=90:10 water:methanol with 0.01% formic acid and 5mM ammonium formate" "solvent b=methanol with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248166863394" "mass error=-2.2688000001380715E-5" "SMILES=CC1=CC(C)=NC(NS(=O)(=O)C2=CC=C(N)C=C2)=N1" "cas=57-68-1" "chebi=102265" "kegg=D02436" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.083746688" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 22 +108.0445 1.153673 +122.0702 5.323878 +123.0772 2.202467 +124.0862 100.000000 +125.089 6.847126 +134.0701 0.714179 +149.0224 1.747990 +154.0624 0.644259 +155.0685 0.624282 +156.0104 10.373071 +157.0126 0.933926 +172.0852 0.564351 +186.0324 3.845578 +196.0852 5.209010 +197.0903 1.378415 +198.0888 2.362283 +204.0427 15.422264 +205.0463 0.869001 +206.0375 0.759127 +212.1036 0.659242 +213.1121 18.109174 +214.1152 2.577036 + + +Name: Sulfamethazine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100805 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 50 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.083746688 +Comments: "accession=AU100805" "author=Nikiforos Alygizakis, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837467" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=50 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.2 min" "solvent a=90:10 water:methanol with 0.01% formic acid and 5mM ammonium formate" "solvent b=methanol with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248166863394" "mass error=-2.2688000001380715E-5" "SMILES=CC1=CC(C)=NC(NS(=O)(=O)C2=CC=C(N)C=C2)=N1" "cas=57-68-1" "chebi=102265" "kegg=D02436" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.083746688" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 24 +108.0453 1.770916 +122.0703 2.803951 +123.078 2.792598 +124.0859 100.000000 +125.0891 7.901010 +149.0231 1.623340 +154.0639 2.111477 +155.0605 2.463390 +155.0714 2.690430 +156.01 2.713134 +169.0745 1.475763 +171.0781 1.555228 +172.0869 1.271427 +181.0634 0.930866 +186.1022 1.033034 +195.0786 1.555228 +196.0859 7.628562 +197.0856 3.871041 +198.0886 5.903054 +199.0904 0.998978 +204.0438 2.622318 +212.1048 2.327165 +213.1122 9.342718 +214.1153 1.725508 + + +Name: Sulfamethazine +Synon: 4-amino-N-(4,6-dimethylpyrimidin-2-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100806 +InChIKey: ASWVTGNCAZCNNR-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 279.091 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 10 eV +Formula: C12H14N4O2S +MW: 278 +ExactMass: 278.08374668799996 +Comments: "accession=AU100806" "author=Nikiforos Alygizakis, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=278.0837467" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=10 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.2 min" "solvent a=90:10 water:methanol with 0.01% formic acid and 5mM ammonium formate" "solvent b=methanol with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=279.091" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08129248146496051" "mass error=-2.2687999944537296E-5" "SMILES=CC1=CC(C)=NC(NS(=O)(=O)C2=CC=C(N)C=C2)=N1" "cas=57-68-1" "chebi=102265" "kegg=D02436" "pubchem cid=5327" "chemspider=5136" "InChI=InChI=1S/C12H14N4O2S/c1-8-7-9(2)15-12(14-8)16-19(17,18)11-5-3-10(13)4-6-11/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ASWVTGNCAZCNNR-UHFFFAOYSA-N" "molecular formula=C12H14N4O2S" "total exact mass=278.08374668799996" "SMILES=CC1=CC(C)=NC(=N1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 4 +124.086 0.740586 +156.0098 1.123942 +186.0319 0.831793 +279.0908 100.000000 + + +Name: Sulfadimethoxine +Synon: 4-amino-n-(2,6-dimethoxypyrimidin-4-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100902 +InChIKey: ZZORFUFYDOWNEF-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 311.0809 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 20 eV +Formula: C12H14N4O4S +MW: 310 +ExactMass: 310.07357592799997 +Comments: "accession=AU100902" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=310.0735759" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=20 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.6 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=311.0809" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.15453214911901536" "mass error=4.8072000026877504E-5" "SMILES=COc1cc(nc(n1)OC)NS(=O)(=O)c2ccc(cc2)N" "cas=122-11-2" "chebi=32161" "pubchem=5323" "chemspider=5132" "InChI=InChI=1S/C12H14N4O4S/c1-19-11-7-10(14-12(15-11)20-2)16-21(17,18)9-5-3-8(13)4-6-9/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ZZORFUFYDOWNEF-UHFFFAOYSA-N" "molecular formula=C12H14N4O4S" "total exact mass=310.07357592799997" "SMILES=COC=1C=C(N=C(N1)OC)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 15 +140.0447 6.249276 +141.0515 0.699085 +154.0604 5.781932 +155.0683 3.398864 +156.0107 100.000000 +156.0763 16.893901 +157.0134 4.171334 +157.0794 0.857441 +158.0069 2.371480 +218.0242 0.965586 +245.1032 9.010853 +246.1061 0.834267 +311.0811 75.335059 +312.0835 7.145340 +313.0796 2.301958 + + +Name: Sulfadimethoxine +Synon: 4-amino-n-(2,6-dimethoxypyrimidin-4-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100903 +InChIKey: ZZORFUFYDOWNEF-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 311.0809 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 30 eV +Formula: C12H14N4O4S +MW: 310 +ExactMass: 310.073575928 +Comments: "accession=AU100903" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=310.0735759" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=30 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.6 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=311.0809" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.15453214893628664" "mass error=4.8071999970034085E-5" "SMILES=COc1cc(nc(n1)OC)NS(=O)(=O)c2ccc(cc2)N" "cas=122-11-2" "chebi=32161" "pubchem=5323" "chemspider=5132" "InChI=InChI=1S/C12H14N4O4S/c1-19-11-7-10(14-12(15-11)20-2)16-21(17,18)9-5-3-8(13)4-6-9/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ZZORFUFYDOWNEF-UHFFFAOYSA-N" "molecular formula=C12H14N4O4S" "total exact mass=310.073575928" "SMILES=COC=1C=C(N=C(N1)OC)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 21 +108.0448 1.310092 +124.0204 1.354502 +126.0659 3.563895 +127.0504 0.843788 +138.0294 1.576552 +141.0517 10.458532 +154.0604 60.575108 +155.0672 5.484623 +156.0105 100.000000 +156.0762 63.495059 +157.0131 4.540913 +157.0798 3.452870 +158.0071 2.320417 +201.0772 2.720107 +212.069 3.896969 +218.0235 0.843788 +230.0808 9.270567 +231.0843 1.232375 +245.1039 10.447430 +246.107 1.176862 +311.0829 3.819252 + + +Name: Sulfadimethoxine +Synon: 4-amino-n-(2,6-dimethoxypyrimidin-4-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100904 +InChIKey: ZZORFUFYDOWNEF-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 311.0809 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 40 eV +Formula: C12H14N4O4S +MW: 310 +ExactMass: 310.073575928 +Comments: "accession=AU100904" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=310.0735759" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=40 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.7 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=311.0809" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.15453214893628664" "mass error=4.8071999970034085E-5" "SMILES=COc1cc(nc(n1)OC)NS(=O)(=O)c2ccc(cc2)N" "cas=122-11-2" "chebi=32161" "pubchem=5323" "chemspider=5132" "InChI=InChI=1S/C12H14N4O4S/c1-19-11-7-10(14-12(15-11)20-2)16-21(17,18)9-5-3-8(13)4-6-9/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ZZORFUFYDOWNEF-UHFFFAOYSA-N" "molecular formula=C12H14N4O4S" "total exact mass=310.073575928" "SMILES=COC=1C=C(N=C(N1)OC)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 27 +112.0515 2.118270 +123.0436 2.184466 +124.0205 1.897617 +124.0508 2.162401 +126.0666 5.803177 +127.0502 2.030009 +132.0558 1.963813 +138.0295 4.898500 +140.045 77.780229 +141.0524 38.989409 +142.058 2.449250 +154.0604 100.000000 +155.0634 5.383936 +156.0104 20.101500 +156.0407 4.236540 +156.0761 54.744042 +157.0639 1.809356 +160.049 1.985878 +178.0597 3.420124 +201.077 8.274492 +202.0789 1.787290 +212.0697 15.114740 +213.0728 2.581642 +229.0713 2.206531 +230.0797 6.421006 +231.0852 1.919682 +245.1026 1.919682 + + +Name: Sulfadimethoxine +Synon: 4-amino-n-(2,6-dimethoxypyrimidin-4-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU100905 +InChIKey: ZZORFUFYDOWNEF-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 311.0809 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 50 eV +Formula: C12H14N4O4S +MW: 310 +ExactMass: 310.07357592799997 +Comments: "accession=AU100905" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=310.0735759" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=50 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.7 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=311.0809" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.15453214911901536" "mass error=4.8072000026877504E-5" "SMILES=COc1cc(nc(n1)OC)NS(=O)(=O)c2ccc(cc2)N" "cas=122-11-2" "chebi=32161" "pubchem=5323" "chemspider=5132" "InChI=InChI=1S/C12H14N4O4S/c1-19-11-7-10(14-12(15-11)20-2)16-21(17,18)9-5-3-8(13)4-6-9/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=ZZORFUFYDOWNEF-UHFFFAOYSA-N" "molecular formula=C12H14N4O4S" "total exact mass=310.07357592799997" "SMILES=COC=1C=C(N=C(N1)OC)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 22 +112.051 5.243790 +123.0427 7.773689 +124.0502 6.439742 +126.0287 5.841766 +126.0666 6.255750 +127.0491 3.955842 +132.0559 9.521619 +133.0628 5.105796 +138.0293 10.579577 +140.045 45.768169 +141.0521 46.780129 +142.0539 3.817847 +154.0606 100.000000 +156.0102 3.679853 +156.0405 5.243790 +156.0769 17.157314 +157.0629 5.887764 +160.0507 3.909844 +178.0613 9.429623 +184.0741 4.737810 +201.0768 9.015639 +212.0705 7.589696 + + +Name: Sulfadoxine +Synon: 4-amino-N-(5,6-dimethoxypyrimidin-4-yl)benzenesulfonamide +SYNON: $:00in-source +DB#: AU101001 +InChIKey: PJSFRIWCGOHTNF-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 311.0809 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: Ramp 21.8-32.7 eV +Formula: C12H14N4O4S +MW: 310 +ExactMass: 310.07357592799997 +Comments: "accession=AU101001" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=310.0736" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=Ramp 21.8-32.7 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=4.8 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=311.0809" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.15453214911901536" "mass error=4.8072000026877504E-5" "SMILES=COc1c(ncnc1OC)NS(=O)(=O)c2ccc(cc2)N" "cas=2447-57-6" "kegg=C07630" "pubchem cid=17134" "chemspider=16218" "InChI=InChI=1S/C12H14N4O4S/c1-19-10-11(14-7-15-12(10)20-2)16-21(17,18)9-5-3-8(13)4-6-9/h3-7H,13H2,1-2H3,(H,14,15,16)" "InChIKey=PJSFRIWCGOHTNF-UHFFFAOYSA-N" "molecular formula=C12H14N4O4S" "total exact mass=310.07357592799997" "SMILES=COC1=C(N=CN=C1OC)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 42 +53.0386 0.535490 +54.0339 0.505437 +65.0381 7.755041 +68.0491 10.088247 +69.0329 1.049123 +78.0332 1.038195 +79.0179 1.721217 +80.0363 0.707612 +80.0493 1.446642 +92.0498 46.272062 +93.0559 3.808535 +96.0447 1.331894 +108.0463 57.395771 +109.049 3.816731 +109.0643 0.531392 +110.0614 7.111633 +113.0359 0.703513 +120.0568 1.860554 +124.0215 1.529971 +124.0512 0.572373 +126.0665 2.939730 +138.0301 0.707612 +140.0457 34.351948 +141.0528 5.744222 +154.0615 32.562428 +155.0682 7.798754 +156.0118 100.000000 +156.0771 40.377575 +157.0147 7.961314 +157.0796 2.486203 +158.0078 3.766188 +201.0773 1.349653 +212.0697 2.576362 +213.0752 0.527294 +218.0236 1.945249 +230.0808 5.531119 +231.085 0.811431 +245.1045 18.128791 +246.1073 2.479373 +311.0829 49.986340 +312.0854 8.491339 +313.0812 2.222556 + + +Name: Sulfadiazine +Synon: 4-amino-n-pyrimidin-2-ylbenzenesulfonamide +SYNON: $:00in-source +DB#: AU101101 +InChIKey: SEEPANYCNGTZFQ-UHFFFAOYSA-N +Precursor_type: [M+H]+ +Spectrum_type: MS2 +PrecursorMZ: 251.0597 +Instrument_type: LC-ESI-QTOF +Instrument: Bruker maXis Impact +Ion_mode: P +Collision_energy: 10 eV +Formula: C10H10N4O2S +MW: 250 +ExactMass: 250.05244656 +Comments: "accession=AU101101" "author=Nikiforos Alygizakis, Anna Bletsou, Nikolaos Thomaidis, University of Athens" "license=CC BY-SA" "copyright=Copyright (C) 2015 Department of Chemistry, University of Athens" "exact mass=250.0524466" "instrument=Bruker maXis Impact" "instrument type=LC-ESI-QTOF" "ms level=MS2" "ionization=ESI" "fragmentation mode=CID" "collision energy=10 eV" "resolution=35000" "column=Acclaim RSLC C18 2.2um, 2.1x100mm, Thermo" "flow gradient=99/1 at 0-1 min, 61/39 at 3 min, 0.1/99.9 at 14-16 min, 99/1 at 16.1-20 min" "flow rate=200 uL/min at 0-3 min, 400 uL/min at 14 min, 480 uL/min at 16-19 min, 200 uL/min at 19.1-20 min" "retention time=3.3 min" "solvent a=water with 0.01% formic acid and 5mM ammonium formate" "solvent b=90:10 methanol:water with 0.01% formic acid and 5mM ammonium formate" "precursor m/z=251.0597" "precursor type=[M+H]+" "ionization mode=positive" "mass accuracy=0.08985910518808851" "mass error=-2.2559999990789947E-5" "SMILES=c1cnc(nc1)NS(=O)(=O)c2ccc(cc2)N" "cas=141582-64-1" "chebi=9328" "kegg=C07658" "pubchem=5215" "chemspider=5026" "InChI=InChI=1S/C10H10N4O2S/c11-8-2-4-9(5-3-8)17(15,16)14-10-12-6-1-7-13-10/h1-7H,11H2,(H,12,13,14)" "InChIKey=SEEPANYCNGTZFQ-UHFFFAOYSA-N" "molecular formula=C10H10N4O2S" "total exact mass=250.05244656" "SMILES=C1=CN=C(N=C1)NS(C2=CC=C(C=C2)N)(=O)=O" +Num Peaks: 6 +156.0106 9.361897 +174.0199 0.724251 +176.012 0.693756 +251.0596 100.000000 +252.0616 7.867653 +253.0565 2.729283 \ No newline at end of file diff --git a/tests/test_databases.py b/tests/test_databases.py index daae038..20c7ff7 100644 --- a/tests/test_databases.py +++ b/tests/test_databases.py @@ -25,6 +25,7 @@ import shutil import pickle from metaboblend.databases import * +from metaboblend.parse import reformat_xml class DatabasesTestCase(unittest.TestCase): diff --git a/tests/test_isomorphism_database.py b/tests/test_isomorphism_database.py index 96ff705..adba71e 100644 --- a/tests/test_isomorphism_database.py +++ b/tests/test_isomorphism_database.py @@ -21,6 +21,7 @@ import os +import sys import unittest import shutil import tempfile diff --git a/tests/test_parse.py b/tests/test_parse.py new file mode 100644 index 0000000..5fc95eb --- /dev/null +++ b/tests/test_parse.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright © 2019-2020 Ralf Weber +# +# This file is part of MetaboBlend. +# +# MetaboBlend is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# MetaboBlend is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with MetaboBlend. If not, see . +# + + +import os +import unittest +import shutil +import tempfile +from metaboblend.parse import * + + +class IsomorphDbTestCase(unittest.TestCase): + temp_results_dir = None + + @classmethod + def to_test_results(cls, *args): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), cls.temp_results_dir.name, *args) + + @classmethod + def to_test_data(cls, *args): + return os.path.join(os.path.dirname(os.path.realpath(__file__)), cls.temp_results_dir.name, "test_data", *args) + + @classmethod + def setUpClass(cls): + cls.temp_results_dir = tempfile.TemporaryDirectory(dir=os.path.dirname(os.path.realpath(__file__))) + + shutil.copytree(os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_data"), + cls.to_test_results("test_data")) + + def test_parse_msp(self): + for i, ms in enumerate(parse_msp(self.to_test_data("mona_msp.msp"))): + + if i < 2: + self.assertEqual(ms, None) + else: + print(ms) + + self.assertEqual(list(parse_msp(self.to_test_data("massbank_msp.txt")))[0], None) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_suite_auxiliary.py b/tests/test_suite_auxiliary.py index c44171d..cc211e2 100644 --- a/tests/test_suite_auxiliary.py +++ b/tests/test_suite_auxiliary.py @@ -27,6 +27,7 @@ from pathlib import Path from . import test_auxiliary +from . import test_parse sys.path.insert(0, str(Path(__file__).parent.parent.resolve())) @@ -35,6 +36,7 @@ suite = unittest.TestSuite() suite.addTest(unittest.findTestCases(test_auxiliary)) + suite.addTest(unittest.findTestCases(test_parse)) report = os.path.join(os.path.abspath(os.path.join(__file__, os.pardir)), 'results', 'results_test_suite_auxiliary') runTestSuite(suite, report, title='Process Test Suite Report', verbosity=2) From 5caf8f6cb9c7312054482d68555195fd962411f5 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Mon, 30 Nov 2020 14:18:07 +0000 Subject: [PATCH 31/35] Add unit testing and documentation for parse.py --- metaboblend/build_structures.py | 9 ++- metaboblend/parse.py | 88 ++++++++++++++++++++--- tests/test_parse.py | 123 +++++++++++++++++++++++++++++++- 3 files changed, 207 insertions(+), 13 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index 105a14a..d445e20 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -31,7 +31,6 @@ from rdkit import Chem - from .results import ResultsDb from .parse import parse_ms_data from .algorithms import subset_sum @@ -155,6 +154,9 @@ def add_bonds(mols, edges, atoms_available, bond_types, bond_enthalpies): * **2.0** Double + :param bond_enthalpies: Dictionary of bond enthalpies, as generated by + :py:meth:`metaboblend.build_structures.get_bond_enthalpies`. + :return: If unsuccessful, returns None, else returns an :py:meth:`rdkit.Chem.EditableMol` object containing the substructures combined into a final single molecule. """ @@ -789,6 +791,11 @@ def substructure_combination_build(substructure_subset, configs_iso, prescribed_ :param isomeric_smiles: True/False, should output smiles be written with isomeric information? + :param bond_enthalpies: Dictionary of bond enthalpies, as generated by + :py:meth:`metaboblend.build_structures.get_bond_enthalpies`. + + :param retain_substructures: Whether to record the substructures used to generate final structures. + :return: List of smiles representing molecules generated (and the substructures used to generate them). """ diff --git a/metaboblend/parse.py b/metaboblend/parse.py index 28aa2f2..5495a58 100644 --- a/metaboblend/parse.py +++ b/metaboblend/parse.py @@ -30,11 +30,17 @@ def parse_ms_data(ms_data, msn=True): """ - Parse raw data provided by user and yield formatted input data. + Parse raw data provided by user and yield formatted input data. Decides what type of data has been provided + (i.e. whether a dictionary has been given vs path to MSP file; if a dictionary, checks whether neutral masses + need to be calculated from precursor ions). :param ms_data: Dictionary containing input data or path to an MSP file. - :return: None + :param msn: If True, formats the data for use by :py:meth:`metaboblend.build_structures.annotate_msn`; else, formats + input data for use by :py:meth:`metaboblend.build_structures.generate_structures`. Only relevant if a + dictionary has been provided. + + :return: Yields a dictionary for use by build functions to generate structures. """ if isinstance(ms_data, dict): @@ -65,16 +71,41 @@ def parse_ms_data(ms_data, msn=True): def precursor_ion_to_neutral_mass(mass, precursor_type): - """ Convert precursor ion to predicted neutral mass for substructure searching. """ + """ + Convert precursor ion to predicted neutral mass for substructure searching. + + :param mass: Charged mass to be neutralised. + + :param precursor_type: Type of precursor ion. + + :return: Neutral mass. + """ # conversions - precursor_dict = {"[M+H]+": 1.007276} + precursor_dict = {"[M+H]+": 1.007276, + "[M+Na]+": 22.989221, + "[M+K]+": 38.963158, + "[M-H]-": -1.007276, + "[M+Cl]-": 34.969401, + "[M+Na-2H]-": 20.974668, + "[M+K-2H]-": 36.948605, + "[M+Hac-H]-": 59.013853} return mass - precursor_dict[precursor_type] def precursor_ions_to_neutral_masses(ms_dict, which="both"): - """ Convert precursor ion and fragment ions to neutral. """ + """ + Convert precursor ion and fragment ions to neutral. + + :param ms_dict: Dictionary used by build functions to generate structures. Converts the precursor ion mass and/or + the fragment ions to their respective neutral masses. + + :param which: Whether to convert the precursor ion ("precursor"), the fragment ions ("fragments") or both ("both") + to their respective neutral masses. If which is "none", returns the original dictionary. + + :return: Returns `ms_dict` with additional items corresponding to neutralised masses. + """ if which == "precursor" or which == "both": ms_dict["exact_mass"] = precursor_ion_to_neutral_mass(ms_dict["precursor_mz"], @@ -92,12 +123,28 @@ def precursor_ions_to_neutral_masses(ms_dict, which="both"): def parse_msp(msp_path): - """ Parse msp files and yield data for each compound. """ + """ + Parse msp files and yield data for each compound. Accepts MSP files in MoNa or MassBank format. We expect that + the following are provided in the MSP: + + - A unique accession ID. + - The molecular formula of the compound. + - The precursor mz representing the mass of the charged precursor ion. + - Fragment mzs representing masses of charged fragment ions. + - The type of precursor, e.g. "[M+H]+". + + Code adapted from `msp2db` (https://github.com/computational-metabolomics/msp2db/blob/master/msp2db/parse.py). + + :param msp_path: Path of an MSP file to be converted into a dictionary. + + :return: Dictionary in a form useable by :py:meth:`metaboblend.build_structures.annotate_msn` and + :py:meth:`metaboblend.build_structures.generate_structures`. + """ meta_parse = get_msp_regex() reached_spectra = False - empty_dict = {"ms_id": None, "mf": None, "precursor_mz": None, "fragment_mzs": []} + empty_dict = {"ms_id": None, "mf": None, "precursor_mz": None, "precursor_type": None, "fragment_mzs": []} entry_dict = copy.deepcopy(empty_dict) with open(msp_path, "r") as msp_file: @@ -134,7 +181,24 @@ def parse_msp(msp_path): def reformat_msp_input(entry_dict): - """ Reformat input for use by build functions. """ + """ + Reformat input for use by build functions. + + :param entry_dict: Dictionary containing MSn information extracted from an MSP file (by + :py:meth:`metaboblend.parse.parse_msp`. The dictionary must contain the following: + + - ms_id - a unique accession number + - mf - the molecular formula of the compound (in the format "CXHXNXOXPXSX") + - precursor_mz - mz representing the mass of the charged precursor ion + - precursor_type - the type of precursor ion (e.g. "[M+H]+") + - fragment_mzs - mz(s) representing the mass of charged fragment ions + + :return: If the correct inputs were not provided in the MSP (and, hence, were not available in `entry_dict`), + returns None (and generates a warning with i) the accession (if available) and ii) the variable that was not + able to be extracted from the MSP). Else, returns the same dictionary after reformatting the molecular formula, + using :py:meth:`metaboblend.parse.mc_to_list`, and converting the precursor ions to their corresponding + neutral masses. + """ if entry_dict["mf"] is not None: # convert from C5H6... to [5, 6, ...] entry_dict["mf"] = mc_to_list(entry_dict["mf"]) @@ -157,7 +221,13 @@ def reformat_msp_input(entry_dict): def mc_to_list(mc): - """ Convert molecular formula to list format. """ + """ + Convert molecular formula string to list format. + + :param mc: Molecular formula (in the format "C1H2N3O4P5S6") + + :return: Molecular formula (in the format `[1, 2, 3, 4, 5, 6]`) + """ if isinstance(mc, list): return mc diff --git a/tests/test_parse.py b/tests/test_parse.py index 5fc95eb..513fb68 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -21,9 +21,10 @@ import os -import unittest +import copy import shutil import tempfile +import unittest from metaboblend.parse import * @@ -44,6 +45,13 @@ def setUpClass(cls): shutil.copytree(os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_data"), cls.to_test_results("test_data")) + + cls.neutral_fragment_masses = [155.00332400000002, 173.01262400000002, 175.004724, + 250.052324, 251.054324, 252.049224] + cls.exact_mass = 250.052424 + cls.mf = [10, 10, 4, 2, 0, 1] + cls.precursor_mz = 251.0597 + cls.fragment_mzs = [156.0106, 174.0199, 176.012, 251.0596, 252.0616, 253.0565] def test_parse_msp(self): for i, ms in enumerate(parse_msp(self.to_test_data("mona_msp.msp"))): @@ -51,10 +59,119 @@ def test_parse_msp(self): if i < 2: self.assertEqual(ms, None) else: - print(ms) + self.assertNotEqual(ms, None) + + self.assertEqual(ms, {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "fragment_mzs": self.fragment_mzs, "precursor_type": "[M+H]+", + "exact_mass": self.exact_mass, "neutral_fragment_masses": self.neutral_fragment_masses}) self.assertEqual(list(parse_msp(self.to_test_data("massbank_msp.txt")))[0], None) + # ensure that parse_msp provides same output as parse_ms_data when providing an msn file + for parse_msp_dict, parse_ms_dict in zip(parse_msp(self.to_test_data("mona_msp.msp")), + parse_ms_data(self.to_test_data("mona_msp.msp"))): + + self.assertEqual(parse_msp_dict, parse_ms_dict) + + def test_parse_ms_data(self): + + # exact mass and neutral fragment masses should not be overwritten by parse_ms_data + full_ms_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "fragment_mzs": self.fragment_mzs, "precursor_type": "[M+H]+", "exact_mass": "abcd", + "neutral_fragment_masses": ["a", "b", "c", "d"]} + + self.assertEqual(list(parse_ms_data({"AU101101": copy.deepcopy(full_ms_dict)}))[0], full_ms_dict) + + # if exact mass is present should not be overwritten by parse_ms_data + exact_mass_ms_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "fragment_mzs": self.fragment_mzs, "precursor_type": "[M+H]+", "exact_mass": "abc"} + + parsed_exact_mass_ms_dict = list(parse_ms_data({"test": copy.deepcopy(exact_mass_ms_dict)}))[0] + exact_mass_ms_dict["ms_id"] = "test" + exact_mass_ms_dict["neutral_fragment_masses"] = self.neutral_fragment_masses + self.assertEqual(parsed_exact_mass_ms_dict, exact_mass_ms_dict) + + # neutral fragment masses should not be overwritten by parse_ms_data + neutral_fragment_masses_ms_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "precursor_type": "[M+H]+", "fragment_mzs": self.fragment_mzs, + "neutral_fragment_masses": ["a", "b", "c", "d"]} + + parsed_neutral_fragment_masses_ms_dict = list(parse_ms_data({"AU101101": copy.deepcopy(neutral_fragment_masses_ms_dict)}))[0] + neutral_fragment_masses_ms_dict["exact_mass"] = self.exact_mass + self.assertEqual(parsed_neutral_fragment_masses_ms_dict, neutral_fragment_masses_ms_dict) + + uncalculated_ms_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "fragment_mzs": self.fragment_mzs, "precursor_type": "[M+H]+"} + parsed_uncalculated_ms_dict = list(parse_ms_data({"AU101101": copy.deepcopy(uncalculated_ms_dict)}))[0] + uncalculated_ms_dict["exact_mass"] = self.exact_mass + uncalculated_ms_dict["neutral_fragment_masses"] = self.neutral_fragment_masses + self.assertEqual(parsed_uncalculated_ms_dict, uncalculated_ms_dict) + + # test with msn=False + generate_structures_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "prescribed_mass": "m", "precursor_type": "[M+H]+"} + parsed_generate_structures_dict = list(parse_ms_data({"AU101101": copy.deepcopy(generate_structures_dict)}, False))[0] + generate_structures_dict["exact_mass"] = self.exact_mass + self.assertEqual(parsed_generate_structures_dict, generate_structures_dict) + + # test with exact mass provided + generate_structures_dict["exact_mass"] = "a" + parsed_generate_structures_dict = list(parse_ms_data({"AU101101": copy.deepcopy(generate_structures_dict)}, False))[0] + self.assertEqual(parsed_generate_structures_dict, generate_structures_dict) + + def test_precursor_ions_to_neutral_masses(self): + + ms_dict = {"ms_id": "AU101101", "mf": self.mf, "precursor_mz": self.precursor_mz, + "fragment_mzs": self.fragment_mzs, "precursor_type": "[M+H]+"} + + for which in ["both", "fragments", "precursor", "none"]: + processed_ms_dict = precursor_ions_to_neutral_masses(copy.deepcopy(ms_dict), which) + + if which in ["both", "fragments"]: + self.assertEqual(processed_ms_dict["neutral_fragment_masses"], self.neutral_fragment_masses) + + if which in ["both", "precursor"]: + self.assertEqual(processed_ms_dict["exact_mass"], self.exact_mass) + + ms_dict["precursor_type"] = "[M-H]-" + + for which in ["both", "fragments", "precursor", "none"]: + processed_ms_dict = precursor_ions_to_neutral_masses(copy.deepcopy(ms_dict), which) + + if which in ["both", "fragments"]: + neutral_fragment_masses = [nfm + 1.007276 for nfm in self.fragment_mzs] + self.assertEqual(processed_ms_dict["neutral_fragment_masses"], neutral_fragment_masses) + + if which in ["both", "precursor"]: + self.assertEqual(processed_ms_dict["exact_mass"], self.precursor_mz + 1.007276) + + def test_reformat_msp_input(self): + + unformatted_msp_dict = {'ms_id': 'AU101101', 'mf': 'C10H10N4O2S', 'precursor_mz': '251.0597', + 'fragment_mzs': self.fragment_mzs, + 'precursor_type': '[M+H]+'} + + formatted_msp_dict = {'ms_id': 'AU101101', 'mf': self.mf, 'precursor_mz': self.precursor_mz, + 'fragment_mzs': self.fragment_mzs, 'precursor_type': '[M+H]+', + 'exact_mass': self.exact_mass, + 'neutral_fragment_masses': self.neutral_fragment_masses} + + self.assertEqual(reformat_msp_input(unformatted_msp_dict), formatted_msp_dict) + + unformatted_msp_dict["precursor_mz"] = None + self.assertWarns(UserWarning, reformat_msp_input(unformatted_msp_dict)) + + unformatted_msp_dict["precursor_mz"] = self.precursor_mz + unformatted_msp_dict["fragment_mzs"] = [] + self.assertWarns(UserWarning, reformat_msp_input(unformatted_msp_dict)) + + def test_mc_to_list(self): + + mc_lists = [[12, 14, 4, 4, 0, 1], [10, 10, 4, 2, 0, 1], [46, 94, 1, 8, 1, 0], [46, 94, 1, 8, 1, 0], None] + + for i, word_formula in enumerate(["C12H14N4O4S", "C10H10N4O2S", "C46H94NO8P", "C46H94NO8P1", "C10H9ClN4O2S"]): + self.assertEqual(mc_to_list(word_formula), mc_lists[i]) + -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() From bdd71e46b0bc344e68dd886ab7be291f9443922d Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Mon, 30 Nov 2020 14:47:55 +0000 Subject: [PATCH 32/35] Amend connectivity database unit tests so that they fail in case of the generation of an empty DB --- tests/test_isomorphism_database.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_isomorphism_database.py b/tests/test_isomorphism_database.py index adba71e..6bda34a 100644 --- a/tests/test_isomorphism_database.py +++ b/tests/test_isomorphism_database.py @@ -67,6 +67,7 @@ def setUpClass(cls): [1, 2], # boxes cls.path_ri ) + shutil.copyfile(cls.to_test_results("connectivity.sqlite"), "connectivity.sqlite") def test_create_connectivity_database(self): ref_db = sqlite3.connect(self.to_test_data("connectivity.sqlite")) @@ -77,12 +78,12 @@ def test_create_connectivity_database(self): test_db_cursor = test_db.cursor() test_db_cursor.execute("SELECT * FROM subgraphs") - ref_rows = {} - for row in ref_db_cursor.fetchall(): - ref_rows[row[0]] = row - + test_rows = {} for row in test_db_cursor.fetchall(): - self.assertEqual(row, ref_rows[row[0]]) + test_rows[row[0]] = row + + for row in ref_db_cursor.fetchall(): + self.assertEqual(row, test_rows[row[0]]) ref_db.close() test_db.close() From 40c516aa1c644be49e5ff0f080f47d77429fd308 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Mon, 30 Nov 2020 14:51:51 +0000 Subject: [PATCH 33/35] Amend results docstrings --- metaboblend/results.py | 10 ++++------ tests/test_isomorphism_database.py | 1 - 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/metaboblend/results.py b/metaboblend/results.py index 573ca26..898443f 100644 --- a/metaboblend/results.py +++ b/metaboblend/results.py @@ -45,13 +45,13 @@ def __init__(self, path_results, msn=True): self.substructure_combo_id = 0 def connect(self): - """Connects to the results database.""" + """ Connects to the results database. """ self.conn = sqlite3.connect(self.path_results_db) self.cursor = self.conn.cursor() def create_results_db(self): - """Generates a new results database.""" + """ Generates a new results database. """ if os.path.exists(self.path_results_db): os.remove(self.path_results_db) @@ -269,9 +269,7 @@ def get_structures(self, ms_id_num): return [item for t in self.cursor.fetchall() for item in t] def generate_csv_output(self): - """ - Generate CSV file output for i) queries and tool parameters and ii) structures generated. - """ + """ Generate CSV file output for i) queries and tool parameters and ii) structures generated. """ with open(os.path.join(self.path_results, "metaboblend_queries.csv"), "w", newline="") as results_file, \ open(os.path.join(self.path_results, "metaboblend_structures.csv"), "w", newline="") as ms_file: @@ -296,6 +294,6 @@ def generate_csv_output(self): ms_writer.writerow(structure) def close(self): - """Close the connection to the SQLITE3 database.""" + """ Close the connection to the SQLITE3 database. """ self.conn.close() diff --git a/tests/test_isomorphism_database.py b/tests/test_isomorphism_database.py index 6bda34a..0304114 100644 --- a/tests/test_isomorphism_database.py +++ b/tests/test_isomorphism_database.py @@ -67,7 +67,6 @@ def setUpClass(cls): [1, 2], # boxes cls.path_ri ) - shutil.copyfile(cls.to_test_results("connectivity.sqlite"), "connectivity.sqlite") def test_create_connectivity_database(self): ref_db = sqlite3.connect(self.to_test_data("connectivity.sqlite")) From 04260a7f2ca8f455b6ce1308bd7ae38b08b9e4e5 Mon Sep 17 00:00:00 2001 From: Jack Gisby <43494691+jackgisby@users.noreply.github.com> Date: Mon, 30 Nov 2020 16:53:10 +0000 Subject: [PATCH 34/35] Update docstrings of user-facing build functions --- metaboblend/build_structures.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/metaboblend/build_structures.py b/metaboblend/build_structures.py index d445e20..16987dc 100644 --- a/metaboblend/build_structures.py +++ b/metaboblend/build_structures.py @@ -234,11 +234,18 @@ def annotate_msn(msn_data: Union[str, os.PathLike, Dict[str, Dict[str, Union[int text format. For the generation of structures without MSn data, see :py:meth:`metaboblend.build_structures.generate_structures`. - :param msn_data: Dictionary in the form - `msn_data[id] = {mf: [C, H, N, O, P, S], exact_mass: float, fragment_masses=[]}`. id represents a unique - identifier for a given spectral tree or fragmentation spectrum, mf is a list of integers referring to the - molecular formula of the structure of interest, exact_mass is the mass of this molecular formula to >=4d.p. - and fragment_masses are neutral fragment masses generated by this structure used to inform candidate scoring. + :param msn_data: Either a dictionary or the path to an MSP file. MSP files are parsed by + :py:meth:`metaboblend.parse.parse_ms_data` before being converted into a dictionary. If a dictionary is + provided, it must contain one item per fragmentation spectrum; the keys of the dictionary should be a unique ID + for the query and the corresponding value must itself be a dictionary, containing the following: + + - "exact_mass": `float` (neutral mass of query) OR "precursor_mz": `float` (mz of precursor ion) + - "mf": `[C, H, N, O, P, S]` (a list of 6 integers) + - "neutral_fragment_masses": `[float, float, ...]` (list of neutral fragment masses) OR "fragment_mzs": + `[float, float, ...]` (list of fragment mzs) + - "precursor_type": `str` (e.g. "[M+H]+", required for calculating neutral masses from ion mzs) + + The dictionary or MSP path is fed to :py:meth:`metaboverse.parse.parse_ms_data`. :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. @@ -383,11 +390,17 @@ def generate_structures(ms_data: Union[str, os.PathLike, Dict[str, Dict[str, Uni text format. For the generation of structures from MSn data, see :py:meth:`metaboblend.build_structures.annotate_msn`. - :param ms_data: Dictionary in the form ms_data[id] = - `{mf: [C, H, N, O, P, S], exact_mass: float, prescribed_mass=int}`. id represents a unique identifier for - a given test, mf is a list of integers referring to molecular formula of the structure of interest, - exact_mass is the mass of this structure to >=4d.p. and prescribed_mass is the neutral mass of a substructure - used to limit structures generated. + :param ms_data: A dictionary that must contain one item per fragmentation spectrum; the keys of the dictionary + should be a unique ID for the query and the corresponding value must itself be a dictionary, containing the + following: + + - "exact_mass": `float` (neutral mass of query) OR "precursor_mz": `float` (mz of precursor ion) + - "mf": `[C, H, N, O, P, S]` (a list of 6 integers) + - "precursor_type": `str` (e.g. "[M+H]+", required for calculating neutral masses from ion mzs) + - (optional) "prescribed_mass": 'float' (neutral mass of substructure). + + The dictionary or MSP path is fed to :py:meth:`metaboverse.parse.parse_ms_data`. A single neutral substructure + mass may be provided ("prescribed_mass") to guide the structure generation process. :param path_substructure_db: The path to the SQLite 3 substructure database, as generated by :py:meth:`metaboblend.databases.SubstructureDb`. From 399b9a8553a670d55ffddcff649a8bccb8a09e49 Mon Sep 17 00:00:00 2001 From: Jack Gisby Date: Thu, 3 Dec 2020 09:58:29 +0000 Subject: [PATCH 35/35] Only test isomorphism database on non-windows systems --- tests/test_isomorphism_database.py | 62 ++++++++++++++++-------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/tests/test_isomorphism_database.py b/tests/test_isomorphism_database.py index 0304114..6ad11a4 100644 --- a/tests/test_isomorphism_database.py +++ b/tests/test_isomorphism_database.py @@ -46,46 +46,50 @@ def setUpClass(cls): shutil.copytree(os.path.join(os.path.dirname(os.path.realpath(__file__)), "test_data"), cls.to_test_results("test_data")) + def test_create_connectivity_database(self): + pkg_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + if sys.platform == "win32" or sys.platform == "win64": # TODO: add RI as dependency - cls.path_ri = os.path.join(pkg_path, "tools", "RI_win", "RI3.6-release", "ri36") + self.path_ri = os.path.join(pkg_path, "tools", "RI_win", "RI3.6-release", "ri36") - elif sys.platform == "darwin": - cls.path_ri = os.path.join(pkg_path, "tools", "RI_mac", "RI3.6-release", "ri36") + else: - elif sys.platform == "linux2": - if "bb" in "socket.gethostname": - cls.path_ri = os.path.join(pkg_path, "tools", "RI_unix", "RI3.6-release", "ri36") - else: - cls.path_ri = os.path.join(pkg_path, "tools", "RI_bb", "RI3.6-release", "ri36") + if sys.platform == "darwin": + self.path_ri = os.path.join(pkg_path, "tools", "RI_mac", "RI3.6-release", "ri36") - elif sys.platform == "linux": - cls.path_ri = os.path.join(pkg_path, "tools", "RI_unix", "RI3.6-release", "ri36") + elif sys.platform == "linux2": + if "bb" in "socket.gethostname": + self.path_ri = os.path.join(pkg_path, "tools", "RI_unix", "RI3.6-release", "ri36") + else: + self.path_ri = os.path.join(pkg_path, "tools", "RI_bb", "RI3.6-release", "ri36") - create_connectivity_database(cls.to_test_results("connectivity.sqlite"), - 3, # sizes - [1, 2], # boxes - cls.path_ri - ) + elif sys.platform == "linux": + self.path_ri = os.path.join(pkg_path, "tools", "RI_unix", "RI3.6-release", "ri36") - def test_create_connectivity_database(self): - ref_db = sqlite3.connect(self.to_test_data("connectivity.sqlite")) - ref_db_cursor = ref_db.cursor() - ref_db_cursor.execute("SELECT * FROM subgraphs") + create_connectivity_database(self.to_test_results("connectivity.sqlite"), + 3, # sizes + [1, 2], # boxes + self.path_ri + ) + + ref_db = sqlite3.connect(self.to_test_data("connectivity.sqlite")) + ref_db_cursor = ref_db.cursor() + ref_db_cursor.execute("SELECT * FROM subgraphs") - test_db = sqlite3.connect(self.to_test_results("connectivity.sqlite")) - test_db_cursor = test_db.cursor() - test_db_cursor.execute("SELECT * FROM subgraphs") + test_db = sqlite3.connect(self.to_test_results("connectivity.sqlite")) + test_db_cursor = test_db.cursor() + test_db_cursor.execute("SELECT * FROM subgraphs") - test_rows = {} - for row in test_db_cursor.fetchall(): - test_rows[row[0]] = row + test_rows = {} + for row in test_db_cursor.fetchall(): + test_rows[row[0]] = row - for row in ref_db_cursor.fetchall(): - self.assertEqual(row, test_rows[row[0]]) + for row in ref_db_cursor.fetchall(): + self.assertEqual(row, test_rows[row[0]]) - ref_db.close() - test_db.close() + ref_db.close() + test_db.close() if __name__ == '__main__':