Repository: matchms/matchms
Pull request #448 (2023-06-27 09:44:32)

Add smiles from compound name
Added a function to add annotations based on compound names. 
The functionality to get the annotations from pubchem can be found in matchmsextras pubchem_lookup. 
This function just uses a csv file with annotated compound names to add annotations. 


Patch:
diff --git a/.github/workflows/CI_build.yml b/.github/workflows/CI_build.yml
index d373d141..d2aa9073 100644
--- a/.github/workflows/CI_build.yml
+++ b/.github/workflows/CI_build.yml
@@ -114,8 +114,6 @@ jobs:
         exclude:
           - os: ubuntu-latest
             python-version: 3.9
-          - os: macos-latest
-            python-version: 3.7
     steps:
       - uses: actions/checkout@v2
       - name: Set up Python 3.8
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b200caed..0660a5a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 
 ### Added
+
+- New filter functions to repair a smiles that mismatches parent mass
+=======
 - New filter functions to repair a smiles that mismatches parent mass [#440](https://github.com/matchms/matchms/pull/440)
   - Updated adduct coversion and known adducts
   - added repair_adduct_based_on_smiles
@@ -16,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - added repair_smiles_of_salts
   - added require_parent_mass_match_smiles
   - added function to combine this in repair_parent_mass_match_smiles_wrapper
+- Added repair_smiles_from_compound_name [#448](https://github.com/matchms/matchms/pull/448)
 
 ### Changed
 - Use pandas for loading adducts dict
diff --git a/matchms/filtering/__init__.py b/matchms/filtering/__init__.py
index 09b2df35..fc1f4ba6 100644
--- a/matchms/filtering/__init__.py
+++ b/matchms/filtering/__init__.py
@@ -76,6 +76,7 @@ from .repair_parent_mass_from_smiles.repair_smiles_of_salts import \
     repair_smiles_of_salts
 from .repair_parent_mass_from_smiles.require_parent_mass_match_smiles import \
     require_parent_mass_match_smiles
+from .repair_smiles_from_compound_name import repair_smiles_from_compound_name
 from .require_minimum_number_of_peaks import require_minimum_number_of_peaks
 from .require_minimum_of_high_peaks import require_minimum_of_high_peaks
 from .require_precursor_below_mz import require_precursor_below_mz
@@ -122,6 +123,7 @@ __all__ = [
     "repair_parent_mass_match_smiles_wrapper",
     "repair_precursor_is_parent_mass",
     "repair_smiles_of_salts",
+    "repair_smiles_from_compound_name",
     "require_parent_mass_match_smiles",
     "require_minimum_number_of_peaks",
     "require_minimum_of_high_peaks",
diff --git a/matchms/filtering/filter_utils/interpret_unknown_adduct.py b/matchms/filtering/filter_utils/interpret_unknown_adduct.py
index 9b7140e0..65661c39 100644
--- a/matchms/filtering/filter_utils/interpret_unknown_adduct.py
+++ b/matchms/filtering/filter_utils/interpret_unknown_adduct.py
@@ -109,6 +109,8 @@ def get_mass_of_ion(ions):
     for ion in ions:
         sign, number, ion = split_ion(ion)
         atom_mass = get_mass_of_formula(ion)
+        if atom_mass is None:
+            return None
 
         if sign == "-":
             number = -int(number)
@@ -151,7 +153,11 @@ def get_mass_of_formula(formula):
         if parts[index].isnumeric():
             continue
         periodic_table = Chem.GetPeriodicTable()
-        atom_mass = periodic_table.GetMostCommonIsotopeMass(parts[index])
+        try:
+            atom_mass = periodic_table.GetMostCommonIsotopeMass(parts[index])
+        except RuntimeError:
+            logger.warning("The atom: %s in the formula %s is not known", parts[index], formula)
+            return None
         multiplier = int(parts[index + 1]) if len(parts) > index + 1 and parts[index + 1].isnumeric() else 1
         mass += atom_mass * multiplier
     return mass
diff --git a/matchms/filtering/repair_smiles_from_compound_name.py b/matchms/filtering/repair_smiles_from_compound_name.py
new file mode 100644
index 00000000..6c58df62
--- /dev/null
+++ b/matchms/filtering/repair_smiles_from_compound_name.py
@@ -0,0 +1,74 @@
+import logging
+import numpy as np
+import pandas as pd
+from matchms import Spectrum
+from matchms.metadata_utils import (is_valid_inchi, is_valid_inchikey,
+                                    is_valid_smiles)
+
+
+logger = logging.getLogger("matchms")
+
+
+def repair_smiles_from_compound_name(spectrum_in: Spectrum,
+                                     annotated_compound_names_file,
+                                     mass_tolerance=0.1):
+    """Adds annotations (smiles, inchi, inchikey) based on compound name
+
+    Based on a table of compound names and smiles matches (stored in a csv file) this function
+    adds the new annotations to the input spectrums if the smiles seem consistent with the available
+    spectrum metadata (e.g., parent mass).
+    This function can be used with csv files that are returned by the pubchem_lookup.py from matchmextras.
+    Parameters
+    ----------
+    spectrum_in:
+        The input spectrum
+    annotated_compound_names_file: str
+        A csv file containing the compound names and matching smiles, inchi, inchikey and monoisotopic_mass.
+    mass_tolerance
+        Acceptable mass difference between query compound and pubchem result.
+    """
+    annotated_compound_names = load_compond_name_annotations(annotated_compound_names_file)
+    if spectrum_in is None:
+        return None
+    spectrum = spectrum_in.clone()
+    if check_fully_annotated(spectrum):
+        return spectrum
+    compound_name = spectrum.get("compound_name")
+    parent_mass = spectrum.get('parent_mass')
+
+    if is_plausible_name(compound_name) and parent_mass is not None:
+        matching_compound_name = annotated_compound_names[annotated_compound_names["compound_name"] == compound_name]
+        mass_differences = np.abs(matching_compound_name["monoisotopic_mass"]-parent_mass)
+        within_mass_tolerance = matching_compound_name[mass_differences < mass_tolerance]
+        if within_mass_tolerance.shape[0] > 0:
+            # Select the match with the most
+            best_match = within_mass_tolerance.loc[within_mass_tolerance["monoisotopic_mass"].idxmin()]
+            spectrum.set("smiles", best_match["smiles"])
+            spectrum.set("inchi", best_match["inchi"])
+            spectrum.set("inchikey", best_match["inchikey"])
+            logger.info("Added smiles %s based on the compound name %s", best_match["smiles"], compound_name)
+            return spectrum
+    return spectrum
+
+
+def load_compond_name_annotations(annotated_compound_names_file):
+    """Loads in the annotated compound names and checks format"""
+    annotated_compound_names = pd.read_csv(annotated_compound_names_file)
+    assert list(annotated_compound_names.columns) == ["compound_name", "smiles", "inchi",
+                                                      "inchikey", "monoisotopic_mass"], \
+        "Choose a different annotated compound names file with columns compound_name, smiles, inchi, inchikey, monoisotopic_mass"
+    return annotated_compound_names
+
+
+def check_fully_annotated(spectrum: Spectrum) -> bool:
+    if not is_valid_smiles(spectrum.get("smiles")):
+        return False
+    if not is_valid_inchikey(spectrum.get("inchikey")):
+        return False
+    if not is_valid_inchi(spectrum.get("inchi")):
+        return False
+    return True
+
+
+def is_plausible_name(compound_name):
+    return isinstance(compound_name, str) and len(compound_name) > 4


Test patch:
diff --git a/tests/filtering/test_repair_smiles_from_compound_name.py b/tests/filtering/test_repair_smiles_from_compound_name.py
new file mode 100644
index 00000000..413b1ee5
--- /dev/null
+++ b/tests/filtering/test_repair_smiles_from_compound_name.py
@@ -0,0 +1,45 @@
+import csv
+import os
+import pytest
+from matchms.filtering import repair_smiles_from_compound_name
+from ..builder_Spectrum import SpectrumBuilder
+
+
+@pytest.fixture()
+def csv_file_annotated_compound_names(tmp_path):
+    data = [
+        ["compound_name", "smiles", "inchi", "inchikey", "monoisotopic_mass"],
+        ["compound_1", "smile_1", "inchi_1", 'inchikey_1', 100],
+        ["compound_2", None, None, None, None]]
+    csv_file_name = os.path.join(tmp_path, "annotated_compounds.csv")
+    with open(csv_file_name, "w", encoding="utf-8") as csv_file:
+        csv.writer(csv_file).writerows(data)
+    return csv_file_name
+
+
+@pytest.mark.parametrize("compound_name, smile, inchi, inchikey, parent_mass, expected_smiles", [
+    ("compound_1", "input_smile_1", "inchi_1", "inchikey_1", 100.01, 'smile_1'),
+    ("compound_2", "input_smile_2", "inchi_2", "inchikey_2", 100.01, 'input_smile_2'),
+    ("compound_1", "input_smile_1", "inchi_1", "inchikey_1", 200.01, 'input_smile_1'),
+    ("compound_3", "input_smile_1", "inchi_1", "inchikey_1", 200.01, 'input_smile_1'),
+    # test that correct annotations are skipped
+    ("compound_1", "C1CSSC1CCCCC(=O)O",
+     "InChI=1S/C8H14O2S2/c9-8(10)4-2-1-3-7-5-6-11-12-7/h7H,1-6H2,(H,9,10)", "AGBQKNBQESQNJD-UHFFFAOYSA-N", 100.01,
+     'C1CSSC1CCCCC(=O)O'),
+    ("compound_1", "C1CSSC1CCCCC(=O)O",
+     "InChI=1S/C8H14O2S2/c9-8(10)4-2-1-3-7-5-6-11-12-7/h7H,1-6H2,(H,9,10)", "AGBQKNBQESQNJD-UHFFFAOYSA_bla_bla", 100.01,
+     "smile_1"),
+])
+def test_clean_compound_name(compound_name, parent_mass, smile, inchi, inchikey,
+                             expected_smiles,
+                             csv_file_annotated_compound_names):
+    # pylint: disable=too-many-arguments
+    pytest.importorskip("rdkit")
+    builder = SpectrumBuilder()
+    spectrum_in = builder.with_metadata({"compound_name": compound_name,
+                                         "parent_mass": parent_mass,
+                                         "smiles": smile,
+                                         "inchi": inchi,
+                                         "inchikey": inchikey}).build()
+    spectrum = repair_smiles_from_compound_name(spectrum_in, csv_file_annotated_compound_names, mass_tolerance=0.1)
+    assert spectrum.get("smiles") == expected_smiles, "Expected different smiles."