Repository: matchms/matchms (python)

Issue:
Handle None spectra when saving
When saving a list of spectra containing None values the saving raises an error. 

Wanted behavior: None values in a list of spectra are skipped. 

Pull request:
Remove None Spectra from Spectrum lists before exporting
Fixes #672

@niekdejonge could you check if this fulfills your requirements?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72b7074b..32be97fd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,16 +5,11 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## Unreleased
-
 ### Changed
 - Avoid using unstable sorting while sorting collected matching peaks [#636](https://github.com/matchms/matchms/pull/636).
-- Losses will no longer be stored as part of a `Spectrum` object, but will be computed on the fly (using `spectrum.losses` or `spectrum.compute_losses(loss_mz_from, loss_mz_to)`)[#681](https://github.com/matchms/matchms/pull/681)
-
-### Removed
-- `add_losses()` filter was removed. Losses will no longer be stored as part of a `Spectrum` object, but will be computed on the fly [#681](https://github.com/matchms/matchms/pull/681).
 
 ### Fixed
+- Remove empty spectra before exporting to file [#686](https://github.com/matchms/matchms/pull/686).
 - Name position in mirror plots [#678](https://github.com/matchms/matchms/pull/678).
 
 ## [0.26.4] -2024-06-14
diff --git a/matchms/Fragments.py b/matchms/Fragments.py
index 5777d199..1badbd3e 100644
--- a/matchms/Fragments.py
+++ b/matchms/Fragments.py
@@ -63,7 +63,6 @@ class Fragments:
         return np.all(self.mz[:-1] <= self.mz[1:])
 
     def clone(self):
-        """Return a deepcopy of the Fragments instance."""
         return Fragments(self.mz, self.intensities)
 
     @property
diff --git a/matchms/Spectrum.py b/matchms/Spectrum.py
index 9eac1763..08855d9b 100644
--- a/matchms/Spectrum.py
+++ b/matchms/Spectrum.py
@@ -89,10 +89,12 @@ class Spectrum:
         if metadata_harmonization is True:
             self._metadata.harmonize_values()
         self.peaks = Fragments(mz=mz, intensities=intensities)
+        self.losses = None
 
     def __eq__(self, other):
         return \
             self.peaks == other.peaks and \
+            self.losses == other.losses and \
             self._metadata == other._metadata
 
     def __hash__(self):
@@ -132,6 +134,7 @@ class Spectrum:
                          intensities=self.peaks.intensities,
                          metadata=self._metadata.data,
                          metadata_harmonization=False)
+        clone.losses = self.losses
         return clone
 
     def plot(self, figsize=(8, 6), dpi=200, **kwargs):
@@ -231,42 +234,11 @@ class Spectrum:
 
     @property
     def losses(self) -> Optional[Fragments]:
-        return self.compute_losses()
+        return self._losses.clone() if self._losses is not None else None
 
-    def compute_losses(
-            self, loss_mz_from: float = 0.0,
-            loss_mz_to : float = None
-            ) -> Optional[Fragments]:
-        """This will compute the "losses", i.e. the differences between the precursor_mz and 
-        the individual fragment m/z values. Only losses between loss_mz_from and loss_mz_to
-        will be kept.
-
-        Parameters
-        ----------
-        loss_mz_from:
-            Float value to set the minimum acceptable loss value. Default is 0.0.
-        loss_mz_to:
-            Float value to set the maximum acceptable loss value. Default is None which means
-            that the los_mz_to will be set to the spectrum's precursor_mz.
-        """
-        precursor_mz = self.get("precursor_mz", None)
-        if precursor_mz:
-            if loss_mz_to is None:
-                # Set max to precursor_mz
-                loss_mz_to = precursor_mz
-            assert isinstance(precursor_mz, (float, int)), ("Expected 'precursor_mz' to be a scalar number.",
-                                                            "Consider applying 'add_precursor_mz' filter first.")
-            peaks_mz, peaks_intensities = self.peaks.mz, self.peaks.intensities
-            losses_mz = (precursor_mz - peaks_mz)[::-1]
-            losses_intensities = peaks_intensities[::-1]
-
-            # Add losses that are within given boundaries
-            mask = np.where((losses_mz >= loss_mz_from)
-                            & (losses_mz <= loss_mz_to))
-            losses = Fragments(mz=losses_mz[mask],
-                               intensities=losses_intensities[mask])
-            return losses
-        return None
+    @losses.setter
+    def losses(self, value: Fragments):
+        self._losses = value
 
     @property
     def peaks(self) -> Fragments:
diff --git a/matchms/exporting/metadata_export.py b/matchms/exporting/metadata_export.py
index e42e019d..262aee3b 100644
--- a/matchms/exporting/metadata_export.py
+++ b/matchms/exporting/metadata_export.py
@@ -3,6 +3,7 @@ import json
 from typing import Any, Dict, List, Optional, Set, Tuple
 import numpy as np
 from ..Spectrum import Spectrum
+from ..utils import filter_empty_spectra
 
 
 def _get_metadata_dict(spectrum: Spectrum, include_fields: Optional[List[str]] = None) -> Dict[str, Any]:
@@ -38,8 +39,9 @@ def export_metadata_as_json(spectrums: List[Spectrum], filename: str,
     identifier:
         Identifier used for naming each spectrum in the output file.
     """
+    spectra = filter_empty_spectra(spectrums)
     metadata_dicts = []
-    for spec in spectrums:
+    for spec in spectra:
         metadata_dict = _get_metadata_dict(spec, include_fields)
         if metadata_dict:
             metadata_dicts.append(metadata_dict)
@@ -61,12 +63,13 @@ def export_metadata_as_csv(spectra: List[Spectrum], filename: str,
     identifier:
         Identifier used for naming each spectrum in the output file.
     """
+    spectra = filter_empty_spectra(spectra)
     metadata, columns = get_metadata_as_array(spectra)
 
     if include_fields is not None:
         metadata, columns = _subset_metadata(include_fields, metadata, columns)
 
-    with open(filename, 'a', encoding="utf-8") as csvfile:  #TODO: assert if file exists
+    with open(filename, 'a+', encoding="utf-8") as csvfile:
         writer = csv.writer(csvfile)
         writer.writerow(columns)
         for data in metadata:
@@ -102,6 +105,7 @@ def get_metadata_as_array(spectra: List[Spectrum]) -> Tuple[np.array, List[str]]
     Returns:
         Tuple[np.array, List[str]]: Metadata and union of all columns detected in all spectra.
     """
+    spectra = filter_empty_spectra(spectra)
     keys = spectra[0].metadata.keys()
     for s in spectra:
         keys |= s.metadata.keys()
diff --git a/matchms/exporting/save_as_json.py b/matchms/exporting/save_as_json.py
index 743e894c..280366d2 100644
--- a/matchms/exporting/save_as_json.py
+++ b/matchms/exporting/save_as_json.py
@@ -1,7 +1,7 @@
 import json
 from typing import List
 from ..Spectrum import Spectrum
-from ..utils import fingerprint_export_warning
+from ..utils import filter_empty_spectra, fingerprint_export_warning
 
 
 def save_as_json(spectrums: List[Spectrum],
@@ -9,6 +9,8 @@ def save_as_json(spectrums: List[Spectrum],
                  export_style: str = "matchms"):
     """Save spectrum(s) as json file.
 
+    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
+
     Example:
 
     .. code-block:: python
@@ -41,12 +43,13 @@ def save_as_json(spectrums: List[Spectrum],
         # Assume that input was single Spectrum
         spectrums = [spectrums]
 
-    fingerprint_export_warning(spectrums)
+    spectra = filter_empty_spectra(spectrums)
+    fingerprint_export_warning(spectra)
 
     # Write to json file
     encoder_class = create_spectrum_json_encoder(export_style)
     with open(filename, "w", encoding="utf-8") as fout:
-        json.dump(spectrums, fout, cls=encoder_class)
+        json.dump(spectra, fout, cls=encoder_class)
 
 
 def create_spectrum_json_encoder(export_style):
diff --git a/matchms/exporting/save_as_mgf.py b/matchms/exporting/save_as_mgf.py
index e9cc77ae..38d9609d 100644
--- a/matchms/exporting/save_as_mgf.py
+++ b/matchms/exporting/save_as_mgf.py
@@ -1,7 +1,7 @@
 from typing import List, Union
 import pyteomics.mgf as py_mgf
 from ..Spectrum import Spectrum
-from ..utils import fingerprint_export_warning
+from ..utils import filter_empty_spectra, fingerprint_export_warning
 
 
 def save_as_mgf(spectrums: Union[List[Spectrum], Spectrum],
@@ -9,6 +9,8 @@ def save_as_mgf(spectrums: Union[List[Spectrum], Spectrum],
                 export_style: str = "matchms"):
     """Save spectrum(s) as mgf file.
 
+    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
+
     Example:
 
     .. code-block:: python
@@ -41,7 +43,8 @@ def save_as_mgf(spectrums: Union[List[Spectrum], Spectrum],
         # Assume that input was single Spectrum
         spectrums = [spectrums]
 
-    fingerprint_export_warning(spectrums)
+    spectra = filter_empty_spectra(spectrums)
+    fingerprint_export_warning(spectra)
 
     def spectrum_dict_generator(matchms_spectrums):
         """Generates dictionaries in the format expected by py_mgf"""
@@ -53,4 +56,4 @@ def save_as_mgf(spectrums: Union[List[Spectrum], Spectrum],
                 del spectrum_dict["params"]["fingerprint"]
             yield spectrum_dict
 
-    py_mgf.write(spectrum_dict_generator(spectrums), filename, file_mode="a")
+    py_mgf.write(spectrum_dict_generator(spectra), filename, file_mode="a")
diff --git a/matchms/exporting/save_as_msp.py b/matchms/exporting/save_as_msp.py
index fe81cd5b..4025c322 100644
--- a/matchms/exporting/save_as_msp.py
+++ b/matchms/exporting/save_as_msp.py
@@ -3,7 +3,7 @@ import os
 from typing import IO, Dict, List, Union
 from ..Fragments import Fragments
 from ..Spectrum import Spectrum
-from ..utils import fingerprint_export_warning
+from ..utils import filter_empty_spectra, fingerprint_export_warning
 
 
 logger = logging.getLogger("matchms")
@@ -17,6 +17,8 @@ def save_as_msp(spectrums: List[Spectrum], filename: str,
                 mode: str = "a", style: str = "matchms"):
     """Save spectrum(s) as msp file.
 
+    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
+
     Example:
 
     .. code-block:: python
@@ -56,12 +58,13 @@ def save_as_msp(spectrums: List[Spectrum], filename: str,
     if not filename.endswith(".msp"):
         logger.warning("Spectrum(s) will be stored as msp file with extension .%s",
                        filename.split(".")[-1])
-    spectrums = _ensure_list(spectrums)
+    spectra = _ensure_list(spectrums)
+    spectra = filter_empty_spectra(spectra)
 
-    fingerprint_export_warning(spectrums)
+    fingerprint_export_warning(spectra)
 
     with open(filename, mode, encoding="utf-8") as outfile:
-        for spectrum in spectrums:
+        for spectrum in spectra:
             _write_spectrum(spectrum, outfile, write_peak_comments, style)
 
 
diff --git a/matchms/exporting/save_spectra.py b/matchms/exporting/save_spectra.py
index 40d07bd3..5e2df631 100644
--- a/matchms/exporting/save_spectra.py
+++ b/matchms/exporting/save_spectra.py
@@ -4,6 +4,7 @@ import pickle
 from typing import List
 from matchms.exporting import save_as_json, save_as_mgf, save_as_msp
 from matchms.Spectrum import Spectrum
+from matchms.utils import filter_empty_spectra
 
 
 logger = logging.getLogger("matchms")
@@ -29,12 +30,12 @@ def save_spectra(spectrums: List[Spectrum],
         Converts the keys to the required export style. One of ["matchms", "massbank", "nist", "riken", "gnps"].
         Default is "matchms"
     append:
-        Only supported for ".mgf", and ".msp" filetypes. If True, will try to append to an existing file, instead of creating 
+        Only supported for ".mgf", and ".msp" filetypes. If True, will try to append to an existing file, instead of creating
         a new file. Default is `False`.
     """
     if os.path.exists(file) and not append:
         raise FileExistsError(f"The specified file: {file} already exists.")
-    
+
     ftype = os.path.splitext(file)[1].lower()[1:]
     if append and ftype not in ('mgf', 'msp'):
         raise ValueError(f"{ftype} isn't supported for when `append` is True")
@@ -71,5 +72,7 @@ def save_as_pickled_file(spectrums, filename: str) -> None:
     if not isinstance(spectrums[0], Spectrum):
         raise TypeError("Expected list of spectra")
 
+    spectrums = filter_empty_spectra(spectrums)
+
     with open(filename, "wb") as f:
         pickle.dump(spectrums, f)
diff --git a/matchms/filtering/SpectrumProcessor.py b/matchms/filtering/SpectrumProcessor.py
index affea0e8..8de7defd 100644
--- a/matchms/filtering/SpectrumProcessor.py
+++ b/matchms/filtering/SpectrumProcessor.py
@@ -299,7 +299,7 @@ class ProcessingReport:
             if spectrum_new.metadata != spectrum_old.metadata:
                 self.counter_changed_metadata[filter_function_name] += 1
             # Add peak changes
-            if spectrum_new.peaks != spectrum_old.peaks:
+            if spectrum_new.peaks != spectrum_old.peaks or spectrum_new.losses != spectrum_old.losses:
                 self.counter_changed_peaks[filter_function_name] += 1
 
     def to_dataframe(self):
diff --git a/matchms/filtering/__init__.py b/matchms/filtering/__init__.py
index bc08be60..66126bd3 100644
--- a/matchms/filtering/__init__.py
+++ b/matchms/filtering/__init__.py
@@ -146,6 +146,7 @@ from matchms.filtering.metadata_processing.require_retention_time import \
     require_retention_time
 from matchms.filtering.metadata_processing.require_valid_annotation import \
     require_valid_annotation
+from matchms.filtering.peak_processing.add_losses import add_losses
 from matchms.filtering.peak_processing.normalize_intensities import \
     normalize_intensities
 from matchms.filtering.peak_processing.reduce_to_number_of_peaks import \
@@ -175,6 +176,7 @@ from matchms.filtering.SpeciesString import SpeciesString
 __all__ = [
     "add_compound_name",
     "add_fingerprint",
+    "add_losses",
     "add_parent_mass",
     "add_precursor_mz",
     "add_retention_index",
diff --git a/matchms/filtering/default_pipelines.py b/matchms/filtering/default_pipelines.py
index 99d76f7a..e0bcab1f 100644
--- a/matchms/filtering/default_pipelines.py
+++ b/matchms/filtering/default_pipelines.py
@@ -59,6 +59,7 @@ OTHER_FILTERS = [matchms.filtering.metadata_processing.require_precursor_mz.requ
                  msfilters.remove_peaks_outside_top_k,
                  msfilters.require_minimum_number_of_peaks,
                  msfilters.add_fingerprint,
+                 msfilters.add_losses,
                  msfilters.repair_parent_mass_match_smiles_wrapper,
                  msfilters.require_maximum_number_of_peaks,
                  (msfilters.repair_adduct_and_parent_mass_based_on_smiles, {'mass_tolerance': 0.1}),
diff --git a/matchms/filtering/filter_order.py b/matchms/filtering/filter_order.py
index 9575e704..d5703c67 100644
--- a/matchms/filtering/filter_order.py
+++ b/matchms/filtering/filter_order.py
@@ -59,6 +59,7 @@ ALL_FILTERS = [msfilters.require_correct_ms_level,
                msfilters.require_maximum_number_of_peaks,
                msfilters.require_minimum_number_of_high_peaks,
                msfilters.add_fingerprint,
+               msfilters.add_losses,
                ]
 
 FILTER_FUNCTION_NAMES = {x.__name__: x for x in ALL_FILTERS}
diff --git a/matchms/filtering/peak_processing/add_losses.py b/matchms/filtering/peak_processing/add_losses.py
new file mode 100644
index 00000000..1c699d56
--- /dev/null
+++ b/matchms/filtering/peak_processing/add_losses.py
@@ -0,0 +1,42 @@
+import logging
+import numpy as np
+from matchms.Fragments import Fragments
+from matchms.typing import SpectrumType
+
+
+logger = logging.getLogger("matchms")
+
+
+def add_losses(spectrum_in: SpectrumType, loss_mz_from=0.0, loss_mz_to=1000.0) -> SpectrumType:
+    """Derive losses based on precursor mass.
+
+    Parameters
+    ----------
+    spectrum_in:
+        Input spectrum.
+    loss_mz_from:
+        Minimum allowed m/z value for losses. Default is 0.0.
+    loss_mz_to:
+        Maximum allowed m/z value for losses. Default is 1000.0.
+    """
+    if spectrum_in is None:
+        return None
+
+    spectrum = spectrum_in.clone()
+
+    precursor_mz = spectrum.get("precursor_mz", None)
+    if precursor_mz:
+        assert isinstance(precursor_mz, (float, int)), ("Expected 'precursor_mz' to be a scalar number.",
+                                                        "Consider applying 'add_precursor_mz' filter first.")
+        peaks_mz, peaks_intensities = spectrum.peaks.mz, spectrum.peaks.intensities
+        losses_mz = (precursor_mz - peaks_mz)[::-1]
+        losses_intensities = peaks_intensities[::-1]
+        # Add losses which are within given boundaries
+        mask = np.where((losses_mz >= loss_mz_from)
+                           & (losses_mz <= loss_mz_to))
+        spectrum.losses = Fragments(mz=losses_mz[mask],
+                                    intensities=losses_intensities[mask])
+    else:
+        logger.warning("No precursor_mz found. Consider applying 'add_precursor_mz' filter first.")
+
+    return spectrum
diff --git a/matchms/filtering/peak_processing/normalize_intensities.py b/matchms/filtering/peak_processing/normalize_intensities.py
index 1cf3a4d1..5c612cd3 100644
--- a/matchms/filtering/peak_processing/normalize_intensities.py
+++ b/matchms/filtering/peak_processing/normalize_intensities.py
@@ -8,7 +8,7 @@ logger = logging.getLogger("matchms")
 
 
 def normalize_intensities(spectrum_in: SpectrumType) -> SpectrumType:
-    """Normalize intensities of peaks to unit height."""
+    """Normalize intensities of peaks (and losses) to unit height."""
 
     if spectrum_in is None:
         return None
@@ -29,4 +29,10 @@ def normalize_intensities(spectrum_in: SpectrumType) -> SpectrumType:
     normalized_intensities = intensities / max_intensity
     spectrum.peaks = Fragments(mz=mz, intensities=normalized_intensities)
 
+    # Normalize loss intensities
+    if spectrum.losses is not None and len(spectrum.losses) > 0:
+        mz, intensities = spectrum.losses.mz, spectrum.losses.intensities
+        normalized_intensities = intensities / max_intensity
+        spectrum.losses = Fragments(mz=mz, intensities=normalized_intensities)
+
     return spectrum
diff --git a/matchms/utils.py b/matchms/utils.py
index c3b9ffc6..4b7cc228 100644
--- a/matchms/utils.py
+++ b/matchms/utils.py
@@ -73,3 +73,7 @@ def _load_key_conversions(file: str, source: str, target: str) -> dict:
 def fingerprint_export_warning(spectrums: List[SpectrumType]):
     if any(x.get("fingerprint") is not None for x in spectrums):
         logger.warning("fingerprint found but will not be written to file.")
+
+
+def filter_empty_spectra(spectrums: List[SpectrumType]) -> List[SpectrumType]:
+    return [x for x in spectrums if x is not None]


Test patch:
diff --git a/tests/exporting/test_exporting_metadata.py b/tests/exporting/test_exporting_metadata.py
index 7f7d732d..563fb616 100644
--- a/tests/exporting/test_exporting_metadata.py
+++ b/tests/exporting/test_exporting_metadata.py
@@ -1,4 +1,5 @@
 import filecmp
+import json
 import os
 import numpy as np
 import pytest
@@ -35,7 +36,7 @@ def test_get_metadata_as_array(spectra):
 
 def test_export_as_csv(tmp_path, spectra):
     module_root = os.path.join(os.path.dirname(__file__), "..")
-    expected = os.path.join(module_root, "testdata", "expected_metadata.csv")    
+    expected = os.path.join(module_root, "testdata", "expected_metadata.csv")
     outpath = tmp_path / "metadata.csv"
     export_metadata_as_csv(spectra, outpath)
 
@@ -55,7 +56,19 @@ def test_subset_metadata(spectra):
 def test_export_metadata_as_json(tmp_path, spectra):
     outpath = tmp_path / "metadata.json"
     module_root = os.path.join(os.path.dirname(__file__), "..")
-    expected = os.path.join(module_root, "testdata","expected_metadata.json")    
+    expected = os.path.join(module_root, "testdata", "expected_metadata.json")
 
     export_metadata_as_json(spectra, outpath)
     filecmp.cmp(outpath, expected)
+
+
+@pytest.mark.parametrize("file_name", ["metadata.csv", "metadata.json"])
+def test_export_metadata_none_spectra(tmp_path, spectra, file_name):
+    outpath = os.path.join(tmp_path, file_name)
+
+    spectra.append(None)
+    export_metadata_as_json(spectra, outpath)
+
+    with open(outpath, "r", encoding="utf-8") as f:
+        actual = json.load(f)
+        assert len(actual) == (len(spectra) - 1)
diff --git a/tests/exporting/test_save_as_json_load_from_json.py b/tests/exporting/test_save_as_json_load_from_json.py
index 7392083b..dec7ccac 100644
--- a/tests/exporting/test_save_as_json_load_from_json.py
+++ b/tests/exporting/test_save_as_json_load_from_json.py
@@ -46,10 +46,11 @@ def test_save_and_load_json_spectrum_list(metadata_harmonization, tmp_path, buil
                                       metadata_harmonization=metadata_harmonization).build()
     spectrum2 = builder.with_metadata({"test_field": "test2"},
                                       metadata_harmonization=metadata_harmonization).build()
+    spectrum3 = None
 
     # Write to test file
     filename = os.path.join(tmp_path, "test.json")
-    save_as_json([spectrum1, spectrum2], filename)
+    save_as_json([spectrum1, spectrum2, spectrum3], filename)
 
     # test if file exists
     assert os.path.isfile(filename)
@@ -58,6 +59,7 @@ def test_save_and_load_json_spectrum_list(metadata_harmonization, tmp_path, buil
     spectrum_imports = load_from_json(filename, metadata_harmonization=metadata_harmonization)
     assert spectrum_imports[0] == spectrum1, "Original and saved+loaded spectrum not identical"
     assert spectrum_imports[1] == spectrum2, "Original and saved+loaded spectrum not identical"
+    assert len(spectrum_imports) == 2
 
 
 @pytest.mark.parametrize("style, expected",
diff --git a/tests/exporting/test_save_as_mgf.py b/tests/exporting/test_save_as_mgf.py
index a5b88ebf..688ae9e3 100644
--- a/tests/exporting/test_save_as_mgf.py
+++ b/tests/exporting/test_save_as_mgf.py
@@ -51,11 +51,12 @@ def test_save_as_mgf_spectrum_list():
                                       metadata_harmonization=False).build()
     spectrum2 = builder.with_metadata({"test_field": "test2"},
                                       metadata_harmonization=False).build()
+    spectrum3 = None
 
     # Write to test file
     with tempfile.TemporaryDirectory() as d:
         filename = os.path.join(d, "test.mgf")
-        save_as_mgf([spectrum1, spectrum2], filename)
+        save_as_mgf([spectrum1, spectrum2, spectrum3], filename)
 
         # test if file exists
         assert os.path.isfile(filename)
@@ -67,6 +68,9 @@ def test_save_as_mgf_spectrum_list():
         assert mgf_content[1].split("=")[1] == "test1\n"
         assert mgf_content[8].split("=")[1] == "test2\n"
 
+        end_ion_count_actual = len([x for x in mgf_content if x == "END IONS\n"])
+        assert end_ion_count_actual == 2
+
 
 @pytest.mark.parametrize("style, expected",
                          [("matchms", ["PRECURSOR_MZ=100.1\n", "PRECURSOR_MZ=200.2\n"]),
diff --git a/tests/exporting/test_save_as_msp.py b/tests/exporting/test_save_as_msp.py
index 51cf3221..d2914059 100644
--- a/tests/exporting/test_save_as_msp.py
+++ b/tests/exporting/test_save_as_msp.py
@@ -165,3 +165,15 @@ def test_save_as_msp_from_mgf(test_file, expected_file, filename):
     save_as_msp(actual, filename, mode="w", write_peak_comments=True)
     actual = list(load_from_msp(filename))
     assert expected == actual
+
+def test_filter_none_spectra(filename, data):
+    """ Test for removal of None valued Spectra
+    """
+
+    spectra = data
+    spectra.append(None)
+
+    save_as_msp(spectra, filename)
+    reloaded_spectra = list(load_from_msp(filename))
+
+    assert len(reloaded_spectra) == (len(spectra) - 1)
diff --git a/tests/exporting/test_save_spectra.py b/tests/exporting/test_save_spectra.py
index c1943495..953076e1 100644
--- a/tests/exporting/test_save_spectra.py
+++ b/tests/exporting/test_save_spectra.py
@@ -41,3 +41,25 @@ def test_spectra(file_name):
         spectrum.set("num_peaks", None)
         reloaded_spectrum.set("num_peaks", None)
         assert spectrum == reloaded_spectrum
+
+
+@pytest.mark.parametrize("file_name", ["spectra.pickle"])
+def test_save_as_pickled_file_none_spectra(file_name):
+    """ Tests only pickled file saving with filtered None valued Spectrums
+
+    Params:
+    -------
+    spectra: Spectra objects to store
+
+    Returns:
+    --------
+    reloaded_spectra: Spectra loaded from saved msp file.
+    """
+    spectrum_list = load_test_spectra_file()
+    spectrum_list.append(None)
+
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, file_name)
+        save_spectra(spectrum_list, filename)
+        reloaded_spectra = list(load_spectra(filename))
+    assert len(reloaded_spectra) == (len(spectrum_list) - 1)
diff --git a/tests/filtering/test_add_losses.py b/tests/filtering/test_add_losses.py
new file mode 100644
index 00000000..fce3ac3c
--- /dev/null
+++ b/tests/filtering/test_add_losses.py
@@ -0,0 +1,61 @@
+import numpy as np
+import pytest
+from testfixtures import LogCapture
+from matchms.filtering import add_losses
+from ..builder_Spectrum import SpectrumBuilder
+
+
+@pytest.mark.parametrize("mz, loss_mz_to, expected_mz, expected_intensities", [
+    [np.array([100, 150, 200, 300], dtype="float"), 1000, np.array([145, 245, 295, 345], "float"), np.array([1000, 100, 200, 700], "float")],
+    [np.array([100, 150, 200, 450], dtype="float"), 1000, np.array([245, 295, 345], "float"), np.array([100, 200, 700], "float")],
+    [np.array([100, 150, 200, 300], dtype="float"), 250, np.array([145, 245], "float"), np.array([1000, 100], "float")]
+])
+def test_add_losses_parameterized(mz, loss_mz_to, expected_mz, expected_intensities):
+    intensities = np.array([700, 200, 100, 1000], "float")
+    metadata = {"precursor_mz": 445.0}
+    spectrum_in = SpectrumBuilder().with_mz(mz).with_intensities(
+        intensities).with_metadata(metadata).build()
+
+    spectrum = add_losses(spectrum_in, loss_mz_to=loss_mz_to)
+
+    assert np.allclose(spectrum.losses.mz, expected_mz), "Expected different loss m/z."
+    assert np.allclose(spectrum.losses.intensities, expected_intensities), "Expected different intensities."
+
+
+@pytest.mark.parametrize("mz, intensities", [
+    [np.array([100, 150, 200, 300], dtype="float"), np.array([700, 200, 100, 1000], dtype="float")],
+    [[], []]
+])
+def test_add_losses_without_precursor_mz_parameterized(mz, intensities):
+    spectrum_in = SpectrumBuilder().with_mz(mz).with_intensities(intensities).build()
+    spectrum = add_losses(spectrum_in)
+
+    with LogCapture() as log:
+        spectrum = add_losses(spectrum_in)
+
+    assert spectrum == spectrum_in and spectrum is not spectrum_in
+    log.check(
+        ("matchms", "WARNING",
+         "No precursor_mz found. Consider applying 'add_precursor_mz' filter first.")
+    )
+
+
+def test_add_losses_with_precursor_mz_wrong_type():
+    """Test if correct assert error is raised for precursor-mz as string."""
+    mz = np.array([100, 150, 200, 300], dtype="float")
+    intensities = np.array([700, 200, 100, 1000], "float")
+    metadata = {"precursor_mz": "445.0"}
+    spectrum_in = SpectrumBuilder().with_mz(mz).with_intensities(
+        intensities).with_metadata(metadata).build()
+
+    with pytest.raises(AssertionError) as msg:
+        _ = add_losses(spectrum_in)
+
+    assert "Expected 'precursor_mz' to be a scalar number." in str(msg.value)
+
+
+def test_add_losses_with_input_none():
+    """Test if input spectrum is None."""
+    spectrum_in = None
+    spectrum = add_losses(spectrum_in)
+    assert spectrum is None
diff --git a/tests/filtering/test_normalize_intensities.py b/tests/filtering/test_normalize_intensities.py
index e5713d62..ecd001a2 100644
--- a/tests/filtering/test_normalize_intensities.py
+++ b/tests/filtering/test_normalize_intensities.py
@@ -1,6 +1,6 @@
 import numpy as np
 import pytest
-from matchms.filtering import normalize_intensities
+from matchms.filtering import add_losses, normalize_intensities
 from ..builder_Spectrum import SpectrumBuilder
 
 
@@ -27,7 +27,8 @@ def test_normalize_intensities_losses_present(mz, intensities, metadata, expecte
     spectrum_in = SpectrumBuilder().with_mz(mz).with_intensities(
         intensities).with_metadata(metadata).build()
 
-    spectrum = normalize_intensities(spectrum_in)
+    spectrum = add_losses(spectrum_in)
+    spectrum = normalize_intensities(spectrum)
 
     assert max(spectrum.peaks.intensities) == 1.0, "Expected the spectrum to be scaled to 1.0."
     assert np.array_equal(spectrum.peaks.intensities, intensities/100), "Expected different intensities"
diff --git a/tests/filtering/test_spectrum_processor.py b/tests/filtering/test_spectrum_processor.py
index 033fffbf..e084105e 100644
--- a/tests/filtering/test_spectrum_processor.py
+++ b/tests/filtering/test_spectrum_processor.py
@@ -110,6 +110,7 @@ def test_filter_spectrums_report(spectrums):
                                            "correct_charge",
                                            ])
     processor.parse_and_add_filter(filter_description=("require_minimum_number_of_peaks", {"n_required": 2}))
+    processor.parse_and_add_filter(filter_description="add_losses")
     spectrums, report = processor.process_spectrums(spectrums)
     assert len(spectrums) == 2
     actual_masses = [s.get("precursor_mz") for s in spectrums]
@@ -119,9 +120,10 @@ def test_filter_spectrums_report(spectrums):
     assert report.counter_changed_metadata == {'make_charge_int': 2, 'interpret_pepmass': 3, 'derive_ionmode': 3}
     report_df = report.to_dataframe()
     assert np.all(report_df.loc[["require_minimum_number_of_peaks", "interpret_pepmass",
-                                 "correct_charge"]].values == np.array(
+                                 "add_losses", "correct_charge"]].values == np.array(
         [[1, 0, 0],
          [0, 3, 0],
+         [0, 0, 2],
          [0, 0, 0]]))
 
 
diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py
index 36edcd3c..d6428758 100644
--- a/tests/test_spectrum.py
+++ b/tests/test_spectrum.py
@@ -232,36 +232,6 @@ def test_metadata_default_filtering(spectrum, input_dict, default_filtering, exp
         "Expected different _metadata dictionary."
 
 
-@pytest.mark.parametrize("mz, loss_mz_to, expected_mz, expected_intensities", [
-    [np.array([100, 150, 200, 300], dtype="float"), 1000, np.array([145, 245, 295, 345], "float"), np.array([1000, 100, 200, 700], "float")],
-    [np.array([100, 150, 200, 450], dtype="float"), 1000, np.array([245, 295, 345], "float"), np.array([100, 200, 700], "float")],
-    [np.array([100, 150, 200, 300], dtype="float"), 250, np.array([145, 245], "float"), np.array([1000, 100], "float")]
-])
-def test_compute_losses_parameterized(mz, loss_mz_to, expected_mz, expected_intensities):
-    intensities = np.array([700, 200, 100, 1000], "float")
-    metadata = {"precursor_mz": 445.0}
-    spectrum = SpectrumBuilder().with_mz(mz).with_intensities(
-        intensities).with_metadata(metadata).build()
-
-    losses = spectrum.compute_losses(loss_mz_to=loss_mz_to)
-
-    assert np.allclose(losses.mz, expected_mz), "Expected different loss m/z."
-    assert np.allclose(losses.intensities, expected_intensities), "Expected different intensities."
-
-
-def test_losses_property():
-    mz = np.array([100, 150, 200, 300.0])
-    intensities = np.array([700, 200, 100, 1000], "float")
-    metadata = {"precursor_mz": 445.0}
-    spectrum = SpectrumBuilder().with_mz(mz).with_intensities(
-        intensities).with_metadata(metadata).build()
-
-    # Check if class property "losses" works
-    losses_default = spectrum.compute_losses()
-    assert np.allclose(spectrum.losses.mz, losses_default.mz)
-    assert np.allclose(spectrum.losses.intensities, losses_default.intensities)
-
-
 def test_spectrum_plot_same_peak_height():
     intensities_with_zero_variance = np.array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], dtype="float")
     spectrum = _create_test_spectrum_with_intensities(intensities_with_zero_variance)