22
33import 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
58from corems .molecular_formula .input .masslist_ref import ImportMassListRef
69from 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