Skip to main content

mscore/algorithm/
utility.rs

1use std::collections::HashMap;
2use std::f64::consts::SQRT_2;
3use rayon::prelude::*;
4use rayon::ThreadPoolBuilder;
5
6use std::collections::VecDeque;
7
8fn gauss_kronrod(f: &dyn Fn(f64) -> f64, a: f64, b: f64) -> (f64, f64) {
9    let nodes = [
10        0.0, 0.20778495500789848, 0.40584515137739717, 0.58608723546769113,
11        0.74153118559939444, 0.86486442335976907, 0.94910791234275852, 0.99145537112081264,
12    ];
13    let weights_gauss = [
14        0.41795918367346939, 0.38183005050511894, 0.27970539148927667, 0.12948496616886969,
15    ];
16    let weights_kronrod = [
17        0.20948214108472783, 0.20443294007529889, 0.19035057806478541, 0.16900472663926790,
18        0.14065325971552592, 0.10479001032225018, 0.06309209262997855, 0.02293532201052922,
19    ];
20
21    let c1 = (b - a) / 2.0;
22    let c2 = (b + a) / 2.0;
23
24    let mut integral_gauss = 0.0;
25    let mut integral_kronrod = 0.0;
26
27    for i in 0..4 {
28        let x = c1 * nodes[i] + c2;
29        integral_gauss += weights_gauss[i] * (f(x) + f(2.0 * c2 - x));
30    }
31
32    for i in 0..8 {
33        let x = c1 * nodes[i] + c2;
34        integral_kronrod += weights_kronrod[i] * (f(x) + f(2.0 * c2 - x));
35    }
36
37    integral_gauss *= c1;
38    integral_kronrod *= c1;
39
40    (integral_kronrod, (integral_kronrod - integral_gauss).abs())
41}
42
43pub fn adaptive_integration(f: &dyn Fn(f64) -> f64, a: f64, b: f64, epsabs: f64, epsrel: f64) -> (f64, f64) {
44    let mut intervals = VecDeque::new();
45    intervals.push_back((a, b));
46
47    let mut result = 0.0;
48    let mut total_error = 0.0;
49
50    while let Some((a, b)) = intervals.pop_front() {
51        let (integral, error) = gauss_kronrod(f, a, b);
52        if error < epsabs || error < epsrel * integral.abs() {
53            result += integral;
54            total_error += error;
55        } else {
56            let mid = (a + b) / 2.0;
57            intervals.push_back((a, mid));
58            intervals.push_back((mid, b));
59        }
60    }
61
62    (result, total_error)
63}
64
65
66
67
68// Numerical integration using the trapezoidal rule
69fn integrate<F>(f: F, a: f64, b: f64, n: usize) -> f64
70    where
71        F: Fn(f64) -> f64,
72{
73    let dx = (b - a) / n as f64;
74    let mut sum = 0.0;
75    for i in 0..n {
76        let x = a + i as f64 * dx;
77        sum += f(x);
78    }
79    sum * dx
80}
81
82// Complementary error function (erfc)
83fn erfc(x: f64) -> f64 {
84    1.0 - erf(x)
85}
86
87// Error function (erf)
88fn erf(x: f64) -> f64 {
89    let t = 1.0 / (1.0 + 0.5 * x.abs());
90    let tau = t * (-x * x - 1.26551223 + t * (1.00002368 +
91        t * (0.37409196 + t * (0.09678418 + t * (-0.18628806 +
92            t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 +
93                t * (-0.82215223 + t * 0.17087277)))))))))
94        .exp();
95    if x >= 0.0 {
96        1.0 - tau
97    } else {
98        tau - 1.0
99    }
100}
101
102// Exponentially modified Gaussian function
103fn emg(x: f64, mu: f64, sigma: f64, lambda: f64) -> f64 {
104    let part1 = lambda / 2.0 * (-lambda * (x - mu) + lambda * lambda * sigma * sigma / 2.0).exp();
105    let part2 = erfc((mu + lambda * sigma * sigma - x) / (sigma * 2.0_f64.sqrt()));
106    part1 * part2
107}
108
109pub fn custom_cdf_normal(x: f64, mean: f64, std_dev: f64) -> f64 {
110    let z = (x - mean) / std_dev;
111    0.5 * (1.0 + erf(z / SQRT_2))
112}
113
114pub fn accumulated_intensity_cdf_normal(sample_start: f64, sample_end: f64, mean: f64, std_dev: f64) -> f64 {
115    let cdf_start = custom_cdf_normal(sample_start, mean, std_dev);
116    let cdf_end = custom_cdf_normal(sample_end, mean, std_dev);
117    cdf_end - cdf_start
118}
119
120pub fn calculate_bounds_normal(mean: f64, std: f64, z_score: f64) -> (f64, f64) {
121    (mean - z_score * std, mean + z_score * std)
122}
123
124pub fn emg_function(x: f64, mu: f64, sigma: f64, lambda: f64) -> f64 {
125    let prefactor = lambda / 2.0 * ((lambda / 2.0) * (2.0 * mu + lambda * sigma.powi(2) - 2.0 * x)).exp();
126    let erfc_part = erfc((mu + lambda * sigma.powi(2) - x) / (SQRT_2 * sigma));
127    prefactor * erfc_part
128}
129
130pub fn emg_cdf_range(lower_limit: f64, upper_limit: f64, mu: f64, sigma: f64, lambda: f64, n_steps: Option<usize>) -> f64 {
131    let n_steps = n_steps.unwrap_or(1000);
132    integrate(|x| emg(x, mu, sigma, lambda), lower_limit, upper_limit, n_steps)
133}
134
135pub fn calculate_bounds_emg(mu: f64, sigma: f64, lambda: f64, step_size: f64, target: f64, lower_start: f64, upper_start: f64, n_steps: Option<usize>) -> (f64, f64) {
136    assert!(0.0 <= target && target <= 1.0, "target must be in [0, 1]");
137
138    // Degenerate parameters must not reach the allocation below. An infinite sigma makes
139    // `(upper - lower) / step_size` infinite, `as usize` saturates it to usize::MAX, and the
140    // `collect` then aborts the whole process with a capacity overflow rather than returning.
141    // Hand back non-finite bounds and let the caller decide what an unusable peak means.
142    if !mu.is_finite() || !sigma.is_finite() || !lambda.is_finite() || !(step_size > 0.0) {
143        return (f64::NAN, f64::NAN);
144    }
145
146    let lower_initial = mu - lower_start * sigma - 2.0;
147    let upper_initial = mu + upper_start * sigma;
148
149    let span = upper_initial - lower_initial;
150    if !span.is_finite() || span < 0.0 {
151        return (f64::NAN, f64::NAN);
152    }
153    // A finite but absurd span would still allocate unboundedly: 1e8 points is already 800 MB.
154    // Anything past that is a nonsensical elution window, not a slow one.
155    const MAX_STEPS: f64 = 1e8;
156    let steps_f = (span / step_size).round();
157    if !steps_f.is_finite() || steps_f > MAX_STEPS {
158        return (f64::NAN, f64::NAN);
159    }
160
161    let steps = steps_f as usize;
162    let search_space: Vec<f64> = (0..=steps).map(|i| lower_initial + i as f64 * step_size).collect();
163
164    let calc_cdf = |low: usize, high: usize| -> f64 {
165        emg_cdf_range(search_space[low], search_space[high], mu, sigma, lambda, n_steps)
166    };
167
168    // Binary search for cutoff values
169    let (mut low, mut high) = (0, steps);
170    while low < high {
171        let mid = low + (high - low) / 2;
172        if calc_cdf(0, mid) < target {
173            low = mid + 1;
174        } else {
175            high = mid;
176        }
177    }
178    let upper_cutoff_index = low;
179
180    low = 0;
181    high = upper_cutoff_index;
182    while low < high {
183        let mid = high - (high - low) / 2;
184        let prob_mid_to_upper = calc_cdf(mid, upper_cutoff_index);
185
186        if prob_mid_to_upper < target {
187            high = mid - 1;
188        } else {
189            low = mid;
190        }
191    }
192    let lower_cutoff_index = high;
193
194    (search_space[lower_cutoff_index], search_space[upper_cutoff_index])
195}
196
197pub fn calculate_frame_occurrence_emg(retention_times: &[f64], rt: f64, sigma: f64, lambda_: f64, target_p: f64, step_size: f64, n_steps: Option<usize>) -> Vec<i32> {
198    let (rt_min, rt_max) = calculate_bounds_emg(rt, sigma, lambda_, step_size, target_p, 20.0, 60.0, n_steps);
199
200    // A degenerate EMG parameter upstream can make these bounds non-finite. `partial_cmp` returns
201    // None for NaN, so the comparators below used to unwrap a None and panic a worker thread
202    // mid-simulation. A peptide with no finite elution window occupies no frames; say that instead
203    // of aborting the run.
204    if !rt_min.is_finite() || !rt_max.is_finite() {
205        return Vec::new();
206    }
207
208    // Closest-frame search. `total_cmp` rather than `partial_cmp().unwrap()`: the inputs are finite
209    // by the guard above, but this can never panic regardless of what reaches it.
210    let first_frame = retention_times.iter()
211        .enumerate()
212        .min_by(|(_, &a), (_, &b)| (a - rt_min).abs().total_cmp(&(b - rt_min).abs()))
213        .map(|(idx, _)| idx + 1) // Rust is zero-indexed, so +1 to match Python's 1-indexing
214        .unwrap_or(0); // Fallback in case of an empty slice
215
216    // Finding the frame closest to rt_max
217    let last_frame = retention_times.iter()
218        .enumerate()
219        .min_by(|(_, &a), (_, &b)| (a - rt_max).abs().total_cmp(&(b - rt_max).abs()))
220        .map(|(idx, _)| idx + 1) // Same adjustment for 1-indexing
221        .unwrap_or(0); // Fallback
222
223    // Generating the range of frames
224    (first_frame..=last_frame).map(|x| x as i32).collect()
225}
226
227pub fn calculate_frame_abundance_emg(time_map: &HashMap<i32, f64>, occurrences: &[i32], rt: f64, sigma: f64, lambda_: f64, rt_cycle_length: f64, n_steps: Option<usize>) -> Vec<f64> {
228    let mut frame_abundance = Vec::new();
229
230    for &occurrence in occurrences {
231        if let Some(&time) = time_map.get(&occurrence) {
232            let start = time - rt_cycle_length;
233            let i = emg_cdf_range(start, time, rt, sigma, lambda_, n_steps);
234            frame_abundance.push(i);
235        }
236    }
237
238    frame_abundance
239}
240
241// retention_times: &[f64], rt: f64, sigma: f64, lambda_: f64
242pub fn calculate_frame_occurrences_emg_par(retention_times: &[f64], rts: Vec<f64>, sigmas: Vec<f64>, lambdas: Vec<f64>, target_p: f64, step_size: f64, num_threads: usize, n_steps: Option<usize>) -> Vec<Vec<i32>> {
243    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
244    let result = thread_pool.install(|| {
245        rts.into_par_iter().zip(sigmas.into_par_iter()).zip(lambdas.into_par_iter())
246            .map(|((rt, sigma), lambda)| {
247                calculate_frame_occurrence_emg(retention_times, rt, sigma, lambda, target_p, step_size, n_steps)
248            })
249            .collect()
250    });
251    result
252}
253
254pub fn calculate_frame_abundances_emg_par(time_map: &HashMap<i32, f64>, occurrences: Vec<Vec<i32>>, rts: Vec<f64>, sigmas: Vec<f64>, lambdas: Vec<f64>, rt_cycle_length: f64, num_threads: usize, n_steps: Option<usize>) -> Vec<Vec<f64>> {
255    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
256    let result = thread_pool.install(|| {
257        occurrences.into_par_iter().zip(rts.into_par_iter()).zip(sigmas.into_par_iter()).zip(lambdas.into_par_iter())
258            .map(|(((occurrences, rt), sigma), lambda)| {
259                calculate_frame_abundance_emg(time_map, &occurrences, rt, sigma, lambda, rt_cycle_length, n_steps)
260            })
261            .collect()
262    });
263    result
264}
265
266/// Project an EMG retention-time profile onto an explicit event timeline
267/// (instrument-dispatch P2).
268///
269/// Each event carries its own `[start, end]` exposure interval (seconds), so
270/// unlike [`calculate_frame_abundance_emg`] — which integrates a *fixed*
271/// `[time - rt_cycle_length, time]` and is therefore wrong for unequal event
272/// durations / dead time — this integrates the EMG over each event's true
273/// interval. Only events overlapping the analyte's RT support (computed once via
274/// [`calculate_bounds_emg`] at `target_p`) are integrated, implementing the
275/// RT-support truncation policy. Returns `(event_index, abundance)` for events
276/// with positive abundance, in ascending event order.
277///
278/// # Arguments
279///
280/// * `event_intervals` - per-event `[start, end]` exposure intervals (seconds),
281///   indexed by event position in the run timeline.
282/// * `mu`, `sigma`, `lambda` - EMG parameters of the analyte's RT profile.
283/// * `target_p` - probability mass defining the RT support (e.g. 0.9999).
284/// * `step_size` - search step for the bounds binary search.
285/// * `n_steps` - integration steps for each interval CDF (defaults to 1000).
286pub fn project_emg_over_events(
287    event_intervals: &[(f64, f64)],
288    mu: f64,
289    sigma: f64,
290    lambda: f64,
291    target_p: f64,
292    step_size: f64,
293    n_steps: Option<usize>,
294) -> Vec<(usize, f64)> {
295    // RT support: the [lower, upper] window capturing `target_p` of the mass.
296    // Events outside it are skipped (their CDF mass is negligible).
297    let (lower, upper) = calculate_bounds_emg(mu, sigma, lambda, step_size, target_p, 20.0, 60.0, n_steps);
298    event_intervals
299        .iter()
300        .enumerate()
301        .filter(|(_, &(start, end))| end >= lower && start <= upper)
302        .filter_map(|(idx, &(start, end))| {
303            let abundance = emg_cdf_range(start, end, mu, sigma, lambda, n_steps);
304            if abundance > 0.0 {
305                Some((idx, abundance))
306            } else {
307                None
308            }
309        })
310        .collect()
311}
312
313/// Parallel [`project_emg_over_events`] over many analytes sharing one event
314/// timeline. `rts`/`sigmas`/`lambdas` are aligned per analyte; returns one
315/// `(event_index, abundance)` list per analyte.
316pub fn project_emg_over_events_par(
317    event_intervals: &[(f64, f64)],
318    rts: Vec<f64>,
319    sigmas: Vec<f64>,
320    lambdas: Vec<f64>,
321    target_p: f64,
322    step_size: f64,
323    num_threads: usize,
324    n_steps: Option<usize>,
325) -> Vec<Vec<(usize, f64)>> {
326    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
327    thread_pool.install(|| {
328        rts.into_par_iter()
329            .zip(sigmas.into_par_iter())
330            .zip(lambdas.into_par_iter())
331            .map(|((rt, sigma), lambda)| {
332                project_emg_over_events(event_intervals, rt, sigma, lambda, target_p, step_size, n_steps)
333            })
334            .collect()
335    })
336}
337
338/// Returns the CDF in the range [sample_start, sample_end] for a Normal(mean, std_dev).
339pub fn normal_cdf_range(lower_limit: f64, upper_limit: f64, mean: f64, std_dev: f64) -> f64 {
340    let cdf_start = custom_cdf_normal(lower_limit, mean, std_dev);
341    let cdf_end = custom_cdf_normal(upper_limit, mean, std_dev);
342    cdf_end - cdf_start
343}
344
345/// Calculate the bounding interval [lower, upper] around `mean` that captures `target` total probability
346/// using a binary search across a discretized search space. This mirrors `calculate_bounds_emg`.
347pub fn calculate_bounds_gaussian(
348    mean: f64,
349    sigma: f64,
350    step_size: f64,
351    target: f64,
352    lower_start: f64,
353    upper_start: f64
354) -> (f64, f64) {
355    assert!((0.0..=1.0).contains(&target), "target must be in [0, 1]");
356
357    let lower_initial = mean - lower_start * sigma;
358    let upper_initial = mean + upper_start * sigma;
359
360    let steps = ((upper_initial - lower_initial) / step_size).ceil() as usize;
361    let search_space: Vec<f64> = (0..=steps)
362        .map(|i| lower_initial + i as f64 * step_size)
363        .collect();
364
365    let calc_cdf = |low: usize, high: usize| -> f64 {
366        normal_cdf_range(search_space[low], search_space[high], mean, sigma)
367    };
368
369    // 1) Find upper cutoff
370    let (mut low, mut high) = (0, steps);
371    while low < high {
372        let mid = low + (high - low) / 2;
373        if calc_cdf(0, mid) < target {
374            low = mid + 1;
375        } else {
376            high = mid;
377        }
378    }
379    let upper_cutoff_index = low;
380
381    // 2) Find lower cutoff
382    low = 0;
383    high = upper_cutoff_index;
384    while low < high {
385        let mid = high - (high - low) / 2;
386        if calc_cdf(mid, upper_cutoff_index) < target {
387            high = mid - 1;
388        } else {
389            low = mid;
390        }
391    }
392    let lower_cutoff_index = high;
393
394    (search_space[lower_cutoff_index], search_space[upper_cutoff_index])
395}
396
397/// Returns all scan indices (0-based) that fall into the range where Normal(mean, sigma)
398/// has at least `target_p` coverage.
399///
400/// For timsTOF data, `inverse_ion_mobility` runs backward (highest to lowest values correspond to scans).
401///
402/// # Arguments
403///
404/// - `inverse_ion_mobility`: The inverse ion mobility values for all scans (descending order).
405/// - `mean`: The mean of the Gaussian distribution.
406/// - `sigma`: The standard deviation of the Gaussian distribution.
407/// - `target_p`: The target probability to capture.
408/// - `step_size`: Step size for searching bounds.
409/// - `n_lower_start`: Initial lower bound factor (relative to sigma).
410/// - `n_upper_start`: Initial upper bound factor (relative to sigma).
411///
412/// # Returns
413///
414/// A `Vec<usize>` containing all scan indices (0-based) within the computed range.
415///
416/// # Example
417///
418/// ```rust
419/// use mscore::algorithm::utility::calculate_scan_occurrence_gaussian;
420///
421/// let inverse_ion_mobility = vec![0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3];
422/// let scans = calculate_scan_occurrence_gaussian(
423///     &inverse_ion_mobility,
424///     1.1,  // mean
425///     0.001,  // sigma
426///     0.9999, // target probability
427///     0.01, // step size
428///     3.0,  // n_lower_start
429///     3.0   // n_upper_start
430/// );
431///
432/// assert_eq!(scans, vec![2]); // Scans corresponding to 1.3 ± 1σ
433/// ```
434pub fn calculate_scan_occurrence_gaussian(
435    inverse_ion_mobility: &[f64],
436    mean: f64,
437    sigma: f64,
438    target_p: f64,
439    step_size: f64,
440    n_lower_start: f64,
441    n_upper_start: f64,
442) -> Vec<i32> {
443    // Calculate bounds for the Gaussian
444    let (ims_lower, ims_upper) = calculate_bounds_gaussian(mean, sigma, step_size, target_p, n_lower_start, n_upper_start);
445
446    // Create a list of tuples (inverse_ion_mobility_value, index) in reverse order
447    let indexed_values: Vec<(f64, usize)> = inverse_ion_mobility
448        .iter()
449        .rev()
450        .enumerate()
451        .map(|(i, &val)| (val, i))
452        .collect();
453
454    // `total_cmp`, not `partial_cmp().unwrap()`: the same NaN hazard as the retention-time search
455    // above — a non-finite bound would otherwise panic a worker thread rather than degrade.
456    // Find the closest index to ims_lower
457    let upper_idx = indexed_values
458        .iter()
459        .enumerate()
460        .min_by(|(_, (val_a, _)), (_, (val_b, _))| {
461            (val_a - ims_lower).abs().total_cmp(&(val_b - ims_lower).abs())
462        })
463        .map(|(idx, _)| idx)
464        .unwrap_or(0);
465
466    // Find the closest index to ims_upper
467    let lower_idx = indexed_values
468        .iter()
469        .enumerate()
470        .min_by(|(_, (val_a, _)), (_, (val_b, _))| {
471            (val_a - ims_upper).abs().total_cmp(&(val_b - ims_upper).abs())
472        })
473        .map(|(idx, _)| idx)
474        .unwrap_or(indexed_values.len() - 1);
475
476    // Extract the indices of the scans in the found range
477    if lower_idx <= upper_idx {
478        indexed_values[lower_idx..=upper_idx]
479            .iter()
480            .map(|&(_, idx)| idx as i32)
481            .collect()
482    } else {
483        Vec::new()
484    }
485}
486
487
488/// Compute the abundance in each occurrence frame by looking at
489/// the probability of Normal(mean, sigma) within `[time - rt_cycle_length, time]`.
490pub fn calculate_abundance_gaussian(
491    time_map: &HashMap<i32, f64>,
492    occurrences: &[i32],
493    mean: f64,
494    sigma: f64,
495    cycle_length: f64,
496) -> Vec<f64> {
497    let mut frame_abundance = Vec::new();
498
499    for &occurrence in occurrences {
500        if let Some(&time) = time_map.get(&occurrence) {
501            let start = time - cycle_length;
502            let val = normal_cdf_range(start, time, mean, sigma);
503            frame_abundance.push(val);
504        }
505    }
506
507    frame_abundance
508}
509
510pub fn calculate_scan_occurrences_gaussian_par(
511    times: &[f64],
512    means: Vec<f64>,
513    sigmas: Vec<f64>,
514    target_p: f64,
515    step_size: f64,
516    n_lower_start: f64,
517    n_upper_start: f64,
518    num_threads: usize
519) -> Vec<Vec<i32>> {
520    let thread_pool = ThreadPoolBuilder::new()
521        .num_threads(num_threads)
522        .build()
523        .unwrap();
524
525    thread_pool.install(|| {
526        means.into_par_iter()
527            .zip(sigmas.into_par_iter())
528            .map(|(m, s)| {
529                calculate_scan_occurrence_gaussian(
530                    times,
531                    m,
532                    s,
533                    target_p,
534                    step_size,
535                    n_lower_start,
536                    n_upper_start
537                )
538            })
539            .collect()
540    })
541}
542
543/// Parallel version for multiple (mean, sigma) pairs to get abundance
544pub fn calculate_scan_abundances_gaussian_par(
545    time_map: &HashMap<i32, f64>,
546    occurrences: Vec<Vec<i32>>,
547    means: Vec<f64>,
548    sigmas: Vec<f64>,
549    cycle_length: f64,
550    num_threads: usize
551) -> Vec<Vec<f64>> {
552    let thread_pool = ThreadPoolBuilder::new()
553        .num_threads(num_threads)
554        .build()
555        .unwrap();
556
557    thread_pool.install(|| {
558        occurrences.into_par_iter()
559            .zip(means.into_par_iter())
560            .zip(sigmas.into_par_iter())
561            .map(|((occ, m), s)| {
562                calculate_abundance_gaussian(
563                    time_map,
564                    &occ,
565                    m,
566                    s,
567                    cycle_length
568                )
569            })
570            .collect()
571    })
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
579        (a - b).abs() < epsilon
580    }
581
582    #[test]
583    fn test_project_emg_reduces_to_legacy_under_uniform_intervals() {
584        // A uniform frame timeline: frames at t = 1..=N seconds, each frame's
585        // exposure interval being [t - cycle, t] (the legacy assumption).
586        let (mu, sigma, lambda) = (25.0, 1.5, 0.3);
587        let cycle = 1.0_f64;
588        let times: Vec<f64> = (1..=60).map(|i| i as f64).collect();
589        let intervals: Vec<(f64, f64)> = times.iter().map(|&t| (t - cycle, t)).collect();
590
591        // Legacy path: occurrence frames + per-frame abundance over [t-cycle, t].
592        let occ = calculate_frame_occurrence_emg(&times, mu, sigma, lambda, 0.9999, 0.01, None);
593        let mut time_map = std::collections::HashMap::new();
594        for (i, &t) in times.iter().enumerate() {
595            time_map.insert((i + 1) as i32, t); // 1-indexed frame ids
596        }
597        let legacy_abund =
598            calculate_frame_abundance_emg(&time_map, &occ, mu, sigma, lambda, cycle, None);
599
600        // New event-interval path.
601        let projected = project_emg_over_events(&intervals, mu, sigma, lambda, 0.9999, 0.01, None);
602
603        // The non-negligible projected events must reproduce the legacy
604        // per-frame abundances (frame id == event_index + 1).
605        for (&frame_id, &abund) in occ.iter().zip(legacy_abund.iter()) {
606            if abund <= 0.0 {
607                continue;
608            }
609            let event_idx = (frame_id - 1) as usize;
610            let found = projected.iter().find(|(idx, _)| *idx == event_idx);
611            assert!(found.is_some(), "event {event_idx} (frame {frame_id}) missing from projection");
612            let (_, p_abund) = found.unwrap();
613            assert!(
614                (p_abund - abund).abs() < 1e-9,
615                "abundance mismatch at frame {frame_id}: legacy {abund} vs projected {p_abund}"
616            );
617        }
618    }
619
620    #[test]
621    fn test_project_emg_respects_unequal_event_durations() {
622        // Two events covering the SAME [start, end] as one wide event must split
623        // its mass (area-preserving) — the legacy fixed-cycle kernel can't do this.
624        let (mu, sigma, lambda) = (10.0, 1.0, 0.2);
625        let wide = vec![(8.0, 12.0)];
626        let split = vec![(8.0, 10.0), (10.0, 12.0)];
627        let wide_p = project_emg_over_events(&wide, mu, sigma, lambda, 0.9999, 0.01, None);
628        let split_p = project_emg_over_events(&split, mu, sigma, lambda, 0.9999, 0.01, None);
629        let wide_sum: f64 = wide_p.iter().map(|(_, a)| a).sum();
630        let split_sum: f64 = split_p.iter().map(|(_, a)| a).sum();
631        // Mass is conserved up to trapezoidal-quadrature discretization (each
632        // interval is integrated with n_steps; the split is in fact finer).
633        assert!((wide_sum - split_sum).abs() < 1e-3, "split must conserve mass: {wide_sum} vs {split_sum}");
634        assert_eq!(split_p.len(), 2, "both sub-intervals should carry mass");
635    }
636
637    #[test]
638    fn test_normal_cdf_range() {
639        let mean = 0.0;
640        let std_dev = 1.0;
641
642        // For a standard normal, nearly all probability is within ~[-10, 10].
643        // So normal_cdf_range(-10, 10, 0, 1) should be ~1.0
644        let prob_all = normal_cdf_range(-10.0, 10.0, mean, std_dev);
645        assert!(approx_eq(prob_all, 1.0, 1e-6),
646                "CDF range from -10 to 10 should capture nearly all probability, got {prob_all}");
647
648        // Check an interval around mean ± 1σ -> about 68% of mass
649        let prob_1sigma = normal_cdf_range(-1.0, 1.0, mean, std_dev);
650        assert!(
651            (prob_1sigma - 0.68).abs() < 0.02,
652            "Expected ~0.68 within ±1σ, got {prob_1sigma}"
653        );
654    }
655
656    #[test]
657    fn test_calculate_bounds_gaussian() {
658        let mean = 0.0;
659        let sigma = 1.0;
660        let target = 0.68;
661        // We'll discretize in steps of 0.1
662        let (low, high) = calculate_bounds_gaussian(mean, sigma, 0.01, target, 5.0, 5.0);
663
664        // Check that the coverage is close to 0.68
665        let coverage = normal_cdf_range(low, high, mean, sigma);
666        assert!(
667            (coverage - target).abs() < 0.1,
668            "Expected coverage ~0.68, got {coverage} for interval [{low}, {high}]"
669        );
670    }
671
672    #[test]
673    fn test_calculate_frame_occurrence_gaussian() {
674        // Suppose we have 10 frames of retention times from 0.0 to 9.0
675        let retention_times: Vec<f64> = (0..10).map(|x| x as f64).collect();
676        let mean = 5.0;   // Centered around the 5th second
677        let sigma = 1.0;
678        let target_p = 0.68;
679        let step_size = 0.1;
680
681        // This should capture frames near t=5.0, about ±1.0 in the "most probable" sense.
682        // "lower_start" and "upper_start" here are up to you; let's do 5.0 each side
683        let frames = calculate_scan_occurrence_gaussian(
684            &retention_times,
685            mean,
686            sigma,
687            target_p,
688            step_size,
689            5.0,
690            5.0
691        );
692
693        // Expect frames near 4, 5, 6
694        // Because those times (4.0, 5.0, 6.0) are the main chunk of ±1σ around 5.0
695        assert!(
696            !frames.is_empty(),
697            "We expect at least a few frames around 5.0"
698        );
699        assert!(
700            frames.contains(&5),
701            "We definitely expect the central frame (index=5 in 1-based indexing) to be included"
702        );
703    }
704
705    #[test]
706    fn test_calculate_frame_abundance_gaussian() {
707        // Set up a mock time map: frame_index -> time
708        // We'll pretend each frame index i runs from i-1 to i in real-time
709        let mut time_map = HashMap::new();
710        for i in 1..=5 {
711            time_map.insert(i as i32, i as f64);
712        }
713
714        // Suppose we only have two frames to check
715        let occurrences = vec![1, 3];
716        let mean = 3.0;
717        let sigma = 1.0;
718        let im_cycle_length = 1.0;
719
720        let abundances = calculate_abundance_gaussian(
721            &time_map,
722            &occurrences,
723            mean,
724            sigma,
725            im_cycle_length
726        );
727
728        // We'll do a basic sanity check:
729        // - For frame 1, it integrates from time=0 to time=1.
730        // - For frame 3, from 2 to 3.
731        assert_eq!(abundances.len(), 2, "We should have 2 abundance values");
732        let (a1, a2) = (abundances[0], abundances[1]);
733
734        // The second abundance (covering [2,3]) should be bigger,
735        // because it's closer to mean=3.0
736        assert!(
737            a2 > a1,
738            "Expected frame near t=3 to have higher abundance than t=1"
739        );
740    }
741
742    #[test]
743    fn test_parallel_functions() {
744        // Just a quick sanity check
745        let retention_times: Vec<f64> = (0..10).map(|x| x as f64).collect();
746        let means = vec![3.0, 5.0];
747        let sigmas = vec![1.0, 1.5];
748
749        let target_p = 0.68;
750        let step_size = 0.1;
751        let num_threads = 2;
752
753        let res_occurrences = calculate_scan_occurrences_gaussian_par(
754            &retention_times,
755            means.clone(),
756            sigmas.clone(),
757            target_p,
758            step_size,
759            5.0,
760            5.0,
761            num_threads
762        );
763        assert_eq!(res_occurrences.len(), 2, "Should produce 2 sets of occurrences");
764
765        // Mock time_map for abundances
766        let mut time_map = HashMap::new();
767        for i in 1..=10 {
768            time_map.insert(i, i as f64);
769        }
770
771        let res_abundances = calculate_scan_abundances_gaussian_par(
772            &time_map,
773            res_occurrences,
774            means,
775            sigmas,
776            1.0,          // rt_cycle_length
777            num_threads
778        );
779        assert_eq!(res_abundances.len(), 2, "Should produce 2 sets of abundances");
780    }
781}
782
783#[cfg(test)]
784mod emg_degenerate_tests {
785    use super::*;
786
787    /// A degenerate EMG parameter upstream used to make the retention-time bounds non-finite, and
788    /// the closest-frame search then unwrapped a `None` from `partial_cmp` and panicked a worker
789    /// thread mid-simulation. It must degrade to "this peptide occupies no frames" instead.
790    #[test]
791    fn non_finite_bounds_return_no_frames_instead_of_panicking() {
792        let rts: Vec<f64> = (0..100).map(|i| i as f64 * 0.5).collect();
793        for (sigma, lambda_) in [(f64::NAN, 1.0), (1.0, f64::NAN), (0.0, 0.0), (f64::INFINITY, 1.0)] {
794            let frames = calculate_frame_occurrence_emg(&rts, 10.0, sigma, lambda_, 0.999, 0.0001, None);
795            assert!(
796                frames.is_empty() || frames.iter().all(|&f| f >= 0),
797                "sigma={sigma}, lambda={lambda_} produced {frames:?}"
798            );
799        }
800    }
801
802    #[test]
803    fn well_behaved_parameters_still_return_a_contiguous_frame_window() {
804        let rts: Vec<f64> = (0..100).map(|i| i as f64 * 0.5).collect();
805        let frames = calculate_frame_occurrence_emg(&rts, 10.0, 0.5, 0.5, 0.999, 0.0001, None);
806        assert!(!frames.is_empty(), "a valid peak must occupy at least one frame");
807        for w in frames.windows(2) {
808            assert_eq!(w[1], w[0] + 1, "frame window must be contiguous");
809        }
810    }
811}