77# Modification names that indicate isobaric/isotopic labels
88_LABEL_MOD_PREFIXES = ("TMT" , "iTRAQ" , "Label" )
99
10+ # SDRF terminal target notations -> DIA-NN site tokens
11+ _TERMINAL_SITE_MAP = {
12+ "Protein N-term" : "*n" ,
13+ "N-term" : "n" ,
14+ "Any N-term" : "n" ,
15+ "Protein C-term" : "*c" ,
16+ "C-term" : "c" ,
17+ "Any C-term" : "c" ,
18+ }
19+
1020
1121class DiannModificationConverter :
1222 """Converts SDRF modification strings to DIA-NN notation.
1323
14- DIA-NN format: Name,DeltaMass,Site[,label]
15- The optional 'label' suffix indicates isotopic labels that don't affect RT.
24+ DIA-NN format: ``Name,DeltaMass,Sites[,label]``. ``Sites`` is a single
25+ *concatenated* string of target residues/termini **without separators**
26+ (e.g. ``STY``, ``MP``, ``nK``); the comma only ever delimits the optional
27+ fourth ``label`` field.
28+
29+ DIA-NN keeps only the first residue of a comma-separated site list and
30+ de-duplicates ``--var-mod``/``--fixed-mod`` entries by modification name, so
31+ a modification targeting several residues must be emitted as a single entry
32+ with the sites concatenated. Two SDRF representations would otherwise be
33+ silently truncated by DIA-NN, dropping every residue except the first:
34+
35+ * a single SDRF cell with comma-separated targets (``TA=S,T,Y``); and
36+ * the same modification (same name + mass) declared across several SDRF
37+ cells with different targets (``Oxidation`` on ``M`` and on ``P``).
38+
39+ This converter handles both: it concatenates multi-residue sites within a
40+ cell (``TA=S,T,Y`` -> ``STY``) and merges same-(name, mass) modifications
41+ across cells (``Oxidation`` ``M`` + ``P`` -> ``Oxidation,15.994915,MP``).
1642 """
1743
1844 def __init__ (self ):
@@ -23,48 +49,74 @@ def convert_modification(self, mod_string: str, is_fixed: bool) -> str:
2349
2450 Args:
2551 mod_string: SDRF mod string (e.g., "NT=Carbamidomethyl;TA=C;MT=fixed;AC=UNIMOD:4")
26- is_fixed: Whether this is a fixed modification
52+ is_fixed: Whether this is a fixed modification (kept for API compatibility)
2753
2854 Returns:
29- DIA-NN format string (e.g., "Carbamidomethyl,57.021464,C")
55+ DIA-NN format string (e.g., "Carbamidomethyl,57.021464,C"). Multi-residue
56+ sites declared in one cell are concatenated (e.g. "Phospho,79.966331,STY").
3057
3158 Raises:
32- ValueError: If modification not found in Unimod
59+ ValueError: If modification not found in Unimod or has no target site
3360 """
34- name = self ._extract_name (mod_string )
35- site = self ._extract_site (mod_string )
36- delta_mass = self ._get_delta_mass (name , mod_string )
37-
38- parts = [name , str (delta_mass ), site ]
39-
40- # Add 'label' suffix for isobaric label modifications
41- if any (name .startswith (prefix ) for prefix in _LABEL_MOD_PREFIXES ):
42- parts .append ("label" )
43-
44- return "," .join (parts )
61+ name , delta_mass , sites , is_label = self ._parse_modification (mod_string )
62+ return self ._format (name , delta_mass , sites , is_label )
4563
4664 def convert_all_modifications (self , fixed_mods : list [str ], var_mods : list [str ]) -> tuple [list [str ], list [str ]]:
4765 """Convert lists of SDRF modifications to DIA-NN format.
4866
67+ Modifications sharing the same name and delta mass are merged into a
68+ single DIA-NN entry with their target sites combined, so DIA-NN does not
69+ silently drop residues (see the class docstring).
70+
4971 Args:
5072 fixed_mods: List of SDRF fixed modification strings
5173 var_mods: List of SDRF variable modification strings
5274
5375 Returns:
5476 Tuple of (fixed_diann_mods, var_diann_mods)
5577 """
56- fixed_result = []
57- var_result = []
78+ return self ._convert_and_merge (fixed_mods ), self ._convert_and_merge (var_mods )
5879
59- for mod in fixed_mods :
60- if mod .strip ():
61- fixed_result .append (self .convert_modification (mod , is_fixed = True ))
80+ def _convert_and_merge (self , mods : list [str ]) -> list [str ]:
81+ """Convert and merge SDRF modification strings.
6282
63- for mod in var_mods :
64- if mod .strip ():
65- var_result .append (self .convert_modification (mod , is_fixed = False ))
83+ Entries sharing ``(name, delta_mass, is_label)`` are combined into one,
84+ accumulating their target sites. The first-seen order of distinct
85+ modifications is preserved.
86+ """
87+ order : list [tuple ] = []
88+ merged : dict [tuple , dict ] = {}
89+ for mod in mods :
90+ if not mod or not mod .strip ():
91+ continue
92+ name , delta_mass , sites , is_label = self ._parse_modification (mod )
93+ key = (name , delta_mass , is_label )
94+ if key not in merged :
95+ merged [key ] = {"name" : name , "mass" : delta_mass , "sites" : [], "label" : is_label }
96+ order .append (key )
97+ for token in sites :
98+ if token not in merged [key ]["sites" ]:
99+ merged [key ]["sites" ].append (token )
100+ return [self ._format (m ["name" ], m ["mass" ], m ["sites" ], m ["label" ]) for m in (merged [key ] for key in order )]
101+
102+ def _format (self , name : str , delta_mass : float , sites : list [str ], is_label : bool ) -> str :
103+ """Assemble a DIA-NN modification string from its components.
104+
105+ Site tokens are concatenated (no separators), sorted for deterministic
106+ output; the comma-delimited ``label`` flag is appended for labels only.
107+ """
108+ parts = [name , str (delta_mass ), "" .join (sorted (set (sites )))]
109+ if is_label :
110+ parts .append ("label" )
111+ return "," .join (parts )
66112
67- return fixed_result , var_result
113+ def _parse_modification (self , mod_string : str ) -> tuple [str , float , list [str ], bool ]:
114+ """Parse an SDRF modification string into (name, delta_mass, site tokens, is_label)."""
115+ name = self ._extract_name (mod_string )
116+ sites = self ._extract_sites (mod_string )
117+ delta_mass = self ._get_delta_mass (name , mod_string )
118+ is_label = any (name .startswith (prefix ) for prefix in _LABEL_MOD_PREFIXES )
119+ return name , delta_mass , sites , is_label
68120
69121 def _extract_name (self , mod_string : str ) -> str :
70122 """Extract and validate modification name via Unimod lookup."""
@@ -86,28 +138,36 @@ def _extract_name(self, mod_string: str) -> str:
86138
87139 return ptm .get_name ()
88140
89- def _extract_site (self , mod_string : str ) -> str :
90- """Extract target site and convert to DIA-NN notation."""
141+ def _extract_sites (self , mod_string : str ) -> list [str ]:
142+ """Extract target site(s) and convert to DIA-NN site tokens.
143+
144+ Handles a single residue (``TA=M``), a comma-separated residue list in a
145+ single cell (``TA=S,T,Y``), and terminal notations (``PP=Protein
146+ N-term``). Returns a de-duplicated list of DIA-NN site tokens.
147+ """
91148 ta_match = re .search (r"TA=(.+?)(;|$)" , mod_string )
92149 pp_match = re .search (r"PP=(.+?)(;|$)" , mod_string )
93150
94151 if ta_match :
95- site = ta_match .group (1 )
152+ raw = ta_match .group (1 )
96153 elif pp_match :
97- site = pp_match .group (1 )
154+ raw = pp_match .group (1 )
98155 else :
99156 raise ValueError (f"No target site (TA= or PP=) in: { mod_string } " )
100157
101- # Convert to DIA-NN site notation
102- if site == "Protein N-term" :
103- return "*n"
104- elif site in ("N-term" , "Any N-term" ):
105- return "n"
106- elif site == "Protein C-term" :
107- return "*c"
108- elif site in ("C-term" , "Any C-term" ):
109- return "c"
110- return site
158+ tokens : list [str ] = []
159+ for part in raw .split ("," ):
160+ part = part .strip ()
161+ if not part :
162+ continue
163+ # Convert to DIA-NN site notation; residues map to themselves
164+ token = _TERMINAL_SITE_MAP .get (part , part )
165+ if token not in tokens :
166+ tokens .append (token )
167+
168+ if not tokens :
169+ raise ValueError (f"No target site (TA= or PP=) in: { mod_string } " )
170+ return tokens
111171
112172 def _get_delta_mass (self , name : str , mod_string : str ) -> float :
113173 """Get monoisotopic delta mass from Unimod."""
0 commit comments