1use crate::sim::containers::{IonScalar, MobilityEnv, PeptideScalar, ScansSim};
22use crate::sim::scheme::{
23 AcquisitionEvent, AcquisitionScheme, ActivationPolicy, DataMode, InstrumentCapabilities,
24 InstrumentKind, IsolationWindow, RepeatPolicy,
25};
26use mscore::algorithm::utility::{
27 calculate_abundance_gaussian, calculate_frame_abundances_emg_par,
28 calculate_frame_occurrences_emg_par, calculate_scan_abundances_gaussian_par,
29 calculate_scan_occurrence_gaussian, calculate_scan_occurrences_gaussian_par, normal_cdf_range,
30 project_emg_over_events_par,
31};
32use std::collections::HashMap;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MobilityModality {
42 None,
44 Tims,
46}
47
48#[derive(Debug, Clone)]
54pub struct SamplingGeometry {
55 pub modality: MobilityModality,
56 pub inv_mobility: Vec<f64>,
57}
58
59impl SamplingGeometry {
60 pub fn none() -> Self {
62 SamplingGeometry { modality: MobilityModality::None, inv_mobility: Vec::new() }
63 }
64
65 pub fn tims(inv_mobility: Vec<f64>) -> Self {
67 SamplingGeometry { modality: MobilityModality::Tims, inv_mobility }
68 }
69
70 pub fn from_scans(scans: &[ScansSim]) -> Self {
75 let mut inv: Vec<f64> = scans
76 .iter()
77 .map(|s| s.mobility as f64)
78 .filter(|m| m.is_finite())
79 .collect();
80 inv.sort_by(f64::total_cmp);
81 SamplingGeometry::tims(inv)
82 }
83
84 pub fn num_scans(&self) -> usize {
86 match self.modality {
87 MobilityModality::None => 1,
88 MobilityModality::Tims => self.inv_mobility.len(),
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy)]
112pub struct InstrumentConfig {
113 pub kind: InstrumentKind,
114 pub capabilities: InstrumentCapabilities,
115 pub mobility: MobilityModality,
116 pub activation: ActivationPolicy,
117}
118
119impl InstrumentConfig {
120 pub fn bruker_pasef(ce_bias: f64, ce_slope: f64) -> Self {
124 InstrumentConfig {
125 kind: InstrumentKind::TimsTofDia,
126 capabilities: InstrumentCapabilities::bruker_timstof(),
127 mobility: MobilityModality::Tims,
128 activation: ActivationPolicy::bruker_pasef(ce_bias, ce_slope),
129 }
130 }
131
132 pub fn astral(ce: crate::sim::scheme::CollisionEnergyPolicy) -> Self {
141 InstrumentConfig {
142 kind: InstrumentKind::OrbitrapAstral,
143 capabilities: InstrumentCapabilities::astral(),
144 mobility: MobilityModality::None,
145 activation: ActivationPolicy::thermo_nce(ce),
146 }
147 }
148
149 pub fn is_scan_based(&self) -> bool {
152 self.mobility == MobilityModality::None
153 }
154}
155
156#[derive(Debug, Clone)]
163pub struct EventSlot {
164 pub global_index: usize,
166 pub cycle_index: u64,
167 pub event_in_cycle: usize,
169 pub ms_level: u8,
170 pub interval: (f64, f64),
172}
173
174#[derive(Debug, Clone)]
176pub struct EventTimeline {
177 pub events: Vec<EventSlot>,
178}
179
180impl EventTimeline {
181 pub fn from_scheme(scheme: &AcquisitionScheme) -> Result<Self, String> {
186 scheme.validate()?;
189 let RepeatPolicy::FixedCycleTime { cycle_time_s, gradient_length_s, start_time_s } =
190 scheme.repeat;
191 if !(cycle_time_s.is_finite() && cycle_time_s > 0.0) {
192 return Err("cycle_time_s must be finite and > 0".into());
193 }
194 if !start_time_s.is_finite() || start_time_s < 0.0 {
195 return Err("start_time_s must be finite and >= 0".into());
196 }
197 let n_cycles = scheme.num_cycles().ok_or("scheme has no derivable cycle count")?;
198 let events_per_cycle = scheme.cycle.len();
199 if events_per_cycle == 0 {
200 return Err("empty cycle".into());
201 }
202
203 let explicit: Vec<Option<f64>> = scheme
208 .cycle
209 .iter()
210 .map(|e| {
211 let d = match e {
212 AcquisitionEvent::Ms1(m) => m.duration_s,
213 AcquisitionEvent::DiaMs2Frame(f) => f.duration_s,
214 };
215 d.filter(|v| v.is_finite() && *v > 0.0)
216 })
217 .collect();
218 let explicit_sum: f64 = explicit.iter().flatten().sum();
219 let n_unspecified = explicit.iter().filter(|d| d.is_none()).count();
220 if explicit_sum > cycle_time_s + 1e-9 {
221 return Err(format!(
222 "explicit event durations ({explicit_sum}) exceed cycle_time_s ({cycle_time_s})"
223 ));
224 }
225 let per_unspecified = if n_unspecified > 0 {
229 let remaining = cycle_time_s - explicit_sum;
230 if remaining <= 1e-12 {
231 return Err(format!(
232 "explicit durations ({explicit_sum}) leave no time for {n_unspecified} \
233 unspecified event(s) in cycle_time_s {cycle_time_s}"
234 ));
235 }
236 remaining / n_unspecified as f64
237 } else {
238 0.0
239 };
240 let durations: Vec<f64> =
241 explicit.iter().map(|d| d.unwrap_or(per_unspecified)).collect();
242
243 let mut events = Vec::with_capacity(n_cycles as usize * events_per_cycle);
244 let mut global_index = 0usize;
245 for k in 0..n_cycles {
246 let cycle_start = start_time_s + k as f64 * cycle_time_s;
247 if cycle_start >= gradient_length_s {
250 break;
251 }
252 let mut t = cycle_start;
253 for (j, ev) in scheme.cycle.iter().enumerate() {
254 let start = t;
255 let end = t + durations[j];
256 t = end;
257 let ms_level = match ev {
258 AcquisitionEvent::Ms1(_) => 1,
259 AcquisitionEvent::DiaMs2Frame(_) => 2,
260 };
261 events.push(EventSlot {
262 global_index,
263 cycle_index: k,
264 event_in_cycle: j,
265 ms_level,
266 interval: (start, end),
267 });
268 global_index += 1;
269 }
270 }
271 Ok(EventTimeline { events })
272 }
273
274 pub fn ms1_intervals(&self) -> Vec<(f64, f64)> {
277 self.events
278 .iter()
279 .filter(|e| e.ms_level == 1)
280 .map(|e| e.interval)
281 .collect()
282 }
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum MzCoordSpace {
292 Physical,
294 NativeTof,
296 NativeFreq,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum IntensityStage {
304 Yield,
305 Time,
306 Mobility,
307 Transmitted,
308 Detected,
309 Centroided,
310}
311
312#[derive(Debug, Clone)]
315pub struct RenderedSpectrum {
316 pub mz: Vec<f64>,
317 pub intensity: Vec<f64>,
318 pub coords: MzCoordSpace,
319 pub mode: DataMode,
320 pub detector_applied: bool,
321 pub stage: IntensityStage,
322}
323
324#[derive(Debug, Clone)]
328pub enum RenderedEvent {
329 Scan {
330 ms_level: u8,
331 retention_time_s: f64,
332 isolation: Option<IsolationWindow>,
333 spectrum: RenderedSpectrum,
334 },
335 MobilityFrame {
336 ms_level: u8,
337 retention_time_s: f64,
338 scans: Vec<(u32, RenderedSpectrum)>,
339 },
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub enum ProjectionMode {
355 LegacyCompat,
356 Accurate,
357}
358
359#[derive(Debug, Clone, Copy)]
364pub struct ProjectionParams {
365 pub target_p: f64,
366 pub frame_step_size: f64,
367 pub scan_step_size: f64,
368 pub n_steps: Option<usize>,
369 pub remove_epsilon: f64,
370 pub num_threads: usize,
371 pub num_decimals: u32,
376}
377
378impl Default for ProjectionParams {
379 fn default() -> Self {
380 ProjectionParams {
381 target_p: 0.999,
382 frame_step_size: 0.001,
383 scan_step_size: 0.0001,
384 n_steps: Some(1000),
385 remove_epsilon: 1e-4,
386 num_threads: 4,
387 num_decimals: 4,
388 }
389 }
390}
391
392#[derive(Debug, Clone, Copy)]
401pub enum DistributionSource {
402 Columns,
403 Projector { mode: ProjectionMode, env: MobilityEnv, params: ProjectionParams },
404}
405
406pub fn project_time_legacy(
421 rt_mus: &[f64],
422 rt_sigmas: &[f64],
423 rt_lambdas: &[f64],
424 frame_ids: &[u32],
425 frame_times: &[f64],
426 rt_cycle_length: f64,
427 target_p: f64,
428 step_size: f64,
429 n_steps: Option<usize>,
430 remove_epsilon: f64,
431 num_threads: usize,
432) -> Vec<Vec<(u32, f64)>> {
433 assert_eq!(frame_ids.len(), frame_times.len(), "frame_ids/frame_times length mismatch");
434 let n = frame_ids.len();
435 let nt = num_threads.max(1);
436 let positions = calculate_frame_occurrences_emg_par(
438 frame_times,
439 rt_mus.to_vec(),
440 rt_sigmas.to_vec(),
441 rt_lambdas.to_vec(),
442 target_p,
443 step_size,
444 nt,
445 n_steps,
446 );
447 let occ_ids: Vec<Vec<i32>> = positions
449 .iter()
450 .map(|ps| {
451 ps.iter()
452 .map(|&p| {
453 assert!(p >= 1 && (p as usize) <= n, "occurrence position {p} out of range");
454 frame_ids[(p - 1) as usize] as i32
455 })
456 .collect()
457 })
458 .collect();
459 let time_map: HashMap<i32, f64> =
461 frame_ids.iter().zip(frame_times).map(|(&id, &t)| (id as i32, t)).collect();
462 let abund = calculate_frame_abundances_emg_par(
463 &time_map,
464 occ_ids.clone(),
465 rt_mus.to_vec(),
466 rt_sigmas.to_vec(),
467 rt_lambdas.to_vec(),
468 rt_cycle_length,
469 nt,
470 n_steps,
471 );
472 occ_ids
473 .into_iter()
474 .zip(abund)
475 .map(|(ids, ab)| {
476 ids.into_iter()
477 .map(|id| id as u32)
478 .zip(ab)
479 .filter(|(_, a)| *a > remove_epsilon)
480 .collect()
481 })
482 .collect()
483}
484
485pub fn project_mobility_legacy_par(
492 means: &[f64],
493 sigmas: &[f64],
494 scan_ids: &[u32],
495 scan_mobilities: &[f64],
496 im_cycle_length: f64,
497 target_p: f64,
498 step_size: f64,
499 num_threads: usize,
500) -> Vec<Vec<(i32, f64)>> {
501 assert_eq!(scan_ids.len(), scan_mobilities.len(), "scan_ids/mobilities length mismatch");
502 let nt = num_threads.max(1);
503 let occ = calculate_scan_occurrences_gaussian_par(
504 scan_mobilities,
505 means.to_vec(),
506 sigmas.to_vec(),
507 target_p,
508 step_size,
509 5.0,
510 5.0,
511 nt,
512 );
513 let time_map: HashMap<i32, f64> =
514 scan_ids.iter().zip(scan_mobilities).map(|(&id, &m)| (id as i32, m)).collect();
515 let abund = calculate_scan_abundances_gaussian_par(
516 &time_map,
517 occ.clone(),
518 means.to_vec(),
519 sigmas.to_vec(),
520 im_cycle_length,
521 nt,
522 );
523 occ.into_iter()
524 .zip(abund)
525 .map(|(o, a)| o.into_iter().zip(a).collect())
526 .collect()
527}
528
529pub fn project_mobility_ion_legacy(
541 mean: f64,
542 sigma: f64,
543 scan_ids: &[u32],
544 scan_mobilities: &[f64],
545 im_cycle_length: f64,
546 target_p: f64,
547 step_size: f64,
548) -> Vec<(i32, f64)> {
549 assert_eq!(scan_ids.len(), scan_mobilities.len(), "scan_ids/mobilities length mismatch");
550 if scan_ids.is_empty() {
551 return Vec::new();
552 }
553 if !(sigma > 0.0) {
555 let idx = nearest_scan_ascending(scan_mobilities, mean);
556 return vec![(scan_ids[idx] as i32, 1.0)];
557 }
558 let occ =
559 calculate_scan_occurrence_gaussian(scan_mobilities, mean, sigma, target_p, step_size, 5.0, 5.0);
560 let time_map: HashMap<i32, f64> =
561 scan_ids.iter().zip(scan_mobilities).map(|(&id, &m)| (id as i32, m)).collect();
562 let abund = calculate_abundance_gaussian(&time_map, &occ, mean, sigma, im_cycle_length);
563 occ.into_iter().zip(abund).collect()
564}
565
566fn nearest_scan_ascending(grid: &[f64], value: f64) -> usize {
568 grid.iter()
569 .enumerate()
570 .min_by(|(_, a), (_, b)| {
571 (**a - value)
572 .abs()
573 .partial_cmp(&(**b - value).abs())
574 .unwrap_or(std::cmp::Ordering::Equal)
575 })
576 .map(|(i, _)| i)
577 .unwrap_or(0)
578}
579
580pub fn project_time(
591 peptides: &[PeptideScalar],
592 timeline: &EventTimeline,
593 target_p: f64,
594 step_size: f64,
595 num_threads: usize,
596) -> Vec<Vec<(usize, f64)>> {
597 let intervals: Vec<(f64, f64)> = timeline.events.iter().map(|e| e.interval).collect();
599 let rts: Vec<f64> = peptides.iter().map(|p| p.rt_mu as f64).collect();
601 let sigmas: Vec<f64> = peptides.iter().map(|p| p.rt_sigma as f64).collect();
602 let lambdas: Vec<f64> = peptides.iter().map(|p| p.rt_lambda as f64).collect();
603 project_emg_over_events_par(
604 &intervals,
605 rts,
606 sigmas,
607 lambdas,
608 target_p,
609 step_size,
610 num_threads.max(1), None,
612 )
613}
614
615pub fn project_mobility_ion(
630 ion: &IonScalar,
631 geometry: &SamplingGeometry,
632 env: &MobilityEnv,
633 target_p: f64,
634 step_size: f64,
635) -> Vec<(i32, f64)> {
636 match geometry.modality {
637 MobilityModality::None => vec![(0, 1.0)],
638 MobilityModality::Tims => project_mobility_gaussian(
639 &geometry.inv_mobility,
640 ion.inv_mobility(env),
641 ion.inv_mobility_std as f64,
642 target_p,
643 step_size,
644 ),
645 }
646}
647
648pub fn project_mobility_gaussian(
654 grid_ascending: &[f64],
655 mean: f64,
656 sigma: f64,
657 target_p: f64,
658 step_size: f64,
659) -> Vec<(i32, f64)> {
660 let n = grid_ascending.len();
661 if n == 0 {
662 return Vec::new();
663 }
664 if n == 1 {
665 if !(sigma > 0.0) {
666 return vec![(0, 1.0)];
667 }
668 let a = normal_cdf_range(mean - 6.0 * sigma, mean + 6.0 * sigma, mean, sigma);
669 return vec![(0, a)];
670 }
671 if !(sigma > 0.0) {
673 let scan = nearest_scan_ascending(grid_ascending, mean);
674 return vec![(scan as i32, 1.0)];
675 }
676 let occ =
679 calculate_scan_occurrence_gaussian(grid_ascending, mean, sigma, target_p, step_size, 3.0, 3.0);
680 let rev: Vec<f64> = grid_ascending.iter().rev().copied().collect();
681 let mut out: Vec<(i32, f64)> = occ
682 .into_iter()
683 .map(|i| {
684 let i = i as usize;
685 let v = rev[i];
686 let hi = if i > 0 { (rev[i - 1] + v) / 2.0 } else { v + (v - rev[1]) / 2.0 };
689 let lo = if i + 1 < n { (v + rev[i + 1]) / 2.0 } else { v - (rev[i - 1] - v) / 2.0 };
690 let abundance = normal_cdf_range(lo, hi, mean, sigma);
691 ((n - 1 - i) as i32, abundance) })
693 .collect();
694 out.sort_by_key(|(scan, _)| *scan);
695 out
696}
697
698pub fn project_mobility_accurate_par(
702 means: &[f64],
703 sigmas: &[f64],
704 grid_ascending: &[f64],
705 target_p: f64,
706 step_size: f64,
707 num_threads: usize,
708) -> Vec<Vec<(i32, f64)>> {
709 use rayon::prelude::*;
710 use rayon::ThreadPoolBuilder;
711 let pool = ThreadPoolBuilder::new().num_threads(num_threads.max(1)).build().unwrap();
712 pool.install(|| {
713 means
714 .par_iter()
715 .zip(sigmas.par_iter())
716 .map(|(&m, &s)| project_mobility_gaussian(grid_ascending, m, s, target_p, step_size))
717 .collect()
718 })
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::sim::scheme::{
725 Analyzer, CollisionEnergyPolicy, DiaGeometry, DiaMs2Frame, DiaWindow,
726 EnergyUnit, InstrumentKind, Ms1Event, Provenance, SchemeSource,
727 };
728 use mscore::data::spectrum::MzSpectrum;
729
730 #[test]
731 fn instrument_config_bundles_the_dispatch_axes() {
732 let bruker = InstrumentConfig::bruker_pasef(54.1984, -0.0345);
734 assert_eq!(bruker.kind, InstrumentKind::TimsTofDia);
735 assert_eq!(bruker.mobility, MobilityModality::Tims);
736 assert!(bruker.capabilities.has_tims_mobility);
737 assert!(bruker.capabilities.has_quad_isotope_transmission);
738 assert!(!bruker.is_scan_based());
739 assert_eq!(bruker.activation.unit, EnergyUnit::ElectronVolt);
740 assert_eq!(bruker.activation.collision_energy_for_scan(250), Some(54.1984 - 0.0345 * 250.0));
741
742 let astral = InstrumentConfig::astral(CollisionEnergyPolicy::Value(27.0));
746 assert_eq!(astral.kind, InstrumentKind::OrbitrapAstral);
747 assert_eq!(astral.mobility, MobilityModality::None);
748 assert!(!astral.capabilities.has_tims_mobility);
749 assert!(!astral.capabilities.has_quad_isotope_transmission);
750 assert!(astral.is_scan_based());
751 assert_eq!(astral.activation.unit, EnergyUnit::NormalizedCe);
752 assert_eq!(astral.activation.collision_energy_for_scan(250), None);
754 }
755
756 fn scheme_one_ms1_two_ms2() -> AcquisitionScheme {
757 let win = |c: f64| DiaWindow {
758 isolation: IsolationWindow { center_mz: c, width_mz: 25.0 },
759 collision_energy: CollisionEnergyPolicy::Value(25.0),
760 geometry: DiaGeometry::MzOnly,
761 };
762 let ms2 = |c: f64| {
763 AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
764 windows: vec![win(c)],
765 analyzer: Analyzer::Tof,
766 data_mode: DataMode::Centroid,
767 duration_s: None,
768 vendor_group_id: None,
769 })
770 };
771 AcquisitionScheme {
772 version: 1,
773 instrument: InstrumentKind::TimsTofDia,
774 cycle: vec![
775 AcquisitionEvent::Ms1(Ms1Event {
776 analyzer: Analyzer::Tof,
777 data_mode: DataMode::Profile,
778 mz_range: Some((100.0, 1700.0)),
779 duration_s: None,
780 }),
781 ms2(450.0),
782 ms2(550.0),
783 ],
784 repeat: RepeatPolicy::FixedCycleTime {
785 cycle_time_s: 1.2,
786 gradient_length_s: 12.0,
787 start_time_s: 0.0,
788 },
789 mz_range: (100.0, 1700.0),
790 provenance: Provenance { source: SchemeSource::Programmatic, notes: String::new() },
791 }
792 }
793
794 #[test]
795 fn timeline_expands_to_contiguous_intervals() {
796 let scheme = scheme_one_ms1_two_ms2();
797 let tl = EventTimeline::from_scheme(&scheme).unwrap();
798 assert_eq!(tl.events.len(), 30);
800 let first3 = &tl.events[..3];
802 assert!((first3[0].interval.0 - 0.0).abs() < 1e-9);
803 assert!((first3[2].interval.1 - 1.2).abs() < 1e-9);
804 for w in first3.windows(2) {
805 assert!((w[0].interval.1 - w[1].interval.0).abs() < 1e-9, "events must be contiguous");
806 }
807 assert!((tl.events[3].interval.0 - 1.2).abs() < 1e-9);
809 assert_eq!(tl.ms1_intervals().len(), 10);
811 }
812
813 fn peptide(rt: f64, sigma: f64, lambda: f64) -> PeptideScalar {
814 use mscore::data::peptide::PeptideSequence;
815 PeptideScalar {
816 protein_id: 0,
817 peptide_id: 1,
818 sequence: PeptideSequence::new("PEPTIDEK".into(), Some(1)),
819 proteins: "P".into(),
820 decoy: false,
821 missed_cleavages: 0,
822 n_term: None,
823 c_term: None,
824 mono_isotopic_mass: 930.0,
825 retention_time: rt as f32,
826 rt_mu: rt,
827 rt_sigma: sigma,
828 rt_lambda: lambda,
829 events: 1.0,
830 condition_id: None,
831 }
832 }
833
834 #[test]
835 fn project_time_picks_events_near_rt() {
836 let scheme = scheme_one_ms1_two_ms2();
837 let tl = EventTimeline::from_scheme(&scheme).unwrap();
838 let pep = peptide(6.0, 0.3, 0.5);
840 let proj = project_time(&[pep], &tl, 0.9999, 0.01, 1);
841 assert_eq!(proj.len(), 1);
842 let hits = &proj[0];
843 assert!(!hits.is_empty(), "peptide must touch some events");
846 assert!(hits.len() < tl.events.len(), "RT support must be truncated, not all events");
847 for w in hits.windows(2) {
849 assert_eq!(w[1].0, w[0].0 + 1, "touched events must be contiguous in global index");
850 }
851 for (_, abund) in hits {
852 assert!(*abund > 0.0);
853 }
854 }
855
856 fn ion(ccs: f64, mz: f64, charge: i8, std: f64) -> IonScalar {
857 IonScalar {
858 ion_id: 1,
859 peptide_id: 1,
860 sequence: "PEPTIDEK".into(),
861 charge,
862 relative_abundance: 1.0,
863 mz,
864 ccs,
865 inv_mobility_std: std,
866 simulated_spectrum: MzSpectrum::new(vec![mz], vec![1.0]),
867 condition_id: None,
868 }
869 }
870
871 #[test]
872 fn timeline_mixed_durations_fill_remaining_cycle_time() {
873 let mut scheme = scheme_one_ms1_two_ms2();
875 if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
876 m.duration_s = Some(0.8);
877 }
878 let tl = EventTimeline::from_scheme(&scheme).unwrap();
879 let first3 = &tl.events[..3];
880 assert!((first3[0].interval.1 - first3[0].interval.0 - 0.8).abs() < 1e-9);
881 assert!((first3[1].interval.1 - first3[1].interval.0 - 0.2).abs() < 1e-9);
882 assert!((first3[2].interval.1 - 1.2).abs() < 1e-9);
884 }
885
886 #[test]
887 fn timeline_rejects_durations_overrunning_cycle() {
888 let mut scheme = scheme_one_ms1_two_ms2();
889 if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
890 m.duration_s = Some(2.0); }
892 assert!(EventTimeline::from_scheme(&scheme).is_err());
893 }
894
895 #[test]
896 fn timeline_honours_nonzero_start_time() {
897 let mut scheme = scheme_one_ms1_two_ms2();
898 scheme.repeat = RepeatPolicy::FixedCycleTime {
899 cycle_time_s: 1.2,
900 gradient_length_s: 12.0,
901 start_time_s: 3.0,
902 };
903 let tl = EventTimeline::from_scheme(&scheme).unwrap();
904 assert!((tl.events[0].interval.0 - 3.0).abs() < 1e-9);
905 assert_eq!(tl.ms1_intervals().len(), 7);
907 }
908
909 #[test]
910 fn project_mobility_nonuniform_grid_uses_local_bins() {
911 let mut grid = vec![0.60, 0.61, 0.62, 0.63, 0.64];
913 grid.extend([0.9, 1.2, 1.5]);
914 let geom = SamplingGeometry::tims(grid);
915 let env = MobilityEnv::default();
916 let i = ion(450.0, 600.0, 2, 0.05);
917 let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
918 assert!(!m.is_empty());
920 for w in m.windows(2) {
921 assert!(w[0].0 < w[1].0, "scan indices must be sorted ascending");
922 }
923 for (scan, a) in &m {
924 assert!((*scan as usize) < geom.num_scans());
925 assert!(*a >= 0.0);
926 }
927 }
928
929 #[test]
930 fn legacy_time_matches_raw_kernels() {
931 use mscore::algorithm::utility::{
932 calculate_frame_abundance_emg, calculate_frame_occurrence_emg,
933 };
934 let n = 60usize;
936 let frame_ids: Vec<u32> = (1..=n as u32).collect();
937 let frame_times: Vec<f64> = (1..=n).map(|i| i as f64 * 0.1).collect();
938 let rt_cycle = 0.1;
939 let (mu, sigma, lambda) = (3.0_f64, 0.4_f64, 0.6_f64);
941 let out = project_time_legacy(
942 &[mu], &[sigma], &[lambda], &frame_ids, &frame_times, rt_cycle, 0.9999, 0.01, None, -1.0, 1,
943 );
944
945 let positions = calculate_frame_occurrence_emg(&frame_times, mu, sigma, lambda, 0.9999, 0.01, None);
947 let occ_ids: Vec<i32> = positions.iter().map(|&p| frame_ids[(p - 1) as usize] as i32).collect();
948 let tmap: std::collections::HashMap<i32, f64> =
949 frame_ids.iter().zip(&frame_times).map(|(&id, &t)| (id as i32, t)).collect();
950 let abund = calculate_frame_abundance_emg(&tmap, &occ_ids, mu, sigma, lambda, rt_cycle, None);
951 let expected: Vec<(u32, f64)> = occ_ids.iter().map(|&id| id as u32).zip(abund).collect();
952 assert_eq!(out[0], expected, "LegacyCompat time must mirror the raw kernels");
953 }
954
955 #[test]
956 fn legacy_mobility_matches_raw_kernels() {
957 use mscore::algorithm::utility::{
958 calculate_abundance_gaussian, calculate_scan_occurrence_gaussian,
959 };
960 let nn = 200usize;
963 let scan_ids: Vec<u32> = (0..nn as u32).rev().collect();
964 let scan_mob: Vec<f64> = (0..nn).map(|i| 0.804 + i as f64 * 0.004).collect();
965 let im_cycle = 0.004;
966 let env = MobilityEnv::default();
967 let i = ion(450.0, 600.0, 2, 0.02);
968 let mean = i.inv_mobility(&env);
969 let sigma = i.inv_mobility_std as f64;
970 let out = project_mobility_ion_legacy(mean, sigma, &scan_ids, &scan_mob, im_cycle, 0.9999, 0.0001);
971
972 let occ = calculate_scan_occurrence_gaussian(&scan_mob, mean, sigma, 0.9999, 0.0001, 5.0, 5.0);
973 let tmap: std::collections::HashMap<i32, f64> =
974 scan_ids.iter().zip(&scan_mob).map(|(&id, &m)| (id as i32, m)).collect();
975 let abund = calculate_abundance_gaussian(&tmap, &occ, mean, sigma, im_cycle);
976 let expected: Vec<(i32, f64)> = occ.into_iter().zip(abund).collect();
977 assert_eq!(out, expected, "LegacyCompat mobility must mirror the raw kernels");
978 assert!(!out.is_empty());
979 }
980
981 #[test]
982 fn timeline_rejects_explicit_durations_starving_unspecified_events() {
983 let mut scheme = scheme_one_ms1_two_ms2();
985 if let AcquisitionEvent::Ms1(ref mut m) = scheme.cycle[0] {
986 m.duration_s = Some(1.2); }
988 assert!(EventTimeline::from_scheme(&scheme).is_err());
989 }
990
991 #[test]
992 fn project_mobility_zero_sigma_is_point_mass_not_nan() {
993 let grid: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
994 let geom = SamplingGeometry::tims(grid);
995 let env = MobilityEnv::default();
996 let i = ion(450.0, 600.0, 2, 0.0); let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
998 assert_eq!(m.len(), 1, "zero-sigma -> single point-mass scan");
999 assert!(m[0].1.is_finite() && (m[0].1 - 1.0).abs() < 1e-12);
1000 let scan_ids: Vec<u32> = (0..100).rev().collect();
1002 let scan_mob: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
1003 let mean = i.inv_mobility(&env);
1004 let lm = project_mobility_ion_legacy(mean, 0.0, &scan_ids, &scan_mob, 0.005, 0.9999, 0.0001);
1005 assert_eq!(lm.len(), 1);
1006 assert!(lm[0].1.is_finite());
1007 }
1008
1009 #[test]
1010 fn project_mobility_none_marginalises_to_single_scan() {
1011 let geom = SamplingGeometry::none();
1012 let env = MobilityEnv::default();
1013 let i = ion(350.0, 500.0, 2, 0.01);
1014 let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
1015 assert_eq!(m, vec![(0, 1.0)]);
1016 assert_eq!(geom.num_scans(), 1);
1017 }
1018
1019 #[test]
1020 fn project_mobility_tims_lands_in_grid() {
1021 let grid: Vec<f64> = (0..100).map(|i| 1.005 + i as f64 * 0.005).collect();
1023 let geom = SamplingGeometry::tims(grid);
1024 let env = MobilityEnv::default();
1025 let i = ion(450.0, 600.0, 2, 0.02);
1027 let mean = i.inv_mobility(&env);
1028 assert!(mean > 0.6 && mean < 1.5, "test ion 1/K0 {mean} should fall in grid");
1029 let m = project_mobility_ion(&i, &geom, &env, 0.9999, 0.01);
1030 assert!(!m.is_empty(), "ion must occupy >= 1 scan");
1031 let total: f64 = m.iter().map(|(_, a)| a).sum();
1032 assert!(total > 0.0);
1033 }
1034}