Repository: matchms/matchms
Pull request #34 (2020-05-20 15:12:29)

229 clean compound name
Originally here https://github.com/matchms/matchms-backup/pull/230

Addressing #23 .
It grew a bit bigger, because cleaning up the compound names turned out to be a very important for subsequent lookup-based  metadata cleaning.

- Changed ``add_adducts`` to ``derive_adduct_from_name``. It now checks potential adducts based on regular expression and removes it from name if found.
- Added analogous ``derive_formula_from_name`` filter.
- Added ``clean_compound_name`` function to remove known parts that do not belong to the compound name.
- Added tests.
- Added below order of filters to ``default_filters``.

The workflow for me would then look like (though I also added it to ``default_filters``):
```python
def name_cleaning(s):
    s = add_compound_name(s)
    s = derive_adducts_from_name(s)
    s = derive_formula_from_name(s)
    s = clean_compound_name(s)
    return s

spectrums_cleaned_name = [name_cleaning(s) for s in spectrums]
```

HannoSpreeuw: Does pipenv circumvent this issue?

As I mentioned on Slack,
```pipenv shell
pipenv install .
```
worked for me, although it took quite long, 15 minutes.

But perhaps you already tried that.
CunliangGeng: No, I did not try `pipenv`,  it's new to me.
HannoSpreeuw: Okay, then you probably need to do a
`pip install pipenv` first.
jspaaks: PR #35 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2d24b45..75f8f386 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- Add extensive compound name cleaning and harmonization [#23](https://github.com/matchms/matchms/issues/23)
 - Faster cosine score implementation using numba [#29](https://github.com/matchms/matchms/issues/29)
 - Cosine score based on Hungarian algorithm [#40](https://github.com/matchms/matchms/pull/40)
 - Modified cosine score [#26](https://github.com/matchms/matchms/issues/26)
diff --git a/matchms/filtering/__init__.py b/matchms/filtering/__init__.py
index d9dbf20f..1c7615f7 100644
--- a/matchms/filtering/__init__.py
+++ b/matchms/filtering/__init__.py
@@ -1,9 +1,12 @@
-from .add_adduct import add_adduct
+from .add_compound_name import add_compound_name
 from .add_losses import add_losses
 from .add_parent_mass import add_parent_mass
 from .add_precursor_mz import add_precursor_mz
+from .clean_compound_name import clean_compound_name
 from .correct_charge import correct_charge
 from .default_filters import default_filters
+from .derive_adduct_from_name import derive_adduct_from_name
+from .derive_formula_from_name import derive_formula_from_name
 from .derive_inchi_from_smiles import derive_inchi_from_smiles
 from .derive_inchikey_from_inchi import derive_inchikey_from_inchi
 from .derive_ionmode import derive_ionmode
@@ -25,12 +28,15 @@ from .SpeciesString import SpeciesString
 
 
 __all__ = [
-    "add_adduct",
+    "add_compound_name",
     "add_losses",
     "add_parent_mass",
     "add_precursor_mz",
+    "clean_compound_name",
     "correct_charge",
     "default_filters",
+    "derive_adduct_from_name",
+    "derive_formula_from_name",
     "derive_inchi_from_smiles",
     "derive_inchikey_from_inchi",
     "derive_ionmode",
diff --git a/matchms/filtering/add_adduct.py b/matchms/filtering/add_adduct.py
deleted file mode 100644
index 367037df..00000000
--- a/matchms/filtering/add_adduct.py
+++ /dev/null
@@ -1,26 +0,0 @@
-from ..typing import SpectrumType
-
-
-def add_adduct(spectrum_in: SpectrumType) -> SpectrumType:
-    """Add adduct to metadata (if not present yet).
-
-    Method to interpret the given compound name to find the adduct.
-    """
-
-    spectrum = spectrum_in.clone()
-
-    if spectrum.get("adduct", None) is None:
-        try:
-            name = spectrum.get("name")
-            adduct = name.split(' ')[-1]
-            adduct = adduct.replace('\n', '') \
-                           .replace(' ', '')  \
-                           .replace('[', '')  \
-                           .replace(']', '')  \
-                           .replace('*', '')
-            if adduct:
-                spectrum.set("adduct", adduct)
-        except KeyError:
-            print("Spectrum's metadata does not have a 'name'.")
-
-    return spectrum
diff --git a/matchms/filtering/add_compound_name.py b/matchms/filtering/add_compound_name.py
new file mode 100644
index 00000000..71c14b98
--- /dev/null
+++ b/matchms/filtering/add_compound_name.py
@@ -0,0 +1,22 @@
+from ..typing import SpectrumType
+
+
+def add_compound_name(spectrum_in: SpectrumType) -> SpectrumType:
+    """Add compound_name to correct field: "compound_name" in metadata."""
+    if spectrum_in is None:
+        return None
+
+    spectrum = spectrum_in.clone()
+
+    if spectrum.get("compound_name", None) is None:
+        if isinstance(spectrum.get("name", None), str):
+            spectrum.set("compound_name", spectrum.get("name"))
+            return spectrum
+
+        if isinstance(spectrum.get("title", None), str):
+            spectrum.set("compound_name", spectrum.get("title"))
+            return spectrum
+
+        print("No compound name found in metadata.")
+
+    return spectrum
diff --git a/matchms/filtering/clean_compound_name.py b/matchms/filtering/clean_compound_name.py
new file mode 100644
index 00000000..7f611e83
--- /dev/null
+++ b/matchms/filtering/clean_compound_name.py
@@ -0,0 +1,64 @@
+import re
+from ..typing import SpectrumType
+
+
+def clean_compound_name(spectrum_in: SpectrumType) -> SpectrumType:
+    """Clean compound name.
+
+    A list of frequently seen name additions that do not belong to the compound
+    name will be removed."""
+
+    def remove_parts_by_regular_expression(name):
+        """Clean name string by removing known parts that don't belong there."""
+        name = name.strip()
+        # remove type NCGC00180417-03_C31H40O16_
+        name = re.split(r"[A-Z]{3,}[0-9]{8,}-[0-9]{2,}_[A-Z,0-9]{4,}_", name)[-1]
+        # remove type NCGC00160232-01! or MLS001142816-01!
+        name = re.split(r"[A-Z]{3,}[0-9]{8,}-[0-9]{2,3}\!", name)[-1]
+        # remove type Massbank:EA008813 option1|option2|option3
+        name = re.split(r"((Massbank:)|(MassbankEU:))[A-Z]{2}[0-9]{5,6}.*\|", name)[-1]
+        # remove type Massbank:EA008813 or MassbankEU:EA008813
+        name = re.split(r"((Massbank:)|(MassbankEU:))[A-Z]{2}[0-9]{5,6}", name)[-1]
+        # remove type HMDB:HMDB00943-1336
+        name = re.split(r"HMDB:HMDB[0-9]{4,}-[0-9]{1,}", name)[-1]
+        # remove type MoNA:662599
+        name = re.split(r"MoNA:[0-9]{5,}", name)[-1]
+        # ReSpect:PS013405 option1|option2|option3...
+        name = re.split(r"ReSpect:[A-Z]{2,}[0-9]{6}.*\|", name)[-1]
+        # ReSpect:PS013405 option1
+        name = re.split(r"[A-Z]{2,}[0-9]{6}( )", name)[-1]
+        # remove type 0072_2-Mercaptobenzothiaz
+        name = re.split(r"^[0-9]{4}_", name)[-1]
+        # remove type nameofcompound_CID20_170920 or Spiraeoside_HCD30_170919
+        name = re.split(r"_((HCD)|(CID))[0-9]{2}_[0-9]{5,6}$", name)[0]
+        return name
+
+    def remove_known_non_compound_parts(name):
+        """Remove known non compound-name strings from name."""
+        parts_remove = ["Spectral Match to",
+                        "from NIST14",
+                        "Massbank:"]
+        for part in parts_remove:
+            name = name.replace(part, "")
+        return name.strip("; ")
+
+    if spectrum_in is None:
+        return None
+
+    spectrum = spectrum_in.clone()
+
+    if spectrum.get("compound_name", None) is not None:
+        name = spectrum.get("compound_name")
+    else:
+        assert spectrum.get("name", None) in [None, ""], ("Found 'name' but not 'compound_name' in metadata",
+                                                          "Apply 'add_compound_name' filter first.")
+        return spectrum
+
+    # Clean compound name
+    name_cleaned = remove_parts_by_regular_expression(name)
+    name_cleaned = remove_known_non_compound_parts(name_cleaned)
+    if name_cleaned != name:
+        spectrum.set("compound_name", name_cleaned)
+        print("Added cleaned compound name:", name_cleaned)
+
+    return spectrum
diff --git a/matchms/filtering/default_filters.py b/matchms/filtering/default_filters.py
index 6b9d15ae..1e7e1d75 100644
--- a/matchms/filtering/default_filters.py
+++ b/matchms/filtering/default_filters.py
@@ -1,7 +1,10 @@
 from ..typing import SpectrumType
-from .add_adduct import add_adduct
+from .add_compound_name import add_compound_name
 from .add_precursor_mz import add_precursor_mz
+from .clean_compound_name import clean_compound_name
 from .correct_charge import correct_charge
+from .derive_adduct_from_name import derive_adduct_from_name
+from .derive_formula_from_name import derive_formula_from_name
 from .derive_ionmode import derive_ionmode
 from .make_charge_scalar import make_charge_scalar
 from .make_ionmode_lowercase import make_ionmode_lowercase
@@ -27,8 +30,11 @@ def default_filters(spectrum: SpectrumType) -> SpectrumType:
     spectrum = make_charge_scalar(spectrum)
     spectrum = make_ionmode_lowercase(spectrum)
     spectrum = set_ionmode_na_when_missing(spectrum)
+    spectrum = add_compound_name(spectrum)
+    spectrum = derive_adduct_from_name(spectrum)
+    spectrum = derive_formula_from_name(spectrum)
+    spectrum = clean_compound_name(spectrum)
     spectrum = add_precursor_mz(spectrum)
-    spectrum = add_adduct(spectrum)
     spectrum = derive_ionmode(spectrum)
     spectrum = correct_charge(spectrum)
     return spectrum
diff --git a/matchms/filtering/derive_adduct_from_name.py b/matchms/filtering/derive_adduct_from_name.py
new file mode 100644
index 00000000..ad0f3ce6
--- /dev/null
+++ b/matchms/filtering/derive_adduct_from_name.py
@@ -0,0 +1,46 @@
+from ..typing import SpectrumType
+from ..utils import looks_like_adduct
+
+
+def derive_adduct_from_name(spectrum_in: SpectrumType, remove_adduct_from_name=True) -> SpectrumType:
+    """Find adduct in compound name and add to metadata (if not present yet).
+
+    Method to interpret the given compound name to find the adduct.
+    Args:
+    ----
+    spectrum_in: SpectrumType
+        Input spectrum.
+    remove_adduct_from_name: bool
+        Remove found adducts from compound name if set to True. Default is True.
+    """
+    if spectrum_in is None:
+        return None
+
+    spectrum = spectrum_in.clone()
+
+    if spectrum.get("compound_name", None) is not None:
+        name = spectrum.get("compound_name")
+    else:
+        assert spectrum.get("name", None) in [None, ""], ("Found 'name' but not 'compound_name' in metadata",
+                                                          "Apply 'add_compound_name' filter first.")
+        return spectrum
+
+    # Detect adduct in compound name
+    adduct_from_name = None
+    name_split = name.split(" ")
+    for name_part in name_split[::-1][:2]:
+        if looks_like_adduct(name_part):
+            adduct_from_name = name_part
+            break
+
+    if adduct_from_name and remove_adduct_from_name:
+        name_adduct_removed = " ".join([x for x in name_split if x != adduct_from_name])
+        spectrum.set("compound_name", name_adduct_removed)
+        print("Removed adduct {} from compound name.".format(adduct_from_name))
+
+    # Add found adduct to metadata (if not present yet)
+    if adduct_from_name and not looks_like_adduct(spectrum.get("adduct")):
+        spectrum.set("adduct", adduct_from_name.strip().replace("*", ""))
+        print("Added adduct {} to metadata.".format(adduct_from_name))
+
+    return spectrum
diff --git a/matchms/filtering/derive_formula_from_name.py b/matchms/filtering/derive_formula_from_name.py
new file mode 100644
index 00000000..81ff4c36
--- /dev/null
+++ b/matchms/filtering/derive_formula_from_name.py
@@ -0,0 +1,48 @@
+import re
+from ..typing import SpectrumType
+
+
+def derive_formula_from_name(spectrum_in: SpectrumType, remove_formula_from_name=True) -> SpectrumType:
+    """Detect and remove misplaced formula in compound name and add to metadata.
+
+    Method to find misplaced formulas in compound name based on regular expression.
+    Args:
+    ----
+    spectrum_in: SpectrumType
+        Input spectrum.
+    remove_formula_from_name: bool
+        Remove found formula from compound name if set to True. Default is True.
+    """
+    if spectrum_in is None:
+        return None
+
+    spectrum = spectrum_in.clone()
+
+    if spectrum.get("compound_name", None) is not None:
+        name = spectrum.get("compound_name")
+    else:
+        assert spectrum.get("name", None) in [None, ""], ("Found 'name' but not 'compound_name' in metadata",
+                                                          "Apply 'add_compound_name' filter first.")
+        return spectrum
+
+    # Detect formula at end of compound name
+    end_of_name = name.split(" ")[-1]
+    formula_from_name = end_of_name if looks_like_formula(end_of_name) else None
+
+    if formula_from_name and remove_formula_from_name:
+        name_formula_removed = " ".join(name.split(" ")[:-1])
+        spectrum.set("compound_name", name_formula_removed)
+        print("Removed formula {} from compound name.".format(formula_from_name))
+
+    # Add found formula to metadata (if not present yet)
+    if formula_from_name and spectrum.get("formula", None) is None:
+        spectrum.set("formula", formula_from_name)
+        print("Added formula {} to metadata.".format(formula_from_name))
+
+    return spectrum
+
+
+def looks_like_formula(formula):
+    """Return True if input string has expected format of a molecular formula."""
+    regexp = r"^[C][0-9][0-9A-Z]{4,}$"
+    return re.search(regexp, formula) is not None
diff --git a/matchms/filtering/derive_ionmode.py b/matchms/filtering/derive_ionmode.py
index 257fb5c5..db31f00d 100644
--- a/matchms/filtering/derive_ionmode.py
+++ b/matchms/filtering/derive_ionmode.py
@@ -29,8 +29,18 @@ def derive_ionmode(spectrum_in: SpectrumType, adducts_filename=None) -> Spectrum
     known_adducts = load_adducts(filename=adducts_filename)
 
     adduct = spectrum.get("adduct", None)
+    # Harmonize adduct string
+    if adduct:
+        adduct = adduct.replace("\n", "") \
+                       .replace(" ", "") \
+                       .replace("[", "") \
+                       .replace("]", "") \
+                       .replace("*", "")
 
     ionmode = spectrum.get("ionmode")
+    if ionmode:
+        assert ionmode == ionmode.lower(), ("Ionmode field not harmonized.",
+                                            "Apply 'make_ionmode_lowercase' filter first.")
 
     # Try completing missing or incorrect ionmodes
     if ionmode not in ["positive", "negative"]:
diff --git a/matchms/utils.py b/matchms/utils.py
index 28327f95..b00cd3a0 100644
--- a/matchms/utils.py
+++ b/matchms/utils.py
@@ -106,3 +106,16 @@ def is_valid_inchikey(inchikey: str) -> bool:
     if re.fullmatch(regexp, inchikey):
         return True
     return False
+
+
+def looks_like_adduct(adduct):
+    """Return True if input string has expected format of an adduct."""
+    if not isinstance(adduct, str):
+        return False
+    adduct = adduct.strip().replace("*", "")
+
+    # Format 1, e.g. "[2M-H]" or "[2M+Na]+"
+    regexp1 = r"^\[(([0-9]M)|(M[0-9])|(M)|(MBr)|(MCl))[+-0-9][A-Z0-9\+\-\(\)|(Na)|(Ca)|(Mg)|(Cl)|(Li)|(Br)|(Ser)]{1,}[\]0-9+-]{1,4}"
+    # Format 2, e.g. "M+Na+K" or "M+H-H20"
+    regexp2 = r"^(([0-9]M)|(M[0-9])|(M)|(MBr)|(MCl))[+-0-9][A-Z0-9\+\-\(\)|(Na)|(Ca)|(Mg)|(Cl)|(Li)|(Br)|(Ser)]{1,}"
+    return re.search(regexp1, adduct) is not None or re.search(regexp2, adduct) is not None
diff --git a/readthedocs/index.rst b/readthedocs/index.rst
index 9f1dbe9d..9723558d 100644
--- a/readthedocs/index.rst
+++ b/readthedocs/index.rst
@@ -62,6 +62,9 @@ Should output
 
 .. testoutput::
 
+    Removed adduct M-H from compound name.
+    Added adduct M-H to metadata.
+    ...
     Reference scan id: 675
     Query scan id: 2833
     Score: 0.0293


Test patch:
diff --git a/tests/test_clean_compound_name.py b/tests/test_clean_compound_name.py
new file mode 100644
index 00000000..428a5db9
--- /dev/null
+++ b/tests/test_clean_compound_name.py
@@ -0,0 +1,46 @@
+import numpy
+from matchms import Spectrum
+from matchms.filtering import clean_compound_name
+
+
+def test_clean_compound_name_removing_known_non_name_parts():
+    """Test difficult but representative examples."""
+    test_name_strings = [
+        ["MLS000863588-01!2-methoxy-3-methyl-9H-carbazole",
+         "2-methoxy-3-methyl-9H-carbazole"],
+        ["NCGC00160217-01!SOPHOCARPINE",
+         "SOPHOCARPINE"],
+        ["0072_2-Mercaptobenzothiaz",
+         "2-Mercaptobenzothiaz"],
+        [r"MassbankEU:ET110206 NPE_327.1704_12.2|N-succinylnorpheniramine",
+         "N-succinylnorpheniramine"],
+        ["Massbank:CE000307 Trans-Zeatin-[d5]",
+         "Trans-Zeatin-[d5]"],
+        ["HMDB:HMDB00500-718 4-Hydroxybenzoic acid",
+         "4-Hydroxybenzoic acid"],
+        ["MoNA:2346734 Piroxicam (Feldene)",
+         "Piroxicam (Feldene)"],
+        ["ReSpect:PS013405 option1|option2|option3",
+         "option3"],
+        ["ReSpect:PS013405 option1name",
+         "option1name"]
+    ]
+    for name_strings in test_name_strings:
+        spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                               intensities=numpy.array([], dtype="float"),
+                               metadata={"compound_name": name_strings[0]})
+
+        spectrum = clean_compound_name(spectrum_in)
+
+        assert spectrum.get("compound_name") == name_strings[1], "Expected different cleaned name."
+
+
+def test_clean_compound_name_empty_name_given():
+    """Test cleaning on spectrum with empty name."""
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": ""})
+
+    spectrum = clean_compound_name(spectrum_in)
+
+    assert spectrum.get("compound_name", None) == "", "Expected empty name."
diff --git a/tests/test_derive_adduct_from_name.py b/tests/test_derive_adduct_from_name.py
new file mode 100644
index 00000000..6bbf38f2
--- /dev/null
+++ b/tests/test_derive_adduct_from_name.py
@@ -0,0 +1,48 @@
+import numpy
+from matchms import Spectrum
+from matchms.filtering import derive_adduct_from_name
+
+
+def test_derive_adduct_from_name():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ [M+H+K]"})
+
+    spectrum = derive_adduct_from_name(spectrum_in)
+
+    assert spectrum.get("adduct") == "[M+H+K]", "Expected different adduct."
+    assert spectrum.get("compound_name") == "peptideXYZ", "Expected different cleaned name."
+
+
+def test_derive_adduct_from_name_dont_overwrite_present_adduct():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ [M+H+K]",
+                                     "adduct": "M+H"})
+
+    spectrum = derive_adduct_from_name(spectrum_in)
+
+    assert spectrum.get("adduct") == "M+H", "Expected different adduct."
+    assert spectrum.get("compound_name") == "peptideXYZ", "Expected different cleaned name."
+
+
+def test_derive_adduct_from_name_dont_remove_from_name():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ [M+H+K]"})
+
+    spectrum = derive_adduct_from_name(spectrum_in, remove_adduct_from_name=False)
+
+    assert spectrum.get("adduct") == "[M+H+K]", "Expected different adduct."
+    assert spectrum.get("compound_name") == spectrum_in.get("compound_name"), "Expected no change to name."
+
+
+def test_derive_adduct_from_name_no_compound_name_empty_name():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"name": ""})
+
+    spectrum = derive_adduct_from_name(spectrum_in)
+
+    assert spectrum.get("adduct", None) is None, "Expected None for adduct."
+    assert spectrum.get("compound_name", None) is None, "Expected None for name."
diff --git a/tests/test_derive_formula_from_name.py b/tests/test_derive_formula_from_name.py
new file mode 100644
index 00000000..0e36e469
--- /dev/null
+++ b/tests/test_derive_formula_from_name.py
@@ -0,0 +1,48 @@
+import numpy
+from matchms import Spectrum
+from matchms.filtering import derive_formula_from_name
+
+
+def test_derive_formula_from_name():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ [M+H+K] C5H12NO2"})
+
+    spectrum = derive_formula_from_name(spectrum_in)
+
+    assert spectrum.get("formula") == "C5H12NO2", "Expected different formula."
+    assert spectrum.get("compound_name") == "peptideXYZ [M+H+K]", "Expected different cleaned name."
+
+
+def test_derive_formula_from_name_dont_overwrite_present_adduct():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ C5H12NO2",
+                                     "formula": "totallycorrectformula"})
+
+    spectrum = derive_formula_from_name(spectrum_in)
+
+    assert spectrum.get("formula") == "totallycorrectformula", "Expected different adduct."
+    assert spectrum.get("compound_name") == "peptideXYZ", "Expected different cleaned name."
+
+
+def test_derive_formula_from_name_remove_formula_false():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"compound_name": "peptideXYZ [M+H+K] C5H12NO2"})
+
+    spectrum = derive_formula_from_name(spectrum_in, remove_formula_from_name=False)
+
+    assert spectrum.get("formula") == "C5H12NO2", "Expected different formula."
+    assert spectrum.get("compound_name") == spectrum_in.get("compound_name"), "Expected no name change."
+
+
+def test_derive_formula_from_name_no_name_given():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={})
+
+    spectrum = derive_formula_from_name(spectrum_in)
+
+    assert spectrum.get("formula", None) is None, "Expected None for adduct."
+    assert spectrum.get("compound_name", None) is None, "Expected None for name."
diff --git a/tests/test_derive_ionmode.py b/tests/test_derive_ionmode.py
new file mode 100644
index 00000000..9c1bc834
--- /dev/null
+++ b/tests/test_derive_ionmode.py
@@ -0,0 +1,30 @@
+import numpy
+from matchms import Spectrum
+from matchms.filtering import derive_ionmode
+
+
+def test_derive_ionmode_positive_adduct():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"adduct": "[M+H]"})
+
+    spectrum = derive_ionmode(spectrum_in)
+
+    assert spectrum.get("ionmode") == "positive", "Expected different ionmode."
+
+
+def test_derive_ionmode_negative_adduct():
+    spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
+                           intensities=numpy.array([], dtype="float"),
+                           metadata={"adduct": "M-H-"})
+
+    spectrum = derive_ionmode(spectrum_in)
+
+    assert spectrum.get("ionmode") == "negative", "Expected different ionmode."
+
+
+def test_derive_ionmode_empty_spectrum():
+    spectrum_in = None
+    spectrum = derive_ionmode(spectrum_in)
+
+    assert spectrum is None, "Expected differnt handling of None spectrum."