Repository: matchms/matchms
Pull request #284 (2021-12-23 09:49:11)

Issue 249 comments
Local version of initial PR (#277) from @maximskorik

> * modified `load_from_msp` to read peak comments
> * modified `save_as_msp` to write peak comments to a file and increased the tab size to avoid misaligned comments in the output file
> * added peak-comments handling to `Spectrum` class: the comments can now be stored as an `mz: comment` dictionary in `peak_comments` property of `Spectrum()` objects

> **Notes:**
> 0d7d513 selects **mz** & **intensity** rather than **rt** as the commit message suggests


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bf88df6..cf4cf3b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,10 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - better, more flexible string handling of `ModifiedCosine` [#275](https://github.com/matchms/matchms/pull/275)
 - matchms logger, replacing all former `print` statments to better control logging output [#271](https://github.com/matchms/matchms/pull/271)
 - `add_logging_to_file()`, `set_matchms_logger_level()`, `reset_matchms_logger()` functions to adapt logging output to user needs [#271](https://github.com/matchms/matchms/pull/271)
+- peak comments (as an `mz: comment` dictionary) are now part of metadata and can be addressed via a `Spectrum()` object `peak_comments` property [#284](https://github.com/matchms/matchms/pull/284)
+- peak comments are dynamically updated whenever the respective peaks are changed [#277](https://github.com/matchms/matchms/pull/277)
 
 ## Changed
 
-- `save_to_msp()` can now also write to files with other than ".msp" extensions such as ".dat" [#276](https://github.com/matchms/matchms/pull/276)
+- `save_as_msp()` can now also write to files with other than ".msp" extensions such as ".dat" [#276](https://github.com/matchms/matchms/pull/276)
+- `save_as_msp()` now writes peak comments (if present) to the output file [#277](https://github.com/matchms/matchms/pull/277)
+- `load_from_msp()` now also reads peak comments [#277](https://github.com/matchms/matchms/pull/277)
 
 ## Changed
 
diff --git a/matchms/Spectrum.py b/matchms/Spectrum.py
index 1361d8dc..34edbd59 100644
--- a/matchms/Spectrum.py
+++ b/matchms/Spectrum.py
@@ -19,11 +19,13 @@ class Spectrum:
 
         spectrum = Spectrum(mz=np.array([100, 150, 200.]),
                               intensities=np.array([0.7, 0.2, 0.1]),
-                              metadata={'id': 'spectrum1'})
+                              metadata={'id': 'spectrum1',
+                                        "peak_comments": {200.: "the peak at 200 m/z"}})
 
         print(spectrum.peaks.mz[0])
         print(spectrum.peaks.intensities[0])
         print(spectrum.get('id'))
+        print(spectrum.peak_comments.get(200))
 
     Should output
 
@@ -32,6 +34,7 @@ class Spectrum:
         100.0
         0.7
         spectrum1
+        the peak at 200 m/z
 
     Attributes
     ----------
@@ -51,7 +54,12 @@ class Spectrum:
 
     """
 
-    def __init__(self, mz: numpy.array, intensities: numpy.array, metadata: Optional[dict] = None):
+    _peak_comments_mz_tolerance = 1e-05
+
+    def __init__(self,
+                 mz: numpy.array,
+                 intensities: numpy.array,
+                 metadata: Optional[dict] = None):
         """
 
         Parameters
@@ -63,12 +71,12 @@ class Spectrum:
         metadata
             Dictionary with for example the scan number of precursor m/z.
         """
-        self.peaks = Spikes(mz=mz, intensities=intensities)
-        self.losses = None
         if metadata is None:
-            self.metadata = {}
+            self._metadata = {}
         else:
-            self.metadata = metadata
+            self._metadata = metadata
+        self.peaks = Spikes(mz=mz, intensities=intensities)
+        self.losses = None
 
     def __eq__(self, other):
         return \
@@ -223,4 +231,41 @@ class Spectrum:
 
     @peaks.setter
     def peaks(self, value: Spikes):
+        if isinstance(self.get("peak_comments"), dict):
+            self._reiterate_peak_comments(value)
         self._peaks = value
+
+    @property
+    def peak_comments(self):
+        return self.get("peak_comments")
+
+    @peak_comments.setter
+    def peak_comments(self, value):
+        self.set("peak_comments", value)
+
+    @classmethod
+    def update_peak_comments_mz_tolerance(cls, mz_tolerance: float):
+        cls._peak_comments_mz_tolerance = mz_tolerance
+
+    def _reiterate_peak_comments(self, peaks: Spikes):
+        """Update the peak comments to reflect the new peaks."""
+        if not isinstance(self.get("peak_comments", None), dict):
+            return None
+
+        mz_tolerance = self._peak_comments_mz_tolerance
+
+        def _append_new_comment(key):
+            if new_key_comment is not None:
+                comment = "; ".join([new_key_comment, self.metadata["peak_comments"].get(key)])
+            else:
+                comment = self.metadata["peak_comments"].get(key)
+            return comment
+
+        for key in list(self.metadata["peak_comments"].keys()):
+            if key not in peaks.mz:
+                if numpy.isclose(key, peaks.mz, rtol=mz_tolerance).any():
+                    new_key = peaks.mz[numpy.isclose(key, peaks.mz, rtol=mz_tolerance).argmax()]
+                    new_key_comment = self.metadata["peak_comments"].get(new_key, None)
+                    new_key_comment = _append_new_comment(key)
+                    self._metadata["peak_comments"][new_key] = new_key_comment
+                self._metadata["peak_comments"].pop(key)
diff --git a/matchms/exporting/save_as_msp.py b/matchms/exporting/save_as_msp.py
index d7b73529..ca60ee9e 100644
--- a/matchms/exporting/save_as_msp.py
+++ b/matchms/exporting/save_as_msp.py
@@ -1,84 +1,108 @@
-import logging
-import os
-from typing import IO
-from typing import List
-from ..Spectrum import Spectrum
-from ..Spikes import Spikes
-
-
-logger = logging.getLogger("matchms")
-
-
-_extentions_not_allowed = ["mzml", "mzxml", "json", "mgf"]
-
-
-def save_as_msp(spectra: List[Spectrum], filename: str):
-    """Save spectrum(s) as msp file.
-
-    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
-
-    Example:
-
-    .. code-block:: python
-
-        import numpy
-        from matchms import Spectrum
-        from matchms.exporting import save_as_msp
-
-        # Create dummy spectrum
-        spectrum = Spectrum(mz=numpy.array([100, 200, 300], dtype="float"),
-                            intensities=numpy.array([10, 10, 500], dtype="float"),
-                            metadata={"charge": -1,
-                                      "inchi": '"InChI=1S/C6H12"',
-                                      "precursor_mz": 222.2})
-
-        # Write spectrum to test file
-        save_as_msp(spectrum, "test.msp")
-
-    Parameters
-    ----------
-    spectra:
-        Expected input are match.Spectrum.Spectrum() objects.
-    filename:
-        Provide filename to save spectrum(s).
-    """
-    file_extension = filename.split(".")[-1]
-    assert file_extension.lower() not in _extentions_not_allowed, \
-        f"File extension '.{file_extension}' not allowed."
-    if not filename.endswith(".msp"):
-        logger.warning("Spectra will be stored as msp file with extension .%s",
-                       filename.split(".")[-1])
-    spectra = _ensure_list(spectra)
-
-    with open(filename, "w", encoding="utf-8") as outfile:
-        for spectrum in spectra:
-            _write_spectrum(spectrum, outfile)
-
-
-def _write_spectrum(spectrum: Spectrum, outfile: IO):
-    _write_metadata(spectrum.metadata, outfile)
-    _write_peaks(spectrum.peaks, outfile)
-    outfile.write(os.linesep)
-
-
-def _write_peaks(peaks: Spikes, outfile: IO):
-    outfile.write(f"NUM PEAKS: {len(peaks)}\n")
-    for mz, intensity in zip(peaks.mz, peaks.intensities):
-        outfile.write(f"{mz}\t{intensity}\n")
-
-
-def _write_metadata(metadata: dict, outfile: IO):
-    for key, value in metadata.items():
-        if not _is_num_peaks(key):
-            outfile.write(f"{key.upper()}: {value}\n")
-
-
-def _is_num_peaks(key: str) -> bool:
-    return key.lower().startswith("num peaks")
-
-
-def _ensure_list(spectra) -> List[Spectrum]:
-    if not isinstance(spectra, list):
-        # Assume that input was single Spectrum
-        spectra = [spectra]
-    return spectra
+import logging
+import os
+from typing import IO
+from typing import Dict
+from typing import List
+from typing import Union
+from ..Spectrum import Spectrum
+from ..Spikes import Spikes
+
+
+logger = logging.getLogger("matchms")
+
+
+_extentions_not_allowed = ["mzml", "mzxml", "json", "mgf"]
+
+
+def save_as_msp(spectra: List[Spectrum], filename: str,
+                write_peak_comments: bool = True):
+    """Save spectrum(s) as msp file.
+
+    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
+
+    Example:
+
+    .. code-block:: python
+
+        import numpy
+        from matchms import Spectrum
+        from matchms.exporting import save_as_msp
+
+        # Create dummy spectrum
+        spectrum = Spectrum(mz=numpy.array([100, 200, 300], dtype="float"),
+                            intensities=numpy.array([10, 10, 500], dtype="float"),
+                            metadata={"charge": -1,
+                                      "inchi": '"InChI=1S/C6H12"',
+                                      "precursor_mz": 222.2})
+
+        # Write spectrum to test file
+        save_as_msp(spectrum, "test.msp")
+
+    Parameters
+    ----------
+    spectra:
+        Expected input are match.Spectrum.Spectrum() objects.
+    filename:
+        Provide filename to save spectrum(s).
+    write_peak_comments:
+        Writes peak comments to individual peaks after the respective mz/intensity pair
+        when set to True. Default is True.
+    """
+    file_extension = filename.split(".")[-1]
+    assert file_extension.lower() not in _extentions_not_allowed, \
+        f"File extension '.{file_extension}' not allowed."
+    if not filename.endswith(".msp"):
+        logger.warning("Spectra will be stored as msp file with extension .%s",
+                       filename.split(".")[-1])
+    spectra = _ensure_list(spectra)
+
+    with open(filename, "w", encoding="utf-8") as outfile:
+        for spectrum in spectra:
+            _write_spectrum(spectrum, outfile, write_peak_comments)
+
+
+def _write_spectrum(spectrum: Spectrum, outfile: IO, write_peak_comments: bool):
+    _write_metadata(spectrum.metadata, outfile)
+    if write_peak_comments is True:
+        _write_peaks(spectrum.peaks, spectrum.peak_comments, outfile)
+    else:
+        _write_peaks(spectrum.peaks, None, outfile)
+    outfile.write(os.linesep)
+
+
+def _write_peaks(peaks: Spikes, peak_comments: Spectrum.peak_comments, outfile: IO):
+    outfile.write(f"NUM PEAKS: {len(peaks)}\n")
+    for mz, intensity in zip(peaks.mz, peaks.intensities):
+        peak_comment = _format_peak_comment(mz, peak_comments)
+        outfile.write(f"{mz}\t{intensity}{peak_comment}\n".expandtabs(12))
+
+
+def _write_metadata(metadata: dict, outfile: IO):
+    for key, value in metadata.items():
+        if not (_is_num_peaks(key) or _is_peak_comments(key)):
+            outfile.write(f"{key.upper()}: {value}\n")
+
+
+def _format_peak_comment(mz: Union[int, float], peak_comments: Dict):
+    """Format peak comment for given mz to return the quoted comment or empty string if no peak comment is present."""
+    if peak_comments is None:
+        return ""
+    peak_comment = peak_comments.get(mz, None)
+    if peak_comment is None:
+        return ""
+    return f"\t\"{peak_comment}\""
+
+
+def _is_num_peaks(key: str) -> bool:
+    return key.lower().startswith("num peaks")
+
+
+def _is_peak_comments(key: str) -> bool:
+    return key.lower().startswith("peak_comments")
+
+
+def _ensure_list(spectra) -> List[Spectrum]:
+    if not isinstance(spectra, list):
+        # Assume that input was single Spectrum
+        spectra = [spectra]
+    return spectra
diff --git a/matchms/importing/load_from_msp.py b/matchms/importing/load_from_msp.py
index 07d7ff02..6366b203 100644
--- a/matchms/importing/load_from_msp.py
+++ b/matchms/importing/load_from_msp.py
@@ -1,3 +1,4 @@
+import re
 from typing import Generator
 from typing import Iterator
 from typing import Tuple
@@ -12,6 +13,7 @@ def parse_msp_file(filename: str) -> Generator[dict, None, None]:
     params = {}
     masses = []
     intensities = []
+    peak_comments = {}
 
     # Peaks counter. Used to track and count the number of peaks
     peakscount = 0
@@ -31,6 +33,9 @@ def parse_msp_file(filename: str) -> Generator[dict, None, None]:
 
                 for peak in peak_pairs:
                     mz, intensity = get_peak_values(peak)
+                    comment = get_peak_comment(peak)
+                    if comment is not None:
+                        peak_comments.update({mz: comment})
 
                     peakscount += 1
                     masses.append(mz)
@@ -42,12 +47,14 @@ def parse_msp_file(filename: str) -> Generator[dict, None, None]:
                     yield {
                         'params': (params),
                         'm/z array': numpy.array(masses),
-                        'intensity array': numpy.array(intensities)
+                        'intensity array': numpy.array(intensities),
+                        'peak comments': peak_comments
                     }
 
                     params = {}
                     masses = []
                     intensities = []
+                    peak_comments = {}
 
 
 def get_peak_values(peak: str) -> Tuple[float, float]:
@@ -65,6 +72,15 @@ def get_peak_tuples(rline: str) -> Iterator[str]:
     return peak_pairs
 
 
+def get_peak_comment(rline: str) -> str:
+    """ Get the peak comment from the line containing the peak information. """
+    try:
+        comment = re.findall(r'[\"\'](.*)[\"\']', rline)[0]
+    except IndexError:
+        comment = None
+    return comment
+
+
 def parse_metadata(rline: str, params: dict):
     """ Reads metadata contained in line into params dict. """
     splitted_line = rline.split(":", 1)
@@ -116,6 +132,9 @@ def load_from_msp(filename: str) -> Generator[Spectrum, None, None]:
         metadata = spectrum.get("params", None)
         mz = spectrum["m/z array"]
         intensities = spectrum["intensity array"]
+        peak_comments = spectrum["peak comments"]
+        if peak_comments != {}:
+            metadata["peak_comments"] = peak_comments
 
         # Sort by mz (if not sorted already)
         if not numpy.all(mz[:-1] <= mz[1:]):
diff --git a/setup.py b/setup.py
index 85a0605f..d1c5c41c 100644
--- a/setup.py
+++ b/setup.py
@@ -58,6 +58,7 @@ setup(
         "scipy",
     ],
     extras_require={"dev": ["bump2version",
+                            "decorator",
                             "isort>=4.2.5,<5",
                             "pylint<2.12",
                             "prospector[with_pyroma]",


Test patch:
diff --git a/tests/test_load_from_msp.py b/tests/test_load_from_msp.py
index 15ccfb54..bf878764 100644
--- a/tests/test_load_from_msp.py
+++ b/tests/test_load_from_msp.py
@@ -83,10 +83,18 @@ def test_load_from_msp_tabs():
         39987
     ]])
 
+    expected_peak_comments = {113.03854: "Theoretical m/z 113.039125, Mass diff 0 (0 ppm), Formula C9H5",
+                              125.03855: "Theoretical m/z 125.039125, Mass diff 0 (0 ppm), Formula C10H5",
+                              249.07072: "Theoretical m/z 249.070425, Mass diff -0.001 (0 ppm), Formula C20H9",
+                              252.09323: "Theoretical m/z 252.093354, Mass diff 0 (0.49 ppm), SMILES "
+                                         "C1=CC=2C=CC=C3C4=CC=CC5=CC=CC(C(=C1)C23)=C54, Annotation [C20H12]+, "
+                                         "Rule of HR False"}
+
     for idx, spectrum in enumerate(spectra):
         assert_matching_inchikey(spectrum, expected_inchikey[idx])
         numpy.testing.assert_array_almost_equal(spectrum.peaks.mz, expected_mz[idx])
         numpy.testing.assert_array_almost_equal(spectrum.peaks.intensities, expected_intensities[idx])
+        assert spectrum.peak_comments == expected_peak_comments
 
 
 def test_load_from_msp_multiline():
diff --git a/tests/test_save_as_msp.py b/tests/test_save_as_msp.py
index 36a1b96b..f63c4090 100644
--- a/tests/test_save_as_msp.py
+++ b/tests/test_save_as_msp.py
@@ -1,137 +1,159 @@
-import os
-import tempfile
-from typing import List
-import numpy
-import pytest
-from matchms import Spectrum
-from matchms.exporting import save_as_msp
-from matchms.importing import load_from_msp
-
-
-@pytest.fixture
-def none_spectrum():
-    return None
-
-
-@pytest.fixture
-def spectrum():
-    return Spectrum(mz=numpy.array([100, 200, 290, 490, 510], dtype="float"),
-                    intensities=numpy.array([0.1, 0.2, 1.0, 0.3, 0.4], dtype="float"))
-
-
-@pytest.fixture(params=["rcx_gc-ei_ms_20201028_perylene.msp", "MoNA-export-GC-MS-first10.msp", "Hydrogen_chloride.msp"])
-def data(request):
-    module_root = os.path.join(os.path.dirname(__file__), "..")
-    spectrums_file = os.path.join(module_root, "tests", request.param)
-    spectra = load_from_msp(spectrums_file)
-    return list(spectra)
-
-
-@pytest.fixture
-def filename():
-    with tempfile.TemporaryDirectory() as temp_dir:
-        filename = os.path.join(temp_dir, "test.msp")
-        yield filename
-
-
-def test_spectrum_none_exception(none_spectrum, filename):
-    """ Test for exception being thrown if the spectrum to be saved. """
-    with pytest.raises(AttributeError) as exception:
-        save_as_msp(none_spectrum, filename)
-
-    message = exception.value.args[0]
-    assert message == "'NoneType' object has no attribute 'metadata'"
-
-
-def test_not_allowed_filename_extension():
-    """ Test for exception if output file ends with not allowed extension."""
-    with tempfile.TemporaryDirectory() as temp_dir:
-        filename = os.path.join(temp_dir, "test.mzml")
-
-        with pytest.raises(AssertionError) as exception:
-            save_as_msp(None, filename)
-
-        message = exception.value.args[0]
-        assert message == "File extension '.mzml' not allowed."
-
-
-def test_non_msp_filename_extension(spectrum, caplog):
-    """ Test for log message if output file doesn't end with .msp. """
-    with tempfile.TemporaryDirectory() as temp_dir:
-        filename = os.path.join(temp_dir, "test.dat")
-
-        save_as_msp(spectrum, filename)
-
-        expected_log = "Spectra will be stored as msp file with extension .dat"
-        assert expected_log in caplog.text
-
-
-def test_file_exists_single_spectrum(spectrum, filename):
-    """ Test checking if the file is created. """
-    save_as_msp(spectrum, filename)
-
-    assert os.path.isfile(filename)
-
-
-def test_stores_all_spectra(filename, data):
-    """ Test checking if all spectra contained in the original file are stored
-    and loaded back in properly. """
-    spectra = save_and_reload_spectra(filename, data)
-
-    assert len(spectra) == len(data)
-
-
-def test_have_metadata(filename, data):
-    """ Test checking of all metadate is stored correctly. """
-    spectra = save_and_reload_spectra(filename, data)
-
-    assert len(spectra) == len(data)
-
-    for actual, expected in zip(spectra, data):
-        assert actual.metadata == expected.metadata
-
-
-def test_have_peaks(filename, data):
-    """ Test checking if all peaks are stored correctly. """
-    spectra = save_and_reload_spectra(filename, data)
-
-    assert len(spectra) == len(data)
-
-    for actual, expected in zip(spectra, data):
-        assert actual.peaks == expected.peaks
-
-
-def save_and_reload_spectra(filename, spectra: List[Spectrum]):
-    """ Utility function to save spectra to msp and load them again.
-
-    Params:
-    -------
-    spectra: Spectra objects to store
-
-    Returns:
-    --------
-    reloaded_spectra: Spectra loaded from saved msp file.
-    """
-
-    save_as_msp(spectra, filename)
-    reloaded_spectra = list(load_from_msp(filename))
-    return reloaded_spectra
-
-
-def test_num_peaks_last_metadata_field(filename, data):
-    """ Test to check whether the last line before the peaks is NUM PEAKS: ... """
-    save_as_msp(data, filename)
-
-    with open(filename, mode='r', encoding="utf-8") as file:
-        content = file.readlines()
-        for idx, line in enumerate(content):
-            if line.startswith('NUM PEAKS: '):
-                num_peaks = int(line.split()[2])
-                peaks = content[idx + 1: idx + num_peaks + 1]
-                for peak in peaks:
-                    mz, intensity = peak.split()
-                    mz = float(mz)
-                    intensity = float(intensity)
-
-                    assert isinstance(mz, float)
-                    assert isinstance(intensity, float)
+import os
+import tempfile
+from typing import List
+import numpy
+import pytest
+from matchms import Spectrum
+from matchms.exporting import save_as_msp
+from matchms.importing import load_from_msp
+
+
+@pytest.fixture
+def none_spectrum():
+    return None
+
+
+@pytest.fixture
+def spectrum():
+    return Spectrum(mz=numpy.array([100, 200, 290, 490, 510], dtype="float"),
+                    intensities=numpy.array([0.1, 0.2, 1.0, 0.3, 0.4], dtype="float"))
+
+
+@pytest.fixture(params=["rcx_gc-ei_ms_20201028_perylene.msp", "MoNA-export-GC-MS-first10.msp", "Hydrogen_chloride.msp"])
+def data(request):
+    module_root = os.path.join(os.path.dirname(__file__), "..")
+    spectrums_file = os.path.join(module_root, "tests", request.param)
+    spectra = load_from_msp(spectrums_file)
+    return list(spectra)
+
+
+@pytest.fixture
+def filename():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, "test.msp")
+        yield filename
+
+
+def test_spectrum_none_exception(none_spectrum, filename):
+    """ Test for exception being thrown if the spectrum to be saved. """
+    with pytest.raises(AttributeError) as exception:
+        save_as_msp(none_spectrum, filename)
+
+    message = exception.value.args[0]
+    assert message == "'NoneType' object has no attribute 'metadata'"
+
+
+def test_not_allowed_filename_extension():
+    """ Test for exception if output file ends with not allowed extension."""
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, "test.mzml")
+
+        with pytest.raises(AssertionError) as exception:
+            save_as_msp(None, filename)
+
+        message = exception.value.args[0]
+        assert message == "File extension '.mzml' not allowed."
+
+
+def test_non_msp_filename_extension(spectrum, caplog):
+    """ Test for log message if output file doesn't end with .msp. """
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, "test.dat")
+
+        save_as_msp(spectrum, filename)
+
+        expected_log = "Spectra will be stored as msp file with extension .dat"
+        assert expected_log in caplog.text
+
+
+def test_file_exists_single_spectrum(spectrum, filename):
+    """ Test checking if the file is created. """
+    save_as_msp(spectrum, filename)
+
+    assert os.path.isfile(filename)
+
+
+def test_stores_all_spectra(filename, data):
+    """ Test checking if all spectra contained in the original file are stored
+    and loaded back in properly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+
+def test_have_metadata(filename, data):
+    """ Test checking of all metadate is stored correctly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+    for actual, expected in zip(spectra, data):
+        assert actual.metadata == expected.metadata
+
+
+def test_have_peaks(filename, data):
+    """ Test checking if all peaks are stored correctly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+    for actual, expected in zip(spectra, data):
+        assert actual.peaks == expected.peaks
+
+
+def test_have_peak_comments(filename, data):
+    """ Test checking if all peak comments are stored correctly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+    for actual, expected in zip(spectra, data):
+        assert actual.peak_comments == expected.peak_comments, \
+            "Expected different peak comments"
+
+
+def test_dont_write_peak_comments(filename, data):
+    """ Test checking if no peak comments are written to file. """
+    spectra = save_and_reload_spectra(filename, data, write_peak_comments=False)
+
+    assert len(spectra) == len(data)
+
+    for actual, _ in zip(spectra, data):
+        assert actual.peak_comments is None, \
+            "Expected that no peak comments are written to file"
+
+
+def save_and_reload_spectra(filename, spectra: List[Spectrum], write_peak_comments=True):
+    """ Utility function to save spectra to msp and load them again.
+
+    Params:
+    -------
+    spectra: Spectra objects to store
+
+    Returns:
+    --------
+    reloaded_spectra: Spectra loaded from saved msp file.
+    """
+
+    save_as_msp(spectra, filename, write_peak_comments)
+    reloaded_spectra = list(load_from_msp(filename))
+    return reloaded_spectra
+
+
+def test_num_peaks_last_metadata_field(filename, data):
+    """ Test to check whether the last line before the peaks is NUM PEAKS: ... """
+    save_as_msp(data, filename)
+
+    with open(filename, mode='r', encoding="utf-8") as file:
+        content = file.readlines()
+        for idx, line in enumerate(content):
+            if line.startswith('NUM PEAKS: '):
+                num_peaks = int(line.split()[2])
+                peaks = content[idx + 1: idx + num_peaks + 1]
+                for peak in peaks:
+                    mz, intensity = peak.split()[:2]
+                    mz = float(mz)
+                    intensity = float(intensity)
+
+                    assert isinstance(mz, float)
+                    assert isinstance(intensity, float)
diff --git a/tests/test_spectrum_peak_comments.py b/tests/test_spectrum_peak_comments.py
new file mode 100644
index 00000000..4a6b0c30
--- /dev/null
+++ b/tests/test_spectrum_peak_comments.py
@@ -0,0 +1,36 @@
+import numpy
+from matchms import Spectrum
+from matchms import Spikes
+from matchms.filtering import normalize_intensities
+
+
+def _create_test_spectrum():
+    intensities = numpy.array([1, 1, 5, 5, 5, 5, 7, 7, 7, 9, 9], dtype="float")
+    return _create_test_spectrum_with_intensities(intensities)
+
+
+def _create_test_spectrum_with_intensities(intensities):
+    mz = numpy.array([10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110], dtype="float")
+    return Spectrum(mz=mz, intensities=intensities)
+
+
+def test_peak_comments_after_filter():
+    spectrum_in: Spectrum = _create_test_spectrum()
+    spectrum_in.set("peak_comments", {10: "blub"})
+
+    spectrum = normalize_intensities(spectrum_in)
+    assert spectrum.get("peak_comments")[10] == "blub"
+
+
+def test_reiterating_peak_comments():
+    mz = numpy.array([100.0003, 100.0004, 100.0005, 110., 200., 300., 400.0176], dtype='float')
+    intensities = numpy.array([1, 2, 3, 4, 5, 6, 7], dtype='float')
+    peak_comments = ["m/z 100.0003", None, "m/z 100.0005", "m/z 110.", "m/z 200.", "m/z 300.", "m/z 400.0176"]
+    peak_comments = {mz[i]: peak_comments[i] for i in range(len(mz))}
+    spectrum = Spectrum(mz=mz, intensities=intensities,
+                        metadata={"peak_comments": peak_comments})
+
+    spectrum.peaks = Spikes(mz=numpy.array([100.0004, 110., 400.018], dtype='float'),
+                            intensities=numpy.array([5, 4, 7], dtype='float'))
+
+    assert spectrum.peak_comments == {100.0004: "m/z 100.0003; m/z 100.0005", 110.: "m/z 110.", 400.018: "m/z 400.0176"}