Skip to content

Commit 322eb58

Browse files
authored
Merge pull request #305 from ypriverol/fix/diann-multimod-sites
fix(diann): merge multi-residue modifications into a single entry (Oxidation on P / pT,pY dropped)
2 parents 0c5258b + 8e35c65 commit 322eb58

5 files changed

Lines changed: 186 additions & 45 deletions

File tree

.github/workflows/conda-build.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,17 @@ jobs:
4444

4545
- name: Install build dependencies
4646
run: |
47-
conda install -n sdrf-pipelines-build conda-build conda-verify anaconda-client
47+
# conda-build registers the `conda build` subcommand on the conda it is
48+
# installed into; the running CLI is base conda, so install into base.
49+
conda install -n base -c conda-forge conda-build conda-verify anaconda-client
4850
4951
- name: Verify conda recipe
5052
run: |
51-
conda-verify .
53+
conda run -n base conda-verify .
5254
5355
- name: Build and test the sdrf-pipelines package
5456
run: |
5557
# conda build automatically runs the tests defined in meta.yaml test section
56-
conda build --package-format .tar.bz2 recipe
58+
conda run -n base conda build --package-format .tar.bz2 recipe
5759
env:
5860
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ env.SETUPTOOLS_SCM_PRETEND_VERSION }}

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# History of changes for sdrf-pipelines
22

3+
## Version 0.1.5
4+
5+
### Bug Fixes
6+
- DIA-NN converter (`converters/diann/modifications.py`): correctly handle modifications that target
7+
multiple residues. DIA-NN keeps only the first residue of a comma-separated site list and
8+
de-duplicates `--var-mod`/`--fixed-mod` entries by name, so the previous output silently dropped
9+
every residue except the first (e.g. all hydroxyproline `Oxidation` on `P`, and all `pT`/`pY`
10+
phosphosites). Sites declared in a single SDRF cell (`TA=S,T,Y`) are now concatenated into one
11+
DIA-NN site string (`Phospho,79.966331,STY`), and the same modification (same name + mass)
12+
declared across several SDRF cells (`Oxidation` on `M` and on `P`) is merged into a single entry
13+
(`Oxidation,15.994915,MP`).
14+
15+
### Chores
16+
- Bump `idna` to 3.18 in `uv.lock` (supersedes Dependabot #304, which targeted 3.15).
17+
- CI: fix the Conda Build workflow — install `conda-build`/`conda-verify` into the `base` env and invoke
18+
via `conda run -n base`, so the `conda build` subcommand is registered (was failing with
19+
`conda: error: argument COMMAND: invalid choice: 'build'`).
20+
321
## Version 1.0.0 From 0.0.32
422

523
### Development

src/sdrf_pipelines/converters/diann/modifications.py

Lines changed: 99 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,38 @@
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

1121
class 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."""

tests/test_diann_modifications.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,64 @@ def test_convert_all_modifications(self):
5252
assert fixed_result[0] == "Carbamidomethyl,57.021464,C"
5353
assert len(var_result) == 1
5454
assert var_result[0] == "Oxidation,15.994915,M"
55+
56+
# --- multi-residue sites in a single SDRF cell (TA=S,T,Y) -----------------
57+
# DIA-NN keeps only the first residue of a comma-separated site list, so the
58+
# converter must concatenate the residues into one site string.
59+
60+
def test_var_mod_multi_site_single_cell_phospho(self):
61+
converter = DiannModificationConverter()
62+
result = converter.convert_modification("NT=Phospho;MT=variable;TA=S,T,Y;AC=UNIMOD:21", is_fixed=False)
63+
assert result == "Phospho,79.966331,STY"
64+
65+
def test_var_mod_multi_site_single_cell_oxidation_mp(self):
66+
converter = DiannModificationConverter()
67+
result = converter.convert_modification("NT=Oxidation;MT=variable;TA=M,P;AC=UNIMOD:35", is_fixed=False)
68+
assert result == "Oxidation,15.994915,MP"
69+
70+
def test_var_mod_multi_site_with_spaces(self):
71+
converter = DiannModificationConverter()
72+
result = converter.convert_modification("NT=Phospho;MT=variable;TA=S, T, Y;AC=UNIMOD:21", is_fixed=False)
73+
assert result == "Phospho,79.966331,STY"
74+
75+
# --- same modification declared across several SDRF cells ------------------
76+
# DIA-NN de-duplicates entries by name and would keep only the first cell,
77+
# so same-(name, mass) modifications must be merged into one entry.
78+
79+
def test_merge_oxidation_m_and_p_across_cells(self):
80+
converter = DiannModificationConverter()
81+
var_mods = [
82+
"NT=Oxidation;MT=variable;TA=M;AC=UNIMOD:35",
83+
"NT=Oxidation;MT=variable;TA=P;AC=UNIMOD:35",
84+
]
85+
_, var_result = converter.convert_all_modifications([], var_mods)
86+
assert var_result == ["Oxidation,15.994915,MP"]
87+
88+
def test_merge_phospho_across_three_cells(self):
89+
converter = DiannModificationConverter()
90+
var_mods = [
91+
"NT=Phospho;MT=variable;TA=S;AC=UNIMOD:21",
92+
"NT=Phospho;MT=variable;TA=T;AC=UNIMOD:21",
93+
"NT=Phospho;MT=variable;TA=Y;AC=UNIMOD:21",
94+
]
95+
_, var_result = converter.convert_all_modifications([], var_mods)
96+
assert var_result == ["Phospho,79.966331,STY"]
97+
98+
def test_merge_preserves_distinct_modifications(self):
99+
converter = DiannModificationConverter()
100+
var_mods = [
101+
"NT=Oxidation;MT=variable;TA=M;AC=UNIMOD:35",
102+
"NT=Oxidation;MT=variable;TA=P;AC=UNIMOD:35",
103+
"NT=Acetyl;MT=variable;PP=Protein N-term;AC=UNIMOD:1",
104+
]
105+
_, var_result = converter.convert_all_modifications([], var_mods)
106+
assert var_result == ["Oxidation,15.994915,MP", "Acetyl,42.010565,*n"]
107+
108+
def test_merge_deduplicates_repeated_site(self):
109+
converter = DiannModificationConverter()
110+
var_mods = [
111+
"NT=Oxidation;MT=variable;TA=M;AC=UNIMOD:35",
112+
"NT=Oxidation;MT=variable;TA=M;AC=UNIMOD:35",
113+
]
114+
_, var_result = converter.convert_all_modifications([], var_mods)
115+
assert var_result == ["Oxidation,15.994915,M"]

uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)