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
68fn 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
82fn erfc(x: f64) -> f64 {
84 1.0 - erf(x)
85}
86
87fn 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
102fn 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 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 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 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 if !rt_min.is_finite() || !rt_max.is_finite() {
205 return Vec::new();
206 }
207
208 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) .unwrap_or(0); 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) .unwrap_or(0); (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
241pub 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
266pub 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 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
313pub 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
338pub 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
345pub 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 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 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
397pub 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 let (ims_lower, ims_upper) = calculate_bounds_gaussian(mean, sigma, step_size, target_p, n_lower_start, n_upper_start);
445
446 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 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 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 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
488pub 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
543pub 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 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 let occ = calculate_frame_occurrence_emg(×, 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); }
597 let legacy_abund =
598 calculate_frame_abundance_emg(&time_map, &occ, mu, sigma, lambda, cycle, None);
599
600 let projected = project_emg_over_events(&intervals, mu, sigma, lambda, 0.9999, 0.01, None);
602
603 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 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 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 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 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 let (low, high) = calculate_bounds_gaussian(mean, sigma, 0.01, target, 5.0, 5.0);
663
664 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 let retention_times: Vec<f64> = (0..10).map(|x| x as f64).collect();
676 let mean = 5.0; let sigma = 1.0;
678 let target_p = 0.68;
679 let step_size = 0.1;
680
681 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 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 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 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 assert_eq!(abundances.len(), 2, "We should have 2 abundance values");
732 let (a1, a2) = (abundances[0], abundances[1]);
733
734 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 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 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, 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 #[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}