Skip to main content

mscore/data/
peptide.rs

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