Repository: matchms/matchms

Issue:
add equivalent filter of merge_ppm
https://github.com/matchms/matchms/blob/61602e744be587be98122d5964d5ed75cca2ecba/matchms/old/ms_functions.py#L74-L75

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=VULNERABILITY) (and [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=SECURITY_HOTSPOT) to review)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=matchms_matchms&pullRequest=79&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=matchms_matchms&pullRequest=79) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo.png' alt='No Duplication information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=matchms_matchms&pullRequest=79&metric=duplicated_lines_density&view=list) No Duplication information



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31a27315..79391e9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- Added similarity score based on comparing parent masses [#79](https://github.com/matchms/matchms/pull/79)
+
 ## [0.4.0] - 2020-06-11
 
 ### Added
diff --git a/matchms/similarity/ParentmassMatch.py b/matchms/similarity/ParentmassMatch.py
new file mode 100644
index 00000000..91ed0608
--- /dev/null
+++ b/matchms/similarity/ParentmassMatch.py
@@ -0,0 +1,21 @@
+from matchms.typing import SpectrumType
+
+
+class ParentmassMatch:
+    """Return True if spectrums match in parent mass (within tolerance), and False otherwise."""
+
+    def __init__(self, tolerance: float = 0.1):
+        """
+        Parameters:
+        ----------
+        tolerance
+            Specify tolerance below which two masses are counted as match.
+        """
+        self.tolerance = tolerance
+
+    def __call__(self, spectrum: SpectrumType, reference_spectrum: SpectrumType) -> bool:
+        """Compare parent masses"""
+        parentmass = spectrum.get("parent_mass")
+        parentmass_ref = reference_spectrum.get("parent_mass")
+        assert parentmass is not None and parentmass_ref is not None, "Missing parent mass."
+        return abs(parentmass-parentmass_ref) <= self.tolerance
diff --git a/matchms/similarity/ParentmassMatchParallel.py b/matchms/similarity/ParentmassMatchParallel.py
new file mode 100644
index 00000000..e51de2ee
--- /dev/null
+++ b/matchms/similarity/ParentmassMatchParallel.py
@@ -0,0 +1,42 @@
+from typing import List
+import numba
+import numpy
+from matchms.typing import SpectrumType
+
+
+class ParentmassMatchParallel:
+    """Return True if spectrums match in parent mass (within tolerance), and False otherwise."""
+
+    def __init__(self, tolerance: float = 0.1):
+        """
+        Parameters:
+        ----------
+        tolerance
+            Specify tolerance below which two masses are counted as match.
+        """
+        self.tolerance = tolerance
+
+    def __call__(self, reference_spectrums: List[SpectrumType],
+                 spectrums: List[SpectrumType]) -> numpy.ndarray:
+        """Compare parent masses between all reference_spectrums and spectrums."""
+        def collect_parentmasses(spectrums):
+            """Collect parentmasses."""
+            parentmasses = []
+            for spectrum in spectrums:
+                parentmass = spectrum.get("parent_mass")
+                assert parentmass is not None, "Missing parent mass."
+                parentmasses.append(parentmass)
+            return numpy.asarray(parentmasses)
+
+        parentmasses_ref = collect_parentmasses(reference_spectrums)
+        parentmasses_query = collect_parentmasses(spectrums)
+        return calculate_parentmass_scores(parentmasses_ref, parentmasses_query, self.tolerance)
+
+
+@numba.njit
+def calculate_parentmass_scores(parentmasses_ref, parentmasses_query, tolerance):
+    scores = numpy.zeros((len(parentmasses_ref), len(parentmasses_query)))
+    for i, parentmass_ref in enumerate(parentmasses_ref):
+        for j, parentmass_query in enumerate(parentmasses_query):
+            scores[i, j] = (abs(parentmass_ref-parentmass_query) <= tolerance)
+    return scores
diff --git a/matchms/similarity/__init__.py b/matchms/similarity/__init__.py
index 171aca27..d9117900 100644
--- a/matchms/similarity/__init__.py
+++ b/matchms/similarity/__init__.py
@@ -5,6 +5,8 @@ from .CosineHungarian import CosineHungarian
 from .FingerprintSimilarityParallel import FingerprintSimilarityParallel
 from .IntersectMz import IntersectMz
 from .ModifiedCosine import ModifiedCosine
+from .ParentmassMatch import ParentmassMatch
+from .ParentmassMatchParallel import ParentmassMatchParallel
 
 
 __all__ = [
@@ -14,4 +16,6 @@ __all__ = [
     "FingerprintSimilarityParallel",
     "IntersectMz",
     "ModifiedCosine",
+    "ParentmassMatch",
+    "ParentmassMatchParallel",
 ]


Test patch:
diff --git a/tests/test_ParentmassMatch.py b/tests/test_ParentmassMatch.py
new file mode 100644
index 00000000..03f11204
--- /dev/null
+++ b/tests/test_ParentmassMatch.py
@@ -0,0 +1,53 @@
+import numpy
+import pytest
+from matchms import Spectrum
+from matchms.similarity import ParentmassMatch
+
+
+def test_parentmass_match():
+    "Test with default tolerance."
+    spectrum_1 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 100.0})
+
+    spectrum_2 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 101.0})
+
+    similarity_score = ParentmassMatch()
+    score = similarity_score(spectrum_1, spectrum_2)
+    assert not score, "Expected different score."
+
+
+def test_parentmass_match_tolerance2():
+    "Test with tolerance > difference."
+    spectrum_1 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 100.0})
+
+    spectrum_2 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 101.0})
+
+    similarity_score = ParentmassMatch(tolerance=2.0)
+    score = similarity_score(spectrum_1, spectrum_2)
+    assert score, "Expected different score."
+
+
+def test_parentmass_match_missing_parentmass():
+    "Test with missing parentmass."
+    spectrum_1 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 100.0})
+
+    spectrum_2 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={})
+
+    similarity_score = ParentmassMatch(tolerance=2.0)
+
+    with pytest.raises(AssertionError) as msg:
+        _ = similarity_score(spectrum_1, spectrum_2)
+
+    expected_message_part = "Missing parent mass."
+    assert expected_message_part in str(msg.value), "Expected particular error message."
diff --git a/tests/test_ParentmassMatchParallel.py b/tests/test_ParentmassMatchParallel.py
new file mode 100644
index 00000000..3d66692b
--- /dev/null
+++ b/tests/test_ParentmassMatchParallel.py
@@ -0,0 +1,73 @@
+import numpy
+from matchms import Spectrum
+from matchms.similarity import ParentmassMatchParallel
+from matchms.similarity.ParentmassMatchParallel import \
+    calculate_parentmass_scores
+
+
+def test_parentmass_match():
+    "Test with default tolerance."
+    spectrum_1 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 100.0})
+
+    spectrum_2 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 101.0})
+
+    spectrum_a = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 99.0})
+
+    spectrum_b = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 98.0})
+
+    similarity_score = ParentmassMatchParallel()
+    scores = similarity_score([spectrum_1, spectrum_2], [spectrum_a, spectrum_b])
+    assert numpy.all(scores == numpy.array([[False, False],
+                                            [False, False]])), "Expected different scores."
+
+
+def test_parentmass_match_tolerance2():
+    """Test with tolerance=2."""
+    spectrum_1 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 100.0})
+
+    spectrum_2 = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 101.0})
+
+    spectrum_a = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 99.0})
+
+    spectrum_b = Spectrum(mz=numpy.array([], dtype="float"),
+                          intensities=numpy.array([], dtype="float"),
+                          metadata={"parent_mass": 98.0})
+
+    similarity_score = ParentmassMatchParallel(tolerance=2.0)
+    scores = similarity_score([spectrum_1, spectrum_2], [spectrum_a, spectrum_b])
+    assert numpy.all(scores == numpy.array([[True, True],
+                                            [True, False]])), "Expected different scores."
+
+
+def test_calculate_parentmass_scores_compiled():
+    """Test the underlying score function (numba compiled)."""
+    parentmasses_ref = numpy.asarray([101, 200, 300])
+    parentmasses_query = numpy.asarray([100, 301])
+    scores = calculate_parentmass_scores(parentmasses_ref, parentmasses_query, tolerance=2.0)
+    assert numpy.all(scores == numpy.array([[1., 0.],
+                                            [0., 0.],
+                                            [0., 1.]])), "Expected different scores."
+
+
+def test_calculate_parentmass_scores():
+    """Test the underlying score function (non-compiled)."""
+    parentmasses_ref = numpy.asarray([101, 200, 300])
+    parentmasses_query = numpy.asarray([100, 301])
+    scores = calculate_parentmass_scores.py_func(parentmasses_ref, parentmasses_query, tolerance=2.0)
+    assert numpy.all(scores == numpy.array([[True, False],
+                                            [False, False],
+                                            [False, True]])), "Expected different scores."