Skip to main content

mscore/algorithm/
peptide.rs

1use crate::chemistry::amino_acid::{amino_acid_composition, amino_acid_masses};
2use crate::chemistry::constants::{MASS_CO, MASS_NH3, MASS_PROTON, MASS_WATER};
3use crate::chemistry::formulas::calculate_mz;
4use crate::chemistry::unimod::{
5    modification_atomic_composition, unimod_modifications_mass_numerical,
6};
7use crate::chemistry::utility::{find_unimod_patterns, unimod_sequence_to_tokens};
8use crate::data::peptide::{FragmentType, PeptideProductIon, PeptideSequence};
9use rayon::prelude::*;
10use rayon::ThreadPoolBuilder;
11use regex::Regex;
12use statrs::distribution::{Binomial, Discrete};
13use std::collections::HashMap;
14use std::sync::LazyLock;
15
16/// calculate the monoisotopic mass of a peptide sequence
17///
18/// Arguments:
19///
20/// * `sequence` - peptide sequence
21///
22/// Returns:
23///
24/// * `mass` - monoisotopic mass of the peptide
25///
26/// # Examples
27///
28/// ```
29/// use mscore::algorithm::peptide::calculate_peptide_mono_isotopic_mass;
30/// use mscore::data::peptide::PeptideSequence;
31///
32/// let peptide_sequence = PeptideSequence::new("PEPTIDEH".to_string(), Some(1));
33/// let mass = calculate_peptide_mono_isotopic_mass(&peptide_sequence);
34/// let mass_quantized = (mass * 1e6).round() as i32;
35/// assert_eq!(mass_quantized, 936418877);
36/// ```
37/// Tables and patterns hoisted out of the per-peptide path.
38///
39/// `calculate_peptide_mono_isotopic_mass` rebuilt the 2 073-entry UniMod table, rebuilt the amino
40/// acid table and recompiled the regex on **every** call. That put one mass calculation at ~190 us,
41/// and 250 000 peptides at ~48 s — the largest remaining serial cost in the simulation. Built once.
42static AA_MASSES: LazyLock<HashMap<&'static str, f64>> = LazyLock::new(amino_acid_masses);
43static UNIMOD_MASSES: LazyLock<HashMap<u32, f64>> = LazyLock::new(unimod_modifications_mass_numerical);
44static UNIMOD_PATTERN: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(r"\[UNIMOD:(\d+)]").unwrap());
46
47pub fn calculate_peptide_mono_isotopic_mass(peptide_sequence: &PeptideSequence) -> f64 {
48    let amino_acid_masses = &*AA_MASSES;
49    let modifications_mz_numerical = &*UNIMOD_MASSES;
50    let pattern = &*UNIMOD_PATTERN;
51
52    let sequence = peptide_sequence.sequence.as_str();
53
54    // Find all occurrences of the pattern
55    let modifications: Vec<u32> = pattern
56        .find_iter(sequence)
57        .filter_map(|mat| mat.as_str()[8..mat.as_str().len() - 1].parse().ok())
58        .collect();
59
60    // Remove the modifications from the sequence
61    let sequence = pattern.replace_all(sequence, "");
62
63    // Count occurrences of each amino acid
64    let mut aa_counts = HashMap::new();
65    for char in sequence.chars() {
66        *aa_counts.entry(char).or_insert(0) += 1;
67    }
68
69    // Mass of amino acids and modifications. HashMap iteration order is
70    // randomized, while floating-point addition is not associative. Sort the
71    // residue counts to make monoisotopic mass calculation bitwise reproducible.
72    let mut aa_counts_sorted: Vec<_> = aa_counts.into_iter().collect();
73    aa_counts_sorted.sort_unstable_by_key(|(aa, _)| *aa);
74
75    let mass_sequence: f64 = aa_counts_sorted
76        .into_iter()
77        .map(|(aa, count)| {
78            amino_acid_masses.get(&aa.to_string()[..]).unwrap_or(&0.0) * count as f64
79        })
80        .sum();
81    let mass_modifications: f64 = modifications
82        .iter()
83        .map(|&mod_id| modifications_mz_numerical.get(&mod_id).unwrap_or(&0.0))
84        .sum();
85
86    mass_sequence + mass_modifications + MASS_WATER
87}
88
89/// calculate the monoisotopic mass of a peptide product ion for a given fragment type
90///
91/// Arguments:
92///
93/// * `sequence` - peptide sequence
94/// * `kind` - fragment type
95///
96/// Returns:
97///
98/// * `mass` - monoisotopic mass of the peptide
99///
100/// # Examples
101/// ```
102/// use mscore::algorithm::peptide::calculate_peptide_product_ion_mono_isotopic_mass;
103/// use mscore::data::peptide::FragmentType;
104/// let sequence = "PEPTIDEH";
105/// let mass = calculate_peptide_product_ion_mono_isotopic_mass(sequence, FragmentType::Y);
106/// assert_eq!(mass, 936.4188766862999);
107/// ```
108pub fn calculate_peptide_product_ion_mono_isotopic_mass(sequence: &str, kind: FragmentType) -> f64 {
109    let (sequence, modifications) = find_unimod_patterns(sequence);
110
111    // Return mz of empty sequence
112    if sequence.is_empty() {
113        return 0.0;
114    }
115
116    let amino_acid_masses = &*AA_MASSES;
117
118    // Add up raw amino acid masses and potential modifications
119    let mass_sequence: f64 = sequence
120        .chars()
121        .map(|aa| amino_acid_masses.get(&aa.to_string()[..]).unwrap_or(&0.0))
122        .sum();
123
124    let mass_modifications: f64 = modifications.iter().sum();
125
126    // Calculate total mass
127    let mass = mass_sequence + mass_modifications + MASS_WATER;
128
129    let mass = match kind {
130        FragmentType::A => mass - MASS_CO - MASS_WATER,
131        FragmentType::B => mass - MASS_WATER,
132        FragmentType::C => mass + MASS_NH3 - MASS_WATER,
133        FragmentType::X => mass + MASS_CO - 2.0 * MASS_PROTON,
134        FragmentType::Y => mass,
135        FragmentType::Z => mass - MASS_NH3,
136    };
137
138    mass
139}
140
141/// calculate the monoisotopic m/z of a peptide product ion for a given fragment type and charge
142///
143/// Arguments:
144///
145/// * `sequence` - peptide sequence
146/// * `kind` - fragment type
147/// * `charge` - charge
148///
149/// Returns:
150///
151/// * `mz` - monoisotopic mass of the peptide
152///
153/// # Examples
154/// ```
155/// use mscore::algorithm::peptide::calculate_product_ion_mz;
156/// use mscore::chemistry::constants::MASS_PROTON;
157/// use mscore::data::peptide::FragmentType;
158/// let sequence = "PEPTIDEH";
159/// let mz = calculate_product_ion_mz(sequence, FragmentType::Y, Some(1));
160/// assert_eq!(mz, 936.4188766862999 + MASS_PROTON);
161/// ```
162pub fn calculate_product_ion_mz(sequence: &str, kind: FragmentType, charge: Option<i32>) -> f64 {
163    let mass = calculate_peptide_product_ion_mono_isotopic_mass(sequence, kind);
164    calculate_mz(mass, charge.unwrap_or(1))
165}
166
167/// get a count dictionary of the amino acid composition of a peptide sequence
168///
169/// Arguments:
170///
171/// * `sequence` - peptide sequence
172///
173/// Returns:
174///
175/// * `composition` - a dictionary of amino acid composition
176///
177/// # Examples
178///
179/// ```
180/// use mscore::algorithm::peptide::calculate_amino_acid_composition;
181///
182/// let sequence = "PEPTIDEH";
183/// let composition = calculate_amino_acid_composition(sequence);
184/// assert_eq!(composition.get("P"), Some(&2));
185/// assert_eq!(composition.get("E"), Some(&2));
186/// assert_eq!(composition.get("T"), Some(&1));
187/// assert_eq!(composition.get("I"), Some(&1));
188/// assert_eq!(composition.get("D"), Some(&1));
189/// assert_eq!(composition.get("H"), Some(&1));
190/// ```
191pub fn calculate_amino_acid_composition(sequence: &str) -> HashMap<String, i32> {
192    let mut composition = HashMap::new();
193    for char in sequence.chars() {
194        *composition.entry(char.to_string()).or_insert(0) += 1;
195    }
196    composition
197}
198
199/// calculate the atomic composition of a peptide sequence
200pub fn peptide_sequence_to_atomic_composition(
201    peptide_sequence: &PeptideSequence,
202) -> HashMap<&'static str, i32> {
203    let token_sequence = unimod_sequence_to_tokens(peptide_sequence.sequence.as_str(), false);
204    let mut collection: HashMap<&'static str, i32> = HashMap::new();
205
206    // Assuming amino_acid_composition and modification_composition return appropriate mappings...
207    let aa_compositions = amino_acid_composition();
208    let mod_compositions = modification_atomic_composition();
209
210    // No need for conversion to HashMap<String, ...> as long as you're directly accessing
211    // the HashMap provided by modification_composition() if it uses String keys.
212    for token in token_sequence {
213        if token.len() == 1 {
214            let char = token.chars().next().unwrap();
215            if let Some(composition) = aa_compositions.get(&char) {
216                for (key, value) in composition.iter() {
217                    *collection.entry(key).or_insert(0) += *value;
218                }
219            }
220        } else {
221            // Directly use &token without .as_str() conversion
222            if let Some(composition) = mod_compositions.get(&token) {
223                for (key, value) in composition.iter() {
224                    *collection.entry(key).or_insert(0) += *value;
225                }
226            }
227        }
228    }
229
230    // Add water
231    *collection.entry("H").or_insert(0) += 2; //
232    *collection.entry("O").or_insert(0) += 1; //
233
234    collection
235}
236
237/// calculate the atomic composition of a product ion
238///
239/// Arguments:
240///
241/// * `product_ion` - a PeptideProductIon instance
242///
243/// Returns:
244///
245/// * `Vec<(&str, i32)>` - a vector of tuples representing the atomic composition of the product ion
246pub fn atomic_product_ion_composition(product_ion: &PeptideProductIon) -> Vec<(&str, i32)> {
247    let mut composition = peptide_sequence_to_atomic_composition(&product_ion.ion.sequence);
248
249    match product_ion.kind {
250        FragmentType::A => {
251            // A: peptide_mass - CO - Water
252            *composition.entry("H").or_insert(0) -= 2;
253            *composition.entry("O").or_insert(0) -= 2;
254            *composition.entry("C").or_insert(0) -= 1;
255        }
256        FragmentType::B => {
257            // B: peptide_mass - Water
258            *composition.entry("H").or_insert(0) -= 2;
259            *composition.entry("O").or_insert(0) -= 1;
260        }
261        FragmentType::C => {
262            // C: peptide_mass + NH3 - Water
263            *composition.entry("H").or_insert(0) += 1;
264            *composition.entry("N").or_insert(0) += 1;
265            *composition.entry("O").or_insert(0) -= 1;
266        }
267        FragmentType::X => {
268            // X: peptide_mass + CO
269            *composition.entry("C").or_insert(0) += 1; // Add 1 for CO
270            *composition.entry("O").or_insert(0) += 1; // Add 1 for CO
271            *composition.entry("H").or_insert(0) -= 2; // Subtract 2 for 2 protons
272        }
273        FragmentType::Y => (),
274        FragmentType::Z => {
275            *composition.entry("H").or_insert(0) -= 3;
276            *composition.entry("N").or_insert(0) -= 1;
277        }
278    }
279
280    composition.iter().map(|(k, v)| (*k, *v)).collect()
281}
282
283/// Calculate the atomic composition of the complementary fragment.
284///
285/// When a peptide is fragmented, the resulting fragment ion has a complementary
286/// portion (the rest of the precursor). This function calculates the atomic
287/// composition of that complementary fragment, which is needed for quad-selection
288/// dependent isotope distribution calculations.
289///
290/// # Arguments
291///
292/// * `precursor_composition` - atomic composition of the full precursor
293/// * `fragment_composition` - atomic composition of the fragment ion (from atomic_product_ion_composition)
294///
295/// # Returns
296///
297/// * `HashMap<String, i32>` - atomic composition of the complementary fragment
298///
299/// # Examples
300///
301/// ```
302/// use mscore::algorithm::peptide::{peptide_sequence_to_atomic_composition, atomic_product_ion_composition, calculate_complementary_fragment_composition};
303/// use mscore::data::peptide::{PeptideSequence, PeptideProductIon, PeptideIon, FragmentType};
304///
305/// let precursor = PeptideSequence::new("PEPTIDE".to_string(), Some(1));
306/// let precursor_comp = peptide_sequence_to_atomic_composition(&precursor);
307///
308/// // Create a b3 ion (PEP)
309/// let b3_ion = PeptideProductIon {
310///     kind: FragmentType::B,
311///     ion: PeptideIon {
312///         sequence: PeptideSequence::new("PEP".to_string(), Some(1)),
313///         charge: 1,
314///         intensity: 1.0,
315///     },
316/// };
317/// let fragment_comp: Vec<(&str, i32)> = atomic_product_ion_composition(&b3_ion);
318/// let fragment_map: std::collections::HashMap<&str, i32> = fragment_comp.into_iter().collect();
319///
320/// let complementary = calculate_complementary_fragment_composition(&precursor_comp, &fragment_map);
321/// // Complementary should be TIDE as y-ion (precursor - b-ion)
322/// ```
323pub fn calculate_complementary_fragment_composition(
324    precursor_composition: &HashMap<&str, i32>,
325    fragment_composition: &HashMap<&str, i32>,
326) -> HashMap<String, i32> {
327    let mut complementary: HashMap<String, i32> = HashMap::new();
328
329    // Start with precursor - fragment
330    for (element, &prec_count) in precursor_composition.iter() {
331        let frag_count = fragment_composition.get(element).copied().unwrap_or(0);
332        let diff = prec_count - frag_count;
333        if diff != 0 {
334            complementary.insert(element.to_string(), diff);
335        }
336    }
337
338    // Check for any elements in fragment that might not be in precursor (edge case)
339    for (element, &frag_count) in fragment_composition.iter() {
340        if !precursor_composition.contains_key(element) {
341            complementary.insert(element.to_string(), -frag_count);
342        }
343    }
344
345    complementary
346}
347
348/// calculate the atomic composition of a peptide product ion series
349/// Arguments:
350///
351/// * `product_ions` - a vector of PeptideProductIon instances
352/// * `num_threads` - an usize representing the number of threads to use
353/// Returns:
354///
355/// * `Vec<Vec<(String, i32)>>` - a vector of vectors of tuples representing the atomic composition of each product ion
356///
357pub fn fragments_to_composition(
358    product_ions: Vec<PeptideProductIon>,
359    num_threads: usize,
360) -> Vec<Vec<(String, i32)>> {
361    let thread_pool = ThreadPoolBuilder::new()
362        .num_threads(num_threads)
363        .build()
364        .unwrap();
365    let result = thread_pool.install(|| {
366        product_ions
367            .par_iter()
368            .map(|ion| atomic_product_ion_composition(ion))
369            .map(|composition| {
370                composition
371                    .iter()
372                    .map(|(k, v)| (k.to_string(), *v))
373                    .collect()
374            })
375            .collect()
376    });
377    result
378}
379
380/// count the number of protonizable sites in a peptide sequence
381///
382/// # Arguments
383///
384/// * `sequence` - a string representing the peptide sequence
385///
386/// # Returns
387///
388/// * `usize` - the number of protonizable sites in the peptide sequence
389///
390/// # Example
391///
392/// ```
393/// use mscore::algorithm::peptide::get_num_protonizable_sites;
394///
395/// let sequence = "PEPTIDEH";
396/// let num_protonizable_sites = get_num_protonizable_sites(sequence);
397/// assert_eq!(num_protonizable_sites, 2);
398/// ```
399pub fn get_num_protonizable_sites(sequence: &str) -> usize {
400    let mut sites = 1; // n-terminus
401    for s in sequence.chars() {
402        match s {
403            'H' | 'R' | 'K' => sites += 1,
404            _ => {}
405        }
406    }
407    sites
408}
409
410/// simulate the charge state distribution for a peptide sequence
411///
412/// # Arguments
413///
414/// * `sequence` - a string representing the peptide sequence
415/// * `max_charge` - an optional usize representing the maximum charge state to simulate
416/// * `charged_probability` - an optional f64 representing the probability of a site being charged
417///
418/// # Returns
419///
420/// * `Vec<f64>` - a vector of f64 representing the probability of each charge state
421///
422/// # Example
423///
424/// ```
425/// use mscore::algorithm::peptide::simulate_charge_state_for_sequence;
426///
427/// let sequence = "PEPTIDEH";
428/// let charge_state_probs = simulate_charge_state_for_sequence(sequence, None, None);
429/// assert_eq!(charge_state_probs, vec![0.03999999999999999, 0.32, 0.64, 0.0, 0.0]);
430pub fn simulate_charge_state_for_sequence(
431    sequence: &str,
432    max_charge: Option<usize>,
433    charged_probability: Option<f64>,
434) -> Vec<f64> {
435    let charged_prob = charged_probability.unwrap_or(0.8);
436    let max_charge = max_charge.unwrap_or(4)+1;
437    let num_protonizable_sites = get_num_protonizable_sites(sequence);
438    let mut charge_state_probs = vec![0.0; max_charge];
439    let binom = Binomial::new(charged_prob, num_protonizable_sites as u64).unwrap();
440    
441    for charge in 0..max_charge {
442        charge_state_probs[charge] = binom.pmf(charge as u64);
443    }
444    charge_state_probs
445}
446
447/// simulate the charge state distribution for a list of peptide sequences
448///
449/// # Arguments
450///
451/// * `sequences` - a vector of strings representing the peptide sequences
452/// * `num_threads` - an usize representing the number of threads to use
453/// * `max_charge` - an optional usize representing the maximum charge state to simulate
454/// * `charged_probability` - an optional f64 representing the probability of a site being charged
455///
456/// # Returns
457///
458/// * `Vec<Vec<f64>>` - a vector of vectors of f64 representing the probability of each charge state for each sequence
459///
460/// # Example
461///
462/// ```
463/// use mscore::algorithm::peptide::simulate_charge_states_for_sequences;
464///
465/// let sequences = vec!["PEPTIDEH", "PEPTIDEH", "PEPTIDEH"];
466/// let charge_state_probs = simulate_charge_states_for_sequences(sequences, 4, None, None);
467/// assert_eq!(charge_state_probs, vec![vec![0.03999999999999999, 0.32, 0.64, 0.0, 0.0], vec![0.03999999999999999, 0.32, 0.64, 0.0, 0.0], vec![0.03999999999999999, 0.32, 0.64, 0.0, 0.0]]);
468/// ```
469pub fn simulate_charge_states_for_sequences(
470    sequences: Vec<&str>,
471    num_threads: usize,
472    max_charge: Option<usize>,
473    charged_probability: Option<f64>,
474) -> Vec<Vec<f64>> {
475    let pool = ThreadPoolBuilder::new()
476        .num_threads(num_threads)
477        .build()
478        .unwrap();
479    pool.install(|| {
480        sequences
481            .par_iter()
482            .map(|sequence| {
483                simulate_charge_state_for_sequence(sequence, max_charge, charged_probability)
484            })
485            .collect()
486    })
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use std::collections::BTreeSet;
493
494    #[test]
495    fn peptide_monoisotopic_mass_is_bitwise_stable() {
496        let peptide = PeptideSequence::new("CNHHDGPSHADGK".to_string(), Some(1));
497
498        let mut observed_bits = BTreeSet::new();
499        for _ in 0..1024 {
500            observed_bits.insert(calculate_peptide_mono_isotopic_mass(&peptide).to_bits());
501        }
502
503        assert_eq!(
504            observed_bits.len(),
505            1,
506            "identical peptide sequence produced multiple exact monoisotopic masses: {observed_bits:?}"
507        );
508    }
509}