Repository: matchms/matchms
Pull request #252 (2021-10-13 09:31:01)

Derive parent mass from mz precursor if charge is unknown
See #242, for additional discussion on this topic. 

Matchms already has the function add_parent_mass, however this will not add a parent mass if the charge is not specified.
Since in most cases people do not work with multiply charged MS-MS spectra.
I think it would be a good suggestion to still derive the parent mass from the mz_precursor, if the charge is not specified.
So apply the following workflow: Use charge if available, else assume charge == 1.

When creating this pull request, I noticed that the charge also changes if the PROTON_MASS is added or removed. Since, using negative mode is actually getting more common, this would give issues in this case. Do you maybe know of some other info field of information that can be used to differ between positive and negative mode?

I think it will be important to change this, since in all 3 case study datasets I used, the charge (and parent mass) was not given. So I assume that this will be a very common problem for people that want to use matchms. 

If there is no good solution maybe add a comment in the printed message when parent mass cannot be derived to run 
spectrums = [s.set("parent_mass", s.get("precursor_mz")) for s in spectrums] to set the parent mass (or alternatively the charge). 



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95f5f529..13ecc111 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Changed
 
 - Code linting triggered by pylint update [#257](https://github.com/matchms/matchms/pull/257)
+- Refactored `add_parent_mass()` filter can now also handle missing charge entries (if ionmode is known) [#252](https://github.com/matchms/matchms/pull/252)
 
 ## [0.9.2] - 2021-07-20
 
diff --git a/matchms/filtering/add_parent_mass.py b/matchms/filtering/add_parent_mass.py
index 0f02745c..e7b3bd81 100644
--- a/matchms/filtering/add_parent_mass.py
+++ b/matchms/filtering/add_parent_mass.py
@@ -32,28 +32,56 @@ def add_parent_mass(spectrum_in: SpectrumType, estimate_from_adduct: bool = True
     spectrum = spectrum_in.clone()
     adducts_dict = load_adducts_dict()
 
-    if spectrum.get("parent_mass", None) is None or overwrite_existing_entry:
-        parent_mass = None
-        charge = spectrum.get("charge")
-        adduct = clean_adduct(spectrum.get("adduct"))
-        precursor_mz = spectrum.get("precursor_mz", None)
-        if precursor_mz is None:
-            print("Missing precursor m/z to derive parent mass.")
-            return spectrum
-
-        if estimate_from_adduct and adduct in adducts_dict:
-            multiplier = adducts_dict[adduct]["mass_multiplier"]
-            correction_mass = adducts_dict[adduct]["correction_mass"]
-            parent_mass = precursor_mz * multiplier - correction_mass
-
-        if parent_mass is None and charge is not None and charge != 0:
-            # Otherwise assume adduct of shape [M+xH] or [M-xH]
-            protons_mass = PROTON_MASS * charge
-            precursor_mass = precursor_mz * abs(charge)
-            parent_mass = precursor_mass - protons_mass
-
-        if parent_mass is None:
-            print("Not sufficient spectrum metadata to derive parent mass.")
-        else:
-            spectrum.set("parent_mass", float(parent_mass))
+    if spectrum.get("parent_mass", None) and not overwrite_existing_entry:
+        return spectrum
+
+    parent_mass = None
+    charge = _get_charge(spectrum)
+    adduct = clean_adduct(spectrum.get("adduct"))
+    precursor_mz = spectrum.get("precursor_mz", None)
+    if precursor_mz is None:
+        print("Missing precursor m/z to derive parent mass.")
+        return spectrum
+
+    if estimate_from_adduct and (adduct in adducts_dict):
+        multiplier = adducts_dict[adduct]["mass_multiplier"]
+        correction_mass = adducts_dict[adduct]["correction_mass"]
+        parent_mass = precursor_mz * multiplier - correction_mass
+
+    if (parent_mass is None) and _is_valid_charge(charge):
+        # Assume adduct of shape [M+xH] or [M-xH]
+        protons_mass = PROTON_MASS * charge
+        precursor_mass = precursor_mz * abs(charge)
+        parent_mass = precursor_mass - protons_mass
+
+    if parent_mass is None:
+        print("Not sufficient spectrum metadata to derive parent mass.")
+    else:
+        spectrum.set("parent_mass", float(parent_mass))
     return spectrum
+
+
+def _is_valid_charge(charge):
+    return (charge is not None) and (charge != 0)
+
+
+def _get_charge(spectrum):
+    """Get charge from `Spectrum()` object.
+    In case no valid charge is found, guess +1 or -1 based on ionmode.
+    Else return 0.
+    """
+    charge = spectrum.get("charge")
+    if _is_valid_charge(charge):
+        return charge
+    if spectrum.get('ionmode') == "positive":
+        print("Missing charge entry, but positive ionmode detected. "
+              "Consider prior run of `correct_charge()` filter.")
+        return 1
+    if spectrum.get('ionmode') == "negative":
+        print("Missing charge entry, but negative ionmode detected. "
+              "Consider prior run of `correct_charge()` filter.")
+        return -1
+
+    print("Missing charge and ionmode entries. "
+          "Consider prior run of `derive_ionmode()` and `correct_charge()` filters.")
+    return 0


Test patch:
diff --git a/tests/test_add_parent_mass.py b/tests/test_add_parent_mass.py
index ca411603..3fb5d5f7 100644
--- a/tests/test_add_parent_mass.py
+++ b/tests/test_add_parent_mass.py
@@ -1,6 +1,7 @@
 import numpy
 import pytest
 from matchms import Spectrum
+from matchms.constants import PROTON_MASS
 from matchms.filtering import add_parent_mass
 
 
@@ -90,7 +91,9 @@ def test_add_parent_mass_using_adduct(adduct, expected):
     assert isinstance(spectrum.get("parent_mass"), float), "Expected parent mass to be float."
 
 
-def test_add_parent_mass_overwrite():
+@pytest.mark.parametrize("overwrite, expected", [(True, 442.992724),
+                                                 (False, 443.0)])
+def test_add_parent_mass_overwrite(overwrite, expected):
     """Test if parent mass is replaced by newly calculated value."""
     mz = numpy.array([], dtype='float')
     intensities = numpy.array([], dtype='float')
@@ -102,9 +105,9 @@ def test_add_parent_mass_overwrite():
                            intensities=intensities,
                            metadata=metadata)
 
-    spectrum = add_parent_mass(spectrum_in, overwrite_existing_entry=True)
+    spectrum = add_parent_mass(spectrum_in, overwrite_existing_entry=overwrite)
 
-    assert numpy.allclose(spectrum.get("parent_mass"), 442.992724, atol=1e-4), \
+    assert numpy.allclose(spectrum.get("parent_mass"), expected, atol=1e-4), \
         "Expected parent mass to be replaced by new value."
 
 
@@ -128,3 +131,21 @@ def test_empty_spectrum():
     spectrum = add_parent_mass(spectrum_in)
 
     assert spectrum is None, "Expected different handling of None spectrum."
+
+
+@pytest.mark.parametrize("ionmode, expected", [("positive", 444.0 - PROTON_MASS),
+                                               ("negative", 444.0 + PROTON_MASS)])
+def test_use_of_ionmode(ionmode, expected):
+    """Test when there is no charge given, than the ionmode
+    is used to derive parent mass."""
+    mz = numpy.array([], dtype='float')
+    intensities = numpy.array([], dtype='float')
+    metadata = {"precursor_mz": 444.0, "ionmode": ionmode}
+    spectrum_in = Spectrum(mz=mz,
+                           intensities=intensities,
+                           metadata=metadata)
+
+    spectrum = add_parent_mass(spectrum_in)
+
+    assert spectrum.get("parent_mass") == expected, \
+        "Expected a different parent_mass"