Skip to content

Commit 024ae4a

Browse files
committed
Merge branch 'issue_search_mol_formulas' into 'dev'
Fix ion-type nominal-mass binning in search_mol_formulas See merge request mass-spectrometry/corems!259
2 parents aace83d + dcce295 commit 024ae4a

2 files changed

Lines changed: 151 additions & 1 deletion

File tree

‎corems/molecular_id/search/molecularFormulaSearch.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -626,7 +626,14 @@ def search_mol_formulas(
626626

627627
for mf in possible_formulas_list:
628628
if neutral_molform:
629-
nm = int(mf.protonated_mz)
629+
# Bin by the nominal m/z of the requested ion hypothesis so
630+
# candidates land in the same bin run_search looks them up under.
631+
if ion_type == Labels.radical_ion:
632+
nm = int(mf.radical_mz)
633+
elif ion_type == Labels.adduct_ion and adduct_atom:
634+
nm = int(mf.adduct_mz(adduct_atom))
635+
else:
636+
nm = int(mf.protonated_mz)
630637
else:
631638
nm = int(mf.mz_nominal_calc)
632639

‎tests/test_search_mass_list.py‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import pytest
44

5+
from corems.encapsulation.constant import Labels
6+
from corems.mass_spectrum.input.numpyArray import ms_from_array_centroid
7+
from corems.molecular_formula.factory.MolecularFormulaFactory import MolecularFormula
58
from corems.molecular_formula.input.masslist_ref import ImportMassListRef
69
from corems.molecular_id.search.molecularFormulaSearch import SearchMolecularFormulas
710

@@ -22,3 +25,143 @@ def test_search_imported_ref_files(mass_spectrum_ftms, ref_file_location, postgr
2225
)
2326

2427
assert (len(ms_peaks_assigned)) > 10
28+
29+
30+
# ---------------------------------------------------------------------------
31+
# Regression tests for ion-type-aware nominal-mass binning in
32+
# SearchMolecularFormulas.search_mol_formulas().
33+
#
34+
# Neutral candidates are binned by the nominal m/z of the *requested* ion
35+
# hypothesis. Before the fix, every neutral candidate was binned by its
36+
# protonated m/z regardless of ion_type, so radical and adduct candidates
37+
# landed in the wrong bin and were never retrieved for the matching peak.
38+
#
39+
# Every experimental peak below is placed at the m/z CoreMS itself calculates
40+
# for the candidate/ion hypothesis (candidate.protonated_mz / .radical_mz /
41+
# .adduct_mz(atom)); no mass constants are duplicated here. Formula mass math
42+
# itself is covered by tests/test_molecular_formula.py.
43+
# ---------------------------------------------------------------------------
44+
45+
# A neutral composition and a clearly different decoy composition. The decoy
46+
# sits in a different nominal bin, so a correct search never assigns it.
47+
_TRUE_COMPOSITION = {"C": 56, "H": 73, "N": 1}
48+
_DECOY_COMPOSITION = {"C": 40, "H": 30, "O": 15}
49+
50+
51+
def _neutral_candidate(composition, ion_charge):
52+
return MolecularFormula(dict(composition), ion_charge=ion_charge)
53+
54+
55+
def _run_targeted_search(
56+
target_mz, ion_type, ion_charge, polarity, adduct_atom, postgres_database
57+
):
58+
"""Build a two-peak spectrum (one true peak, one far-off decoy peak) and
59+
search a candidate list of [true, decoy] neutral formulas against it."""
60+
decoy_peak_mz = target_mz + 50.0
61+
mz = [target_mz, decoy_peak_mz]
62+
abundance = [1.0, 1.0]
63+
rp, s2n = [[10000.0, 10000.0], [100.0, 100.0]]
64+
65+
mass_spectrum_obj = ms_from_array_centroid(
66+
mz, abundance, rp, s2n, "targeted mf search", polarity=polarity, auto_process=False
67+
)
68+
mass_spectrum_obj.settings.noise_threshold_method = "absolute_abundance"
69+
mass_spectrum_obj.settings.noise_threshold_absolute_abundance = 0
70+
71+
mass_spectrum_obj.molecular_search_settings.url_database = postgres_database
72+
mass_spectrum_obj.molecular_search_settings.error_method = "None"
73+
mass_spectrum_obj.molecular_search_settings.min_ppm_error = -5
74+
mass_spectrum_obj.molecular_search_settings.max_ppm_error = 5
75+
mass_spectrum_obj.molecular_search_settings.mz_error_range = 1
76+
77+
mass_spectrum_obj.process_mass_spec()
78+
79+
candidates = [
80+
_neutral_candidate(_TRUE_COMPOSITION, ion_charge),
81+
_neutral_candidate(_DECOY_COMPOSITION, ion_charge),
82+
]
83+
84+
assigned = SearchMolecularFormulas(mass_spectrum_obj).search_mol_formulas(
85+
candidates,
86+
ion_type,
87+
neutral_molform=True,
88+
find_isotopologues=False,
89+
adduct_atom=adduct_atom,
90+
)
91+
92+
return mass_spectrum_obj, assigned
93+
94+
95+
@pytest.mark.molecular_db
96+
def test_search_mol_formulas_protonated_positive(postgres_database):
97+
"""[M+H]+: binning and matching both use the protonated m/z (unchanged behavior)."""
98+
candidate = _neutral_candidate(_TRUE_COMPOSITION, ion_charge=1)
99+
target_mz = candidate.protonated_mz
100+
101+
mass_spectrum_obj, assigned = _run_targeted_search(
102+
target_mz, Labels.protonated_de_ion, 1, 1, None, postgres_database
103+
)
104+
105+
assert len(assigned) == 1
106+
assert mass_spectrum_obj[0][0].string == "C56 H73 N1"
107+
assert mass_spectrum_obj[0][0].ion_charge == 1
108+
assert not mass_spectrum_obj[1].is_assigned
109+
110+
111+
@pytest.mark.molecular_db
112+
def test_search_mol_formulas_deprotonated_negative(postgres_database):
113+
"""[M-H]-: negative-charge protonated_mz resolves to the deprotonated mass."""
114+
candidate = _neutral_candidate(_TRUE_COMPOSITION, ion_charge=-1)
115+
target_mz = candidate.protonated_mz
116+
117+
mass_spectrum_obj, assigned = _run_targeted_search(
118+
target_mz, Labels.protonated_de_ion, -1, -1, None, postgres_database
119+
)
120+
121+
assert len(assigned) == 1
122+
assert mass_spectrum_obj[0][0].string == "C56 H73 N1"
123+
assert mass_spectrum_obj[0][0].ion_charge == -1
124+
assert not mass_spectrum_obj[1].is_assigned
125+
126+
127+
@pytest.mark.molecular_db
128+
def test_search_mol_formulas_radical_cation(postgres_database):
129+
"""[M].+ radical: the primary regression case.
130+
131+
The radical m/z is ~1 Da (a proton) below the protonated m/z, so before the
132+
fix the candidate was binned under int(protonated_mz) while the peak's
133+
nominal bin is int(radical_mz) -- the candidate was never retrieved and the
134+
peak went unassigned. After the fix, binning uses radical_mz and it matches.
135+
"""
136+
candidate = _neutral_candidate(_TRUE_COMPOSITION, ion_charge=1)
137+
target_mz = candidate.radical_mz
138+
139+
mass_spectrum_obj, assigned = _run_targeted_search(
140+
target_mz, Labels.radical_ion, 1, 1, None, postgres_database
141+
)
142+
143+
assert len(assigned) == 1
144+
assert mass_spectrum_obj[0][0].string == "C56 H73 N1"
145+
assert mass_spectrum_obj[0][0].ion_type == Labels.radical_ion
146+
assert mass_spectrum_obj[0][0].ion_charge == 1
147+
assert not mass_spectrum_obj[1].is_assigned
148+
149+
150+
@pytest.mark.molecular_db
151+
def test_search_mol_formulas_adduct_sodium(postgres_database):
152+
"""[M+Na]+ adduct: the adduct m/z is ~22 Da above the protonated m/z, an
153+
unambiguous nominal-bin displacement that the old protonated-only binning
154+
got wrong. After the fix, binning uses adduct_mz('Na') and it matches."""
155+
candidate = _neutral_candidate(_TRUE_COMPOSITION, ion_charge=1)
156+
target_mz = candidate.adduct_mz("Na")
157+
158+
mass_spectrum_obj, assigned = _run_targeted_search(
159+
target_mz, Labels.adduct_ion, 1, 1, "Na", postgres_database
160+
)
161+
162+
assert len(assigned) == 1
163+
assert mass_spectrum_obj[0][0].string == "C56 H73 N1"
164+
assert mass_spectrum_obj[0][0].ion_type == Labels.adduct_ion
165+
assert mass_spectrum_obj[0][0].adduct_atom == "Na"
166+
assert mass_spectrum_obj[0][0].ion_charge == 1
167+
assert not mass_spectrum_obj[1].is_assigned

0 commit comments

Comments
 (0)