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    let lower_initial = mu - lower_start * sigma - 2.0;
139    let upper_initial = mu + upper_start * sigma;
140
141    let steps = ((upper_initial - lower_initial) / step_size).round() as usize;
142    let search_space: Vec<f64> = (0..=steps).map(|i| lower_initial + i as f64 * step_size).collect();
143
144    let calc_cdf = |low: usize, high: usize| -> f64 {
145        emg_cdf_range(search_space[low], search_space[high], mu, sigma, lambda, n_steps)
146    };
147
148    // Binary search for cutoff values
149    let (mut low, mut high) = (0, steps);
150    while low < high {
151        let mid = low + (high - low) / 2;
152        if calc_cdf(0, mid) < target {
153            low = mid + 1;
154        } else {
155            high = mid;
156        }
157    }
158    let upper_cutoff_index = low;
159
160    low = 0;
161    high = upper_cutoff_index;
162    while low < high {
163        let mid = high - (high - low) / 2;
164        let prob_mid_to_upper = calc_cdf(mid, upper_cutoff_index);
165
166        if prob_mid_to_upper < target {
167            high = mid - 1;
168        } else {
169            low = mid;
170        }
171    }
172    let lower_cutoff_index = high;
173
174    (search_space[lower_cutoff_index], search_space[upper_cutoff_index])
175}
176
177pub 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> {
178    let (rt_min, rt_max) = calculate_bounds_emg(rt, sigma, lambda_, step_size, target_p, 20.0, 60.0, n_steps);
179
180    // Finding the frame closest to rt_min
181    let first_frame = retention_times.iter()
182        .enumerate()
183        .min_by(|(_, &a), (_, &b)| (a - rt_min).abs().partial_cmp(&(b - rt_min).abs()).unwrap())
184        .map(|(idx, _)| idx + 1) // Rust is zero-indexed, so +1 to match Python's 1-indexing
185        .unwrap_or(0); // Fallback in case of an empty slice
186
187    // Finding the frame closest to rt_max
188    let last_frame = retention_times.iter()
189        .enumerate()
190        .min_by(|(_, &a), (_, &b)| (a - rt_max).abs().partial_cmp(&(b - rt_max).abs()).unwrap())
191        .map(|(idx, _)| idx + 1) // Same adjustment for 1-indexing
192        .unwrap_or(0); // Fallback
193
194    // Generating the range of frames
195    (first_frame..=last_frame).map(|x| x as i32).collect()
196}
197
198pub 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> {
199    let mut frame_abundance = Vec::new();
200
201    for &occurrence in occurrences {
202        if let Some(&time) = time_map.get(&occurrence) {
203            let start = time - rt_cycle_length;
204            let i = emg_cdf_range(start, time, rt, sigma, lambda_, n_steps);
205            frame_abundance.push(i);
206        }
207    }
208
209    frame_abundance
210}
211
212// retention_times: &[f64], rt: f64, sigma: f64, lambda_: f64
213pub 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>> {
214    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
215    let result = thread_pool.install(|| {
216        rts.into_par_iter().zip(sigmas.into_par_iter()).zip(lambdas.into_par_iter())
217            .map(|((rt, sigma), lambda)| {
218                calculate_frame_occurrence_emg(retention_times, rt, sigma, lambda, target_p, step_size, n_steps)
219            })
220            .collect()
221    });
222    result
223}
224
225pub 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>> {
226    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
227    let result = thread_pool.install(|| {
228        occurrences.into_par_iter().zip(rts.into_par_iter()).zip(sigmas.into_par_iter()).zip(lambdas.into_par_iter())
229            .map(|(((occurrences, rt), sigma), lambda)| {
230                calculate_frame_abundance_emg(time_map, &occurrences, rt, sigma, lambda, rt_cycle_length, n_steps)
231            })
232            .collect()
233    });
234    result
235}
236
237/// Project an EMG retention-time profile onto an explicit event timeline
238/// (instrument-dispatch P2).
239///
240/// Each event carries its own `[start, end]` exposure interval (seconds), so
241/// unlike [`calculate_frame_abundance_emg`] — which integrates a *fixed*
242/// `[time - rt_cycle_length, time]` and is therefore wrong for unequal event
243/// durations / dead time — this integrates the EMG over each event's true
244/// interval. Only events overlapping the analyte's RT support (computed once via
245/// [`calculate_bounds_emg`] at `target_p`) are integrated, implementing the
246/// RT-support truncation policy. Returns `(event_index, abundance)` for events
247/// with positive abundance, in ascending event order.
248///
249/// # Arguments
250///
251/// * `event_intervals` - per-event `[start, end]` exposure intervals (seconds),
252///   indexed by event position in the run timeline.
253/// * `mu`, `sigma`, `lambda` - EMG parameters of the analyte's RT profile.
254/// * `target_p` - probability mass defining the RT support (e.g. 0.9999).
255/// * `step_size` - search step for the bounds binary search.
256/// * `n_steps` - integration steps for each interval CDF (defaults to 1000).
257pub fn project_emg_over_events(
258    event_intervals: &[(f64, f64)],
259    mu: f64,
260    sigma: f64,
261    lambda: f64,
262    target_p: f64,
263    step_size: f64,
264    n_steps: Option<usize>,
265) -> Vec<(usize, f64)> {
266    // RT support: the [lower, upper] window capturing `target_p` of the mass.
267    // Events outside it are skipped (their CDF mass is negligible).
268    let (lower, upper) = calculate_bounds_emg(mu, sigma, lambda, step_size, target_p, 20.0, 60.0, n_steps);
269    event_intervals
270        .iter()
271        .enumerate()
272        .filter(|(_, &(start, end))| end >= lower && start <= upper)
273        .filter_map(|(idx, &(start, end))| {
274            let abundance = emg_cdf_range(start, end, mu, sigma, lambda, n_steps);
275            if abundance > 0.0 {
276                Some((idx, abundance))
277            } else {
278                None
279            }
280        })
281        .collect()
282}
283
284/// Parallel [`project_emg_over_events`] over many analytes sharing one event
285/// timeline. `rts`/`sigmas`/`lambdas` are aligned per analyte; returns one
286/// `(event_index, abundance)` list per analyte.
287pub fn project_emg_over_events_par(
288    event_intervals: &[(f64, f64)],
289    rts: Vec<f64>,
290    sigmas: Vec<f64>,
291    lambdas: Vec<f64>,
292    target_p: f64,
293    step_size: f64,
294    num_threads: usize,
295    n_steps: Option<usize>,
296) -> Vec<Vec<(usize, f64)>> {
297    let thread_pool = ThreadPoolBuilder::new().num_threads(num_threads).build().unwrap();
298    thread_pool.install(|| {
299        rts.into_par_iter()
300            .zip(sigmas.into_par_iter())
301            .zip(lambdas.into_par_iter())
302            .map(|((rt, sigma), lambda)| {
303                project_emg_over_events(event_intervals, rt, sigma, lambda, target_p, step_size, n_steps)
304            })
305            .collect()
306    })
307}
308
309/// Returns the CDF in the range [sample_start, sample_end] for a Normal(mean, std_dev).
310pub fn normal_cdf_range(lower_limit: f64, upper_limit: f64, mean: f64, std_dev: f64) -> f64 {
311    let cdf_start = custom_cdf_normal(lower_limit, mean, std_dev);
312    let cdf_end = custom_cdf_normal(upper_limit, mean, std_dev);
313    cdf_end - cdf_start
314}
315
316/// Calculate the bounding interval [lower, upper] around `mean` that captures `target` total probability
317/// using a binary search across a discretized search space. This mirrors `calculate_bounds_emg`.
318pub fn calculate_bounds_gaussian(
319    mean: f64,
320    sigma: f64,
321    step_size: f64,
322    target: f64,
323    lower_start: f64,
324    upper_start: f64
325) -> (f64, f64) {
326    assert!((0.0..=1.0).contains(&target), "target must be in [0, 1]");
327
328    let lower_initial = mean - lower_start * sigma;
329    let upper_initial = mean + upper_start * sigma;
330
331    let steps = ((upper_initial - lower_initial) / step_size).ceil() as usize;
332    let search_space: Vec<f64> = (0..=steps)
333        .map(|i| lower_initial + i as f64 * step_size)
334        .collect();
335
336    let calc_cdf = |low: usize, high: usize| -> f64 {
337        normal_cdf_range(search_space[low], search_space[high], mean, sigma)
338    };
339
340    // 1) Find upper cutoff
341    let (mut low, mut high) = (0, steps);
342    while low < high {
343        let mid = low + (high - low) / 2;
344        if calc_cdf(0, mid) < target {
345            low = mid + 1;
346        } else {
347            high = mid;
348        }
349    }
350    let upper_cutoff_index = low;
351
352    // 2) Find lower cutoff
353    low = 0;
354    high = upper_cutoff_index;
355    while low < high {
356        let mid = high - (high - low) / 2;
357        if calc_cdf(mid, upper_cutoff_index) < target {
358            high = mid - 1;
359        } else {
360            low = mid;
361        }
362    }
363    let lower_cutoff_index = high;
364
365    (search_space[lower_cutoff_index], search_space[upper_cutoff_index])
366}
367
368/// Returns all scan indices (0-based) that fall into the range where Normal(mean, sigma)
369/// has at least `target_p` coverage.
370///
371/// For timsTOF data, `inverse_ion_mobility` runs backward (highest to lowest values correspond to scans).
372///
373/// # Arguments
374///
375/// - `inverse_ion_mobility`: The inverse ion mobility values for all scans (descending order).
376/// - `mean`: The mean of the Gaussian distribution.
377/// - `sigma`: The standard deviation of the Gaussian distribution.
378/// - `target_p`: The target probability to capture.
379/// - `step_size`: Step size for searching bounds.
380/// - `n_lower_start`: Initial lower bound factor (relative to sigma).
381/// - `n_upper_start`: Initial upper bound factor (relative to sigma).
382///
383/// # Returns
384///
385/// A `Vec<usize>` containing all scan indices (0-based) within the computed range.
386///
387/// # Example
388///
389/// ```rust
390/// use mscore::algorithm::utility::calculate_scan_occurrence_gaussian;
391///
392/// let inverse_ion_mobility = vec![0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3];
393/// let scans = calculate_scan_occurrence_gaussian(
394///     &inverse_ion_mobility,
395///     1.1,  // mean
396///     0.001,  // sigma
397///     0.9999, // target probability
398///     0.01, // step size
399///     3.0,  // n_lower_start
400///     3.0   // n_upper_start
401/// );
402///
403/// assert_eq!(scans, vec![2]); // Scans corresponding to 1.3 ± 1σ
404/// ```
405pub fn calculate_scan_occurrence_gaussian(
406    inverse_ion_mobility: &[f64],
407    mean: f64,
408    sigma: f64,
409    target_p: f64,
410    step_size: f64,
411    n_lower_start: f64,
412    n_upper_start: f64,
413) -> Vec<i32> {
414    // Calculate bounds for the Gaussian
415    let (ims_lower, ims_upper) = calculate_bounds_gaussian(mean, sigma, step_size, target_p, n_lower_start, n_upper_start);
416
417    // Create a list of tuples (inverse_ion_mobility_value, index) in reverse order
418    let indexed_values: Vec<(f64, usize)> = inverse_ion_mobility
419        .iter()
420        .rev()
421        .enumerate()
422        .map(|(i, &val)| (val, i))
423        .collect();
424
425    // Find the closest index to ims_lower
426    let upper_idx = indexed_values
427        .iter()
428        .enumerate()
429        .min_by(|(_, (val_a, _)), (_, (val_b, _))| {
430            (val_a - ims_lower).abs().partial_cmp(&(val_b - ims_lower).abs()).unwrap()
431        })
432        .map(|(idx, _)| idx)
433        .unwrap_or(0);
434
435    // Find the closest index to ims_upper
436    let lower_idx = indexed_values
437        .iter()
438        .enumerate()
439        .min_by(|(_, (val_a, _)), (_, (val_b, _))| {
440            (val_a - ims_upper).abs().partial_cmp(&(val_b - ims_upper).abs()).unwrap()
441        })
442        .map(|(idx, _)| idx)
443        .unwrap_or(indexed_values.len() - 1);
444
445    // Extract the indices of the scans in the found range
446    if lower_idx <= upper_idx {
447        indexed_values[lower_idx..=upper_idx]
448            .iter()
449            .map(|&(_, idx)| idx as i32)
450            .collect()
451    } else {
452        Vec::new()
453    }
454}
455
456
457/// Compute the abundance in each occurrence frame by looking at
458/// the probability of Normal(mean, sigma) within `[time - rt_cycle_length, time]`.
459pub fn calculate_abundance_gaussian(
460    time_map: &HashMap<i32, f64>,
461    occurrences: &[i32],
462    mean: f64,
463    sigma: f64,
464    cycle_length: f64,
465) -> Vec<f64> {
466    let mut frame_abundance = Vec::new();
467
468    for &occurrence in occurrences {
469        if let Some(&time) = time_map.get(&occurrence) {
470            let start = time - cycle_length;
471            let val = normal_cdf_range(start, time, mean, sigma);
472            frame_abundance.push(val);
473        }
474    }
475
476    frame_abundance
477}
478
479pub fn calculate_scan_occurrences_gaussian_par(
480    times: &[f64],
481    means: Vec<f64>,
482    sigmas: Vec<f64>,
483    target_p: f64,
484    step_size: f64,
485    n_lower_start: f64,
486    n_upper_start: f64,
487    num_threads: usize
488) -> Vec<Vec<i32>> {
489    let thread_pool = ThreadPoolBuilder::new()
490        .num_threads(num_threads)
491        .build()
492        .unwrap();
493
494    thread_pool.install(|| {
495        means.into_par_iter()
496            .zip(sigmas.into_par_iter())
497            .map(|(m, s)| {
498                calculate_scan_occurrence_gaussian(
499                    times,
500                    m,
501                    s,
502                    target_p,
503                    step_size,
504                    n_lower_start,
505                    n_upper_start
506                )
507            })
508            .collect()
509    })
510}
511
512/// Parallel version for multiple (mean, sigma) pairs to get abundance
513pub fn calculate_scan_abundances_gaussian_par(
514    time_map: &HashMap<i32, f64>,
515    occurrences: Vec<Vec<i32>>,
516    means: Vec<f64>,
517    sigmas: Vec<f64>,
518    cycle_length: f64,
519    num_threads: usize
520) -> Vec<Vec<f64>> {
521    let thread_pool = ThreadPoolBuilder::new()
522        .num_threads(num_threads)
523        .build()
524        .unwrap();
525
526    thread_pool.install(|| {
527        occurrences.into_par_iter()
528            .zip(means.into_par_iter())
529            .zip(sigmas.into_par_iter())
530            .map(|((occ, m), s)| {
531                calculate_abundance_gaussian(
532                    time_map,
533                    &occ,
534                    m,
535                    s,
536                    cycle_length
537                )
538            })
539            .collect()
540    })
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    fn approx_eq(a: f64, b: f64, epsilon: f64) -> bool {
548        (a - b).abs() < epsilon
549    }
550
551    #[test]
552    fn test_project_emg_reduces_to_legacy_under_uniform_intervals() {
553        // A uniform frame timeline: frames at t = 1..=N seconds, each frame's
554        // exposure interval being [t - cycle, t] (the legacy assumption).
555        let (mu, sigma, lambda) = (25.0, 1.5, 0.3);
556        let cycle = 1.0_f64;
557        let times: Vec<f64> = (1..=60).map(|i| i as f64).collect();
558        let intervals: Vec<(f64, f64)> = times.iter().map(|&t| (t - cycle, t)).collect();
559
560        // Legacy path: occurrence frames + per-frame abundance over [t-cycle, t].
561        let occ = calculate_frame_occurrence_emg(&times, mu, sigma, lambda, 0.9999, 0.01, None);
562        let mut time_map = std::collections::HashMap::new();
563        for (i, &t) in times.iter().enumerate() {
564            time_map.insert((i + 1) as i32, t); // 1-indexed frame ids
565        }
566        let legacy_abund =
567            calculate_frame_abundance_emg(&time_map, &occ, mu, sigma, lambda, cycle, None);
568
569        // New event-interval path.
570        let projected = project_emg_over_events(&intervals, mu, sigma, lambda, 0.9999, 0.01, None);
571
572        // The non-negligible projected events must reproduce the legacy
573        // per-frame abundances (frame id == event_index + 1).
574        for (&frame_id, &abund) in occ.iter().zip(legacy_abund.iter()) {
575            if abund <= 0.0 {
576                continue;
577            }
578            let event_idx = (frame_id - 1) as usize;
579            let found = projected.iter().find(|(idx, _)| *idx == event_idx);
580            assert!(found.is_some(), "event {event_idx} (frame {frame_id}) missing from projection");
581            let (_, p_abund) = found.unwrap();
582            assert!(
583                (p_abund - abund).abs() < 1e-9,
584                "abundance mismatch at frame {frame_id}: legacy {abund} vs projected {p_abund}"
585            );
586        }
587    }
588
589    #[test]
590    fn test_project_emg_respects_unequal_event_durations() {
591        // Two events covering the SAME [start, end] as one wide event must split
592        // its mass (area-preserving) — the legacy fixed-cycle kernel can't do this.
593        let (mu, sigma, lambda) = (10.0, 1.0, 0.2);
594        let wide = vec![(8.0, 12.0)];
595        let split = vec![(8.0, 10.0), (10.0, 12.0)];
596        let wide_p = project_emg_over_events(&wide, mu, sigma, lambda, 0.9999, 0.01, None);
597        let split_p = project_emg_over_events(&split, mu, sigma, lambda, 0.9999, 0.01, None);
598        let wide_sum: f64 = wide_p.iter().map(|(_, a)| a).sum();
599        let split_sum: f64 = split_p.iter().map(|(_, a)| a).sum();
600        // Mass is conserved up to trapezoidal-quadrature discretization (each
601        // interval is integrated with n_steps; the split is in fact finer).
602        assert!((wide_sum - split_sum).abs() < 1e-3, "split must conserve mass: {wide_sum} vs {split_sum}");
603        assert_eq!(split_p.len(), 2, "both sub-intervals should carry mass");
604    }
605
606    #[test]
607    fn test_normal_cdf_range() {
608        let mean = 0.0;
609        let std_dev = 1.0;
610
611        // For a standard normal, nearly all probability is within ~[-10, 10].
612        // So normal_cdf_range(-10, 10, 0, 1) should be ~1.0
613        let prob_all = normal_cdf_range(-10.0, 10.0, mean, std_dev);
614        assert!(approx_eq(prob_all, 1.0, 1e-6),
615                "CDF range from -10 to 10 should capture nearly all probability, got {prob_all}");
616
617        // Check an interval around mean ± 1σ -> about 68% of mass
618        let prob_1sigma = normal_cdf_range(-1.0, 1.0, mean, std_dev);
619        assert!(
620            (prob_1sigma - 0.68).abs() < 0.02,
621            "Expected ~0.68 within ±1σ, got {prob_1sigma}"
622        );
623    }
624
625    #[test]
626    fn test_calculate_bounds_gaussian() {
627        let mean = 0.0;
628        let sigma = 1.0;
629        let target = 0.68;
630        // We'll discretize in steps of 0.1
631        let (low, high) = calculate_bounds_gaussian(mean, sigma, 0.01, target, 5.0, 5.0);
632
633        // Check that the coverage is close to 0.68
634        let coverage = normal_cdf_range(low, high, mean, sigma);
635        assert!(
636            (coverage - target).abs() < 0.1,
637            "Expected coverage ~0.68, got {coverage} for interval [{low}, {high}]"
638        );
639    }
640
641    #[test]
642    fn test_calculate_frame_occurrence_gaussian() {
643        // Suppose we have 10 frames of retention times from 0.0 to 9.0
644        let retention_times: Vec<f64> = (0..10).map(|x| x as f64).collect();
645        let mean = 5.0;   // Centered around the 5th second
646        let sigma = 1.0;
647        let target_p = 0.68;
648        let step_size = 0.1;
649
650        // This should capture frames near t=5.0, about ±1.0 in the "most probable" sense.
651        // "lower_start" and "upper_start" here are up to you; let's do 5.0 each side
652        let frames = calculate_scan_occurrence_gaussian(
653            &retention_times,
654            mean,
655            sigma,
656            target_p,
657            step_size,
658            5.0,
659            5.0
660        );
661
662        // Expect frames near 4, 5, 6
663        // Because those times (4.0, 5.0, 6.0) are the main chunk of ±1σ around 5.0
664        assert!(
665            !frames.is_empty(),
666            "We expect at least a few frames around 5.0"
667        );
668        assert!(
669            frames.contains(&5),
670            "We definitely expect the central frame (index=5 in 1-based indexing) to be included"
671        );
672    }
673
674    #[test]
675    fn test_calculate_frame_abundance_gaussian() {
676        // Set up a mock time map: frame_index -> time
677        // We'll pretend each frame index i runs from i-1 to i in real-time
678        let mut time_map = HashMap::new();
679        for i in 1..=5 {
680            time_map.insert(i as i32, i as f64);
681        }
682
683        // Suppose we only have two frames to check
684        let occurrences = vec![1, 3];
685        let mean = 3.0;
686        let sigma = 1.0;
687        let im_cycle_length = 1.0;
688
689        let abundances = calculate_abundance_gaussian(
690            &time_map,
691            &occurrences,
692            mean,
693            sigma,
694            im_cycle_length
695        );
696
697        // We'll do a basic sanity check:
698        // - For frame 1, it integrates from time=0 to time=1.
699        // - For frame 3, from 2 to 3.
700        assert_eq!(abundances.len(), 2, "We should have 2 abundance values");
701        let (a1, a2) = (abundances[0], abundances[1]);
702
703        // The second abundance (covering [2,3]) should be bigger,
704        // because it's closer to mean=3.0
705        assert!(
706            a2 > a1,
707            "Expected frame near t=3 to have higher abundance than t=1"
708        );
709    }
710
711    #[test]
712    fn test_parallel_functions() {
713        // Just a quick sanity check
714        let retention_times: Vec<f64> = (0..10).map(|x| x as f64).collect();
715        let means = vec![3.0, 5.0];
716        let sigmas = vec![1.0, 1.5];
717
718        let target_p = 0.68;
719        let step_size = 0.1;
720        let num_threads = 2;
721
722        let res_occurrences = calculate_scan_occurrences_gaussian_par(
723            &retention_times,
724            means.clone(),
725            sigmas.clone(),
726            target_p,
727            step_size,
728            5.0,
729            5.0,
730            num_threads
731        );
732        assert_eq!(res_occurrences.len(), 2, "Should produce 2 sets of occurrences");
733
734        // Mock time_map for abundances
735        let mut time_map = HashMap::new();
736        for i in 1..=10 {
737            time_map.insert(i, i as f64);
738        }
739
740        let res_abundances = calculate_scan_abundances_gaussian_par(
741            &time_map,
742            res_occurrences,
743            means,
744            sigmas,
745            1.0,          // rt_cycle_length
746            num_threads
747        );
748        assert_eq!(res_abundances.len(), 2, "Should produce 2 sets of abundances");
749    }
750}