Repository: compomics/psm_utils

Issue:
Updating metaValues of PeptideHits with numpy objects crashes in idxml writer
After rescoring with ms2rescore the `rescoring_features` values can contain various types:

`{<class 'numpy.int8'>, <class 'float'>, <class 'numpy.float32'>, <class 'numpy.float64'>, <class 'int'>, <class 'numpy.int64'>}
`

[PeptideHit.setMetaValues](https://pyopenms.readthedocs.io/en/latest/apidocs/_autosummary/pyopenms/pyopenms.PeptideHit.html#pyopenms.PeptideHit.setMetaValue) however, does only allow ` in_1: int | float | bytes | str | List[int] | List[float] | List[bytes]` as values. This then causes errors when writing / updating idXML files.

I suggest converting to `float` if value is not type  `str`

Comments:

Patch:
diff --git a/psm_utils/io/idxml.py b/psm_utils/io/idxml.py
index 84ecb26..07d79ad 100644
--- a/psm_utils/io/idxml.py
+++ b/psm_utils/io/idxml.py
@@ -422,7 +422,8 @@ class IdXMLWriter(WriterBase):
 
         for feature, value in psm.rescoring_features.items():
             if feature not in RESCORING_FEATURE_LIST:
-                peptide_hit.setMetaValue(feature, value)
+                # Convert numpy objects to floats since pyopenms does not support numpy objects to be added
+                peptide_hit.setMetaValue(feature, float(value))
 
     def _create_new_ids(self, psm_dict: dict) -> None:
         """
@@ -532,6 +533,9 @@ class IdXMLWriter(WriterBase):
         """Add meta values inplace to :py:class:`~pyopenms.PeptideHit` from a dictionary."""
         if d is not None:
             for key, value in d.items():
+                # Convert numpy objects to floats since pyopenms does not support numpy objects to be added
+                if not isinstance(value, str):
+                    value = float(value)
                 peptide_hit.setMetaValue(key, value)
 
 
diff --git a/psm_utils/io/xtandem.py b/psm_utils/io/xtandem.py
index ebecb76..62eb2e6 100644
--- a/psm_utils/io/xtandem.py
+++ b/psm_utils/io/xtandem.py
@@ -44,6 +44,7 @@ Notes
 
 from __future__ import annotations
 
+import logging
 import re
 import xml.etree.ElementTree as ET
 from pathlib import Path
@@ -57,6 +58,8 @@ from psm_utils.io._base_classes import ReaderBase
 from psm_utils.peptidoform import Peptidoform
 from psm_utils.psm import PSM
 
+logger = logging.getLogger(__name__)
+
 
 class XTandemReader(ReaderBase):
     def __init__(
@@ -187,9 +190,15 @@ class XTandemReader(ReaderBase):
         tree = ET.parse(str(filepath))
         root = tree.getroot()
         full_label = root.attrib["label"]
-        run_match = re.search(r"\/(?P<run>\d+_?\d+)\.(?P<filetype>mgf|mzML|mzml)", full_label)
+        run_match = re.search(r"\/(?P<run>[^\s\/\\]+)\.(?P<filetype>mgf|mzML|mzml)", full_label)
         if run_match:
             run = run_match.group("run")
+        else:
+            run = Path(self.filepath).stem
+            logger.warning(
+                f"Could not parse run from X!Tandem XML label entry. Setting PSM filename `{run}` "
+                "as run."
+            )
 
         return run
 
diff --git a/psm_utils/peptidoform.py b/psm_utils/peptidoform.py
index fd9695d..6da9a37 100644
--- a/psm_utils/peptidoform.py
+++ b/psm_utils/peptidoform.py
@@ -519,9 +519,10 @@ class Peptidoform:
 
 def _format_number_as_string(num):
     """Format number as string for ProForma mass modifications."""
-    sign = "+" if np.sign(num) == 1 else "-"
-    num = str(num).rstrip("0").rstrip(".")
-    return sign + num
+    # Using this method over `:+g` string formatting to avoid rounding and scientific notation
+    plus = "+" if np.sign(num) == 1 else ""  # Add plus sign if positive
+    num = str(num).rstrip("0").rstrip(".")  # Remove trailing zeros and decimal point
+    return plus + num
 
 
 class PeptidoformException(PSMUtilsException):


Test patch:
diff --git a/tests/test_peptidoform.py b/tests/test_peptidoform.py
index 1e048e7..e37a558 100644
--- a/tests/test_peptidoform.py
+++ b/tests/test_peptidoform.py
@@ -1,6 +1,6 @@
 from pyteomics import proforma
 
-from psm_utils.peptidoform import Peptidoform
+from psm_utils.peptidoform import Peptidoform, _format_number_as_string
 
 
 class TestPeptidoform:
@@ -27,7 +27,12 @@ class TestPeptidoform:
                     assert isinstance(mod, proforma.TagBase)
 
     def test_rename_modifications(self):
-        label_mapping = {"ac": "Acetyl", "cm": "Carbamidomethyl"}
+        label_mapping = {
+            "ac": "Acetyl",
+            "cm": "Carbamidomethyl",
+            "+57.021": "Carbamidomethyl",
+            "-18.010565": "Glu->pyro-Glu",
+        }
 
         test_cases = [
             ("ACDEFGHIK", "ACDEFGHIK"),
@@ -36,9 +41,26 @@ class TestPeptidoform:
             ("[Acetyl]-AC[cm]DEFGHIK", "[Acetyl]-AC[Carbamidomethyl]DEFGHIK"),
             ("<[cm]@C>[Acetyl]-ACDEFGHIK", "<[Carbamidomethyl]@C>[Acetyl]-ACDEFGHIK"),
             ("<[Carbamidomethyl]@C>[ac]-ACDEFGHIK", "<[Carbamidomethyl]@C>[Acetyl]-ACDEFGHIK"),
+            ("[ac]-AC[cm]DEFGHIK", "[Acetyl]-AC[Carbamidomethyl]DEFGHIK"),
+            ("AC[+57.021]DEFGHIK", "AC[Carbamidomethyl]DEFGHIK"),
+            ("E[-18.010565]DEK", "E[Glu->pyro-Glu]DEK"),
         ]
 
         for test_case_in, expected_out in test_cases:
             peptidoform = Peptidoform(test_case_in)
             peptidoform.rename_modifications(label_mapping)
             assert peptidoform.proforma == expected_out
+
+
+def test_format_number_as_string():
+    test_cases = [
+        (1212.12, "+1212.12"),
+        (-1212.12, "-1212.12"),
+        (0.1, "+0.1"),
+        (-0.1, "-0.1"),
+        (1212.000, "+1212"),
+        (1212.1200, "+1212.12"),
+    ]
+
+    for test_case_in, expected_out in test_cases:
+        assert _format_number_as_string(test_case_in) == expected_out