Skip to main content

mscore/data/
peptide.rs

1use std::collections::{HashMap};
2use bincode::{Decode, Encode};
3use itertools::Itertools;
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use crate::algorithm::peptide::{calculate_peptide_mono_isotopic_mass, calculate_peptide_product_ion_mono_isotopic_mass, peptide_sequence_to_atomic_composition};
7use crate::chemistry::amino_acid::{amino_acid_masses};
8use crate::chemistry::formulas::calculate_mz;
9use crate::chemistry::utility::{find_unimod_patterns, reshape_prosit_array, unimod_sequence_to_tokens};
10use crate::data::spectrum::MzSpectrum;
11use crate::simulation::annotation::{MzSpectrumAnnotated, ContributionSource, SignalAttributes, SourceType, PeakAnnotation};
12
13// helper types for easier reading
14type Mass = f64;
15type Abundance = f64;
16type IsotopeDistribution = Vec<(Mass, Abundance)>;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PeptideIon {
20    pub sequence: PeptideSequence,
21    pub charge: i32,
22    pub intensity: f64,
23}
24
25impl PeptideIon {
26    pub fn new(sequence: String, charge: i32, intensity: f64, peptide_id: Option<i32>) -> Self {
27        PeptideIon {
28            sequence: PeptideSequence::new(sequence, peptide_id),
29            charge,
30            intensity,
31        }
32    }
33    pub fn mz(&self) -> f64 {
34        calculate_mz(self.sequence.mono_isotopic_mass(), self.charge)
35    }
36
37    pub fn calculate_isotope_distribution(
38        &self,
39        mass_tolerance: f64,
40        abundance_threshold: f64,
41        max_result: i32,
42        intensity_min: f64,
43    ) -> IsotopeDistribution {
44
45        let atomic_composition: HashMap<String, i32> = self.sequence.atomic_composition().iter().map(|(k, v)| (k.to_string(), *v)).collect();
46
47        let distribution: IsotopeDistribution = crate::algorithm::isotope::generate_isotope_distribution(&atomic_composition, mass_tolerance, abundance_threshold, max_result)
48            .into_iter().filter(|&(_, abundance)| abundance > intensity_min).collect();
49
50        let mz_distribution = distribution.iter().map(|(mass, _)| calculate_mz(*mass, self.charge))
51            .zip(distribution.iter().map(|&(_, abundance)| abundance)).collect();
52
53        mz_distribution
54    }
55
56    pub fn calculate_isotopic_spectrum(
57        &self,
58        mass_tolerance: f64,
59        abundance_threshold: f64,
60        max_result: i32,
61        intensity_min: f64,
62    ) -> MzSpectrum {
63        let isotopic_distribution = self.calculate_isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
64        MzSpectrum::new(isotopic_distribution.iter().map(|(mz, _)| *mz).collect(), isotopic_distribution.iter().map(|(_, abundance)| *abundance).collect()) * self.intensity
65    }
66
67    pub fn calculate_isotopic_spectrum_annotated(
68        &self,
69        mass_tolerance: f64,
70        abundance_threshold: f64,
71        max_result: i32,
72        intensity_min: f64,
73    ) -> MzSpectrumAnnotated {
74        let isotopic_distribution = self.calculate_isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
75        let mut annotations = Vec::new();
76        let mut isotope_counter = 0;
77        let mut previous_mz = isotopic_distribution[0].0;
78
79
80
81        for (mz, abundance) in isotopic_distribution.iter() {
82
83            let ppm_tolerance = (mz / 1e6) * 25.0;
84
85            if (mz - previous_mz).abs() > ppm_tolerance {
86                isotope_counter += 1;
87                previous_mz = *mz;
88            }
89
90            let signal_attributes = SignalAttributes {
91                charge_state: self.charge,
92                peptide_id: self.sequence.peptide_id.unwrap_or(-1),
93                isotope_peak: isotope_counter,
94                // precursor ion peaks: no fragment kind/ordinal
95                fragment_kind: None,
96                fragment_ordinal: None,
97            };
98
99            let contribution_source = ContributionSource {
100                intensity_contribution: *abundance,
101                source_type: SourceType::Signal,
102                signal_attributes: Some(signal_attributes)
103            };
104
105            annotations.push(PeakAnnotation {
106                contributions: vec![contribution_source]
107            });
108        }
109
110        MzSpectrumAnnotated::new(isotopic_distribution.iter().map(|(mz, _)| *mz).collect(), isotopic_distribution.iter().map(|(_, abundance)| *abundance).collect(), annotations)
111    }
112}
113
114#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
115pub enum FragmentType { A, B, C, X, Y, Z, }
116
117// implement to string for fragment type
118impl std::fmt::Display for FragmentType {
119    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
120        match self {
121            FragmentType::A => write!(f, "a"),
122            FragmentType::B => write!(f, "b"),
123            FragmentType::C => write!(f, "c"),
124            FragmentType::X => write!(f, "x"),
125            FragmentType::Y => write!(f, "y"),
126            FragmentType::Z => write!(f, "z"),
127        }
128    }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PeptideProductIon {
133    pub kind: FragmentType,
134    pub ion: PeptideIon,
135}
136
137impl PeptideProductIon {
138    pub fn new(kind: FragmentType, sequence: String, charge: i32, intensity: f64, peptide_id: Option<i32>) -> Self {
139        PeptideProductIon {
140            kind,
141            ion: PeptideIon {
142                sequence: PeptideSequence::new(sequence, peptide_id),
143                charge,
144                intensity,
145            },
146        }
147    }
148
149    pub fn mono_isotopic_mass(&self) -> f64 {
150        calculate_peptide_product_ion_mono_isotopic_mass(self.ion.sequence.sequence.as_str(), self.kind)
151    }
152
153    pub fn atomic_composition(&self) -> HashMap<&str, i32> {
154
155        let mut composition = peptide_sequence_to_atomic_composition(&self.ion.sequence);
156
157        match self.kind {
158            FragmentType::A => {
159                *composition.entry("H").or_insert(0) -= 2;
160                *composition.entry("O").or_insert(0) -= 2;
161                *composition.entry("C").or_insert(0) -= 1;
162            },
163
164            FragmentType::B => {
165                // B: peptide_mass - Water
166                *composition.entry("H").or_insert(0) -= 2;
167                *composition.entry("O").or_insert(0) -= 1;
168            },
169
170            FragmentType::C => {
171                // C: peptide_mass + NH3 - Water
172                *composition.entry("H").or_insert(0) += 1;
173                *composition.entry("N").or_insert(0) += 1;
174                *composition.entry("O").or_insert(0) -= 1;
175            },
176
177            FragmentType::X => {
178                // X: peptide_mass + CO + 2*H - Water
179                *composition.entry("C").or_insert(0) += 1;
180                *composition.entry("O").or_insert(0) += 1;
181            },
182
183            FragmentType::Y => {
184                ()
185            },
186
187            FragmentType::Z => {
188                *composition.entry("H").or_insert(0) -= 1;
189                *composition.entry("N").or_insert(0) -= 3;
190            },
191        }
192        composition
193    }
194
195    pub fn mz(&self) -> f64 {
196        calculate_mz(self.mono_isotopic_mass(), self.ion.charge)
197    }
198
199    pub fn isotope_distribution(
200        &self,
201        mass_tolerance: f64,
202        abundance_threshold: f64,
203        max_result: i32,
204        intensity_min: f64,
205    ) -> IsotopeDistribution {
206
207        let atomic_composition: HashMap<String, i32> = self.atomic_composition().iter().map(|(k, v)| (k.to_string(), *v)).collect();
208
209        let distribution: IsotopeDistribution = crate::algorithm::isotope::generate_isotope_distribution(&atomic_composition, mass_tolerance, abundance_threshold, max_result)
210            .into_iter().filter(|&(_, abundance)| abundance > intensity_min).collect();
211
212        let mz_distribution = distribution.iter().map(|(mass, _)| calculate_mz(*mass, self.ion.charge)).zip(distribution.iter().map(|&(_, abundance)| abundance)).collect();
213
214        mz_distribution
215    }
216
217    /// Calculate the isotope distribution of the complementary fragment.
218    ///
219    /// This is used for quad-selection dependent isotope transmission calculations.
220    /// The complementary fragment is the portion of the precursor that remains
221    /// after the fragment ion is produced.
222    ///
223    /// # Arguments
224    ///
225    /// * `precursor_composition` - atomic composition of the full precursor
226    /// * `mass_tolerance` - mass tolerance for isotope distribution calculation
227    /// * `abundance_threshold` - minimum abundance threshold
228    /// * `max_result` - maximum number of isotope peaks
229    ///
230    /// # Returns
231    ///
232    /// * `Vec<(f64, f64)>` - complementary fragment isotope distribution as (mass, abundance) pairs
233    pub fn complementary_isotope_distribution(
234        &self,
235        precursor_composition: &HashMap<&str, i32>,
236        mass_tolerance: f64,
237        abundance_threshold: f64,
238        max_result: i32,
239    ) -> Vec<(f64, f64)> {
240        let fragment_composition = self.atomic_composition();
241        let complementary_composition = crate::algorithm::peptide::calculate_complementary_fragment_composition(
242            precursor_composition,
243            &fragment_composition,
244        );
245
246        crate::algorithm::isotope::generate_isotope_distribution(
247            &complementary_composition,
248            mass_tolerance,
249            abundance_threshold,
250            max_result,
251        )
252    }
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)]
256pub struct PeptideSequence {
257    pub sequence: String,
258    pub peptide_id: Option<i32>,
259}
260
261impl PeptideSequence {
262    pub fn new(raw_sequence: String, peptide_id: Option<i32>) -> Self {
263
264        // constructor will parse the sequence and check if it is valid
265        let pattern = Regex::new(r"\[UNIMOD:(\d+)]").unwrap();
266
267        // remove the modifications from the sequence
268        let sequence = pattern.replace_all(&raw_sequence, "").to_string();
269
270        // check if all remaining characters are valid amino acids
271        let valid_amino_acids = sequence.chars().all(|c| amino_acid_masses().contains_key(&c.to_string()[..]));
272        if !valid_amino_acids {
273            panic!("Invalid amino acid sequence, use only valid amino acids: ARNDCQEGHILKMFPSTWYVU, and modifications in the format [UNIMOD:ID]");
274        }
275
276        PeptideSequence { sequence: raw_sequence, peptide_id }
277    }
278
279    pub fn mono_isotopic_mass(&self) -> f64 {
280        calculate_peptide_mono_isotopic_mass(self)
281    }
282
283    pub fn atomic_composition(&self) -> HashMap<&str, i32> {
284        peptide_sequence_to_atomic_composition(self)
285    }
286
287    pub fn to_tokens(&self, group_modifications: bool) -> Vec<String> {
288        unimod_sequence_to_tokens(&*self.sequence, group_modifications)
289    }
290
291    pub fn to_sage_representation(&self) -> (String, Vec<f64>) {
292        find_unimod_patterns(&*self.sequence)
293    }
294
295    pub fn amino_acid_count(&self) -> usize {
296        self.to_tokens(true).len()
297    }
298
299    pub fn calculate_mono_isotopic_product_ion_spectrum(&self, charge: i32, fragment_type: FragmentType) -> MzSpectrum {
300        let product_ions = self.calculate_product_ion_series(charge, fragment_type);
301        product_ions.generate_mono_isotopic_spectrum()
302    }
303
304    pub fn calculate_mono_isotopic_product_ion_spectrum_annotated(&self, charge: i32, fragment_type: FragmentType) -> MzSpectrumAnnotated {
305        let product_ions = self.calculate_product_ion_series(charge, fragment_type);
306        product_ions.generate_mono_isotopic_spectrum_annotated()
307    }
308
309    pub fn calculate_isotopic_product_ion_spectrum(&self, charge: i32, fragment_type: FragmentType, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrum {
310        let product_ions = self.calculate_product_ion_series(charge, fragment_type);
311        product_ions.generate_isotopic_spectrum(mass_tolerance, abundance_threshold, max_result, intensity_min)
312    }
313
314    pub fn calculate_isotopic_product_ion_spectrum_annotated(&self, charge: i32, fragment_type: FragmentType, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrumAnnotated {
315        let product_ions = self.calculate_product_ion_series(charge, fragment_type);
316        product_ions.generate_isotopic_spectrum_annotated(mass_tolerance, abundance_threshold, max_result, intensity_min)
317    }
318
319    pub fn calculate_product_ion_series(&self, target_charge: i32, fragment_type: FragmentType) -> PeptideProductIonSeries {
320        // TODO: check for n-terminal modifications
321        let tokens = unimod_sequence_to_tokens(self.sequence.as_str(), true);
322        let mut n_terminal_ions = Vec::new();
323        let mut c_terminal_ions = Vec::new();
324
325        // Generate n ions
326        for i in 1..tokens.len() {
327            let n_ion_seq = tokens[..i].join("");
328            n_terminal_ions.push(PeptideProductIon {
329                kind: match fragment_type {
330                    FragmentType::A => FragmentType::A,
331                    FragmentType::B => FragmentType::B,
332                    FragmentType::C => FragmentType::C,
333                    FragmentType::X => FragmentType::A,
334                    FragmentType::Y => FragmentType::B,
335                    FragmentType::Z => FragmentType::C,
336                },
337                ion: PeptideIon {
338                    sequence: PeptideSequence {
339                        sequence: n_ion_seq,
340                        peptide_id: self.peptide_id,
341                    },
342                    charge: target_charge,
343                    intensity: 1.0, // Placeholder intensity
344                },
345            });
346        }
347
348        // Generate c ions
349        for i in 1..tokens.len() {
350            let c_ion_seq = tokens[tokens.len() - i..].join("");
351            c_terminal_ions.push(PeptideProductIon {
352                kind: match fragment_type {
353                    FragmentType::A => FragmentType::X,
354                    FragmentType::B => FragmentType::Y,
355                    FragmentType::C => FragmentType::Z,
356                    FragmentType::X => FragmentType::X,
357                    FragmentType::Y => FragmentType::Y,
358                    FragmentType::Z => FragmentType::Z,
359                },
360                ion: PeptideIon {
361                    sequence: PeptideSequence {
362                        sequence: c_ion_seq,
363                        peptide_id: self.peptide_id,
364                    },
365                    charge: target_charge,
366                    intensity: 1.0, // Placeholder intensity
367                },
368            });
369        }
370
371        PeptideProductIonSeries::new(target_charge, n_terminal_ions, c_terminal_ions)
372    }
373
374    pub fn associate_with_predicted_intensities(
375        &self,
376        // TODO: check docs of prosit if charge is meant as precursor charge or max charge of fragments to generate
377        charge: i32,
378        fragment_type: FragmentType,
379        flat_intensities: Vec<f64>,
380        normalize: bool,
381        half_charge_one: bool,
382    ) -> PeptideProductIonSeriesCollection {
383
384        let reshaped_intensities = reshape_prosit_array(flat_intensities);
385        let max_charge = std::cmp::min(charge, 3).max(1); // Ensure at least 1 for loop range
386        let mut sum_intensity = if normalize { 0.0 } else { 1.0 };
387        let num_tokens = self.amino_acid_count() - 1; // Full sequence length is not counted as fragment, since nothing is cleaved off, therefore -1
388
389        let mut peptide_ion_collection = Vec::new();
390
391        if normalize {
392            for z in 1..=max_charge {
393
394                let intensity_c: Vec<f64> = reshaped_intensities[..num_tokens].iter().map(|x| x[0][z as usize - 1]).filter(|&x| x > 0.0).collect();
395                let intensity_n: Vec<f64> = reshaped_intensities[..num_tokens].iter().map(|x| x[1][z as usize - 1]).filter(|&x| x > 0.0).collect();
396
397                sum_intensity += intensity_n.iter().sum::<f64>() + intensity_c.iter().sum::<f64>();
398            }
399        }
400
401        for z in 1..=max_charge {
402
403            let mut product_ions = self.calculate_product_ion_series(z, fragment_type);
404            let intensity_n: Vec<f64> = reshaped_intensities[..num_tokens].iter().map(|x| x[1][z as usize - 1]).collect();
405            let intensity_c: Vec<f64> = reshaped_intensities[..num_tokens].iter().map(|x| x[0][z as usize - 1]).collect(); // Reverse for y
406
407            let adjusted_sum_intensity = if max_charge == 1 && half_charge_one { sum_intensity * 2.0 } else { sum_intensity };
408
409            for (i, ion) in product_ions.n_ions.iter_mut().enumerate() {
410                ion.ion.intensity = intensity_n[i] / adjusted_sum_intensity;
411            }
412            for (i, ion) in product_ions.c_ions.iter_mut().enumerate() {
413                ion.ion.intensity = intensity_c[i] / adjusted_sum_intensity;
414            }
415
416            peptide_ion_collection.push(PeptideProductIonSeries::new(z, product_ions.n_ions, product_ions.c_ions));
417        }
418
419        PeptideProductIonSeriesCollection::new(peptide_ion_collection)
420    }
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct PeptideProductIonSeries {
425    pub charge: i32,
426    pub n_ions: Vec<PeptideProductIon>,
427    pub c_ions: Vec<PeptideProductIon>,
428}
429
430impl PeptideProductIonSeries {
431    pub fn new(charge: i32, n_ions: Vec<PeptideProductIon>, c_ions: Vec<PeptideProductIon>) -> Self {
432        PeptideProductIonSeries {
433            charge,
434            n_ions,
435            c_ions,
436        }
437    }
438
439    pub fn generate_mono_isotopic_spectrum(&self) -> MzSpectrum {
440        let mz_i_n = self.n_ions.iter().map(|ion| (ion.mz(), ion.ion.intensity)).collect_vec();
441        let mz_i_c = self.c_ions.iter().map(|ion| (ion.mz(), ion.ion.intensity)).collect_vec();
442        let n_spectrum = MzSpectrum::new(mz_i_n.iter().map(|(mz, _)| *mz).collect(), mz_i_n.iter().map(|(_, abundance)| *abundance).collect());
443        let c_spectrum = MzSpectrum::new(mz_i_c.iter().map(|(mz, _)| *mz).collect(), mz_i_c.iter().map(|(_, abundance)| *abundance).collect());
444        MzSpectrum::from_collection(vec![n_spectrum, c_spectrum]).filter_ranged(0.0, 5_000.0, 1e-6, 1e6)
445    }
446
447    pub fn generate_mono_isotopic_spectrum_annotated(&self) -> MzSpectrumAnnotated {
448        let mut annotations: Vec<PeakAnnotation> = Vec::with_capacity(self.n_ions.len() + self.c_ions.len());
449        let mut mz_values = Vec::with_capacity(self.n_ions.len() + self.c_ions.len());
450        let mut intensity_values = Vec::with_capacity(self.n_ions.len() + self.c_ions.len());
451
452        for (index, n_ion) in self.n_ions.iter().enumerate() {
453            let charge = n_ion.ion.charge;
454            let mz = n_ion.mz();
455            let intensity = n_ion.ion.intensity;
456            let signal_attributes = SignalAttributes {
457                charge_state: charge,
458                peptide_id: n_ion.ion.sequence.peptide_id.unwrap_or(-1),
459                isotope_peak: 0,
460                // (perf) keep the fragment identity as structured fields instead of
461                // an eagerly-formatted per-peak String; `description` is derived on demand.
462                fragment_kind: Some(n_ion.kind),
463                fragment_ordinal: Some((index + 1) as i32),
464            };
465            let contribution_source = ContributionSource {
466                intensity_contribution: intensity,
467                source_type: SourceType::Signal,
468                signal_attributes: Some(signal_attributes)
469            };
470
471            annotations.push(PeakAnnotation {
472                contributions: vec![contribution_source]
473            });
474            mz_values.push(mz);
475            intensity_values.push(intensity);
476        }
477
478        for (index, c_ion) in self.c_ions.iter().enumerate() {
479            let charge = c_ion.ion.charge;
480            let mz = c_ion.mz();
481            let intensity = c_ion.ion.intensity;
482            let signal_attributes = SignalAttributes {
483                charge_state: charge,
484                peptide_id: c_ion.ion.sequence.peptide_id.unwrap_or(-1),
485                isotope_peak: 0,
486                fragment_kind: Some(c_ion.kind),
487                fragment_ordinal: Some((index + 1) as i32),
488            };
489            let contribution_source = ContributionSource {
490                intensity_contribution: intensity,
491                source_type: SourceType::Signal,
492                signal_attributes: Some(signal_attributes)
493            };
494
495            annotations.push(PeakAnnotation {
496                contributions: vec![contribution_source]
497            });
498            mz_values.push(mz);
499            intensity_values.push(intensity);
500        }
501
502        MzSpectrumAnnotated::new(mz_values, intensity_values, annotations)
503    }
504
505    pub fn generate_isotopic_spectrum(&self, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrum {
506        let mut spectra: Vec<MzSpectrum> = Vec::new();
507
508        for ion in &self.n_ions {
509            let n_isotopes = ion.isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
510            let spectrum = MzSpectrum::new(n_isotopes.iter().map(|(mz, _)| *mz).collect(), n_isotopes.iter().map(|(_, abundance)| *abundance * ion.ion.intensity).collect());
511            spectra.push(spectrum);
512        }
513
514        for ion in &self.c_ions {
515            let c_isotopes = ion.isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
516            let spectrum = MzSpectrum::new(c_isotopes.iter().map(|(mz, _)| *mz).collect(), c_isotopes.iter().map(|(_, abundance)| *abundance * ion.ion.intensity).collect());
517            spectra.push(spectrum);
518        }
519
520        MzSpectrum::from_collection(spectra).filter_ranged(0.0, 5_000.0, 1e-6, 1e6)
521    }
522
523    pub fn generate_isotopic_spectrum_annotated(&self, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrumAnnotated {
524        let mut annotations: Vec<PeakAnnotation> = Vec::new();
525        let mut mz_values = Vec::new();
526        let mut intensity_values = Vec::new();
527
528        for (index, ion) in self.n_ions.iter().enumerate() {
529            let n_isotopes = ion.isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
530            let mut isotope_counter = 0;
531            let mut previous_mz = n_isotopes[0].0;
532
533            for (mz, abundance) in n_isotopes.iter() {
534                let ppm_tolerance = (mz / 1e6) * 25.0;
535
536                if (mz - previous_mz).abs() > ppm_tolerance {
537                    isotope_counter += 1;
538                    previous_mz = *mz;
539                }
540
541                let signal_attributes = SignalAttributes {
542                    charge_state: ion.ion.charge,
543                    peptide_id: ion.ion.sequence.peptide_id.unwrap_or(-1),
544                    isotope_peak: isotope_counter,
545                    // (perf) keep the full fragment identity (kind + 1-based ordinal)
546                    // as structured fields instead of allocating + formatting a
547                    // "{kind}_{ordinal}_{isotope}" String for every fragment peak in
548                    // the whole simulation. `description` is derived from these on demand.
549                    fragment_kind: Some(ion.kind),
550                    fragment_ordinal: Some((index + 1) as i32),
551                };
552
553                let contribution_source = ContributionSource {
554                    intensity_contribution: *abundance * ion.ion.intensity,
555                    source_type: SourceType::Signal,
556                    signal_attributes: Some(signal_attributes)
557                };
558
559                annotations.push(PeakAnnotation {
560                    contributions: vec![contribution_source]
561                });
562                mz_values.push(*mz);
563                intensity_values.push(*abundance * ion.ion.intensity);
564            }
565        }
566
567        for (index, ion) in self.c_ions.iter().enumerate() {
568            let c_isotopes = ion.isotope_distribution(mass_tolerance, abundance_threshold, max_result, intensity_min);
569            let mut isotope_counter = 0;
570            let mut previous_mz = c_isotopes[0].0;
571
572            for (mz, abundance) in c_isotopes.iter() {
573                let ppm_tolerance = (mz / 1e6) * 25.0;
574
575                if (mz - previous_mz).abs() > ppm_tolerance {
576                    isotope_counter += 1;
577                    previous_mz = *mz;
578                }
579
580                let signal_attributes = SignalAttributes {
581                    charge_state: ion.ion.charge,
582                    peptide_id: ion.ion.sequence.peptide_id.unwrap_or(-1),
583                    isotope_peak: isotope_counter,
584                    fragment_kind: Some(ion.kind), // (perf) see note in the n-ion loop above
585                    fragment_ordinal: Some((index + 1) as i32),
586                };
587
588                let contribution_source = ContributionSource {
589                    intensity_contribution: *abundance * ion.ion.intensity,
590                    source_type: SourceType::Signal,
591                    signal_attributes: Some(signal_attributes)
592                };
593
594                annotations.push(PeakAnnotation {
595                    contributions: vec![contribution_source]
596                });
597
598                mz_values.push(*mz);
599                intensity_values.push(*abundance * ion.ion.intensity);
600            }
601        }
602        MzSpectrumAnnotated::new(mz_values, intensity_values, annotations)
603    }
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
607pub struct PeptideProductIonSeriesCollection {
608    pub peptide_ions: Vec<PeptideProductIonSeries>,
609}
610impl PeptideProductIonSeriesCollection {
611    pub fn new(peptide_ions: Vec<PeptideProductIonSeries>) -> Self {
612        PeptideProductIonSeriesCollection {
613            peptide_ions,
614        }
615    }
616
617    pub fn find_ion_series(&self, charge: i32) -> Option<&PeptideProductIonSeries> {
618        self.peptide_ions.iter().find(|ion_series| ion_series.charge == charge)
619    }
620
621    pub fn generate_isotopic_spectrum(&self, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrum {
622        let mut spectra: Vec<MzSpectrum> = Vec::new();
623
624        for ion_series in &self.peptide_ions {
625            let isotopic_spectrum = ion_series.generate_isotopic_spectrum(mass_tolerance, abundance_threshold, max_result, intensity_min);
626            spectra.push(isotopic_spectrum);
627        }
628
629        MzSpectrum::from_collection(spectra).filter_ranged(0.0, 5_000.0, 1e-6, 1e6)
630    }
631
632    pub fn generate_isotopic_spectrum_annotated(&self, mass_tolerance: f64, abundance_threshold: f64, max_result: i32, intensity_min: f64) -> MzSpectrumAnnotated {
633        let mut annotations: Vec<PeakAnnotation> = Vec::new();
634        let mut mz_values = Vec::new();
635        let mut intensity_values = Vec::new();
636
637        for ion_series in &self.peptide_ions {
638            let isotopic_spectrum = ion_series.generate_isotopic_spectrum_annotated(mass_tolerance, abundance_threshold, max_result, intensity_min);
639            for (mz, intensity) in isotopic_spectrum.mz.iter().zip(isotopic_spectrum.intensity.iter()) {
640                mz_values.push(*mz);
641                intensity_values.push(*intensity);
642            }
643            annotations.extend(isotopic_spectrum.annotations.iter().cloned());
644        }
645
646        MzSpectrumAnnotated::new(mz_values, intensity_values, annotations)
647    }
648}
649
650#[cfg(test)]
651mod annotated_perf_tests {
652    use super::*;
653
654    // Guards optimization "A2": the annotated spectrum generators no longer build
655    // a per-peak `description` String, but the full fragment identity is preserved
656    // as structured fields (fragment_kind + fragment_ordinal) alongside the existing
657    // labels (peptide_id / charge_state / isotope_peak). The human-readable
658    // description is derived on demand and must match the old
659    // "{kind}_{ordinal}_{isotope}" format exactly.
660
661    #[test]
662    fn isotopic_annotated_keeps_full_fragment_identity_and_spectrum() {
663        let seq = PeptideSequence::new("PEPTIDEK".to_string(), Some(7));
664        let series = seq.calculate_product_ion_series(1, FragmentType::B);
665        let spec = series.generate_isotopic_spectrum_annotated(1e-2, 1e-3, 100, 1e-5);
666
667        // vectors stay consistent and non-empty
668        assert!(!spec.mz.is_empty());
669        assert_eq!(spec.mz.len(), spec.intensity.len());
670        assert_eq!(spec.mz.len(), spec.annotations.len());
671
672        // MzSpectrumAnnotated::new invariant: peaks sorted ascending by mz
673        assert!(spec.mz.windows(2).all(|w| w[0] <= w[1]));
674
675        let mut max_isotope = 0;
676        for ann in &spec.annotations {
677            assert_eq!(ann.contributions.len(), 1);
678            let c = &ann.contributions[0];
679            assert_eq!(c.source_type, SourceType::Signal);
680            let sa = c.signal_attributes.as_ref().expect("signal attributes present");
681            assert_eq!(sa.peptide_id, 7);                 // label preserved
682            assert!(sa.charge_state >= 1);                 // label preserved
683            assert!(sa.isotope_peak >= 0);                 // label preserved
684            // fragment identity preserved as structured fields
685            let kind = sa.fragment_kind.expect("fragment kind present");
686            let ordinal = sa.fragment_ordinal.expect("fragment ordinal present");
687            assert!(ordinal >= 1);                         // 1-based
688            // derived description reproduces the old "{kind}_{ordinal}_{isotope}" string
689            assert_eq!(
690                sa.description(),
691                Some(format!("{}_{}_{}", kind, ordinal, sa.isotope_peak)),
692            );
693            max_isotope = max_isotope.max(sa.isotope_peak);
694        }
695        // sanity: an isotope envelope was actually generated (counter advanced)
696        assert!(max_isotope >= 1);
697    }
698
699    #[test]
700    fn mono_annotated_keeps_full_fragment_identity() {
701        let seq = PeptideSequence::new("PEPTIDEK".to_string(), Some(3));
702        let series = seq.calculate_product_ion_series(1, FragmentType::B);
703        let spec = series.generate_mono_isotopic_spectrum_annotated();
704
705        assert!(!spec.mz.is_empty());
706        assert_eq!(spec.mz.len(), spec.annotations.len());
707        for ann in &spec.annotations {
708            let sa = ann.contributions[0].signal_attributes.as_ref().unwrap();
709            assert_eq!(sa.peptide_id, 3);
710            assert_eq!(sa.isotope_peak, 0);                // mono-isotopic: always 0
711            assert!(sa.fragment_kind.is_some());
712            assert!(sa.fragment_ordinal.unwrap() >= 1);
713            // mono description always ends in "_0"
714            assert!(sa.description().unwrap().ends_with("_0"));
715        }
716    }
717}