Skip to main content

mscore/algorithm/
isotope.rs

1extern crate statrs;
2
3use rayon::prelude::*;
4use rayon::ThreadPoolBuilder;
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::chemistry::constants::{MASS_NEUTRON, MASS_PROTON};
8use crate::chemistry::elements::{atoms_isotopic_weights, isotopic_abundance};
9use crate::data::peptide::PeptideIon;
10use crate::data::spectrum::MzSpectrum;
11use crate::data::spectrum::ToResolution;
12use statrs::distribution::{Continuous, Normal};
13
14/// convolve two distributions of masses and abundances
15///
16/// Arguments:
17///
18/// * `dist_a` - first distribution of masses and abundances
19/// * `dist_b` - second distribution of masses and abundances
20/// * `mass_tolerance` - mass tolerance for combining peaks
21/// * `abundance_threshold` - minimum abundance for a peak to be included in the result
22/// * `max_results` - maximum number of peaks to include in the result
23///
24/// Returns:
25///
26/// * `Vec<(f64, f64)>` - combined distribution of masses and abundances
27///
28/// # Examples
29///
30/// ```
31/// use mscore::algorithm::isotope::convolve;
32///
33/// let dist_a = vec![(100.0, 0.5), (101.0, 0.5)];
34/// let dist_b = vec![(100.0, 0.5), (101.0, 0.5)];
35/// let result = convolve(&dist_a, &dist_b, 1e-6, 1e-12, 200);
36/// assert_eq!(result, vec![(200.0, 0.25), (201.0, 0.5), (202.0, 0.25)]);
37/// ```
38pub fn convolve(
39    dist_a: &Vec<(f64, f64)>,
40    dist_b: &Vec<(f64, f64)>,
41    mass_tolerance: f64,
42    abundance_threshold: f64,
43    max_results: usize,
44) -> Vec<(f64, f64)> {
45    let mut result: Vec<(f64, f64)> = Vec::new();
46
47    for (mass_a, abundance_a) in dist_a {
48        for (mass_b, abundance_b) in dist_b {
49            let combined_mass = mass_a + mass_b;
50            let combined_abundance = abundance_a * abundance_b;
51
52            // Skip entries with combined abundance below the threshold
53            if combined_abundance < abundance_threshold {
54                continue;
55            }
56
57            // Insert or update the combined mass in the result distribution
58            if let Some(entry) = result
59                .iter_mut()
60                .find(|(m, _)| (*m - combined_mass).abs() < mass_tolerance)
61            {
62                entry.1 += combined_abundance;
63            } else {
64                result.push((combined_mass, combined_abundance));
65            }
66        }
67    }
68
69    // Sort by abundance (descending) to prepare for trimming
70    result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
71
72    // Trim the vector if it exceeds max_results
73    if result.len() > max_results {
74        result.truncate(max_results);
75    }
76
77    // Optionally, sort by mass if needed for further processing
78    result.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
79
80    result
81}
82
83/// convolve a distribution with itself n times
84///
85/// Arguments:
86///
87/// * `dist` - distribution of masses and abundances
88/// * `n` - number of times to convolve the distribution with itself
89///
90/// Returns:
91///
92/// * `Vec<(f64, f64)>` - distribution of masses and abundances
93///
94/// # Examples
95///
96/// ```
97/// use mscore::algorithm::isotope::convolve_pow;
98///
99/// let dist = vec![(100.0, 0.5), (101.0, 0.5)];
100/// let result = convolve_pow(&dist, 2);
101/// assert_eq!(result, vec![(200.0, 0.25), (201.0, 0.5), (202.0, 0.25)]);
102/// ```
103pub fn convolve_pow(dist: &Vec<(f64, f64)>, n: i32) -> Vec<(f64, f64)> {
104    if n == 0 {
105        return vec![(0.0, 1.0)]; // Return the delta distribution
106    }
107    if n == 1 {
108        return dist.clone();
109    }
110
111    let mut result = dist.clone();
112    let mut power = 2;
113
114    while power <= n {
115        result = convolve(&result, &result, 1e-6, 1e-12, 200); // Square the result to get the next power of 2
116        power *= 2;
117    }
118
119    // If n is not a power of 2, recursively fill in the remainder
120    if power / 2 < n {
121        result = convolve(
122            &result,
123            &convolve_pow(dist, n - power / 2),
124            1e-6,
125            1e-12,
126            200,
127        );
128    }
129
130    result
131}
132
133/// generate the isotope distribution for a given atomic composition
134///
135/// Arguments:
136///
137/// * `atomic_composition` - atomic composition of the peptide
138/// * `mass_tolerance` - mass tolerance for combining peaks
139/// * `abundance_threshold` - minimum abundance for a peak to be included in the result
140/// * `max_result` - maximum number of peaks to include in the result
141///
142/// Returns:
143///
144/// * `Vec<(f64, f64)>` - distribution of masses and abundances
145///
146/// # Examples
147///
148/// ```
149/// use std::collections::HashMap;
150/// use mscore::algorithm::isotope::generate_isotope_distribution;
151///
152/// let mut atomic_composition = HashMap::new();
153/// atomic_composition.insert("C".to_string(), 5);
154/// atomic_composition.insert("H".to_string(), 9);
155/// atomic_composition.insert("N".to_string(), 1);
156/// atomic_composition.insert("O".to_string(), 1);
157/// let result = generate_isotope_distribution(&atomic_composition, 1e-6, 1e-12, 200);
158/// ```
159pub fn generate_isotope_distribution(
160    atomic_composition: &HashMap<String, i32>,
161    mass_tolerance: f64,
162    abundance_threshold: f64,
163    max_result: i32,
164) -> Vec<(f64, f64)> {
165    let mut cumulative_distribution: Option<Vec<(f64, f64)>> = None;
166    let atoms_isotopic_weights: HashMap<String, Vec<f64>> = atoms_isotopic_weights()
167        .iter()
168        .map(|(k, v)| (k.to_string(), v.clone()))
169        .collect();
170    let atomic_isotope_abundance: HashMap<String, Vec<f64>> = isotopic_abundance()
171        .iter()
172        .map(|(k, v)| (k.to_string(), v.clone()))
173        .collect();
174
175    // Iterate elements in a deterministic (sorted) order. `atomic_composition`
176    // is a HashMap whose iteration order is randomized per instance, and the
177    // convolution below is a TRUNCATING float operation (max_result /
178    // abundance_threshold drop low-abundance peaks), so it is not order-invariant
179    // — different element orders yield slightly different distributions. Sorting
180    // pins one canonical result, making isotope generation reproducible across
181    // builder constructions (required for rendered-output parity / determinism).
182    let mut elements: Vec<(&String, i32)> =
183        atomic_composition.iter().map(|(k, v)| (k, *v)).collect();
184    elements.sort_by(|a, b| a.0.cmp(b.0));
185
186    for (element, count) in elements {
187        let elemental_isotope_weights = atoms_isotopic_weights
188            .get(element)
189            .expect("Element not found in isotopic weights table")
190            .clone();
191        let elemental_isotope_abundance = atomic_isotope_abundance
192            .get(element)
193            .expect("Element not found in isotopic abundance table")
194            .clone();
195
196        let element_distribution: Vec<(f64, f64)> = elemental_isotope_weights
197            .iter()
198            .zip(elemental_isotope_abundance.iter())
199            .map(|(&mass, &abundance)| (mass, abundance))
200            .collect();
201
202        let element_power_distribution = if count > 1 {
203            convolve_pow(&element_distribution, count)
204        } else {
205            element_distribution
206        };
207
208        cumulative_distribution = match cumulative_distribution {
209            Some(cum_dist) => Some(convolve(
210                &cum_dist,
211                &element_power_distribution,
212                mass_tolerance,
213                abundance_threshold,
214                max_result as usize,
215            )),
216            None => Some(element_power_distribution),
217        };
218    }
219
220    let final_distribution = cumulative_distribution.expect("Peptide has no elements");
221    // Normalize the distribution
222    let total_abundance: f64 = final_distribution
223        .iter()
224        .map(|&(_, abundance)| abundance)
225        .sum();
226    let result: Vec<_> = final_distribution
227        .into_iter()
228        .map(|(mass, abundance)| (mass, abundance / total_abundance))
229        .collect();
230
231    let mut sort_map: BTreeMap<i64, f64> = BTreeMap::new();
232    let quantize = |mz: f64| -> i64 { (mz * 1_000_000.0).round() as i64 };
233
234    for (mz, intensity) in result {
235        let key = quantize(mz);
236        sort_map
237            .entry(key)
238            .and_modify(|e| *e += intensity)
239            .or_insert(intensity);
240    }
241
242    let mz: Vec<f64> = sort_map
243        .keys()
244        .map(|&key| key as f64 / 1_000_000.0)
245        .collect();
246    let intensity: Vec<f64> = sort_map.values().map(|&intensity| intensity).collect();
247    mz.iter()
248        .zip(intensity.iter())
249        .map(|(&mz, &intensity)| (mz, intensity))
250        .collect()
251}
252
253/// calculate the normal probability density function
254///
255/// Arguments:
256///
257/// * `x` - value to calculate the probability density function of
258/// * `mean` - mean of the normal distribution
259/// * `std_dev` - standard deviation of the normal distribution
260///
261/// Returns:
262///
263/// * `f64` - probability density function of `x`
264///
265/// # Examples
266///
267/// ```
268/// use mscore::algorithm::isotope::normal_pdf;
269///
270/// let pdf = normal_pdf(0.0, 0.0, 1.0);
271/// assert_eq!(pdf, 0.39894228040143265);
272/// ```
273pub fn normal_pdf(x: f64, mean: f64, std_dev: f64) -> f64 {
274    let normal = Normal::new(mean, std_dev).unwrap();
275    normal.pdf(x)
276}
277
278/// calculate the factorial of a number
279///
280/// Arguments:
281///
282/// * `n` - number to calculate factorial of
283///
284/// Returns:
285///
286/// * `f64` - factorial of `n`
287///
288/// # Examples
289///
290/// ```
291/// use mscore::algorithm::isotope::factorial;
292///
293/// let fact = factorial(5);
294/// assert_eq!(fact, 120.0);
295/// ```
296pub fn factorial(n: i32) -> f64 {
297    (1..=n).fold(1.0, |acc, x| acc * x as f64)
298}
299
300pub fn weight(mass: f64, peak_nums: Vec<i32>, normalize: bool) -> Vec<f64> {
301    let lam_val = lam(mass, 0.000594, -0.03091);
302    let factorials: Vec<f64> = peak_nums.iter().map(|&k| factorial(k)).collect();
303    let mut weights: Vec<f64> = peak_nums
304        .iter()
305        .map(|&k| {
306            let pow = lam_val.powi(k);
307            let exp = (-lam_val).exp();
308            exp * pow / factorials[k as usize]
309        })
310        .collect();
311
312    if normalize {
313        let sum: f64 = weights.iter().sum();
314        weights = weights.iter().map(|&w| w / sum).collect();
315    }
316
317    weights
318}
319
320/// calculate the lambda value for a given mass
321///
322/// Arguments:
323///
324/// * `mass` - mass of the peptide
325/// * `slope` - slope of the linear regression
326/// * `intercept` - intercept of the linear regression
327///
328/// Returns:
329///
330/// * `f64` - lambda value
331///
332/// # Examples
333///
334/// ```
335/// use mscore::algorithm::isotope::lam;
336///
337/// let lambda = lam(1000.0, 0.000594, -0.03091);
338/// assert_eq!(lambda, 0.56309);
339pub fn lam(mass: f64, slope: f64, intercept: f64) -> f64 {
340    slope * mass + intercept
341}
342
343/// calculate the isotope pattern for a given mass and charge based on the averagine model
344/// using the normal distribution for peak shapes
345///
346/// Arguments:
347///
348/// * `x` - list of m/z values to probe
349/// * `mass` - mass of the peptide
350/// * `charge` - charge of the peptide
351/// * `sigma` - standard deviation of the normal distribution
352/// * `amp` - amplitude of the isotope pattern
353/// * `k` - number of isotopes to consider
354/// * `step_size` - step size for the m/z values to probe
355///
356/// Returns:
357///
358/// * `Vec<f64>` - isotope pattern
359///
360pub fn iso(
361    x: &Vec<f64>,
362    mass: f64,
363    charge: f64,
364    sigma: f64,
365    amp: f64,
366    k: usize,
367    step_size: f64,
368) -> Vec<f64> {
369    let k_range: Vec<usize> = (0..k).collect();
370    let means: Vec<f64> = k_range
371        .iter()
372        .map(|&k_val| (mass + MASS_NEUTRON * k_val as f64) / charge)
373        .collect();
374    let weights = weight(
375        mass,
376        k_range
377            .iter()
378            .map(|&k_val| k_val as i32)
379            .collect::<Vec<i32>>(),
380        true,
381    );
382
383    let mut intensities = vec![0.0; x.len()];
384    for (i, x_val) in x.iter().enumerate() {
385        for (j, &mean) in means.iter().enumerate() {
386            intensities[i] += weights[j] * normal_pdf(*x_val, mean, sigma);
387        }
388        intensities[i] *= step_size;
389    }
390    intensities
391        .iter()
392        .map(|&intensity| intensity * amp)
393        .collect()
394}
395
396/// generate the isotope pattern for a given mass and charge
397///
398/// Arguments:
399///
400/// * `lower_bound` - lower bound of the isotope pattern
401/// * `upper_bound` - upper bound of the isotope pattern
402/// * `mass` - mass of the peptide
403/// * `charge` - charge of the peptide
404/// * `amp` - amplitude of the isotope pattern
405/// * `k` - number of isotopes to consider
406/// * `sigma` - standard deviation of the normal distribution
407/// * `resolution` - resolution of the isotope pattern
408///
409/// Returns:
410///
411/// * `(Vec<f64>, Vec<f64>)` - isotope pattern
412///
413/// # Examples
414///
415/// ```
416/// use mscore::algorithm::isotope::generate_isotope_pattern;
417///
418/// let (mzs, intensities) = generate_isotope_pattern(1500.0, 1510.0, 3000.0, 2.0, 1e4, 10, 1.0, 3);
419/// ```
420pub fn generate_isotope_pattern(
421    lower_bound: f64,
422    upper_bound: f64,
423    mass: f64,
424    charge: f64,
425    amp: f64,
426    k: usize,
427    sigma: f64,
428    resolution: i32,
429) -> (Vec<f64>, Vec<f64>) {
430    let step_size = f64::min(sigma / 10.0, 1.0 / 10f64.powi(resolution));
431    let size = ((upper_bound - lower_bound) / step_size).ceil() as usize;
432    let mzs: Vec<f64> = (0..size)
433        .map(|i| lower_bound + step_size * i as f64)
434        .collect();
435    let intensities = iso(&mzs, mass, charge, sigma, amp, k, step_size);
436
437    (
438        mzs.iter().map(|&mz| mz + MASS_PROTON).collect(),
439        intensities,
440    )
441}
442
443/// generate the averagine spectrum for a given mass and charge
444///
445/// Arguments:
446///
447/// * `mass` - mass of the peptide
448/// * `charge` - charge of the peptide
449/// * `min_intensity` - minimum intensity for a peak to be included in the result
450/// * `k` - number of isotopes to consider
451/// * `resolution` - resolution of the isotope pattern
452/// * `centroid` - whether to centroid the spectrum
453/// * `amp` - amplitude of the isotope pattern
454///
455/// Returns:
456///
457/// * `MzSpectrum` - averagine spectrum
458///
459/// # Examples
460///
461/// ```
462/// use mscore::algorithm::isotope::generate_averagine_spectrum;
463///
464/// let spectrum = generate_averagine_spectrum(3000.0, 2, 1, 10, 3, true, None);
465/// ```
466pub fn generate_averagine_spectrum(
467    mass: f64,
468    charge: i32,
469    min_intensity: i32,
470    k: i32,
471    resolution: i32,
472    centroid: bool,
473    amp: Option<f64>,
474) -> MzSpectrum {
475    let amp = amp.unwrap_or(1e4);
476    let lb = mass / charge as f64 - 0.2;
477    let ub = mass / charge as f64 + k as f64 + 0.2;
478
479    let (mz, intensities) = generate_isotope_pattern(
480        lb,
481        ub,
482        mass,
483        charge as f64,
484        amp,
485        k as usize,
486        0.008492569002123142,
487        resolution,
488    );
489
490    let spectrum = MzSpectrum::new(mz, intensities)
491        .to_resolution(resolution)
492        .filter_ranged(lb, ub, min_intensity as f64, 1e9);
493
494    if centroid {
495        spectrum.to_centroid(
496            std::cmp::max(min_intensity, 1),
497            1.0 / 10f64.powi(resolution - 1),
498            true,
499        )
500    } else {
501        spectrum
502    }
503}
504
505/// generate the averagine spectra for a given list of masses and charges
506/// using multiple threads
507///
508/// Arguments:
509///
510/// * `masses` - list of masses of the peptides
511/// * `charges` - list of charges of the peptides
512/// * `min_intensity` - minimum intensity for a peak to be included in the result
513/// * `k` - number of isotopes to consider
514/// * `resolution` - resolution of the isotope pattern
515/// * `centroid` - whether to centroid the spectrum
516/// * `num_threads` - number of threads to use
517/// * `amp` - amplitude of the isotope pattern
518///
519/// Returns:
520///
521/// * `Vec<MzSpectrum>` - list of averagine spectra
522///
523/// # Examples
524///
525/// ```
526/// use mscore::algorithm::isotope::generate_averagine_spectra;
527///
528/// let masses = vec![3000.0, 3000.0];
529/// let charges = vec![2, 3];
530/// let spectra = generate_averagine_spectra(masses, charges, 1, 10, 3, true, 4, None);
531/// ```
532pub fn generate_averagine_spectra(
533    masses: Vec<f64>,
534    charges: Vec<i32>,
535    min_intensity: i32,
536    k: i32,
537    resolution: i32,
538    centroid: bool,
539    num_threads: usize,
540    amp: Option<f64>,
541) -> Vec<MzSpectrum> {
542    let amp = amp.unwrap_or(1e5);
543    let mut spectra: Vec<MzSpectrum> = Vec::new();
544    let thread_pool = ThreadPoolBuilder::new()
545        .num_threads(num_threads)
546        .build()
547        .unwrap();
548
549    thread_pool.install(|| {
550        spectra = masses
551            .par_iter()
552            .zip(charges.par_iter())
553            .map(|(&mass, &charge)| {
554                generate_averagine_spectrum(
555                    mass,
556                    charge,
557                    min_intensity,
558                    k,
559                    resolution,
560                    centroid,
561                    Some(amp),
562                )
563            })
564            .collect();
565    });
566
567    spectra
568}
569
570/// generate the precursor spectrum for a given peptide sequence and charge
571/// using isotope convolutions
572///
573/// Arguments:
574///
575/// * `sequence` - peptide sequence
576/// * `charge` - charge of the peptide
577///
578/// Returns:
579///
580/// * `MzSpectrum` - precursor spectrum
581///
582pub fn generate_precursor_spectrum(
583    sequence: &str,
584    charge: i32,
585    peptide_id: Option<i32>,
586) -> MzSpectrum {
587    let peptide_ion = PeptideIon::new(sequence.to_string(), charge, 1.0, peptide_id);
588    peptide_ion.calculate_isotopic_spectrum(1e-3, 1e-9, 200, 1e-6)
589}
590
591/// parallel version of `generate_precursor_spectrum`
592///
593/// Arguments:
594///
595/// * `sequences` - list of peptide sequences
596/// * `charges` - list of charges of the peptides
597/// * `num_threads` - number of threads to use
598///
599/// Returns:
600///
601/// * `Vec<MzSpectrum>` - list of precursor spectra
602///
603pub fn generate_precursor_spectra(
604    sequences: &Vec<&str>,
605    charges: &Vec<i32>,
606    num_threads: usize,
607    peptide_ids: Vec<Option<i32>>,
608) -> Vec<MzSpectrum> {
609    let thread_pool = ThreadPoolBuilder::new()
610        .num_threads(num_threads)
611        .build()
612        .unwrap();
613    // need to zip sequences and charges and peptide_ids
614    let result = thread_pool.install(|| {
615        sequences
616            .par_iter()
617            .zip(charges.par_iter())
618            .zip(peptide_ids.par_iter())
619            .map(|((&sequence, &charge), &peptide_id)| {
620                generate_precursor_spectrum(sequence, charge, peptide_id)
621            })
622            .collect()
623    });
624    result
625}
626
627/// Result of transmission-dependent isotope distribution calculation.
628/// Contains the adjusted distribution and the transmission factor for intensity scaling.
629#[derive(Debug, Clone)]
630pub struct TransmissionDependentIsotopeDistribution {
631    /// The adjusted isotope distribution (m/z, relative_intensity)
632    pub distribution: Vec<(f64, f64)>,
633    /// Fraction of signal transmitted (0.0 to 1.0).
634    /// This is the ratio of the sum of transmitted distribution intensities
635    /// to the sum of full (all isotopes transmitted) distribution intensities.
636    pub transmission_factor: f64,
637}
638
639// Calculates the isotope distribution for a fragment given the isotope distribution of the fragment, the isotope distribution of the complementary fragment, and the transmitted precursor isotopes
640// implemented based on OpenMS: "https://github.com/OpenMS/OpenMS/blob/079143800f7ed036a7c68ea6e124fe4f5cfc9569/src/openms/source/CHEMISTRY/ISOTOPEDISTRIBUTION/CoarseIsotopePatternGenerator.cpp#L415"
641pub fn calculate_transmission_dependent_fragment_ion_isotope_distribution(
642    fragment_isotope_dist: &Vec<(f64, f64)>,
643    comp_fragment_isotope_dist: &Vec<(f64, f64)>,
644    precursor_isotopes: &HashSet<usize>,
645    max_isotope: usize,
646) -> Vec<(f64, f64)> {
647    if fragment_isotope_dist.is_empty() || comp_fragment_isotope_dist.is_empty() {
648        return Vec::new();
649    }
650
651    let mut r_max = fragment_isotope_dist.len();
652    if max_isotope != 0 && r_max > max_isotope {
653        r_max = max_isotope;
654    }
655
656    let mut result = (0..r_max)
657        .map(|i| (fragment_isotope_dist[0].0 + i as f64, 0.0))
658        .collect::<Vec<(f64, f64)>>();
659
660    // Calculation of dependent isotope distribution
661    for (i, &(_mz, intensity)) in fragment_isotope_dist.iter().enumerate().take(r_max) {
662        for &precursor in precursor_isotopes {
663            if precursor >= i && (precursor - i) < comp_fragment_isotope_dist.len() {
664                let comp_intensity = comp_fragment_isotope_dist[precursor - i].1;
665                result[i].1 += comp_intensity;
666            }
667        }
668        result[i].1 *= intensity;
669    }
670
671    result
672}
673
674/// Calculates the transmission-dependent fragment isotope distribution with explicit
675/// transmission factor tracking.
676///
677/// This function computes how the fragment ion isotope pattern changes when only
678/// certain precursor isotopes are transmitted through the quadrupole isolation window.
679/// It also calculates the transmission factor, which represents the fraction of
680/// total signal that is transmitted.
681///
682/// # Arguments
683///
684/// * `fragment_isotope_dist` - Isotope distribution of the fragment ion (m/z, intensity)
685/// * `comp_fragment_isotope_dist` - Isotope distribution of the complementary fragment
686/// * `precursor_isotopes` - Set of precursor isotope indices that were transmitted
687/// * `max_isotope` - Maximum number of isotope peaks to consider (0 for unlimited)
688///
689/// # Returns
690///
691/// A `TransmissionDependentIsotopeDistribution` containing:
692/// - The adjusted isotope distribution
693/// - The transmission factor (ratio of transmitted to full signal)
694///
695/// # Algorithm
696///
697/// Based on OpenMS CoarseIsotopePatternGenerator. For each fragment isotope index i:
698/// P(fragment=i | transmitted precursors) = frag[i] * Σ comp[p-i] for all transmitted p >= i
699///
700/// The transmission factor is calculated as:
701/// transmission_factor = sum(transmitted_distribution) / sum(full_distribution)
702pub fn calculate_transmission_dependent_distribution_with_factor(
703    fragment_isotope_dist: &Vec<(f64, f64)>,
704    comp_fragment_isotope_dist: &Vec<(f64, f64)>,
705    precursor_isotopes: &HashSet<usize>,
706    max_isotope: usize,
707) -> TransmissionDependentIsotopeDistribution {
708    if fragment_isotope_dist.is_empty() || comp_fragment_isotope_dist.is_empty() {
709        return TransmissionDependentIsotopeDistribution {
710            distribution: Vec::new(),
711            transmission_factor: 0.0,
712        };
713    }
714
715    // Calculate full distribution (all isotopes transmitted) for reference
716    let all_isotopes: HashSet<usize> = (0..fragment_isotope_dist.len().max(comp_fragment_isotope_dist.len())).collect();
717    let full_distribution = calculate_transmission_dependent_fragment_ion_isotope_distribution(
718        fragment_isotope_dist,
719        comp_fragment_isotope_dist,
720        &all_isotopes,
721        max_isotope,
722    );
723    let full_sum: f64 = full_distribution.iter().map(|(_, i)| i).sum();
724
725    // Calculate transmitted distribution
726    let transmitted_distribution = calculate_transmission_dependent_fragment_ion_isotope_distribution(
727        fragment_isotope_dist,
728        comp_fragment_isotope_dist,
729        precursor_isotopes,
730        max_isotope,
731    );
732    let transmitted_sum: f64 = transmitted_distribution.iter().map(|(_, i)| i).sum();
733
734    // Calculate transmission factor
735    let transmission_factor = if full_sum > 0.0 {
736        transmitted_sum / full_sum
737    } else {
738        0.0
739    };
740
741    TransmissionDependentIsotopeDistribution {
742        distribution: transmitted_distribution,
743        transmission_factor,
744    }
745}
746
747/// Calculate the transmission factor for a precursor based on which isotopes are transmitted.
748///
749/// This provides a simple way to scale fragment intensities based on precursor transmission
750/// without the computational cost of per-fragment isotope recalculation.
751///
752/// # Arguments
753///
754/// * `precursor_isotope_dist` - Isotope distribution of the precursor (m/z, intensity)
755/// * `transmitted_indices` - Set of precursor isotope indices that were transmitted
756///
757/// # Returns
758///
759/// Transmission factor (0.0 to 1.0) representing the fraction of precursor signal transmitted.
760pub fn calculate_precursor_transmission_factor(
761    precursor_isotope_dist: &[(f64, f64)],
762    transmitted_indices: &HashSet<usize>,
763) -> f64 {
764    if precursor_isotope_dist.is_empty() || transmitted_indices.is_empty() {
765        return 0.0;
766    }
767
768    let total_intensity: f64 = precursor_isotope_dist.iter().map(|(_, i)| i).sum();
769    if total_intensity <= 0.0 {
770        return 0.0;
771    }
772
773    let transmitted_intensity: f64 = precursor_isotope_dist
774        .iter()
775        .enumerate()
776        .filter(|(idx, _)| transmitted_indices.contains(idx))
777        .map(|(_, (_, i))| i)
778        .sum();
779
780    transmitted_intensity / total_intensity
781}
782
783#[cfg(test)]
784mod tests {
785    use super::*;
786
787    fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
788        (a - b).abs() < epsilon
789    }
790
791    /// Test that transmission-dependent distribution produces correct results
792    /// when the same isotope set is used as the internal reference
793    #[test]
794    fn test_transmission_all_isotopes() {
795        // Simple fragment distribution: M0=0.6, M1=0.3, M2=0.1
796        let fragment_dist = vec![
797            (500.0, 0.6),
798            (501.0, 0.3),
799            (502.0, 0.1),
800        ];
801
802        // Complementary distribution: M0=0.7, M1=0.2, M2=0.1
803        let comp_dist = vec![
804            (800.0, 0.7),
805            (801.0, 0.2),
806            (802.0, 0.1),
807        ];
808
809        // Use the same isotope set that the function uses internally for "full" calculation
810        // This is 0..max(fragment_dist.len(), comp_dist.len()) = 0..3
811        let all_isotopes: HashSet<usize> = (0..3).collect();
812
813        let result = calculate_transmission_dependent_distribution_with_factor(
814            &fragment_dist,
815            &comp_dist,
816            &all_isotopes,
817            0,
818        );
819
820        // When same isotopes as internal reference are transmitted, transmission factor = 1.0
821        assert!(
822            approx_eq(result.transmission_factor, 1.0, 0.001),
823            "Transmission factor should be 1.0 when same isotopes as reference transmitted, got {}",
824            result.transmission_factor
825        );
826
827        // Distribution should not be empty
828        assert!(!result.distribution.is_empty());
829    }
830
831    /// Test that passing more isotopes than exist can increase transmission factor > 1.0
832    /// This is expected behavior: higher precursor isotopes can contribute to lower fragment isotopes
833    #[test]
834    fn test_transmission_extra_isotopes() {
835        let fragment_dist = vec![
836            (500.0, 0.6),
837            (501.0, 0.3),
838            (502.0, 0.1),
839        ];
840
841        let comp_dist = vec![
842            (800.0, 0.7),
843            (801.0, 0.2),
844            (802.0, 0.1),
845        ];
846
847        // Pass more isotopes than exist in distributions
848        let extra_isotopes: HashSet<usize> = [0, 1, 2, 3, 4, 5].iter().cloned().collect();
849
850        let result = calculate_transmission_dependent_distribution_with_factor(
851            &fragment_dist,
852            &comp_dist,
853            &extra_isotopes,
854            0,
855        );
856
857        // With extra isotopes, transmission factor can be > 1.0
858        // because higher precursor isotopes contribute to lower fragment isotopes
859        assert!(
860            result.transmission_factor >= 1.0,
861            "Extra isotopes should give transmission factor >= 1.0, got {}",
862            result.transmission_factor
863        );
864    }
865
866    /// Test that partial transmission reduces the transmission factor
867    #[test]
868    fn test_transmission_partial_isotopes() {
869        // Fragment distribution
870        let fragment_dist = vec![
871            (500.0, 0.5),
872            (501.0, 0.3),
873            (502.0, 0.15),
874            (503.0, 0.05),
875        ];
876
877        // Complementary distribution
878        let comp_dist = vec![
879            (800.0, 0.6),
880            (801.0, 0.25),
881            (802.0, 0.1),
882            (803.0, 0.05),
883        ];
884
885        // All isotopes
886        let all_isotopes: HashSet<usize> = [0, 1, 2, 3].iter().cloned().collect();
887        let full_result = calculate_transmission_dependent_distribution_with_factor(
888            &fragment_dist,
889            &comp_dist,
890            &all_isotopes,
891            0,
892        );
893
894        // Only M0 and M1 transmitted
895        let partial_isotopes: HashSet<usize> = [0, 1].iter().cloned().collect();
896        let partial_result = calculate_transmission_dependent_distribution_with_factor(
897            &fragment_dist,
898            &comp_dist,
899            &partial_isotopes,
900            0,
901        );
902
903        // Partial transmission should have lower transmission factor
904        assert!(
905            partial_result.transmission_factor < full_result.transmission_factor,
906            "Partial transmission ({}) should be less than full transmission ({})",
907            partial_result.transmission_factor,
908            full_result.transmission_factor
909        );
910
911        // Transmission factor should be between 0 and 1
912        assert!(
913            partial_result.transmission_factor > 0.0 && partial_result.transmission_factor < 1.0,
914            "Partial transmission factor should be between 0 and 1, got {}",
915            partial_result.transmission_factor
916        );
917    }
918
919    /// Test that only M0 transmitted gives lowest transmission factor
920    #[test]
921    fn test_transmission_m0_only() {
922        let fragment_dist = vec![
923            (500.0, 0.5),
924            (501.0, 0.3),
925            (502.0, 0.15),
926            (503.0, 0.05),
927        ];
928
929        let comp_dist = vec![
930            (800.0, 0.6),
931            (801.0, 0.25),
932            (802.0, 0.1),
933            (803.0, 0.05),
934        ];
935
936        // Only M0 transmitted
937        let m0_only: HashSet<usize> = [0].iter().cloned().collect();
938        let m0_result = calculate_transmission_dependent_distribution_with_factor(
939            &fragment_dist,
940            &comp_dist,
941            &m0_only,
942            0,
943        );
944
945        // M0 and M1 transmitted
946        let m0_m1: HashSet<usize> = [0, 1].iter().cloned().collect();
947        let m0_m1_result = calculate_transmission_dependent_distribution_with_factor(
948            &fragment_dist,
949            &comp_dist,
950            &m0_m1,
951            0,
952        );
953
954        // M0 only should have lower transmission than M0+M1
955        assert!(
956            m0_result.transmission_factor < m0_m1_result.transmission_factor,
957            "M0 only ({}) should transmit less than M0+M1 ({})",
958            m0_result.transmission_factor,
959            m0_m1_result.transmission_factor
960        );
961
962        // When only M0 transmitted, only M0 of fragment should have signal
963        // (higher isotopes need complementary isotopes that require higher precursor isotopes)
964        let m0_intensity = m0_result.distribution.get(0).map(|(_, i)| *i).unwrap_or(0.0);
965        let m1_intensity = m0_result.distribution.get(1).map(|(_, i)| *i).unwrap_or(0.0);
966
967        assert!(
968            m0_intensity > 0.0,
969            "M0 fragment should have intensity when M0 precursor transmitted"
970        );
971        assert!(
972            approx_eq(m1_intensity, 0.0, 1e-10),
973            "M1 fragment should have zero intensity when only M0 precursor transmitted, got {}",
974            m1_intensity
975        );
976    }
977
978    /// Test that empty inputs are handled gracefully
979    #[test]
980    fn test_transmission_empty_inputs() {
981        let empty: Vec<(f64, f64)> = vec![];
982        let non_empty = vec![(500.0, 0.5)];
983        let isotopes: HashSet<usize> = [0].iter().cloned().collect();
984
985        // Empty fragment distribution
986        let result1 = calculate_transmission_dependent_distribution_with_factor(
987            &empty,
988            &non_empty,
989            &isotopes,
990            0,
991        );
992        assert!(result1.distribution.is_empty());
993        assert!(approx_eq(result1.transmission_factor, 0.0, 1e-10));
994
995        // Empty complementary distribution
996        let result2 = calculate_transmission_dependent_distribution_with_factor(
997            &non_empty,
998            &empty,
999            &isotopes,
1000            0,
1001        );
1002        assert!(result2.distribution.is_empty());
1003        assert!(approx_eq(result2.transmission_factor, 0.0, 1e-10));
1004    }
1005
1006    /// Test that the relative isotope pattern changes with partial transmission
1007    #[test]
1008    fn test_isotope_pattern_shift() {
1009        // Use a distribution where we can verify the pattern shift
1010        let fragment_dist = vec![
1011            (500.0, 0.6),
1012            (501.0, 0.3),
1013            (502.0, 0.1),
1014        ];
1015
1016        let comp_dist = vec![
1017            (800.0, 0.7),
1018            (801.0, 0.2),
1019            (802.0, 0.1),
1020        ];
1021
1022        // All isotopes - get reference pattern
1023        let all_isotopes: HashSet<usize> = [0, 1, 2].iter().cloned().collect();
1024        let full_result = calculate_transmission_dependent_distribution_with_factor(
1025            &fragment_dist,
1026            &comp_dist,
1027            &all_isotopes,
1028            0,
1029        );
1030
1031        // Only M0 transmitted
1032        let m0_only: HashSet<usize> = [0].iter().cloned().collect();
1033        let m0_result = calculate_transmission_dependent_distribution_with_factor(
1034            &fragment_dist,
1035            &comp_dist,
1036            &m0_only,
1037            0,
1038        );
1039
1040        // Calculate relative M0 contribution for both
1041        let full_sum: f64 = full_result.distribution.iter().map(|(_, i)| i).sum();
1042        let m0_sum: f64 = m0_result.distribution.iter().map(|(_, i)| i).sum();
1043
1044        let full_m0_fraction = if full_sum > 0.0 {
1045            full_result.distribution[0].1 / full_sum
1046        } else {
1047            0.0
1048        };
1049
1050        let m0_m0_fraction = if m0_sum > 0.0 {
1051            m0_result.distribution[0].1 / m0_sum
1052        } else {
1053            0.0
1054        };
1055
1056        // When only M0 precursor transmitted, M0 fragment should be relatively more dominant
1057        // (because higher fragment isotopes can't form without higher precursor isotopes)
1058        assert!(
1059            m0_m0_fraction >= full_m0_fraction,
1060            "M0 fraction with M0-only transmission ({}) should be >= full transmission ({})",
1061            m0_m0_fraction,
1062            full_m0_fraction
1063        );
1064    }
1065
1066    /// Test max_isotope parameter limits output
1067    #[test]
1068    fn test_max_isotope_limit() {
1069        let fragment_dist = vec![
1070            (500.0, 0.5),
1071            (501.0, 0.3),
1072            (502.0, 0.15),
1073            (503.0, 0.05),
1074        ];
1075
1076        let comp_dist = vec![
1077            (800.0, 0.6),
1078            (801.0, 0.25),
1079            (802.0, 0.1),
1080            (803.0, 0.05),
1081        ];
1082
1083        let all_isotopes: HashSet<usize> = [0, 1, 2, 3].iter().cloned().collect();
1084
1085        // With max_isotope = 2
1086        let result = calculate_transmission_dependent_distribution_with_factor(
1087            &fragment_dist,
1088            &comp_dist,
1089            &all_isotopes,
1090            2,
1091        );
1092
1093        assert_eq!(
1094            result.distribution.len(),
1095            2,
1096            "Distribution should be limited to 2 isotopes, got {}",
1097            result.distribution.len()
1098        );
1099    }
1100
1101    /// Integration test: Verify that simulated frame building produces correct intensity scaling
1102    /// This test mimics the behavior in rustdf's build_fragment_frame function
1103    #[test]
1104    fn test_frame_building_intensity_scaling() {
1105        // Simulate realistic isotope distributions for a small peptide fragment
1106        // Fragment: ~500 Da
1107        let fragment_dist = vec![
1108            (500.25, 0.65),
1109            (501.25, 0.25),
1110            (502.25, 0.08),
1111            (503.25, 0.02),
1112        ];
1113
1114        // Complementary fragment: ~800 Da
1115        let comp_dist = vec![
1116            (800.40, 0.55),
1117            (801.40, 0.28),
1118            (802.40, 0.12),
1119            (803.40, 0.05),
1120        ];
1121
1122        // Simulate frame building with different transmission scenarios
1123        let fraction_events: f64 = 1000.0; // Arbitrary intensity scaling factor
1124
1125        // Scenario 1: Full transmission (reference)
1126        let all_isotopes: HashSet<usize> = (0..4).collect();
1127        let full_dist = calculate_transmission_dependent_fragment_ion_isotope_distribution(
1128            &fragment_dist,
1129            &comp_dist,
1130            &all_isotopes,
1131            0,
1132        );
1133        let full_intensity_sum: f64 = full_dist.iter().map(|(_, i)| i * fraction_events).sum();
1134
1135        // Scenario 2: Only M0 transmitted (narrow quad window)
1136        let m0_only: HashSet<usize> = [0].iter().cloned().collect();
1137        let m0_dist = calculate_transmission_dependent_fragment_ion_isotope_distribution(
1138            &fragment_dist,
1139            &comp_dist,
1140            &m0_only,
1141            0,
1142        );
1143        let m0_intensity_sum: f64 = m0_dist.iter().map(|(_, i)| i * fraction_events).sum();
1144
1145        // Scenario 3: M0+M1 transmitted (typical quad window)
1146        let m0_m1: HashSet<usize> = [0, 1].iter().cloned().collect();
1147        let m0_m1_dist = calculate_transmission_dependent_fragment_ion_isotope_distribution(
1148            &fragment_dist,
1149            &comp_dist,
1150            &m0_m1,
1151            0,
1152        );
1153        let m0_m1_intensity_sum: f64 = m0_m1_dist.iter().map(|(_, i)| i * fraction_events).sum();
1154
1155        // Verify intensity ordering: full > M0+M1 > M0 only
1156        assert!(
1157            full_intensity_sum > m0_m1_intensity_sum,
1158            "Full transmission intensity ({}) should be > M0+M1 ({})",
1159            full_intensity_sum, m0_m1_intensity_sum
1160        );
1161        assert!(
1162            m0_m1_intensity_sum > m0_intensity_sum,
1163            "M0+M1 transmission intensity ({}) should be > M0 only ({})",
1164            m0_m1_intensity_sum, m0_intensity_sum
1165        );
1166
1167        // Verify intensity ratios make physical sense
1168        // M0+M1 should give roughly 60-90% of full intensity (depends on distributions)
1169        let m0_m1_ratio = m0_m1_intensity_sum / full_intensity_sum;
1170        assert!(
1171            m0_m1_ratio > 0.5 && m0_m1_ratio < 1.0,
1172            "M0+M1 ratio ({}) should be between 0.5 and 1.0",
1173            m0_m1_ratio
1174        );
1175
1176        // M0 only should give roughly 30-70% of full intensity
1177        let m0_ratio = m0_intensity_sum / full_intensity_sum;
1178        assert!(
1179            m0_ratio > 0.2 && m0_ratio < 0.8,
1180            "M0 ratio ({}) should be between 0.2 and 0.8",
1181            m0_ratio
1182        );
1183
1184        // Use the factor tracking function to verify explicit transmission factor
1185        let factor_result = calculate_transmission_dependent_distribution_with_factor(
1186            &fragment_dist,
1187            &comp_dist,
1188            &m0_m1,
1189            0,
1190        );
1191
1192        // The transmission factor should match our manual calculation
1193        let manual_factor = m0_m1_intensity_sum / full_intensity_sum;
1194        assert!(
1195            approx_eq(factor_result.transmission_factor, manual_factor, 0.001),
1196            "Transmission factor ({}) should match manual calculation ({})",
1197            factor_result.transmission_factor, manual_factor
1198        );
1199
1200        println!("Frame building intensity test results:");
1201        println!("  Full transmission intensity: {:.2}", full_intensity_sum);
1202        println!("  M0+M1 transmission intensity: {:.2} (ratio: {:.3})", m0_m1_intensity_sum, m0_m1_ratio);
1203        println!("  M0 only transmission intensity: {:.2} (ratio: {:.3})", m0_intensity_sum, m0_ratio);
1204        println!("  Transmission factor (M0+M1): {:.4}", factor_result.transmission_factor);
1205    }
1206
1207    /// Test behavior with realistic peptide masses using the factor tracking function
1208    #[test]
1209    fn test_realistic_peptide_transmission() {
1210        // Simulate a typical tryptic peptide (~1500 Da precursor)
1211        // B-ion fragment ~600 Da, Y-ion (complementary) ~900 Da
1212
1213        // B-ion isotope pattern (normalized)
1214        let b_ion_dist = vec![
1215            (600.30, 0.58),
1216            (601.30, 0.28),
1217            (602.30, 0.10),
1218            (603.30, 0.03),
1219            (604.30, 0.01),
1220        ];
1221
1222        // Complementary (Y-ion like) isotope pattern
1223        let comp_dist = vec![
1224            (900.45, 0.48),
1225            (901.45, 0.30),
1226            (902.45, 0.14),
1227            (903.45, 0.06),
1228            (904.45, 0.02),
1229        ];
1230
1231        // Test various quad isolation scenarios
1232
1233        // Wide window: transmits M0, M+1, M+2
1234        let wide_window: HashSet<usize> = [0, 1, 2].iter().cloned().collect();
1235        let wide_result = calculate_transmission_dependent_distribution_with_factor(
1236            &b_ion_dist,
1237            &comp_dist,
1238            &wide_window,
1239            0,
1240        );
1241
1242        // Narrow window: transmits only M0, M+1
1243        let narrow_window: HashSet<usize> = [0, 1].iter().cloned().collect();
1244        let narrow_result = calculate_transmission_dependent_distribution_with_factor(
1245            &b_ion_dist,
1246            &comp_dist,
1247            &narrow_window,
1248            0,
1249        );
1250
1251        // Very narrow: only M0
1252        let very_narrow: HashSet<usize> = [0].iter().cloned().collect();
1253        let very_narrow_result = calculate_transmission_dependent_distribution_with_factor(
1254            &b_ion_dist,
1255            &comp_dist,
1256            &very_narrow,
1257            0,
1258        );
1259
1260        // Verify decreasing transmission with narrower windows
1261        assert!(
1262            wide_result.transmission_factor > narrow_result.transmission_factor,
1263            "Wide window should have higher transmission"
1264        );
1265        assert!(
1266            narrow_result.transmission_factor > very_narrow_result.transmission_factor,
1267            "Narrow window should have higher transmission than very narrow"
1268        );
1269
1270        // Verify all factors are in valid range (0, 1]
1271        assert!(wide_result.transmission_factor > 0.0 && wide_result.transmission_factor <= 1.0);
1272        assert!(narrow_result.transmission_factor > 0.0 && narrow_result.transmission_factor <= 1.0);
1273        assert!(very_narrow_result.transmission_factor > 0.0 && very_narrow_result.transmission_factor <= 1.0);
1274
1275        println!("Realistic peptide transmission test results:");
1276        println!("  Wide window (M0-M2) transmission factor: {:.4}", wide_result.transmission_factor);
1277        println!("  Narrow window (M0-M1) transmission factor: {:.4}", narrow_result.transmission_factor);
1278        println!("  Very narrow (M0 only) transmission factor: {:.4}", very_narrow_result.transmission_factor);
1279    }
1280}