msfiddle 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ import numpy as np
2
+ import re
3
+ from tqdm import tqdm
4
+
5
+ from rdkit import Chem
6
+ # ignore the warning
7
+ from rdkit import RDLogger
8
+ RDLogger.DisableLog('rdApp.*')
9
+ from rdkit.Chem import AllChem
10
+ from rdkit.Chem.rdMolDescriptors import CalcMolFormula
11
+
12
+ from .mol_utils import ATOMS_WEIGHT, monoisotopic_mass_calculator
13
+
14
+ MSLEVEL_MAP = {
15
+ 'MS2': 2,
16
+ 'MS1': 1,
17
+ '2': 2,
18
+ '1': 1,
19
+ }
20
+ # this is the superset of spectra keys in our data
21
+ mgf_key_order = ['title', 'precursor_type', 'precursor_mz',
22
+ 'molmass', 'ms_level', 'ionmode',
23
+ 'source_instrument', 'instrument_type', 'collision_energy',
24
+ 'smiles', 'inchi_key']
25
+
26
+ def sdf2mgf(path, prefix):
27
+ '''mgf format
28
+ [{
29
+ 'params': {
30
+ 'title': prefix_<index>,
31
+ 'precursor_type': <precursor_type (e.g. [M+NH4]+ and [M+H]+)>,
32
+ 'precursor_mz': <precursor m/z>,
33
+ 'molmass': <isotopic mass>,
34
+ 'ms_level': <ms_level>,
35
+ 'ionmode': <POSITIVE|NEGATIVE>,
36
+ 'source_instrument': <source_instrument>,
37
+ 'instrument_type': <instrument_type>,
38
+ 'collision_energy': <collision energe>,
39
+ 'smiles': <smiles>,
40
+ 'inchi_key': <inchi_key>,
41
+ },
42
+ 'm/z array': mz_array,
43
+ 'intensity array': intensity_array
44
+ }, ...]
45
+ '''
46
+ supp = Chem.SDMolSupplier(path)
47
+ print('Read {} data from {}'.format(len(supp), path))
48
+
49
+ spectra = []
50
+ for idx, mol in enumerate(tqdm(supp)):
51
+ if mol == None or \
52
+ not mol.HasProp('MASS SPECTRAL PEAKS') or \
53
+ not mol.HasProp('PRECURSOR TYPE') or \
54
+ not mol.HasProp('PRECURSOR M/Z') or \
55
+ not mol.HasProp('SPECTRUM TYPE') or \
56
+ not mol.HasProp('COLLISION ENERGY') or \
57
+ not mol.HasProp('ION MODE'):
58
+ continue
59
+
60
+ mz_array = []
61
+ intensity_array = []
62
+ raw_ms = mol.GetProp('MASS SPECTRAL PEAKS').split('\n')
63
+ for line in raw_ms:
64
+ mz_array.append(float(line.split()[0]))
65
+ intensity_array.append(float(line.split()[1]))
66
+ mz_array = np.array(mz_array)
67
+ intensity_array = np.array(intensity_array)
68
+
69
+ inchi_key = 'Unknown' if not mol.HasProp('INCHIKEY') else mol.GetProp('INCHIKEY')
70
+ instrument = 'Unknown' if not mol.HasProp('INSTRUMENT') else mol.GetProp('INSTRUMENT')
71
+ spectrum = {
72
+ 'params': {
73
+ 'title': prefix+'_'+str(idx),
74
+ 'precursor_type': mol.GetProp('PRECURSOR TYPE'),
75
+ 'precursor_mz': mol.GetProp('PRECURSOR M/Z'),
76
+ 'molmass': mol.GetProp('EXACT MASS'),
77
+ 'ms_level': mol.GetProp('SPECTRUM TYPE'),
78
+ 'ionmode': mol.GetProp('ION MODE'),
79
+ 'source_instrument': instrument,
80
+ 'instrument_type': mol.GetProp('INSTRUMENT TYPE'),
81
+ 'collision_energy': mol.GetProp('COLLISION ENERGY'),
82
+ 'smiles': Chem.MolToSmiles(mol, isomericSmiles=True),
83
+ 'inchi_key': inchi_key,
84
+ },
85
+ 'm/z array': mz_array,
86
+ 'intensity array': intensity_array
87
+ }
88
+ spectra.append(spectrum)
89
+ return spectra
90
+
91
+ def filter_spec(spectra, config, type2charge):
92
+ clean_spectra = []
93
+ smiles_list = []
94
+
95
+ records = {'instrument_type': [], 'instrument': [], 'ms_level': [],
96
+ 'atom_type': [], 'precursor_type': [], 'peak_num': [],
97
+ 'max_mz': [], 'invalud_formula': [], 'ppm_tolerance': [],
98
+ 'invalid_intensity_array': []}
99
+ for idx, spectrum in enumerate(tqdm(spectra)):
100
+ smiles = spectrum['params']['smiles']
101
+ mol = Chem.AddHs(Chem.MolFromSmiles(smiles))
102
+ if mol == None: continue
103
+
104
+ # Unify the smiles format
105
+ smiles = Chem.MolToSmiles(mol, isomericSmiles=False, canonical=True)
106
+ spectrum['params']['smiles'] = smiles
107
+
108
+ # Filter by instrument type
109
+ if 'instrument_type' in config.keys():
110
+ instrument_type = spectrum['params']['instrument_type']
111
+ if instrument_type not in config['instrument_type']:
112
+ records['instrument_type'].append(spectrum['params']['title'])
113
+ continue
114
+
115
+ # Filter by instrument (MoNA contains too many intrument names to filter out)
116
+ if 'instrument' in config.keys():
117
+ instrument = spectrum['params']['source_instrument']
118
+ if instrument not in config['instrument']:
119
+ records['instrument'].append(spectrum['params']['title'])
120
+ continue
121
+
122
+ # Filter by mslevel
123
+ if 'ms_level' in config.keys():
124
+ mslevel = spectrum['params']['ms_level']
125
+ if mslevel != config['ms_level']:
126
+ records['ms_level'].append(spectrum['params']['title'])
127
+ continue
128
+
129
+ # Filter by atom number and atom type
130
+ if len(mol.GetAtoms()) > config['max_atom_num'] or len(mol.GetAtoms()) < config['min_atom_num']: continue
131
+ is_compound_countain_rare_atom = False
132
+ for atom in mol.GetAtoms():
133
+ if atom.GetSymbol() not in config['atom_type']:
134
+ is_compound_countain_rare_atom = True
135
+ break
136
+ if is_compound_countain_rare_atom:
137
+ records['atom_type'].append(spectrum['params']['title'])
138
+ continue
139
+
140
+ # Filter by precursor type
141
+ precursor_type = spectrum['params']['precursor_type']
142
+ if precursor_type not in config['precursor_type']:
143
+ records['precursor_type'].append(spectrum['params']['title'])
144
+ continue
145
+
146
+ # Filt by peak number
147
+ if len(spectrum['m/z array']) < config['min_peak_num']:
148
+ records['peak_num'].append(spectrum['params']['title'])
149
+ continue
150
+
151
+ # Filter by max m/z
152
+ if np.max(spectrum['m/z array']) < config['min_mz'] or np.max(spectrum['m/z array']) > config['max_mz']:
153
+ records['max_mz'].append(spectrum['params']['title'])
154
+ continue
155
+
156
+ # Filter by ppm (mass error)
157
+ if 'ppm_tolerance' in config.keys():
158
+ f = CalcMolFormula(mol)
159
+ # try:
160
+ # f = Formula(f)
161
+ # except: # invalud formula
162
+ # records['invalud_formula'].append(spectrum['params']['title'])
163
+ # continue
164
+ molmass = monoisotopic_mass_calculator(f, 'f')
165
+ theo_mz = precursor_mz_calculator(precursor_type, molmass)
166
+ ppm = abs(theo_mz - float(spectrum['params']['precursor_mz'])) / theo_mz * 10**6
167
+
168
+ if ppm > config['ppm_tolerance']:
169
+ records['ppm_tolerance'].append(spectrum['params']['title'])
170
+ continue
171
+
172
+ # Remove the invalid spectra
173
+ if spectrum['intensity array'].max() == spectrum['intensity array'].min():
174
+ records['invalid_intensity_array'].append(spectrum['params']['title'])
175
+ continue
176
+
177
+ spectrum['params']['theoretical_precursor_mz'] = theo_mz
178
+ spectrum['params']['ppm'] = ppm
179
+ spectrum['params']['simulated_precursor_mz'] = simulate_experimental_mz(theo_mz, config['ppm_tolerance'])
180
+ spectrum['params']['charge'] = type2charge[precursor_type] # add charge
181
+ clean_spectra.append(spectrum)
182
+ smiles_list.append(smiles)
183
+
184
+ print('Filtering records: ')
185
+ for k, v in records.items():
186
+ print('\t{}: {}'.format(k, len(v)))
187
+
188
+ return clean_spectra, smiles_list
189
+
190
+ def simulate_experimental_mz(theoretical_mz, relative_mass_tolerance_ppm):
191
+ """
192
+ Simulate experimental precursor m/z value by shifting the theoretical value
193
+ within a Gaussian distribution of mass deviations.
194
+
195
+ :param theoretical_mz: Theoretical precursor m/z value.
196
+ :param relative_mass_tolerance_ppm: Relative mass tolerance in parts per million (ppm).
197
+ :return: Simulated experimental precursor m/z value.
198
+ """
199
+
200
+ # Calculate the standard deviation of the Gaussian distribution as 1/3 of the relative mass tolerance
201
+ std_dev = (relative_mass_tolerance_ppm / 1e6 * theoretical_mz) / 3
202
+
203
+ # Randomly sample a mass deviation from the Gaussian distribution
204
+ mass_deviation = np.random.normal(0, std_dev)
205
+
206
+ # Shift the theoretical m/z value by the sampled mass deviation to simulate the experimental value
207
+ experimental_mz = theoretical_mz + mass_deviation
208
+
209
+ return experimental_mz
210
+
211
+ def ce2nce(ce, precursor_mz, charge):
212
+ charge_factor = {1: 1, 2: 0.9, 3: 0.85, 4: 0.8, 5: 0.75, 6: 0.75, 7: 0.75, 8: 0.75}
213
+ return ce * 500 * charge_factor[charge] / precursor_mz
214
+
215
+ def precursor_mz_calculator(precursor_type, mass):
216
+ if precursor_type == '[M+H]+':
217
+ return mass + ATOMS_WEIGHT['H']
218
+ elif precursor_type == '[M+2H]2+':
219
+ return (mass + 2 * ATOMS_WEIGHT['H']) / 2
220
+ elif precursor_type == '[M+Na]+':
221
+ return mass + ATOMS_WEIGHT['Na']
222
+ elif precursor_type == '[M-H]-':
223
+ return mass - ATOMS_WEIGHT['H']
224
+ elif precursor_type == '[M+H-H2O]+' or precursor_type == '[M-H2O+H]+':
225
+ return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
226
+ elif precursor_type == '[2M+H]+':
227
+ return 2 * mass + ATOMS_WEIGHT['H']
228
+ elif precursor_type == '[2M-H]-':
229
+ return 2 * mass - ATOMS_WEIGHT['H']
230
+ elif precursor_type == '[M+H-2H2O]+':
231
+ return mass + ATOMS_WEIGHT['H'] - 2 * (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
232
+ elif precursor_type == '[M+H-NH3]+':
233
+ return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
234
+ elif precursor_type == '[M+H+NH3]+' or precursor_type == '[M+NH4]+':
235
+ return mass + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
236
+ elif precursor_type == '[M+H-CH2O2]+':
237
+ return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'] * 2)
238
+ elif precursor_type == '[M+H-CH4O2]+':
239
+ return mass + ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 4 + ATOMS_WEIGHT['O'] * 2)
240
+ elif precursor_type == '[M-H-CO2]-' or precursor_type == '[M-CHO2]-':
241
+ return mass - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['O'] * 2)
242
+ elif precursor_type == '[M-H-H2O]-':
243
+ return mass - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
244
+ else:
245
+ raise ValueError('Unsupported precursor type: {}'.format(precursor_type))
246
+
247
+ def mass_calculator(precursor_type, precursor_mz):
248
+ if precursor_type == '[M+H]+':
249
+ return precursor_mz - ATOMS_WEIGHT['H']
250
+ elif precursor_type == '[M+2H]2+':
251
+ return (precursor_mz * 2 - 2 * ATOMS_WEIGHT['H'])
252
+ elif precursor_type == '[M+Na]+':
253
+ return precursor_mz - ATOMS_WEIGHT['Na']
254
+ elif precursor_type == '[M-H]-':
255
+ return precursor_mz + ATOMS_WEIGHT['H']
256
+ elif precursor_type == '[M+H-H2O]+' or precursor_type == '[M-H2O+H]+':
257
+ return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
258
+ elif precursor_type == '[2M+H]+':
259
+ return (precursor_mz - ATOMS_WEIGHT['H']) / 2
260
+ elif precursor_type == '[2M-H]-':
261
+ return (precursor_mz + ATOMS_WEIGHT['H']) / 2
262
+ elif precursor_type == '[M+H-2H2O]+':
263
+ return precursor_mz - ATOMS_WEIGHT['H'] + 2 * (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
264
+ elif precursor_type == '[M+H-NH3]+':
265
+ return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
266
+ elif precursor_type == '[M+H+NH3]+' or precursor_type == '[M+NH4]+':
267
+ return precursor_mz - ATOMS_WEIGHT['H'] - (ATOMS_WEIGHT['N'] + ATOMS_WEIGHT['H'] * 3)
268
+ elif precursor_type == '[M+H-CH2O2]+':
269
+ return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'] * 2)
270
+ elif precursor_type == '[M+H-CH4O2]+':
271
+ return precursor_mz - ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['H'] * 4 + ATOMS_WEIGHT['O'] * 2)
272
+ elif precursor_type == '[M-H-CO2]-' or precursor_type == '[M-CHO2]-':
273
+ return precursor_mz + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['C'] + ATOMS_WEIGHT['O'] * 2)
274
+ elif precursor_type == '[M-H-H2O]-':
275
+ return precursor_mz + ATOMS_WEIGHT['H'] + (ATOMS_WEIGHT['H'] * 2 + ATOMS_WEIGHT['O'])
276
+ else:
277
+ raise ValueError('Unsupported precursor type: {}'.format(precursor_type))
278
+
279
+ if __name__ == '__main__':
280
+ # test sdf2mgf
281
+ spectra = sdf2mgf('../data/origin/Agilent_Combined.sdf', 'agilent_combine')
282
+ print('Number of spectra: {}'.format(len(spectra)))
283
+ for spec in spectra:
284
+ if spec['params']['precursor_type'] == '[M-H]-':
285
+ print(spec)
286
+ break
287
+
288
+ # test filter_spec
289
+ config = {
290
+ 'instrument_type': 'ESI-QTOF',
291
+ 'instrument': ['Unknown'],
292
+ 'ms_level': '2',
293
+ 'atom_type': ['C', 'O', 'N', 'H', 'P', 'S', 'F', 'Cl', 'B', 'Br', 'I', 'Na', 'K'],
294
+ 'precursor_type': ['[M+H]+', '[M-H]-'],
295
+ 'min_mz': 50,
296
+ 'max_mz': 1500,
297
+ 'min_peak_num': 5,
298
+ 'max_atom_num': 300,
299
+ 'min_atom_num': 10,
300
+ 'ppm_tolerance': 10
301
+ }
302
+ type2charge = {
303
+ '[M+H]+': 1,
304
+ '[M-H]-': -1
305
+ }
306
+ clean_spectra, smiles_list = filter_spec(spectra, config, type2charge)
307
+ print('Number of clean spectra: {}'.format(len(clean_spectra)))
308
+ print('Number of smiles: {}'.format(len(smiles_list)))
309
+ for spec in clean_spectra:
310
+ if spec['params']['precursor_type'] == '[M-H]-':
311
+ print(spec)
312
+ break
@@ -0,0 +1,372 @@
1
+ import numpy as np
2
+ import re
3
+ from decimal import Decimal
4
+ from tqdm import tqdm
5
+ import pandas as pd
6
+ import random
7
+ import copy
8
+
9
+ from rdkit import Chem
10
+ # ignore the warning
11
+ from rdkit import RDLogger
12
+ RDLogger.DisableLog('rdApp.*')
13
+ from rdkit.Chem import AllChem
14
+ from rdkit.Chem.rdMolDescriptors import CalcMolFormula
15
+
16
+ from .mol_utils import monoisotopic_mass_calculator, formula_to_dict, formula_to_vector, dict_to_formula, ATOMS_INDEX
17
+
18
+ # ------------------------ Main Functions ------------------------
19
+
20
+ def spec2arr(spectra, encoder):
21
+ '''data format
22
+ [
23
+ {'title': <str>, 'formula': <numpy array>, 'env': <numpy array>, 'spec': <numpy array>},
24
+ {'title': <str>, 'formula': <numpy array>, 'env': <numpy array>, 'spec': <numpy array>},
25
+ ....
26
+ ]
27
+ '''
28
+ data = []
29
+ bad_data = []
30
+ for idx, spectrum in enumerate(tqdm(spectra)):
31
+ data_item = spec2arr_single(spectrum, encoder)
32
+ if data_item != None:
33
+ data.append(data_item)
34
+ else:
35
+ bad_data.append(spectrum['params']['title'])
36
+
37
+ return data, bad_data
38
+
39
+ def spec2pair(data, bad_title, encoder):
40
+ # Extract df of spectra index and smiles
41
+ df = {'index': [], 'smiles': []}
42
+
43
+ for idx, data_item in enumerate(tqdm(data)):
44
+ if data_item['title'] in bad_title: continue
45
+
46
+ df['index'].append(idx)
47
+ df['smiles'].append(data_item['smiles'])
48
+ df = pd.DataFrame(df)
49
+
50
+ # Extract positive pairs
51
+ print('Extracting positive and negative pairs...')
52
+ positive_pairs, negative_pairs = generate_pairs(df)
53
+
54
+ # Ensure balanced samples
55
+ num_samples = min(len(positive_pairs), len(negative_pairs))
56
+ sampled_positive_pairs = random.sample(positive_pairs, num_samples)
57
+ sampled_negative_pairs = random.sample(negative_pairs, num_samples)
58
+
59
+ # Convert pairs to dataframe
60
+ data_pairs = {'idx1': [], 'idx2': [], 'label': []}
61
+ for idx1, idx2 in sampled_positive_pairs:
62
+ data_pairs['idx1'].append(idx1)
63
+ data_pairs['idx2'].append(idx2)
64
+ data_pairs['label'].append(1)
65
+ for idx1, idx2 in sampled_negative_pairs:
66
+ data_pairs['idx1'].append(idx1)
67
+ data_pairs['idx2'].append(idx2)
68
+ data_pairs['label'].append(0)
69
+
70
+ return data_pairs
71
+
72
+ # ------------------------ Helper Functions ------------------------
73
+
74
+ def generate_pairs(df):
75
+ df_group = df.groupby('smiles')
76
+ groups = list(df_group.groups.values())
77
+
78
+ positive_pairs = set()
79
+ negative_pairs = set()
80
+
81
+ for _, row in tqdm(df.iterrows(), total=len(df)):
82
+ group = df_group.get_group(row['smiles'])
83
+
84
+ # Positive pair (from the same group)
85
+ if len(group) > 1:
86
+ positive_pair_candidates = list(set(group.index) - {row.name})
87
+ positive_pair = random.sample(positive_pair_candidates, 1)[0]
88
+ positive_pairs.add(tuple(sorted((row.name, positive_pair))))
89
+
90
+ # Negative pair (from a different group)
91
+ other_groups = [g for g in groups if row.name not in g]
92
+ if other_groups:
93
+ random_group = random.choice(other_groups)
94
+ negative_pair = random.choice(random_group)
95
+ negative_pairs.add(tuple(sorted((row.name, negative_pair))))
96
+
97
+ return positive_pairs, negative_pairs
98
+
99
+ def spec2arr_single(spectrum, encoder):
100
+ # convert molecular object to formula string
101
+ mol = Chem.MolFromSmiles(spectrum['params']['smiles'])
102
+ mol = Chem.AddHs(mol)
103
+ try:
104
+ formula = CalcMolFormula(mol) # formula string
105
+ except:
106
+ print('Failed to convert smiles to formula: {}'.format(spectrum['params']['smiles']))
107
+ return None
108
+
109
+ charge = int(encoder['type2charge'][spectrum['params']['precursor_type']])
110
+
111
+ # spec array
112
+ good_spec, mz_array, int_array, spec_arr = generate_ms(x=spectrum['m/z array'],
113
+ y=spectrum['intensity array'],
114
+ precursor_mz=float(spectrum['params']['theoretical_precursor_mz']),
115
+ resolution=encoder['resolution'],
116
+ max_mz=encoder['max_mz'],
117
+ charge=charge)
118
+ if not good_spec:
119
+ print('Failed to generate mass spectra: {}'.format(spectrum['params']['title']))
120
+ return None # invalid spectra
121
+
122
+ # env array
123
+ adjust_formula, adjust_precursor_type = melt_neutral_precursor(formula, spectrum['params']['precursor_type'])
124
+ ce, nce = parse_collision_energy(ce_str=spectrum['params']['collision_energy'],
125
+ precursor_mz=float(spectrum['params']['theoretical_precursor_mz']),
126
+ charge=abs(charge))
127
+ if ce == None and nce == None:
128
+ print('Failed to parse collision energy: \"{}\"'.format(spectrum['params']['collision_energy']))
129
+ return None # can not process '30-50 eV'
130
+ env_arr = np.array([float(spectrum['params']['theoretical_precursor_mz']),
131
+ nce,
132
+ encoder['precursor_type'][adjust_precursor_type]])
133
+
134
+ # mass
135
+ # iso_mass = monoisotopic_mass_calculator(mol, mode='mol')
136
+ iso_mass = monoisotopic_mass_calculator(adjust_formula, mode='f')
137
+
138
+ # formula array
139
+ formula_vec = formula_to_vector(adjust_formula)
140
+ f_arr = np.array(formula_vec)
141
+
142
+ return {'title': spectrum['params']['title'],
143
+ 'smiles': spectrum['params']['smiles'],
144
+ 'formula': f_arr.astype(np.float32),
145
+ 'mass': iso_mass,
146
+ 'charge': charge,
147
+ 'spec': spec_arr.astype(np.float32),
148
+ 'env': env_arr.astype(np.float32),
149
+ 'mz': mz_array,
150
+ 'intensity': int_array}
151
+
152
+ def generate_ms(x, y, precursor_mz, resolution=1, max_mz=1500, charge=1):
153
+ '''
154
+ Input: x [float list denotes the x-coordinate of peaks]
155
+ y [float list denotes the y-coordinate of peaks]
156
+ precursor_mz [float denotes the parent ion]
157
+ resolution [float denotes the resolution of spectra]
158
+ max_mz [integer denotes the maximum m/z value of spectra]
159
+ charge [float denotes the charge of spectra]
160
+ Return: ms [numpy array denotes the mass spectra]
161
+ '''
162
+ assert len(x) == len(y), 'The length of x and y should be the same.'
163
+
164
+ x, y = remove_precursor_isotopic_peaks(x, y, precursor_mz, charge, tolerance=resolution/10)
165
+ x = np.array(x)
166
+ y = np.array(y)
167
+ if len(x) == 0 or y.max() - y.min() == 0:
168
+ return False, None, None, None
169
+
170
+ # (1) Smooth the MS/MS (no benefit)
171
+ # y = np.sqrt(y)
172
+ # (2) Sharpen the MS/MS
173
+ # y = y ** 2
174
+
175
+ # Prepare parameters using Decimal for precision
176
+ resolution = Decimal(str(resolution))
177
+ max_mz = Decimal(str(max_mz))
178
+
179
+ # Initialize mass spectra vector with zeros
180
+ intensity_val = np.zeros(int(max_mz // resolution))
181
+ mz_val = np.zeros(int(max_mz // resolution)) # Save the max delta m/z falling this bin
182
+
183
+ # Convert x, y to indices and accumulate in the mass spectra vector
184
+ for mz, intensity in zip(x, y):
185
+ idx = int(round(Decimal(str(mz)) // resolution))
186
+
187
+ intensity_val[idx] += intensity # Accumulate the intensity in the bin
188
+ if intensity_val[idx] < intensity: # Update the max delta m/z falling this bin
189
+ mz_val[idx] = mz
190
+
191
+ # Normalize to 0-1 range
192
+ intensity_val = (intensity_val - intensity_val.min()) / (intensity_val.max() - intensity_val.min())
193
+
194
+ # Stack the arrays horizontally
195
+ stacked_ms = np.column_stack((intensity_val, mz_val))
196
+
197
+ return True, x, y, stacked_ms
198
+
199
+ def remove_precursor_isotopic_peaks(x, y, precursor_mz, charge, tolerance=0.1):
200
+ # Calculate the spacing between isotopic peaks
201
+ spacing = 1 / abs(charge)
202
+
203
+ # Generate theoretical isotopic peaks
204
+ max_isotopes = 5 # Typically, we consider up to 5 isotopic peaks for large molecules
205
+ theoretical_isotopic_peaks = [precursor_mz - i * spacing for i in range(max_isotopes)]
206
+
207
+ # Identify isotopic peaks
208
+ isotopic_peaks = []
209
+ for value in x:
210
+ for theoretical_peak in theoretical_isotopic_peaks:
211
+ if abs(value - theoretical_peak) <= tolerance:
212
+ isotopic_peaks.append(value)
213
+ break
214
+
215
+ # Remove isotopic peaks from x and y
216
+ filtered_x = []
217
+ filtered_y = []
218
+ for xi, yi in zip(x, y):
219
+ if xi not in isotopic_peaks:
220
+ filtered_x.append(xi)
221
+ filtered_y.append(yi)
222
+
223
+ return filtered_x, filtered_y
224
+
225
+ def parse_collision_energy(ce_str, precursor_mz, charge=1):
226
+ # ratio constants for NCE
227
+ # charge = int(charge.lstrip('+').lstrip('-'))
228
+ charge_factor = {1: 1, 2: 0.9, 3: 0.85, 4: 0.8, 5: 0.75, 6: 0.75, 7: 0.75, 8: 0.75}
229
+
230
+ ce = None
231
+ nce = None
232
+
233
+ # match collision energy (eV)
234
+ matches_ev = {
235
+ # NIST20
236
+ r"^[\d]+[.]?[\d]*$": lambda x: float(x),
237
+ r"^[\d]+[.]?[\d]*[ ]?eV$": lambda x: float(x.rstrip(" eV")),
238
+ r"^[\d]+[.]?[\d]*[ ]?ev$": lambda x: float(x.rstrip(" ev")),
239
+ r"^[\d]+[.]?[\d]*[ ]?v$": lambda x: float(x.rstrip(" v")),
240
+ r"^[\d]+[.]?[\d]*[ ]?V$": lambda x: float(x.rstrip(" V")),
241
+ r"^NCE=[\d]+[.]?[\d]*% [\d]+[.]?[\d]*eV$": lambda x: float(x.split()[1].rstrip("eV")),
242
+ r"^nce=[\d]+[.]?[\d]*% [\d]+[.]?[\d]*ev$": lambda x: float(x.split()[1].rstrip("ev")),
243
+ # MassBank
244
+ r"^[\d]+[.]?[\d]*[ ]?V$": lambda x: float(x.rstrip(" V")),
245
+ r"^hcd[\d]+[.]?[\d]*$": lambda x: float(x.lstrip('hcd')),
246
+ r"^[\d]+HCD$": lambda x: float(x.rstrip("HCD")), # 35HCD
247
+ }
248
+ for k, v in matches_ev.items():
249
+ if re.match(k, ce_str):
250
+ ce = v(ce_str)
251
+ break
252
+ # match collision energy (NCE)
253
+ matches_nce = {
254
+ # MassBank
255
+ r"^[\d]+[.]?[\d]*[ ]?[%]? \(nominal\)$": lambda x: float(x.rstrip('% (nominal)')),
256
+ r"^[\d]+[.]?[\d]*[ ]?nce$": lambda x: float(x.rstrip(' nce')),
257
+ r"^[\d]+[.]?[\d]*[ ]?\(nce\)$": lambda x: float(x.rstrip(' (nce)')),
258
+ r"^NCE=[\d]+\%$": lambda x: float(x.lstrip('NCE=').rstrip('%')),
259
+ r"^[\d]+[.]?[\d]*\([Nn][Cc][Ee]\)$": lambda x: float(x.split('(')[0]), # 90(NCE)
260
+ r"^HCD \(NCE [\d]+[.]?[\d]*%\)$": lambda x: float(x.split(' ')[-1].rstrip('%)')), # HCD (NCE 40%)
261
+ # CASMI
262
+ r"^[\d]+[.]?[\d]*[ ]?\(nominal\)$": lambda x: float(x.rstrip("(nominal)").rstrip(' ')),
263
+ }
264
+ for k, v in matches_nce.items():
265
+ if re.match(k, ce_str):
266
+ nce = v(ce_str) * 0.01
267
+ break
268
+
269
+ # unknown collision energy
270
+ if ce_str == 'Unknown':
271
+ ce = 40
272
+
273
+ if nce == None and ce != None:
274
+ nce = ce * 500 * charge_factor[charge] / precursor_mz
275
+ elif ce == None and nce != None:
276
+ ce = nce * precursor_mz / (500 * charge_factor[charge])
277
+ else:
278
+ # raise Exception('Collision energy parse error: {}'.format(ce_str))
279
+ return None, None
280
+ return ce, nce
281
+
282
+ def melt_neutral_precursor(formula, precursor_type):
283
+ precursor_type = unify_precursor_type(precursor_type)
284
+
285
+ neutrue_list = ['H2O', 'NH3', 'CO2', 'CH4O2', 'CH2O2']
286
+ neutrue_counts = {}
287
+
288
+ # Step 1: Get the count of neutral losses/adducts
289
+ for neutrue in neutrue_list:
290
+ if neutrue in precursor_type:
291
+ pattern = r'([-+]?\d*)\b' + neutrue + r'\b'
292
+ matches = re.findall(pattern, precursor_type)
293
+ if matches:
294
+ count = matches[0]
295
+ if count == '+': count = 1
296
+ elif count == '-': count = -1
297
+ else:
298
+ try:
299
+ count = int(count)
300
+ except Exception as e:
301
+ print('Get the count of neutral losses/adducts', e)
302
+ print(precursor_type, neutrue, matches, count)
303
+ neutrue_counts[neutrue] = count
304
+ break # only one neutral adduct is allowed
305
+
306
+ # Step 2: Remove neutral losses/adducts from the precursor type string
307
+ pattern = r'([-+]?\d*)(?:' + '|'.join(neutrue_list) + ')' + r'\b'
308
+ adjust_precursor_type = re.sub(pattern, '', precursor_type)
309
+
310
+ # Step 3: Add neutral losses/adducts to formula
311
+ f_dict = formula_to_dict(formula)
312
+ for neutrue, count in neutrue_counts.items():
313
+ n_dict = formula_to_dict(neutrue)
314
+ for k, v in n_dict.items():
315
+ if k in f_dict.keys():
316
+ try:
317
+ f_dict[k] += int(v) * count
318
+ except Exception as e:
319
+ print('Add neutral losses/adducts to formula', e)
320
+ print(f_dict, int(v) * count)
321
+ else:
322
+ f_dict[k] = int(v) * count
323
+ adjust_formula = dict_to_formula(f_dict)
324
+
325
+ return adjust_formula, adjust_precursor_type
326
+
327
+ def unify_precursor_type(precursor_type):
328
+ # +1+-neutrue
329
+ # '[M+H-H2O]+', '[M-H2O+H]+', '[M+H-2H2O]+', '[M+H-NH3]+', '[M+H+NH3]+', '[M+NH4]+', '[M+H-CH2O2]+', '[M+H-CH4O2]+'
330
+ # -1+-neutrue
331
+ # '[M-H-CO2]-', '[M-CHO2]-', '[M-H-H2O]-'
332
+ if precursor_type == '[M+NH4]+': return '[M+H+NH3]+'
333
+ elif precursor_type == '[M-CHO2]-': return '[M-H-CO2]-'
334
+ elif precursor_type == '[M-H2O+H]+': return '[M+H-H2O]+'
335
+ else: return precursor_type
336
+
337
+
338
+
339
+ if __name__ == '__main__':
340
+ # melt_neutral_precursor
341
+ formula = 'C12H14O2'
342
+ precursor_type = '[M+H-CH2O2]+'
343
+ adjust_formula, adjust_precursor_type = melt_neutral_precursor(formula, precursor_type)
344
+ print(adjust_formula, adjust_precursor_type)
345
+ print(formula_to_vector(adjust_formula))
346
+
347
+ # generate_ms
348
+ x = [128.0511, 128.0553, 129.0544, 129.0585, 130.0665, 130.0725]
349
+ y = [102.5, 2.3, 10.99, 1., 999., 22.68]
350
+ precursor_mz = 130.06564948176495
351
+ print('origin mz', x)
352
+ print('origin intensity', y)
353
+ resolution = 0.2
354
+ max_mz = 1500
355
+ charge = 1
356
+ good_ms, mz, intensity, ms = generate_ms(x, y, precursor_mz, resolution, max_mz, charge)
357
+ print(good_ms, ms)
358
+ print('mz', mz)
359
+ print('intensity', intensity)
360
+
361
+ x = [1447.98, 279.24, 695.48, 1446.97, 415.23, 29.69, 1446.89]
362
+ y = [1., 0.02231055, 0.02118375, 0.01257913, 0.01197476, 0.00163897, 0.]
363
+ precursor_mz = 1448.97222710619
364
+ print('origin mz', x)
365
+ print('origin intensity', y)
366
+ resolution = 1.
367
+ max_mz = 1500
368
+ charge = -1
369
+ good_ms, mz, intensity, ms = generate_ms(x, y, precursor_mz, resolution, max_mz, charge)
370
+ print(good_ms, ms)
371
+ print('mz', mz)
372
+ print('intensity', intensity)