1use std::io;
16
17pub const SCHEME_VERSION: u16 = 1;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum InstrumentKind {
22 TimsTofDia,
23 OrbitrapAstral,
24 SciexZenoTof,
25 Other,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum Analyzer {
30 Ftms,
31 Astms,
32 Tof,
33 Itms,
34 Unknown,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum DataMode {
39 Profile,
40 Centroid,
41}
42
43#[derive(Clone, Copy, Debug)]
45pub struct IsolationWindow {
46 pub center_mz: f64,
47 pub width_mz: f64,
48}
49
50impl IsolationWindow {
51 pub fn lower(&self) -> f64 {
52 self.center_mz - self.width_mz / 2.0
53 }
54 pub fn upper(&self) -> f64 {
55 self.center_mz + self.width_mz / 2.0
56 }
57}
58
59#[derive(Clone, Copy, Debug)]
67pub enum CollisionEnergyPolicy {
68 Value(f64),
69 Linear { intercept: f64, slope_per_mz: f64 },
70 Unknown,
71}
72
73impl CollisionEnergyPolicy {
74 pub fn at(&self, center_mz: f64) -> Option<f64> {
76 match *self {
77 CollisionEnergyPolicy::Value(v) => Some(v),
78 CollisionEnergyPolicy::Linear {
79 intercept,
80 slope_per_mz,
81 } => Some(intercept + slope_per_mz * center_mz),
82 CollisionEnergyPolicy::Unknown => None,
83 }
84 }
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ActivationMethod {
94 Cid,
95 Hcd,
96 Etd,
97 Ecd,
98 Unknown,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum EnergyUnit {
109 ElectronVolt,
111 NormalizedCe,
114 Unknown,
116}
117
118#[derive(Clone, Copy, Debug)]
123pub struct ActivationCondition {
124 pub method: ActivationMethod,
125 pub value: f64,
126 pub unit: EnergyUnit,
127}
128
129impl ActivationCondition {
130 pub fn collisional_ev(value: f64) -> Self {
132 ActivationCondition {
133 method: ActivationMethod::Hcd,
134 value,
135 unit: EnergyUnit::ElectronVolt,
136 }
137 }
138
139 pub fn legacy_bruker() -> Self {
142 Self::collisional_ev(0.0)
143 }
144}
145
146#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct InstrumentCapabilities {
154 pub has_tims_mobility: bool,
156 pub has_quad_isotope_transmission: bool,
158}
159
160impl Default for InstrumentCapabilities {
161 fn default() -> Self {
162 Self::bruker_timstof()
164 }
165}
166
167impl InstrumentCapabilities {
168 pub fn bruker_timstof() -> Self {
171 InstrumentCapabilities {
172 has_tims_mobility: true,
173 has_quad_isotope_transmission: true,
174 }
175 }
176
177 pub fn astral() -> Self {
183 InstrumentCapabilities {
184 has_tims_mobility: false,
185 has_quad_isotope_transmission: false,
186 }
187 }
188}
189
190#[derive(Clone, Copy, Debug)]
197pub enum CollisionEnergyModel {
198 PerWindow(CollisionEnergyPolicy),
200 BrukerPasef { ce_bias: f64, ce_slope: f64 },
203}
204
205#[derive(Clone, Copy, Debug)]
210pub struct ActivationPolicy {
211 pub method: ActivationMethod,
212 pub unit: EnergyUnit,
213 pub model: CollisionEnergyModel,
214}
215
216impl ActivationPolicy {
217 pub fn bruker_pasef(ce_bias: f64, ce_slope: f64) -> Self {
221 ActivationPolicy {
222 method: ActivationMethod::Hcd,
223 unit: EnergyUnit::ElectronVolt,
224 model: CollisionEnergyModel::BrukerPasef { ce_bias, ce_slope },
225 }
226 }
227
228 pub fn thermo_nce(ce: CollisionEnergyPolicy) -> Self {
237 ActivationPolicy {
238 method: ActivationMethod::Hcd,
239 unit: EnergyUnit::NormalizedCe,
240 model: CollisionEnergyModel::PerWindow(ce),
241 }
242 }
243
244 pub fn collision_energy_for_scan(&self, scan: u32) -> Option<f64> {
250 match self.model {
251 CollisionEnergyModel::BrukerPasef { ce_bias, ce_slope } => {
252 Some(ce_bias + ce_slope * scan as f64)
253 }
254 CollisionEnergyModel::PerWindow(_) => None,
255 }
256 }
257
258 pub fn condition_for_scan(&self, scan: u32) -> Option<ActivationCondition> {
261 self.collision_energy_for_scan(scan).map(|value| ActivationCondition {
262 method: self.method,
263 value,
264 unit: self.unit,
265 })
266 }
267
268 pub fn condition_for_window(&self, center_mz: f64) -> Option<ActivationCondition> {
270 match self.model {
271 CollisionEnergyModel::PerWindow(p) => p.at(center_mz).map(|value| ActivationCondition {
272 method: self.method,
273 value,
274 unit: self.unit,
275 }),
276 CollisionEnergyModel::BrukerPasef { .. } => None,
277 }
278 }
279}
280
281#[derive(Clone, Copy, Debug)]
286pub enum DiaGeometry {
287 MzOnly,
288 TimsMobility { scan_start: u32, scan_end: u32 },
289}
290
291#[derive(Clone, Copy, Debug)]
293pub struct DiaWindow {
294 pub isolation: IsolationWindow,
295 pub collision_energy: CollisionEnergyPolicy,
296 pub geometry: DiaGeometry,
297}
298
299#[derive(Clone, Debug)]
301pub struct Ms1Event {
302 pub analyzer: Analyzer,
303 pub data_mode: DataMode,
304 pub mz_range: Option<(f64, f64)>,
308 pub duration_s: Option<f64>,
309}
310
311#[derive(Clone, Debug)]
314pub struct DiaMs2Frame {
315 pub windows: Vec<DiaWindow>,
316 pub analyzer: Analyzer,
317 pub data_mode: DataMode,
318 pub duration_s: Option<f64>,
319 pub vendor_group_id: Option<u32>,
325}
326
327#[derive(Clone, Debug)]
328pub enum AcquisitionEvent {
329 Ms1(Ms1Event),
330 DiaMs2Frame(DiaMs2Frame),
331}
332
333#[derive(Clone, Copy, Debug)]
335pub enum RepeatPolicy {
336 FixedCycleTime {
341 cycle_time_s: f64,
342 gradient_length_s: f64,
343 start_time_s: f64,
344 },
345}
346
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
348pub enum SchemeSource {
349 ExtractedBruker,
350 ExtractedThermo,
351 ExtractedSciex,
352 UserTable,
353 Programmatic,
354}
355
356#[derive(Clone, Debug)]
358pub struct Provenance {
359 pub source: SchemeSource,
360 pub notes: String,
361}
362
363#[derive(Clone, Debug)]
365pub struct AcquisitionScheme {
366 pub version: u16,
367 pub instrument: InstrumentKind,
368 pub cycle: Vec<AcquisitionEvent>,
369 pub repeat: RepeatPolicy,
370 pub mz_range: (f64, f64),
371 pub provenance: Provenance,
372}
373
374impl AcquisitionScheme {
375 pub fn windows(&self) -> impl Iterator<Item = &DiaWindow> {
377 self.cycle
378 .iter()
379 .flat_map(|e| match e {
380 AcquisitionEvent::DiaMs2Frame(f) => f.windows.as_slice(),
381 AcquisitionEvent::Ms1(_) => &[][..],
382 }
383 .iter())
384 }
385
386 pub fn ms1_count(&self) -> usize {
388 self.cycle
389 .iter()
390 .filter(|e| matches!(e, AcquisitionEvent::Ms1(_)))
391 .count()
392 }
393
394 pub fn num_cycles(&self) -> Option<u64> {
396 match self.repeat {
397 RepeatPolicy::FixedCycleTime {
398 cycle_time_s,
399 gradient_length_s,
400 start_time_s,
401 } if cycle_time_s.is_finite()
402 && cycle_time_s > 0.0
403 && gradient_length_s.is_finite()
404 && start_time_s.is_finite() =>
405 {
406 Some(((gradient_length_s - start_time_s).max(0.0) / cycle_time_s) as u64)
407 }
408 _ => None,
409 }
410 }
411
412 pub fn dia_frame_schedule(
421 &self,
422 ) -> Vec<(u32, f64, u8, Option<f64>, Option<f64>, Option<f64>)> {
423 let RepeatPolicy::FixedCycleTime { cycle_time_s, start_time_s, .. } = self.repeat;
424 let n_cycles = self.num_cycles().unwrap_or(0);
425 let n_events = self.cycle.len();
426 if n_cycles == 0 || n_events == 0 {
427 return Vec::new();
428 }
429 let per_event_dt = cycle_time_s / n_events as f64;
430 let mut out = Vec::with_capacity(n_cycles as usize * n_events);
431 let mut scan: u32 = 1;
432 for c in 0..n_cycles {
433 let cycle_start = start_time_s + c as f64 * cycle_time_s;
434 for (j, ev) in self.cycle.iter().enumerate() {
435 let rt = cycle_start + j as f64 * per_event_dt;
436 match ev {
437 AcquisitionEvent::Ms1(_) => out.push((scan, rt, 1u8, None, None, None)),
438 AcquisitionEvent::DiaMs2Frame(f) => {
439 if let Some(w) = f.windows.first() {
441 let center = w.isolation.center_mz;
442 let ce = w.collision_energy.at(center);
443 out.push((scan, rt, 2u8, Some(center), Some(w.isolation.width_mz), ce));
444 }
445 }
446 }
447 scan += 1;
448 }
449 }
450 out
451 }
452
453 pub fn from_window_table(
456 instrument: InstrumentKind,
457 ms1: Ms1Event,
458 windows: Vec<DiaWindow>,
459 repeat: RepeatPolicy,
460 mz_range: (f64, f64),
461 ) -> Self {
462 let mut cycle = vec![AcquisitionEvent::Ms1(ms1.clone())];
463 for w in windows {
464 cycle.push(AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
465 windows: vec![w],
466 analyzer: ms1.analyzer,
467 data_mode: DataMode::Centroid,
468 duration_s: None,
469 vendor_group_id: None,
470 }));
471 }
472 AcquisitionScheme {
473 version: SCHEME_VERSION,
474 instrument,
475 cycle,
476 repeat,
477 mz_range,
478 provenance: Provenance {
479 source: SchemeSource::UserTable,
480 notes: "built from explicit window table".to_string(),
481 },
482 }
483 }
484
485 pub fn validate(&self) -> Result<(), String> {
488 if self.version != SCHEME_VERSION {
489 return Err(format!(
490 "unsupported scheme version {} (this build supports {})",
491 self.version, SCHEME_VERSION
492 ));
493 }
494 if self.cycle.is_empty() {
495 return Err("empty cycle".into());
496 }
497 let (lo, hi) = self.mz_range;
498 if !(lo.is_finite() && hi.is_finite()) || lo >= hi {
499 return Err(format!("invalid mz_range ({lo}, {hi})"));
500 }
501 if !matches!(self.cycle.first(), Some(AcquisitionEvent::Ms1(_))) {
503 return Err("cycle must begin with an MS1 event".into());
504 }
505 if self.ms1_count() != 1 {
506 return Err(format!(
507 "cycle must contain exactly one MS1 event (has {})",
508 self.ms1_count()
509 ));
510 }
511 if !self
512 .cycle
513 .iter()
514 .any(|e| matches!(e, AcquisitionEvent::DiaMs2Frame(_)))
515 {
516 return Err("cycle has no MS2 frames".into());
517 }
518
519 let check_dur = |d: Option<f64>, what: &str| -> Result<(), String> {
520 match d {
521 Some(v) if !(v.is_finite() && v > 0.0) => {
522 Err(format!("{what} duration must be finite and > 0"))
523 }
524 _ => Ok(()),
525 }
526 };
527
528 for ev in &self.cycle {
529 match ev {
530 AcquisitionEvent::Ms1(m) => {
531 check_dur(m.duration_s, "MS1")?;
532 if let Some((a, b)) = m.mz_range {
533 if !(a.is_finite() && b.is_finite()) || a >= b {
534 return Err(format!("invalid MS1 mz_range ({a}, {b})"));
535 }
536 }
537 }
538 AcquisitionEvent::DiaMs2Frame(frame) => {
539 check_dur(frame.duration_s, "MS2 frame")?;
540 if frame.windows.is_empty() {
541 return Err("MS2 frame has no windows".into());
542 }
543 let multi = frame.windows.len() > 1;
544 if multi && self.instrument != InstrumentKind::TimsTofDia {
545 return Err(
546 "multi-window MS2 frame is only valid for timsTOF (mobility-partitioned)"
547 .into(),
548 );
549 }
550 for (i, w) in frame.windows.iter().enumerate() {
551 if !(w.isolation.width_mz.is_finite() && w.isolation.width_mz > 0.0) {
552 return Err("window width must be finite and > 0".into());
553 }
554 if !w.isolation.center_mz.is_finite()
555 || !w.isolation.lower().is_finite()
556 || !w.isolation.upper().is_finite()
557 {
558 return Err("window m/z is not finite".into());
559 }
560 if w.isolation.lower() < lo - 1e-6 || w.isolation.upper() > hi + 1e-6 {
561 return Err(format!(
562 "window [{:.4}, {:.4}] outside mz_range [{lo:.4}, {hi:.4}]",
563 w.isolation.lower(),
564 w.isolation.upper()
565 ));
566 }
567 match w.collision_energy {
568 CollisionEnergyPolicy::Value(v) => {
569 if !v.is_finite() || v < 0.0 {
570 return Err("collision energy must be finite and >= 0".into());
571 }
572 }
573 CollisionEnergyPolicy::Linear {
574 intercept,
575 slope_per_mz,
576 } => {
577 if !intercept.is_finite() || !slope_per_mz.is_finite() {
578 return Err("non-finite linear CE coefficients".into());
579 }
580 match w.collision_energy.at(w.isolation.center_mz) {
581 Some(ce) if ce.is_finite() && ce >= 0.0 => {}
582 _ => {
583 return Err(
584 "linear CE resolves to non-finite or negative".into()
585 )
586 }
587 }
588 }
589 CollisionEnergyPolicy::Unknown => {}
590 }
591 match w.geometry {
592 DiaGeometry::TimsMobility {
593 scan_start,
594 scan_end,
595 } => {
596 if self.instrument != InstrumentKind::TimsTofDia {
597 return Err("mobility geometry is only valid for timsTOF".into());
598 }
599 if scan_start > scan_end {
600 return Err("mobility scan_start > scan_end".into());
601 }
602 }
603 DiaGeometry::MzOnly => {
604 if multi {
605 return Err(
606 "multi-window timsTOF frame requires mobility geometry on every window"
607 .into(),
608 );
609 }
610 }
611 }
612 for w2 in &frame.windows[..i] {
615 let mz_overlap = w.isolation.lower() < w2.isolation.upper()
616 && w2.isolation.lower() < w.isolation.upper();
617 let im_overlap = match (w.geometry, w2.geometry) {
618 (
619 DiaGeometry::TimsMobility {
620 scan_start: a0,
621 scan_end: a1,
622 },
623 DiaGeometry::TimsMobility {
624 scan_start: b0,
625 scan_end: b1,
626 },
627 ) => a0 <= b1 && b0 <= a1,
628 _ => true, };
630 if mz_overlap && im_overlap {
631 return Err(
632 "windows within one frame overlap in both m/z and mobility"
633 .into(),
634 );
635 }
636 }
637 }
638 }
639 }
640 }
641 match self.repeat {
642 RepeatPolicy::FixedCycleTime {
643 cycle_time_s,
644 gradient_length_s,
645 start_time_s,
646 } => {
647 if !(cycle_time_s.is_finite() && cycle_time_s > 0.0) {
648 return Err("cycle_time_s must be finite and > 0".into());
649 }
650 if !(gradient_length_s.is_finite() && gradient_length_s > 0.0) {
651 return Err("gradient_length_s must be finite and > 0".into());
652 }
653 if !start_time_s.is_finite() || start_time_s < 0.0 {
654 return Err("start_time_s must be finite and >= 0".into());
655 }
656 if start_time_s > gradient_length_s {
657 return Err("start_time_s must be <= gradient_length_s".into());
658 }
659 }
660 }
661 Ok(())
662 }
663}
664
665impl AcquisitionScheme {
666 pub fn to_bruker_windows(&self) -> Result<Vec<crate::data::meta::DiaMsMsWindow>, String> {
681 let layout = self.bruker_group_layout()?;
682 let mut rows = Vec::new();
683 for (ev, slot) in self.cycle.iter().zip(&layout) {
684 if let (AcquisitionEvent::DiaMs2Frame(frame), Some(group)) = (ev, slot) {
685 for w in &frame.windows {
686 let (scan_num_begin, scan_num_end) = match w.geometry {
687 DiaGeometry::TimsMobility {
688 scan_start,
689 scan_end,
690 } => (scan_start, scan_end),
691 DiaGeometry::MzOnly => {
692 return Err("timsTOF window lacks mobility geometry".into())
693 }
694 };
695 let collision_energy = match w.collision_energy {
696 CollisionEnergyPolicy::Value(v) => v,
697 CollisionEnergyPolicy::Linear { .. } => w
698 .collision_energy
699 .at(w.isolation.center_mz)
700 .ok_or("could not resolve linear CE")?,
701 CollisionEnergyPolicy::Unknown => {
702 return Err("window has unknown collision energy".into())
703 }
704 };
705 rows.push(crate::data::meta::DiaMsMsWindow {
706 window_group: *group,
707 scan_num_begin,
708 scan_num_end,
709 isolation_mz: w.isolation.center_mz,
710 isolation_width: w.isolation.width_mz,
711 collision_energy,
712 });
713 }
714 }
715 }
716 Ok(rows)
717 }
718
719 fn bruker_group_layout(&self) -> Result<Vec<Option<u32>>, String> {
727 self.validate()?;
728 if self.instrument != InstrumentKind::TimsTofDia {
729 return Err("Bruker tables require a timsTOF (TimsTofDia) scheme".into());
730 }
731 use std::collections::HashSet;
732 let mut reserved: HashSet<u32> = HashSet::new();
733 for ev in &self.cycle {
734 if let AcquisitionEvent::DiaMs2Frame(f) = ev {
735 if let Some(g) = f.vendor_group_id {
736 if !reserved.insert(g) {
737 return Err(format!("duplicate vendor window-group id {g}"));
738 }
739 }
740 }
741 }
742 let mut layout = Vec::with_capacity(self.cycle.len());
743 let mut assigned: HashSet<u32> = HashSet::new();
744 let mut next_seq: u32 = 1;
745 for ev in &self.cycle {
746 match ev {
747 AcquisitionEvent::Ms1(_) => layout.push(None),
748 AcquisitionEvent::DiaMs2Frame(frame) => {
749 let group = match frame.vendor_group_id {
750 Some(g) => g,
751 None => {
752 while reserved.contains(&next_seq) || assigned.contains(&next_seq) {
753 next_seq += 1;
754 }
755 next_seq
756 }
757 };
758 if !assigned.insert(group) {
759 return Err(format!("window-group id {group} collides"));
760 }
761 layout.push(Some(group));
762 }
763 }
764 }
765 Ok(layout)
766 }
767
768 pub fn to_bruker_info(
780 &self,
781 num_frames: u32,
782 ) -> Result<Vec<crate::data::meta::DiaMsMisInfo>, String> {
783 const MAX_FRAMES: u32 = 100_000_000;
785 if num_frames > MAX_FRAMES {
786 return Err(format!("num_frames {num_frames} exceeds the {MAX_FRAMES} limit"));
787 }
788 let layout = self.bruker_group_layout()?;
789 let fpc = layout.len() as u32;
790 if fpc == 0 {
791 return Err("empty cycle".into());
792 }
793 let mut rows = Vec::new();
794 for frame_id in 1..=num_frames {
795 let pos = ((frame_id - 1) % fpc) as usize;
796 if let Some(group) = layout[pos] {
797 rows.push(crate::data::meta::DiaMsMisInfo {
798 frame_id,
799 window_group: group,
800 });
801 }
802 }
803 Ok(rows)
804 }
805
806 pub fn to_bruker_tables(
810 &self,
811 num_frames: u32,
812 ) -> Result<
813 (
814 Vec<crate::data::meta::DiaMsMsWindow>,
815 Vec<crate::data::meta::DiaMsMisInfo>,
816 ),
817 String,
818 > {
819 Ok((self.to_bruker_windows()?, self.to_bruker_info(num_frames)?))
820 }
821
822 pub fn from_bruker_d<P: AsRef<std::path::Path>>(path: P) -> io::Result<Self> {
836 use crate::data::meta::{read_dia_ms_ms_info, read_dia_ms_ms_windows, read_meta_data_sql};
837 use std::collections::BTreeMap;
838
839 let folder = path.as_ref().to_string_lossy().into_owned();
840 let to_io =
841 |e: Box<dyn std::error::Error>| io::Error::new(io::ErrorKind::InvalidData, e.to_string());
842 let windows = read_dia_ms_ms_windows(&folder).map_err(to_io)?;
843 let frames = read_meta_data_sql(&folder).map_err(to_io)?;
844 if windows.is_empty() {
845 return Err(io::Error::new(
846 io::ErrorKind::InvalidData,
847 "no DiaFrameMsMsWindows rows (not a DIA .d?)",
848 ));
849 }
850
851 let mut by_group: BTreeMap<u32, Vec<DiaWindow>> = BTreeMap::new();
853 let mut lo = f64::INFINITY;
854 let mut hi = f64::NEG_INFINITY;
855 for w in &windows {
856 let dw = DiaWindow {
857 isolation: IsolationWindow {
858 center_mz: w.isolation_mz,
859 width_mz: w.isolation_width,
860 },
861 collision_energy: CollisionEnergyPolicy::Value(w.collision_energy),
862 geometry: DiaGeometry::TimsMobility {
863 scan_start: w.scan_num_begin,
864 scan_end: w.scan_num_end,
865 },
866 };
867 lo = lo.min(dw.isolation.lower());
868 hi = hi.max(dw.isolation.upper());
869 by_group.entry(w.window_group).or_default().push(dw);
870 }
871
872 let info = read_dia_ms_ms_info(&folder).unwrap_or_default();
875 let mut first_frame: BTreeMap<u32, u32> = BTreeMap::new();
876 for r in &info {
877 first_frame
878 .entry(r.window_group)
879 .and_modify(|f| {
880 if r.frame_id < *f {
881 *f = r.frame_id
882 }
883 })
884 .or_insert(r.frame_id);
885 }
886 let mut ordered_groups: Vec<u32> = by_group.keys().copied().collect();
887 ordered_groups.sort_by_key(|g| first_frame.get(g).copied().unwrap_or(u32::MAX));
890
891 let mut cycle = vec![AcquisitionEvent::Ms1(Ms1Event {
892 analyzer: Analyzer::Tof,
893 data_mode: DataMode::Centroid,
894 mz_range: None,
895 duration_s: None,
896 })];
897 let n_groups = ordered_groups.len();
898 for group in ordered_groups {
899 let ws = by_group.remove(&group).expect("group present");
900 cycle.push(AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
901 windows: ws,
902 analyzer: Analyzer::Tof,
903 data_mode: DataMode::Centroid,
904 duration_s: None,
905 vendor_group_id: Some(group), }));
907 }
908
909 let mut prec_times: Vec<f64> = frames
913 .iter()
914 .filter(|f| f.ms_ms_type == 0 && f.time.is_finite())
915 .map(|f| f.time)
916 .collect();
917 prec_times.sort_by(f64::total_cmp);
918 prec_times.dedup();
919 let mut gaps: Vec<f64> = prec_times
920 .windows(2)
921 .map(|w| w[1] - w[0])
922 .filter(|g| *g > 0.0)
923 .collect();
924 if gaps.is_empty() {
925 return Err(io::Error::new(
926 io::ErrorKind::InvalidData,
927 "could not determine cycle time (need >= 2 distinct precursor frames)",
928 ));
929 }
930 gaps.sort_by(f64::total_cmp);
931 let cycle_time_s = gaps[gaps.len() / 2];
932
933 let times: Vec<f64> = frames.iter().map(|f| f.time).filter(|t| t.is_finite()).collect();
934 let start_time_s = times.iter().copied().fold(f64::INFINITY, f64::min);
935 let gradient_length_s = times.iter().copied().fold(f64::NEG_INFINITY, f64::max);
936
937 let scheme = AcquisitionScheme {
938 version: SCHEME_VERSION,
939 instrument: InstrumentKind::TimsTofDia,
940 cycle,
941 repeat: RepeatPolicy::FixedCycleTime {
942 cycle_time_s,
943 gradient_length_s,
944 start_time_s: if start_time_s.is_finite() {
945 start_time_s
946 } else {
947 0.0
948 },
949 },
950 mz_range: (lo, hi),
951 provenance: Provenance {
952 source: SchemeSource::ExtractedBruker,
953 notes: format!("extracted from Bruker .d ({n_groups} window groups)"),
954 },
955 };
956 scheme
957 .validate()
958 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
959 Ok(scheme)
960 }
961}
962
963#[cfg(feature = "sciex")]
964impl AcquisitionScheme {
965 pub fn from_sciex_wiff<P: AsRef<std::path::Path>>(
979 path: P,
980 cycle_time_s: f64,
981 gradient_length_s: f64,
982 collision_energy: CollisionEnergyPolicy,
983 ) -> io::Result<Self> {
984 let method = sciexwiff::read_method(path)?;
985 if method.swath_windows.is_empty() {
986 return Err(io::Error::new(
987 io::ErrorKind::InvalidData,
988 "no SWATH windows in the .wiff method",
989 ));
990 }
991 let mut cycle = vec![AcquisitionEvent::Ms1(Ms1Event {
992 analyzer: Analyzer::Tof,
993 data_mode: DataMode::Centroid,
994 mz_range: None,
995 duration_s: None,
996 })];
997 let mut lo = f64::INFINITY;
998 let mut hi = f64::NEG_INFINITY;
999 let n = method.swath_windows.len();
1000 for w in &method.swath_windows {
1001 let iso = IsolationWindow {
1002 center_mz: w.center_mz(),
1003 width_mz: w.width_mz(),
1004 };
1005 lo = lo.min(iso.lower());
1006 hi = hi.max(iso.upper());
1007 cycle.push(AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1008 windows: vec![DiaWindow {
1009 isolation: iso,
1010 collision_energy,
1011 geometry: DiaGeometry::MzOnly,
1012 }],
1013 analyzer: Analyzer::Tof,
1014 data_mode: DataMode::Centroid,
1015 duration_s: None,
1016 vendor_group_id: None,
1017 }));
1018 }
1019 let scheme = AcquisitionScheme {
1020 version: SCHEME_VERSION,
1021 instrument: InstrumentKind::SciexZenoTof,
1022 cycle,
1023 repeat: RepeatPolicy::FixedCycleTime {
1024 cycle_time_s,
1025 gradient_length_s,
1026 start_time_s: 0.0,
1027 },
1028 mz_range: (lo, hi),
1029 provenance: Provenance {
1030 source: SchemeSource::ExtractedSciex,
1031 notes: format!(
1032 "extracted from SCIEX .wiff method ({n} SWATH windows; CE caller-supplied, timing caller-supplied)"
1033 ),
1034 },
1035 };
1036 scheme
1037 .validate()
1038 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1039 Ok(scheme)
1040 }
1041}
1042
1043#[cfg(feature = "thermo")]
1044impl AcquisitionScheme {
1045 pub fn from_thermo_raw<P: AsRef<std::path::Path>>(path: P) -> io::Result<Self> {
1050 use thermorawfile::RawFile;
1051 let raw = RawFile::open(path)?;
1052
1053 let analyzer_of = |a: u8| match a {
1054 4 => Analyzer::Ftms,
1055 7 => Analyzer::Astms,
1056 0 => Analyzer::Itms,
1057 _ => Analyzer::Unknown,
1058 };
1059 let data_mode_of = |scan: u32| -> DataMode {
1060 let i = (scan - raw.first_scan) as usize;
1061 let pkt = (raw.data_addr + raw.index[i].offset) as usize;
1062 if pkt + 8 <= raw.bytes.len() {
1063 let ps = u32::from_le_bytes(raw.bytes[pkt + 4..pkt + 8].try_into().unwrap());
1064 if ps > 0 {
1065 DataMode::Profile
1066 } else {
1067 DataMode::Centroid
1068 }
1069 } else {
1070 DataMode::Centroid
1071 }
1072 };
1073 let rt_of = |scan: u32| raw.index[(scan - raw.first_scan) as usize].time;
1074
1075 let mut cycle: Vec<AcquisitionEvent> = Vec::new();
1076 let mut seen_ms1 = false;
1077 let mut closed = false;
1078 let mut start_rt = 0.0f64;
1079 let mut cycle_time_s = 0.0f64;
1080 let mut win_lo = f64::INFINITY;
1081 let mut win_hi = f64::NEG_INFINITY;
1082
1083 for scan in raw.first_scan..=raw.last_scan {
1084 let ev = match raw.scan_event(scan) {
1085 Some(e) => e,
1086 None => {
1087 if seen_ms1 {
1090 return Err(io::Error::new(
1091 io::ErrorKind::InvalidData,
1092 format!("missing scan event for scan {scan} inside the first cycle"),
1093 ));
1094 }
1095 continue;
1096 }
1097 };
1098 if ev.ms_order <= 1 {
1099 if seen_ms1 {
1100 cycle_time_s = (rt_of(scan) - start_rt).max(0.0);
1102 closed = true;
1103 break;
1104 }
1105 seen_ms1 = true;
1106 start_rt = rt_of(scan);
1107 cycle.push(AcquisitionEvent::Ms1(Ms1Event {
1108 analyzer: analyzer_of(ev.analyzer),
1109 data_mode: data_mode_of(scan),
1110 mz_range: None, duration_s: None,
1112 }));
1113 } else if seen_ms1 {
1114 let iso = IsolationWindow {
1115 center_mz: ev.isolation_center,
1116 width_mz: ev.isolation_width,
1117 };
1118 win_lo = win_lo.min(iso.lower());
1119 win_hi = win_hi.max(iso.upper());
1120 cycle.push(AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1121 windows: vec![DiaWindow {
1122 isolation: iso,
1123 collision_energy: CollisionEnergyPolicy::Value(ev.collision_energy),
1124 geometry: DiaGeometry::MzOnly,
1125 }],
1126 analyzer: analyzer_of(ev.analyzer),
1127 data_mode: data_mode_of(scan),
1128 duration_s: None,
1129 vendor_group_id: None,
1130 }));
1131 }
1132 }
1133
1134 if !seen_ms1 {
1135 return Err(io::Error::new(
1136 io::ErrorKind::InvalidData,
1137 "template has no MS1 scan",
1138 ));
1139 }
1140 if !closed {
1141 return Err(io::Error::new(
1142 io::ErrorKind::InvalidData,
1143 "template has only one cycle (no second MS1 to bound it); cannot determine cycle time",
1144 ));
1145 }
1146 let mz_range = if win_lo.is_finite() && win_hi > win_lo {
1147 (win_lo, win_hi)
1148 } else {
1149 return Err(io::Error::new(
1150 io::ErrorKind::InvalidData,
1151 "first cycle has no usable MS2 isolation windows",
1152 ));
1153 };
1154 let gradient_length_s = raw.index.last().map(|e| e.time).unwrap_or(0.0);
1155 let n_ms2 = cycle
1156 .iter()
1157 .filter(|e| matches!(e, AcquisitionEvent::DiaMs2Frame(_)))
1158 .count();
1159 let any_astms = cycle.iter().any(|e| {
1162 matches!(e, AcquisitionEvent::DiaMs2Frame(f) if f.analyzer == Analyzer::Astms)
1163 });
1164
1165 let scheme = AcquisitionScheme {
1166 version: SCHEME_VERSION,
1167 instrument: if any_astms {
1168 InstrumentKind::OrbitrapAstral
1169 } else {
1170 InstrumentKind::Other
1171 },
1172 cycle,
1173 repeat: RepeatPolicy::FixedCycleTime {
1174 cycle_time_s,
1175 gradient_length_s,
1176 start_time_s: start_rt,
1177 },
1178 mz_range,
1179 provenance: Provenance {
1180 source: SchemeSource::ExtractedThermo,
1181 notes: format!("extracted from Thermo .raw (first cycle: 1 MS1 + {n_ms2} MS2)"),
1182 },
1183 };
1184 scheme
1185 .validate()
1186 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
1187 Ok(scheme)
1188 }
1189
1190 pub fn thermo_frame_schedule<P: AsRef<std::path::Path>>(
1201 path: P,
1202 ) -> io::Result<Vec<TemplateScan>> {
1203 use thermorawfile::RawFile;
1204 let raw = RawFile::open(path)?;
1205 if !raw.has_scan_events() {
1210 return Err(io::Error::new(
1211 io::ErrorKind::InvalidData,
1212 "template scan events could not be decoded (unsupported scan-event layout); \
1213 build-from-template needs per-scan ms-level/isolation.",
1214 ));
1215 }
1216 let mut out = Vec::with_capacity(raw.index.len());
1217 for scan in raw.first_scan..=raw.last_scan {
1218 let i = (scan - raw.first_scan) as usize;
1219 let rt = raw.index[i].time * 60.0;
1222 let ev = raw.scan_event(scan);
1223 let ms_level = match ev.as_ref() {
1226 Some(e) if e.ms_order >= 1 => e.ms_order,
1227 _ => {
1228 let pkt = (raw.data_addr + raw.index[i].offset) as usize;
1229 if pkt + 8 <= raw.bytes.len()
1230 && u32::from_le_bytes(raw.bytes[pkt + 4..pkt + 8].try_into().unwrap()) > 0
1231 {
1232 1
1233 } else {
1234 2
1235 }
1236 }
1237 };
1238 let (isolation, collision_energy) = match ev.as_ref() {
1239 Some(e) if ms_level > 1 => (
1240 Some(IsolationWindow {
1241 center_mz: e.isolation_center,
1242 width_mz: e.isolation_width,
1243 }),
1244 Some(e.collision_energy),
1245 ),
1246 _ => (None, None),
1247 };
1248 out.push(TemplateScan { scan, retention_time_s: rt, ms_level, isolation, collision_energy });
1249 }
1250 if out.is_empty() {
1251 return Err(io::Error::new(io::ErrorKind::InvalidData, "template has no scans"));
1252 }
1253 let n_ms2 = out.iter().filter(|s| s.ms_level > 1).count();
1259 if n_ms2 > 0 {
1260 let distinct: std::collections::HashSet<i64> = out
1261 .iter()
1262 .filter_map(|s| s.isolation.map(|w| (w.center_mz * 100.0).round() as i64))
1263 .collect();
1264 let ratio = distinct.len() as f64 / n_ms2 as f64;
1272 if distinct.len() > 2000 && ratio > 0.1 {
1273 return Err(io::Error::new(
1274 io::ErrorKind::InvalidData,
1275 format!(
1276 "template looks like DDA, not DIA: {} distinct isolation centers \
1277 across {} MS2 scans (DIA windows repeat across cycles; DDA has a \
1278 unique precursor per scan). Build-from-template needs a DIA scheme.",
1279 distinct.len(),
1280 n_ms2
1281 ),
1282 ));
1283 }
1284 }
1285 Ok(out)
1286 }
1287}
1288
1289#[cfg(feature = "thermo")]
1293#[derive(Clone, Copy, Debug)]
1294pub struct TemplateScan {
1295 pub scan: u32,
1296 pub retention_time_s: f64,
1297 pub ms_level: u8,
1298 pub isolation: Option<IsolationWindow>,
1300 pub collision_energy: Option<f64>,
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 use super::*;
1307
1308 fn swath_scheme(cycle_time_s: f64, gradient_length_s: f64) -> AcquisitionScheme {
1309 let ms1 = Ms1Event {
1310 analyzer: Analyzer::Tof,
1311 data_mode: DataMode::Centroid,
1312 mz_range: None,
1313 duration_s: None,
1314 };
1315 let mk = |center: f64, width: f64| DiaWindow {
1316 isolation: IsolationWindow { center_mz: center, width_mz: width },
1317 collision_energy: CollisionEnergyPolicy::Linear { intercept: 5.0, slope_per_mz: 0.045 },
1318 geometry: DiaGeometry::MzOnly,
1319 };
1320 AcquisitionScheme::from_window_table(
1321 InstrumentKind::SciexZenoTof,
1322 ms1,
1323 vec![mk(420.0, 40.0), mk(480.0, 20.0)],
1324 RepeatPolicy::FixedCycleTime { cycle_time_s, gradient_length_s, start_time_s: 0.0 },
1325 (400.0, 500.0),
1326 )
1327 }
1328
1329 #[test]
1330 fn dia_frame_schedule_expands_cycles() {
1331 let sched = swath_scheme(3.0, 9.0).dia_frame_schedule();
1333 assert_eq!(sched.len(), 9, "3 cycles x 3 events");
1334 assert_eq!(
1336 sched.iter().map(|r| r.0).collect::<Vec<_>>(),
1337 (1u32..=9).collect::<Vec<_>>()
1338 );
1339 for w in sched.windows(2) {
1341 assert!(w[1].1 > w[0].1, "RT must increase: {:?} !> {:?}", w[1].1, w[0].1);
1342 }
1343 for (k, &i) in [0usize, 3, 6].iter().enumerate() {
1345 assert!((sched[i].1 - (k as f64) * 3.0).abs() < 1e-9);
1346 assert_eq!(sched[i].2, 1, "cycle starts with MS1");
1347 assert!(sched[i].3.is_none() && sched[i].5.is_none(), "MS1 has no iso/CE");
1348 }
1349 assert_eq!(sched[1].2, 2);
1351 assert!((sched[1].3.unwrap() - 420.0).abs() < 1e-9);
1352 assert!((sched[1].4.unwrap() - 40.0).abs() < 1e-9);
1353 assert!(sched[1].5.unwrap() > 0.0, "rolling CE resolved for MS2");
1354 }
1355
1356 #[test]
1357 fn dia_frame_schedule_unknown_ce_is_none() {
1358 let mut s = swath_scheme(3.0, 6.0);
1359 for e in &mut s.cycle {
1361 if let AcquisitionEvent::DiaMs2Frame(f) = e {
1362 for w in &mut f.windows {
1363 w.collision_energy = CollisionEnergyPolicy::Unknown;
1364 }
1365 }
1366 }
1367 let sched = s.dia_frame_schedule();
1368 for r in &sched {
1369 if r.2 == 2 {
1370 assert!(r.5.is_none(), "Unknown CE -> None (caller must supply a model)");
1371 }
1372 }
1373 }
1374
1375 #[test]
1376 fn activation_condition_carries_typed_unit() {
1377 let c = ActivationCondition::collisional_ev(30.8);
1378 assert_eq!(c.method, ActivationMethod::Hcd);
1379 assert_eq!(c.unit, EnergyUnit::ElectronVolt);
1380 assert_eq!(c.value, 30.8);
1381
1382 let legacy = ActivationCondition::legacy_bruker();
1384 assert_eq!(legacy.unit, EnergyUnit::ElectronVolt);
1385
1386 let nce = ActivationCondition { method: ActivationMethod::Hcd, value: 30.0, unit: EnergyUnit::NormalizedCe };
1388 assert_ne!(nce.unit, c.unit);
1389 }
1390
1391 #[test]
1392 fn bruker_pasef_activation_policy_reproduces_legacy_ce() {
1393 let (ce_bias, ce_slope) = (54.1984, -0.0345);
1395 let p = ActivationPolicy::bruker_pasef(ce_bias, ce_slope);
1396 assert_eq!(p.method, ActivationMethod::Hcd);
1397 assert_eq!(p.unit, EnergyUnit::ElectronVolt);
1398 for scan in [0u32, 1, 250, 451, 917] {
1399 assert_eq!(
1400 p.collision_energy_for_scan(scan),
1401 Some(ce_bias + ce_slope * scan as f64),
1402 "CE must match the legacy formula at scan {scan}"
1403 );
1404 assert_eq!(p.condition_for_scan(scan).unwrap().value, ce_bias + ce_slope * scan as f64);
1405 }
1406 let w = ActivationPolicy {
1409 method: ActivationMethod::Hcd,
1410 unit: EnergyUnit::ElectronVolt,
1411 model: CollisionEnergyModel::PerWindow(CollisionEnergyPolicy::Value(25.0)),
1412 };
1413 assert_eq!(w.condition_for_window(700.0).unwrap().value, 25.0);
1414 assert_eq!(w.collision_energy_for_scan(100), None);
1415 assert!(w.condition_for_scan(100).is_none());
1416 }
1417
1418 #[test]
1419 fn instrument_capabilities_default_is_bruker() {
1420 let bruker = InstrumentCapabilities::default();
1424 assert!(bruker.has_tims_mobility);
1425 assert!(bruker.has_quad_isotope_transmission);
1426 assert_eq!(bruker, InstrumentCapabilities::bruker_timstof());
1427
1428 let astral = InstrumentCapabilities::astral();
1430 assert!(!astral.has_tims_mobility);
1431 assert!(!astral.has_quad_isotope_transmission);
1432 assert_ne!(bruker, astral);
1433 }
1434
1435 #[test]
1436 fn thermo_nce_activation_policy_is_normalized_per_window() {
1437 let p = ActivationPolicy::thermo_nce(CollisionEnergyPolicy::Value(27.0));
1439 assert_eq!(p.method, ActivationMethod::Hcd);
1440 assert_eq!(p.unit, EnergyUnit::NormalizedCe);
1441
1442 let cond = p.condition_for_window(650.0).expect("NCE condition for window");
1445 assert_eq!(cond.value, 27.0);
1446 assert_eq!(cond.unit, EnergyUnit::NormalizedCe);
1447 assert_ne!(cond.unit, EnergyUnit::ElectronVolt);
1448
1449 assert_eq!(p.collision_energy_for_scan(100), None);
1451 assert!(p.condition_for_scan(100).is_none());
1452
1453 let rolling = ActivationPolicy::thermo_nce(CollisionEnergyPolicy::Linear {
1455 intercept: 20.0,
1456 slope_per_mz: 0.01,
1457 });
1458 assert_eq!(rolling.condition_for_window(700.0).unwrap().value, 20.0 + 0.01 * 700.0);
1459 assert_eq!(rolling.condition_for_window(700.0).unwrap().unit, EnergyUnit::NormalizedCe);
1460 }
1461
1462 fn linear_windows(n: usize) -> Vec<DiaWindow> {
1463 (0..n)
1464 .map(|i| DiaWindow {
1465 isolation: IsolationWindow {
1466 center_mz: 400.0 + i as f64 * 10.0,
1467 width_mz: 10.0,
1468 },
1469 collision_energy: CollisionEnergyPolicy::Value(25.0),
1470 geometry: DiaGeometry::MzOnly,
1471 })
1472 .collect()
1473 }
1474
1475 #[test]
1476 fn from_window_table_validates() {
1477 let s = AcquisitionScheme::from_window_table(
1478 InstrumentKind::OrbitrapAstral,
1479 Ms1Event {
1480 analyzer: Analyzer::Ftms,
1481 data_mode: DataMode::Profile,
1482 mz_range: Some((390.0, 900.0)),
1483 duration_s: None,
1484 },
1485 linear_windows(5),
1486 RepeatPolicy::FixedCycleTime {
1487 cycle_time_s: 1.0,
1488 gradient_length_s: 600.0,
1489 start_time_s: 0.0,
1490 },
1491 (390.0, 900.0),
1492 );
1493 s.validate().unwrap();
1494 assert_eq!(s.windows().count(), 5);
1495 assert_eq!(s.ms1_count(), 1);
1496 assert_eq!(s.num_cycles(), Some(600));
1497 }
1498
1499 #[test]
1500 fn multi_window_frame_only_tims() {
1501 let frame = DiaMs2Frame {
1502 windows: linear_windows(3),
1503 analyzer: Analyzer::Tof,
1504 data_mode: DataMode::Centroid,
1505 duration_s: None,
1506 vendor_group_id: None,
1507 };
1508 let s = AcquisitionScheme {
1509 version: SCHEME_VERSION,
1510 instrument: InstrumentKind::OrbitrapAstral, cycle: vec![
1512 AcquisitionEvent::Ms1(Ms1Event {
1513 analyzer: Analyzer::Ftms,
1514 data_mode: DataMode::Profile,
1515 mz_range: Some((390.0, 900.0)),
1516 duration_s: None,
1517 }),
1518 AcquisitionEvent::DiaMs2Frame(frame),
1519 ],
1520 repeat: RepeatPolicy::FixedCycleTime {
1521 cycle_time_s: 1.0,
1522 gradient_length_s: 600.0,
1523 start_time_s: 0.0,
1524 },
1525 mz_range: (390.0, 900.0),
1526 provenance: Provenance {
1527 source: SchemeSource::Programmatic,
1528 notes: String::new(),
1529 },
1530 };
1531 assert!(s.validate().is_err());
1532 }
1533
1534 #[test]
1535 fn validate_rejects_bad_schemes() {
1536 let repeat = RepeatPolicy::FixedCycleTime {
1537 cycle_time_s: 1.0,
1538 gradient_length_s: 600.0,
1539 start_time_s: 0.0,
1540 };
1541 let ms1 = || Ms1Event {
1542 analyzer: Analyzer::Ftms,
1543 data_mode: DataMode::Profile,
1544 mz_range: Some((390.0, 900.0)),
1545 duration_s: None,
1546 };
1547 let win = || DiaWindow {
1548 isolation: IsolationWindow {
1549 center_mz: 500.0,
1550 width_mz: 10.0,
1551 },
1552 collision_energy: CollisionEnergyPolicy::Value(25.0),
1553 geometry: DiaGeometry::MzOnly,
1554 };
1555 let frame = |w: DiaWindow| {
1556 AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1557 windows: vec![w],
1558 analyzer: Analyzer::Astms,
1559 data_mode: DataMode::Centroid,
1560 duration_s: None,
1561 vendor_group_id: None,
1562 })
1563 };
1564 let mk = |cycle: Vec<AcquisitionEvent>| AcquisitionScheme {
1565 version: SCHEME_VERSION,
1566 instrument: InstrumentKind::OrbitrapAstral,
1567 cycle,
1568 repeat,
1569 mz_range: (390.0, 900.0),
1570 provenance: Provenance {
1571 source: SchemeSource::Programmatic,
1572 notes: String::new(),
1573 },
1574 };
1575
1576 assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), frame(win())]).validate().is_ok());
1578 assert!(mk(vec![frame(win()), AcquisitionEvent::Ms1(ms1())]).validate().is_err());
1580 assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), AcquisitionEvent::Ms1(ms1()), frame(win())]).validate().is_err());
1582 assert!(mk(vec![AcquisitionEvent::Ms1(ms1())]).validate().is_err());
1584 let mut oob = win();
1586 oob.isolation.center_mz = 2000.0;
1587 assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), frame(oob)]).validate().is_err());
1588 let mut neg = win();
1590 neg.collision_energy = CollisionEnergyPolicy::Value(-5.0);
1591 assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), frame(neg)]).validate().is_err());
1592 let bad_dur = AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1594 windows: vec![win()],
1595 analyzer: Analyzer::Astms,
1596 data_mode: DataMode::Centroid,
1597 duration_s: Some(-1.0),
1598 vendor_group_id: None,
1599 });
1600 assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), bad_dur]).validate().is_err());
1601 }
1602
1603 #[test]
1605 fn from_bruker_d_extracts_cycle() {
1606 let d = match std::env::var("TIMSIM_BRUKER_DIA_D") {
1607 Ok(p) => p,
1608 Err(_) => {
1609 eprintln!("SKIP from_bruker_d_extracts_cycle: set TIMSIM_BRUKER_DIA_D=<dia .d>");
1610 return;
1611 }
1612 };
1613 let s = AcquisitionScheme::from_bruker_d(&d).expect("extract");
1614 s.validate().expect("valid scheme");
1615 assert_eq!(s.instrument, InstrumentKind::TimsTofDia);
1616 assert_eq!(s.ms1_count(), 1);
1617 let n_win = s.windows().count();
1618 assert!(n_win > 1, "expected several windows, got {n_win}");
1619 for w in s.windows() {
1621 assert!(matches!(w.geometry, DiaGeometry::TimsMobility { .. }));
1622 assert!(w.collision_energy.at(w.isolation.center_mz).is_some());
1623 }
1624 let multi = s.cycle.iter().any(|e| matches!(e, AcquisitionEvent::DiaMs2Frame(f) if f.windows.len() > 1));
1626 let n_frames = s.cycle.iter().filter(|e| matches!(e, AcquisitionEvent::DiaMs2Frame(_))).count();
1627 eprintln!(
1628 "from_bruker_d OK: TimsTofDia, {} frames, {} windows ({}), mz {:.1}..{:.1}",
1629 n_frames, n_win, if multi { "mobility-partitioned" } else { "single-window" },
1630 s.mz_range.0, s.mz_range.1
1631 );
1632 }
1633
1634 #[test]
1640 fn to_bruker_windows_group_ids() {
1641 let frame = |gid: Option<u32>, center: f64| {
1642 AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1643 windows: vec![DiaWindow {
1644 isolation: IsolationWindow { center_mz: center, width_mz: 10.0 },
1645 collision_energy: CollisionEnergyPolicy::Value(25.0),
1646 geometry: DiaGeometry::TimsMobility { scan_start: 0, scan_end: 100 },
1647 }],
1648 analyzer: Analyzer::Tof,
1649 data_mode: DataMode::Centroid,
1650 duration_s: None,
1651 vendor_group_id: gid,
1652 })
1653 };
1654 let mk = |frames: Vec<AcquisitionEvent>| {
1655 let mut cycle = vec![AcquisitionEvent::Ms1(Ms1Event {
1656 analyzer: Analyzer::Tof,
1657 data_mode: DataMode::Centroid,
1658 mz_range: None,
1659 duration_s: None,
1660 })];
1661 cycle.extend(frames);
1662 AcquisitionScheme {
1663 version: SCHEME_VERSION,
1664 instrument: InstrumentKind::TimsTofDia,
1665 cycle,
1666 repeat: RepeatPolicy::FixedCycleTime {
1667 cycle_time_s: 1.0,
1668 gradient_length_s: 600.0,
1669 start_time_s: 0.0,
1670 },
1671 mz_range: (300.0, 900.0),
1672 provenance: Provenance { source: SchemeSource::Programmatic, notes: String::new() },
1673 }
1674 };
1675 let ids = |s: &AcquisitionScheme| {
1676 s.to_bruker_windows().map(|r| r.iter().map(|w| w.window_group).collect::<Vec<_>>())
1677 };
1678
1679 assert_eq!(ids(&mk(vec![frame(Some(7), 500.0), frame(Some(42), 600.0)])).unwrap(), vec![7, 42]);
1681 assert_eq!(ids(&mk(vec![frame(None, 500.0), frame(None, 600.0)])).unwrap(), vec![1, 2]);
1683 assert_eq!(ids(&mk(vec![frame(Some(7), 500.0), frame(None, 600.0)])).unwrap(), vec![7, 1]);
1685 assert!(ids(&mk(vec![frame(Some(5), 500.0), frame(Some(5), 600.0)])).is_err());
1687 }
1688
1689 #[test]
1692 fn bruker_windows_round_trip() {
1693 let d = match std::env::var("TIMSIM_BRUKER_DIA_D") {
1694 Ok(p) => p,
1695 Err(_) => {
1696 eprintln!("SKIP bruker_windows_round_trip: set TIMSIM_BRUKER_DIA_D");
1697 return;
1698 }
1699 };
1700 let scheme = AcquisitionScheme::from_bruker_d(&d).expect("extract");
1701 let regenerated = scheme.to_bruker_windows().expect("to_bruker_windows");
1702 let original = crate::data::meta::read_dia_ms_ms_windows(&d).expect("read source");
1703 assert_eq!(regenerated.len(), original.len(), "row count differs");
1704
1705 fn tuples(
1709 rows: &[crate::data::meta::DiaMsMsWindow],
1710 ) -> Vec<(u32, u32, u32, u64, u64, u64)> {
1711 let mut out: Vec<_> = rows
1712 .iter()
1713 .map(|r| {
1714 (
1715 r.window_group,
1716 r.scan_num_begin,
1717 r.scan_num_end,
1718 r.isolation_mz.to_bits(),
1719 r.isolation_width.to_bits(),
1720 r.collision_energy.to_bits(),
1721 )
1722 })
1723 .collect();
1724 out.sort_unstable();
1725 out
1726 }
1727 assert_eq!(
1728 tuples(®enerated),
1729 tuples(&original),
1730 "round-trip windows differ from source (incl. WindowGroup id)"
1731 );
1732 eprintln!(
1733 "bruker_windows_round_trip OK: {} rows match across {} groups",
1734 original.len(),
1735 scheme
1736 .cycle
1737 .iter()
1738 .filter(|e| matches!(e, AcquisitionEvent::DiaMs2Frame(_)))
1739 .count()
1740 );
1741 }
1742
1743 #[test]
1745 fn to_bruker_info_tiling() {
1746 let frame = |c: f64| {
1747 AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1748 windows: vec![DiaWindow {
1749 isolation: IsolationWindow { center_mz: c, width_mz: 10.0 },
1750 collision_energy: CollisionEnergyPolicy::Value(25.0),
1751 geometry: DiaGeometry::TimsMobility { scan_start: 0, scan_end: 100 },
1752 }],
1753 analyzer: Analyzer::Tof,
1754 data_mode: DataMode::Centroid,
1755 duration_s: None,
1756 vendor_group_id: None,
1757 })
1758 };
1759 let s = AcquisitionScheme {
1760 version: SCHEME_VERSION,
1761 instrument: InstrumentKind::TimsTofDia,
1762 cycle: vec![
1763 AcquisitionEvent::Ms1(Ms1Event {
1764 analyzer: Analyzer::Tof,
1765 data_mode: DataMode::Centroid,
1766 mz_range: None,
1767 duration_s: None,
1768 }),
1769 frame(500.0),
1770 frame(600.0),
1771 ],
1772 repeat: RepeatPolicy::FixedCycleTime {
1773 cycle_time_s: 1.0,
1774 gradient_length_s: 600.0,
1775 start_time_s: 0.0,
1776 },
1777 mz_range: (300.0, 900.0),
1778 provenance: Provenance { source: SchemeSource::Programmatic, notes: String::new() },
1779 };
1780 let info: Vec<(u32, u32)> = s
1782 .to_bruker_info(6)
1783 .unwrap()
1784 .iter()
1785 .map(|r| (r.frame_id, r.window_group))
1786 .collect();
1787 assert_eq!(info, vec![(2, 1), (3, 2), (5, 1), (6, 2)]);
1788 let (windows, info2) = s.to_bruker_tables(6).unwrap();
1790 let wg: std::collections::BTreeSet<u32> = windows.iter().map(|w| w.window_group).collect();
1791 let ig: std::collections::BTreeSet<u32> = info2.iter().map(|r| r.window_group).collect();
1792 assert_eq!(wg, ig, "windows/info group ids disagree");
1793
1794 let win = |center: f64, s0: u32, s1: u32| DiaWindow {
1797 isolation: IsolationWindow { center_mz: center, width_mz: 10.0 },
1798 collision_energy: CollisionEnergyPolicy::Value(20.0),
1799 geometry: DiaGeometry::TimsMobility { scan_start: s0, scan_end: s1 },
1800 };
1801 let dia = |gid: u32, ws: Vec<DiaWindow>| {
1802 AcquisitionEvent::DiaMs2Frame(DiaMs2Frame {
1803 windows: ws,
1804 analyzer: Analyzer::Tof,
1805 data_mode: DataMode::Centroid,
1806 duration_s: None,
1807 vendor_group_id: Some(gid),
1808 })
1809 };
1810 let s2 = AcquisitionScheme {
1811 version: SCHEME_VERSION,
1812 instrument: InstrumentKind::TimsTofDia,
1813 cycle: vec![
1814 AcquisitionEvent::Ms1(Ms1Event {
1815 analyzer: Analyzer::Tof,
1816 data_mode: DataMode::Centroid,
1817 mz_range: None,
1818 duration_s: None,
1819 }),
1820 dia(7, vec![win(500.0, 0, 50), win(500.0, 51, 100)]), dia(2, vec![win(600.0, 0, 100)]),
1822 ],
1823 repeat: RepeatPolicy::FixedCycleTime {
1824 cycle_time_s: 1.0,
1825 gradient_length_s: 600.0,
1826 start_time_s: 0.0,
1827 },
1828 mz_range: (300.0, 900.0),
1829 provenance: Provenance { source: SchemeSource::Programmatic, notes: String::new() },
1830 };
1831 let info3: Vec<(u32, u32)> = s2
1833 .to_bruker_info(3)
1834 .unwrap()
1835 .iter()
1836 .map(|r| (r.frame_id, r.window_group))
1837 .collect();
1838 assert_eq!(info3, vec![(2, 7), (3, 2)], "info must follow cycle order, one row/frame");
1839 let w3 = s2.to_bruker_windows().unwrap();
1841 assert_eq!(w3.iter().filter(|w| w.window_group == 7).count(), 2);
1842 assert_eq!(w3.iter().filter(|w| w.window_group == 2).count(), 1);
1843 }
1844
1845 #[test]
1847 fn bruker_info_round_trip() {
1848 let d = match std::env::var("TIMSIM_BRUKER_DIA_D") {
1849 Ok(p) => p,
1850 Err(_) => {
1851 eprintln!("SKIP bruker_info_round_trip: set TIMSIM_BRUKER_DIA_D");
1852 return;
1853 }
1854 };
1855 let scheme = AcquisitionScheme::from_bruker_d(&d).expect("extract");
1856 let frames = crate::data::meta::read_meta_data_sql(&d).expect("frames");
1857 let num_frames = frames.iter().map(|f| f.id).max().unwrap_or(0) as u32;
1858 let regenerated = scheme.to_bruker_info(num_frames).expect("to_bruker_info");
1859 let original = crate::data::meta::read_dia_ms_ms_info(&d).expect("read source");
1860 let key = |r: &crate::data::meta::DiaMsMisInfo| (r.frame_id, r.window_group);
1861 let mut a: Vec<_> = regenerated.iter().map(key).collect();
1862 let mut b: Vec<_> = original.iter().map(key).collect();
1863 a.sort_unstable();
1864 b.sort_unstable();
1865 assert_eq!(a, b, "DiaFrameMsMsInfo round-trip differs from source");
1866 eprintln!("bruker_info_round_trip OK: {} (frame, group) rows over {num_frames} frames", a.len());
1867 }
1868
1869 #[cfg(feature = "sciex")]
1870 #[test]
1871 fn from_sciex_wiff_extracts_windows() {
1872 let wiff = match std::env::var("TIMSIM_SCIEX_WIFF") {
1873 Ok(p) => p,
1874 Err(_) => {
1875 eprintln!("SKIP from_sciex_wiff_extracts_windows: set TIMSIM_SCIEX_WIFF=<.wiff>");
1876 return;
1877 }
1878 };
1879 let s = AcquisitionScheme::from_sciex_wiff(&wiff, 3.5, 1800.0, CollisionEnergyPolicy::Unknown)
1881 .expect("extract");
1882 s.validate().expect("valid scheme");
1883 assert_eq!(s.instrument, InstrumentKind::SciexZenoTof);
1884 assert_eq!(s.ms1_count(), 1);
1885 let n = s.windows().count();
1886 assert!(n > 10, "expected many SWATH windows, got {n}");
1887 for w in s.windows() {
1888 assert!(matches!(w.geometry, DiaGeometry::MzOnly));
1889 assert!(matches!(w.collision_energy, CollisionEnergyPolicy::Unknown));
1890 assert!(w.collision_energy.at(w.isolation.center_mz).is_none());
1891 }
1892 let rolling = CollisionEnergyPolicy::Linear { intercept: 5.0, slope_per_mz: 0.045 };
1894 let s2 = AcquisitionScheme::from_sciex_wiff(&wiff, 3.5, 1800.0, rolling).expect("extract");
1895 s2.validate().expect("valid with rolling CE");
1896 for w in s2.windows() {
1897 let ce = w.collision_energy.at(w.isolation.center_mz).expect("resolvable CE");
1898 assert!(ce.is_finite() && ce > 0.0);
1899 }
1900 eprintln!(
1901 "from_sciex_wiff OK: SciexZenoTof, {} SWATH windows, mz {:.1}..{:.1}",
1902 n, s.mz_range.0, s.mz_range.1
1903 );
1904 }
1905
1906 #[cfg(feature = "thermo")]
1907 #[test]
1908 fn from_thermo_raw_extracts_cycle() {
1909 let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
1910 Ok(p) => p,
1911 Err(_) => {
1912 eprintln!("SKIP from_thermo_raw_extracts_cycle: set TIMSIM_ASTRAL_TEMPLATE");
1913 return;
1914 }
1915 };
1916 let s = AcquisitionScheme::from_thermo_raw(&template).expect("extract");
1917 s.validate().expect("valid scheme");
1918 assert_eq!(s.instrument, InstrumentKind::OrbitrapAstral);
1919 assert_eq!(s.ms1_count(), 1, "one MS1 per cycle");
1920 let n_win = s.windows().count();
1921 assert!(n_win > 1, "expected several MS2 windows, got {n_win}");
1922 for w in s.windows() {
1924 assert!(matches!(w.geometry, DiaGeometry::MzOnly));
1925 assert!(w.collision_energy.at(w.isolation.center_mz).is_some());
1926 assert!(w.isolation.width_mz > 0.0);
1927 }
1928 assert!(s.mz_range.0 > 100.0 && s.mz_range.1 < 3000.0 && s.mz_range.0 < s.mz_range.1);
1931 eprintln!(
1932 "from_thermo_raw OK: instrument={:?}, {} MS2 windows, mz {:.1}..{:.1}, cycle {:.4}s",
1933 s.instrument, n_win, s.mz_range.0, s.mz_range.1,
1934 match s.repeat { RepeatPolicy::FixedCycleTime { cycle_time_s, .. } => cycle_time_s }
1935 );
1936 }
1937
1938 #[cfg(feature = "thermo")]
1939 #[test]
1940 fn thermo_frame_schedule_covers_every_scan() {
1941 let template = match std::env::var("TIMSIM_ASTRAL_TEMPLATE") {
1942 Ok(p) => p,
1943 Err(_) => {
1944 eprintln!("SKIP thermo_frame_schedule_covers_every_scan: set TIMSIM_ASTRAL_TEMPLATE");
1945 return;
1946 }
1947 };
1948 let sched = AcquisitionScheme::thermo_frame_schedule(&template).expect("schedule");
1949 assert!(sched.len() > 100, "expected the full run, got {}", sched.len());
1950 let mut last_rt = f64::NEG_INFINITY;
1952 let (mut n_ms1, mut n_ms2) = (0usize, 0usize);
1953 for (k, f) in sched.iter().enumerate() {
1954 assert_eq!(f.scan, sched[0].scan + k as u32, "scans must be contiguous");
1955 assert!(f.retention_time_s.is_finite());
1956 assert!(f.retention_time_s >= last_rt - 1e-6, "RT must be non-decreasing");
1957 last_rt = f.retention_time_s;
1958 if f.ms_level <= 1 {
1959 n_ms1 += 1;
1960 assert!(f.isolation.is_none(), "MS1 carries no isolation window");
1961 } else {
1962 n_ms2 += 1;
1963 let iso = f.isolation.expect("MS2 carries an isolation window");
1964 assert!(iso.width_mz > 0.0 && iso.center_mz > 0.0);
1965 assert!(f.collision_energy.is_some(), "MS2 carries a collision energy");
1966 }
1967 }
1968 assert!(n_ms1 > 0 && n_ms2 > 0, "expected both MS1 and MS2 scans");
1969 eprintln!(
1970 "thermo_frame_schedule OK: {} scans ({} MS1, {} MS2), RT {:.2}..{:.2}s",
1971 sched.len(), n_ms1, n_ms2, sched.first().unwrap().retention_time_s, last_rt
1972 );
1973 }
1974
1975 #[cfg(feature = "thermo")]
1980 #[test]
1981 fn thermo_frame_schedule_rejects_dda_template() {
1982 let template = match std::env::var("TIMSIM_DDA_TEMPLATE") {
1983 Ok(p) => p,
1984 Err(_) => {
1985 eprintln!("SKIP thermo_frame_schedule_rejects_dda_template: set TIMSIM_DDA_TEMPLATE=<dda .raw>");
1986 return;
1987 }
1988 };
1989 let r = AcquisitionScheme::thermo_frame_schedule(&template);
1990 let e = r.err().expect("a DDA template must be rejected (no DIA tiling)");
1991 assert!(e.to_string().contains("looks like DDA"), "unexpected error: {e}");
1992 eprintln!("thermo_frame_schedule correctly rejected DDA template: {e}");
1993 }
1994
1995 #[cfg(feature = "thermo")]
1998 #[test]
1999 fn thermo_frame_schedule_accepts_variable_length_dia() {
2000 let template = match std::env::var("TIMSIM_VARLEN_DIA_TEMPLATE") {
2001 Ok(p) => p,
2002 Err(_) => {
2003 eprintln!("SKIP thermo_frame_schedule_accepts_variable_length_dia: set TIMSIM_VARLEN_DIA_TEMPLATE=<fusion dia .raw>");
2004 return;
2005 }
2006 };
2007 let sched = AcquisitionScheme::thermo_frame_schedule(&template)
2008 .expect("variable-length DIA template must be accepted");
2009 let n_ms1 = sched.iter().filter(|s| s.ms_level <= 1).count();
2010 let n_ms2_iso = sched
2011 .iter()
2012 .filter(|s| s.ms_level > 1 && s.isolation.is_some_and(|w| w.center_mz > 0.0 && w.width_mz > 0.0))
2013 .count();
2014 assert!(n_ms1 > 0, "expected decoded MS1 scans");
2015 assert!(n_ms2_iso > 0, "expected MS2 scans with decoded isolation windows");
2016 eprintln!("variable-length DIA accepted: {n_ms1} MS1, {n_ms2_iso} MS2 with isolation");
2017 }
2018}