Skip to main content

rustdf/sim/
scheme.rs

1//! Vendor-neutral DIA acquisition **scheme** (input/design side).
2//!
3//! An [`AcquisitionScheme`] describes one DIA cycle as an *ordered sequence of
4//! physical events* ([`AcquisitionEvent`]) and how that cycle tiles the
5//! gradient ([`RepeatPolicy`]). It is the design counterpart to the
6//! [`crate::sim::acquisition::AcquisitionWriter`] (output side): the simulator
7//! generates scans for the scheme, and a writer materializes them.
8//!
9//! The key modelling choice (per review) is that a *physical acquisition unit*
10//! is explicit: [`AcquisitionEvent::DiaMs2Frame`] carries a `Vec<DiaWindow>`, so
11//! a timsTOF MS2 frame holding several mobility-partitioned windows and a linear
12//! Astral/SCIEX MS2 scan (one window) are both represented without overloading a
13//! `window_group` id to imply simultaneity.
14
15use std::io;
16
17/// Current scheme schema version.
18pub 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/// An m/z isolation window (no ion mobility — see [`DiaGeometry`]).
44#[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/// How a window's collision energy is determined.
60///
61/// `Value` covers both a per-window CE and a scheme-wide fixed CE (every window
62/// carries the same value) — there is intentionally no separate `Fixed` variant
63/// (they were structurally identical). `Unknown` means extraction could not
64/// recover it (e.g. SCIEX rolling CE) and a model must be supplied downstream —
65/// it is never silently invented.
66#[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    /// Resolve the CE for a window centered at `center_mz`, if determinable.
75    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/// Dissociation method used to activate precursors. Today the simulator only
88/// models collisional activation (CID/HCD are treated identically by the
89/// timsTOF-trained intensity models); the enum exists so activation type is
90/// **persisted and load-bearing** rather than implicit, and so future
91/// electron-based methods are representable. `Unknown` = not recorded.
92#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ActivationMethod {
94    Cid,
95    Hcd,
96    Etd,
97    Ecd,
98    Unknown,
99}
100
101/// Unit a collision/activation energy is expressed in. A bare number is
102/// meaningless across vendors: Bruker/SCIEX report an absolute lab-frame energy
103/// (eV), Thermo reports a charge/m-z-normalized collision energy (NCE). Carrying
104/// the unit makes conversions explicit and lets a [`crate::sim`] fragment
105/// predictor reject inputs it was not calibrated for instead of silently
106/// mis-encoding them.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub enum EnergyUnit {
109    /// Absolute lab-frame collision energy in electron-volts.
110    ElectronVolt,
111    /// Thermo normalized collision energy (instrument-normalized; requires a
112    /// predictor-specific calibration to become a model input).
113    NormalizedCe,
114    /// Unit not recorded (legacy / unspecified).
115    Unknown,
116}
117
118/// A fully-typed activation condition: *what* dissociation, at *what* energy, in
119/// *what* unit. Produced by the acquisition's activation policy from an event +
120/// precursor context; consumed by a fragment predictor, which maps it into its
121/// own feature encoding. This is deliberately NOT a bare `f64` — see [`EnergyUnit`].
122#[derive(Clone, Copy, Debug)]
123pub struct ActivationCondition {
124    pub method: ActivationMethod,
125    pub value: f64,
126    pub unit: EnergyUnit,
127}
128
129impl ActivationCondition {
130    /// Collisional activation at an absolute eV energy (the Bruker/SCIEX case).
131    pub fn collisional_ev(value: f64) -> Self {
132        ActivationCondition {
133            method: ActivationMethod::Hcd,
134            value,
135            unit: EnergyUnit::ElectronVolt,
136        }
137    }
138
139    /// The legacy provenance: timsTOF collisional activation in eV, the implicit
140    /// assumption for every DB written before activation type was recorded.
141    pub fn legacy_bruker() -> Self {
142        Self::collisional_ev(0.0)
143    }
144}
145
146/// What an instrument is physically capable of, used to gate vendor-specific
147/// behaviour WITHOUT using sampling geometry as a proxy. An Astral, for example,
148/// has quadrupole isolation (so isolation-window transmission still applies) but
149/// no TIMS mobility separation and no mobility-dependent quad isotope
150/// transmission — so only the latter two are gated off for it. Defaults are the
151/// Bruker timsTOF case (everything present) so existing behaviour is unchanged.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub struct InstrumentCapabilities {
154    /// TIMS ion-mobility separation (per-scan mobility filtering of fragments).
155    pub has_tims_mobility: bool,
156    /// Mobility-dependent quadrupole isotope transmission scaling of fragments.
157    pub has_quad_isotope_transmission: bool,
158}
159
160impl Default for InstrumentCapabilities {
161    fn default() -> Self {
162        // Bruker timsTOF: both present — preserves current behaviour.
163        Self::bruker_timstof()
164    }
165}
166
167impl InstrumentCapabilities {
168    /// Bruker timsTOF: TIMS mobility + mobility-dependent quad isotope
169    /// transmission both present (the default; preserves current behaviour).
170    pub fn bruker_timstof() -> Self {
171        InstrumentCapabilities {
172            has_tims_mobility: true,
173            has_quad_isotope_transmission: true,
174        }
175    }
176
177    /// Orbitrap Astral: quadrupole isolation IS present (so isolation-window
178    /// transmission still applies — that is not gated here), but there is NO TIMS
179    /// mobility axis and therefore NO mobility-dependent quad isotope transmission.
180    /// Both capability flags are false; the isotope-transmission config is forced
181    /// to `None` mode for an Astral render (see `IsotopeTransmissionConfig::gated_by`).
182    pub fn astral() -> Self {
183        InstrumentCapabilities {
184            has_tims_mobility: false,
185            has_quad_isotope_transmission: false,
186        }
187    }
188}
189
190/// How an acquisition's collision energy is produced, as a vendor-neutral model.
191/// Composes the existing per-window [`CollisionEnergyPolicy`] (DIA / fixed CE,
192/// already DATA on each window) and adds the Bruker DDA-PASEF mobility-dependent
193/// form, so the DDA CE formula is an *instrument* decision instead of being
194/// hardcoded in the selection job. A Thermo NCE model becomes another variant
195/// (P6) without touching the selection code.
196#[derive(Clone, Copy, Debug)]
197pub enum CollisionEnergyModel {
198    /// CE from a per-window policy (DIA / fixed); evaluated at a window m/z.
199    PerWindow(CollisionEnergyPolicy),
200    /// Bruker DDA-PASEF: `ce_bias + ce_slope * scan` (scan = Bruker mobility
201    /// coordinate). Reproduces the legacy `dda_selection_scheme` formula exactly.
202    BrukerPasef { ce_bias: f64, ce_slope: f64 },
203}
204
205/// Vendor-neutral activation policy: the typed [`ActivationCondition`] producer
206/// for an acquisition. Pairs a dissociation method + energy unit with a
207/// [`CollisionEnergyModel`]. The Bruker timsTOF default reproduces the legacy CE
208/// numbers byte-for-byte; P6 supplies a Thermo policy with the same interface.
209#[derive(Clone, Copy, Debug)]
210pub struct ActivationPolicy {
211    pub method: ActivationMethod,
212    pub unit: EnergyUnit,
213    pub model: CollisionEnergyModel,
214}
215
216impl ActivationPolicy {
217    /// Bruker timsTOF DDA-PASEF: collisional activation (HCD), eV, CE linear in
218    /// scan. `ce_bias`/`ce_slope` default (in `dda_selection_scheme`) to
219    /// 54.1984 / -0.0345 — pass those to reproduce the legacy output exactly.
220    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    /// Thermo Orbitrap Astral: collisional activation (HCD) reported as a
229    /// **normalized** collision energy (NCE), produced per isolation window. The
230    /// per-window NCE is carried in the window's own [`CollisionEnergyPolicy`]
231    /// (`ce`), and the unit is [`EnergyUnit::NormalizedCe`] — so a downstream
232    /// fragment predictor that was calibrated in eV will *reject* it rather than
233    /// silently mis-encode an NCE as an absolute energy. There is no scan
234    /// dependence (no IMS): `condition_for_scan` is `None`; use
235    /// [`Self::condition_for_window`].
236    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    /// Collision energy (eV) the instrument applies at a Bruker mobility scan.
245    /// Only meaningful for scan-parameterised models (Bruker DDA-PASEF). A
246    /// per-window model has NO scan dependence — it returns `None` (use
247    /// [`Self::condition_for_window`]) rather than silently misreading the scan
248    /// number as an m/z.
249    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    /// Typed activation condition at a Bruker mobility scan (scan-parameterised
259    /// models only; `None` for per-window models).
260    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    /// Typed activation condition for a per-window model at `center_mz`.
269    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/// Window geometry: m/z only, or (timsTOF) an additional ion-mobility partition.
282/// Mobility is kept off [`IsolationWindow`] so "mobility on a non-IMS instrument"
283/// is unrepresentable. Scan numbers are Bruker-grid coordinates (they require the
284/// reference dataset's calibration to map to physical mobility).
285#[derive(Clone, Copy, Debug)]
286pub enum DiaGeometry {
287    MzOnly,
288    TimsMobility { scan_start: u32, scan_end: u32 },
289}
290
291/// One DIA isolation window within a frame.
292#[derive(Clone, Copy, Debug)]
293pub struct DiaWindow {
294    pub isolation: IsolationWindow,
295    pub collision_energy: CollisionEnergyPolicy,
296    pub geometry: DiaGeometry,
297}
298
299/// An MS1 (precursor) acquisition.
300#[derive(Clone, Debug)]
301pub struct Ms1Event {
302    pub analyzer: Analyzer,
303    pub data_mode: DataMode,
304    /// The MS1 acquisition m/z range, if known. `None` when not recoverable
305    /// (e.g. Thermo `scan_event` does not carry it — it must not be confused
306    /// with the DIA window coverage in [`AcquisitionScheme::mz_range`]).
307    pub mz_range: Option<(f64, f64)>,
308    pub duration_s: Option<f64>,
309}
310
311/// One physical MS2 acquisition unit. Holds one window for Astral/SCIEX; several
312/// mobility-partitioned windows (sharing the frame) for timsTOF.
313#[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    /// The vendor's native group id for this frame, preserved for round-trip
320    /// fidelity (Bruker `WindowGroup`). `None` for vendors without one. The cycle
321    /// order is authoritative for *simultaneity/ordering*; this is *identity* only
322    /// — needed so a Bruker adapter regenerates the original `WindowGroup` values
323    /// that companion tables (`DiaFrameMsMsInfo`) reference.
324    pub vendor_group_id: Option<u32>,
325}
326
327#[derive(Clone, Debug)]
328pub enum AcquisitionEvent {
329    Ms1(Ms1Event),
330    DiaMs2Frame(DiaMs2Frame),
331}
332
333/// How the cycle repeats over the gradient.
334#[derive(Clone, Copy, Debug)]
335pub enum RepeatPolicy {
336    /// The cycle repeats every `cycle_time_s`, starting at `start_time_s`, until
337    /// `gradient_length_s`. Per-event `duration_s` (when present) takes
338    /// precedence for fine RT placement; otherwise events are spread across the
339    /// cycle uniformly.
340    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/// Where the scheme came from (scheme-level attribution; not per-field).
357#[derive(Clone, Debug)]
358pub struct Provenance {
359    pub source: SchemeSource,
360    pub notes: String,
361}
362
363/// A vendor-neutral DIA acquisition design.
364#[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    /// Iterate every DIA window across all MS2 frames in the cycle.
376    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    /// Number of MS1 events in one cycle.
387    pub fn ms1_count(&self) -> usize {
388        self.cycle
389            .iter()
390            .filter(|e| matches!(e, AcquisitionEvent::Ms1(_)))
391            .count()
392    }
393
394    /// Number of full cycles that fit the gradient (if derivable).
395    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    /// Expand a `FixedCycleTime` DIA scheme into a per-frame schedule over the whole
413    /// gradient — the synthesized analogue of `thermo_frame_schedule` for schemes that
414    /// carry no per-scan timing (e.g. a SCIEX `.wiff` SWATH method). Each cycle emits its
415    /// events (MS1 then one MS2 frame per window) at evenly-spaced times within the
416    /// cycle. Rows are `(scan, retention_time_s, ms_level, isolation_center_mz,
417    /// isolation_width_mz, collision_energy)`; the MS1 row's isolation/CE are `None`, and
418    /// a window's CE is resolved from its `CollisionEnergyPolicy` at the window center
419    /// (`None` if the policy is `Unknown`). Empty unless `repeat` is `FixedCycleTime`.
420    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                        // SCIEX from_sciex_wiff builds one window per MS2 frame.
440                        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    /// Build a scheme from an explicit window list (injected / CSV). One MS1
454    /// followed by one single-window MS2 frame per window.
455    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    /// Validate internal consistency. Returns a human-readable error on the first
486    /// problem found.
487    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        // Structure: exactly one MS1, as the first event, then >= 1 MS2 frame.
502        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                        // Within one frame, windows must not overlap in BOTH m/z
613                        // and mobility (overlap across sequential frames is fine).
614                        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, // m/z-only windows share all mobility
629                            };
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    /// Render the scheme's MS2 windows as Bruker `DiaFrameMsMsWindows` rows — the
667    /// backward-compatibility adapter for the existing timsTOF write path.
668    ///
669    /// Each MS2 frame is one window group; its windows must carry `TimsMobility`
670    /// geometry (the Bruker-grid scan ranges). The `WindowGroup` id is the frame's
671    /// preserved [`DiaMs2Frame::vendor_group_id`] when present (so a Bruker
672    /// round-trip keeps the ids that `DiaFrameMsMsInfo` references), else a 1..N
673    /// fallback in cycle order. A `Linear` CE policy is resolved at the window
674    /// center (a lossy materialization, not an inverse); `Unknown` CE and
675    /// non-timsTOF schemes are rejected. The scheme is validated first.
676    ///
677    /// Row order is not meaningful (the SQLite table has none); the companion
678    /// frame→group table `DiaFrameMsMsInfo` needs the run frame schedule and is a
679    /// separate step.
680    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    /// Per-cycle-position window-group id: `None` for an MS1 event, `Some(group)`
720    /// for an MS2 frame. The group id is the frame's preserved `vendor_group_id`
721    /// (Bruker `WindowGroup`) when set, else a collision-safe positional id drawn
722    /// from the unused set (so preserved and fallback ids never collide).
723    /// Duplicate explicit ids are rejected. Validates first; requires a timsTOF
724    /// scheme. This is the single source of truth shared by `to_bruker_windows`
725    /// and `to_bruker_info`, so the two tables always agree.
726    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    /// Generate the Bruker `DiaFrameMsMsInfo` (frame → window group) rows for a run
769    /// of `num_frames` total frames, tiling the scheme's cycle. Cycle position
770    /// `(frame_id - 1) % cycle_len` selects the event (1-based frame ids), MS1
771    /// frames produce no row, and each MS2 frame maps to its window-group id (the
772    /// same ids `to_bruker_windows` emits, so the two tables reference the same
773    /// groups). The final cycle may be partial.
774    ///
775    /// Precondition: this models a **clean generated run** — frame 1 is the
776    /// cycle's leading MS1 and frame ids are contiguous (as TimSim produces). It
777    /// is not a reproducer for arbitrary real files with prefix/calibration
778    /// frames, gaps, or acquisition starting mid-cycle.
779    pub fn to_bruker_info(
780        &self,
781        num_frames: u32,
782    ) -> Result<Vec<crate::data::meta::DiaMsMisInfo>, String> {
783        // Bound the work so an absurd frame count errors cleanly instead of OOM.
784        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    /// Both Bruker DIA tables for a `num_frames`-frame run: the
807    /// `DiaFrameMsMsWindows` rows and the `DiaFrameMsMsInfo` (frame→group) rows,
808    /// with consistent window-group ids.
809    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    /// Extract the acquisition scheme from a real Bruker timsTOF DIA `.d`.
823    ///
824    /// Reads `DiaFrameMsMsWindows` and the frame table. Each **window group**
825    /// becomes one [`DiaMs2Frame`] holding its mobility-partitioned windows
826    /// (`TimsMobility` geometry — the scan ranges are Bruker-grid coordinates),
827    /// preceded by a single MS1 event. Cycle timing comes from the spacing of
828    /// the precursor (MS1) frames. The returned scheme is validated.
829    ///
830    /// Window groups (frames) are ordered by their **first occurrence in
831    /// `DiaFrameMsMsInfo`** (ascending frame id), i.e. the real acquisition order
832    /// within a cycle — not merely ascending `WindowGroup` id — so the cycle
833    /// faithfully represents permuted/reused group numbering. (If the info table
834    /// is absent, falls back to ascending group id.)
835    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        // Group windows by window group (ascending); each group is a frame.
852        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        // Order groups by first occurrence (ascending frame id) in DiaFrameMsMsInfo
873        // = real intra-cycle acquisition order. Missing info -> ascending group id.
874        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        // Stable sort by first-occurrence frame; groups absent from the info table
888        // (key u32::MAX) keep their ascending-id relative order.
889        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), // preserve Bruker WindowGroup identity
906            }));
907        }
908
909        // Cycle timing from the precursor (MS1, MsMsType == 0) frame spacing.
910        // Use the MEDIAN of the positive gaps between distinct, finite precursor
911        // times, so one anomalous interval doesn't set the whole cycle.
912        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    /// Extract the SWATH scheme from a real SCIEX ZenoTOF `.wiff` method.
966    ///
967    /// Each SWATH window becomes a single-window [`DiaMs2Frame`] (no ion
968    /// mobility) preceded by an MS1.
969    ///
970    /// **Collision energy** is caller-supplied (`collision_energy`), because
971    /// SCIEX SWATH uses *rolling* CE computed by the instrument from an m/z
972    /// formula at acquisition time — it is **not** stored per-window in the
973    /// `.wiff` method (verified: the per-window MS2 parameter streams are
974    /// byte-identical across windows). Pass [`CollisionEnergyPolicy::Unknown`]
975    /// to leave it unset, or a [`CollisionEnergyPolicy::Linear`] rolling model.
976    /// The `.wiff` method also lacks run timing, so `cycle_time_s` /
977    /// `gradient_length_s` are caller-supplied. The returned scheme is validated.
978    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    /// Extract the acquisition scheme from a real Thermo `.raw` by walking the
1046    /// first complete cycle (first MS1 up to the next MS1). Each MS2 scan becomes
1047    /// a single-window [`DiaMs2Frame`] (Thermo has no mobility), with the
1048    /// observed isolation center/width and CE.
1049    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                    // A missing event *inside* the selected cycle would silently
1088                    // drop a window — reject it. Before the first MS1 it's ignorable.
1089                    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                    // start of the next cycle -> close this one
1101                    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, // scan_event does not carry the MS1 range
1111                    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        // Instrument from the analyzers of events actually in the cycle (an ASTMS
1160        // MS2 frame ⇒ Orbitrap Astral), not from incidental scans elsewhere.
1161        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    /// Extract the template's FULL per-scan schedule (every scan, not just the
1191    /// first cycle): scan number, its actual retention time, ms-level, and (for
1192    /// MS2) the isolation window + collision energy.
1193    ///
1194    /// This is the build-from-template timeline source (P6e): an Astral run mirrors
1195    /// these frames 1:1, so the trunk's per-frame abundances are keyed to the
1196    /// template's real scan RTs and the authored output's RT/schedule are correct
1197    /// by construction (rather than relabelling a Bruker-timed frame with a template
1198    /// RT). The RT is the template's recorded time; ms-level comes from the scan
1199    /// event (falling back to the profile/centroid packet type when absent).
1200    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        // Reject a template whose per-scan scan events can't be decoded at all — without
1206        // them the ms-level/isolation are unknown and the schedule would be unreliable.
1207        // (Both fixed-stride (Astral/Velos) and variable-length (Fusion-class) layouts
1208        // decode; this guards a layout whose event grammar we can't walk.)
1209        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            // Thermo stores scan time in MINUTES; expose SECONDS so the `_s` field is
1220            // honest and downstream (seconds-based) consumers need no further scaling.
1221            let rt = raw.index[i].time * 60.0;
1222            let ev = raw.scan_event(scan);
1223            // ms-level: prefer the scan event; else classify by packet type
1224            // (a non-zero profile section = MS1 FTMS).
1225            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        // DIA-suitability: a DIA template tiles the m/z range with a SMALL set of windows
1254        // that REPEAT across cycles; a DDA acquisition has a (near-)unique precursor per
1255        // MS2 scan — no tiling. Building a DIA simulation from DDA precursors is degenerate
1256        // (simulated peptides almost never fall in an arbitrary one-off window), so reject
1257        // a DDA-shaped schedule with a clear message instead of producing an empty run.
1258        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            // DIA tiles with a small, fixed window set that repeats every cycle — even
1265            // ultra-narrow DIA stays in the low hundreds and repeats heavily. DDA has
1266            // thousands of distinct precursors, each seen only a handful of times.
1267            // Require BOTH a large absolute window count (run-length-independent) AND a
1268            // high distinct/MS2 ratio, so neither a short DIA run nor an extreme-narrow
1269            // DIA scheme is misjudged. Measured: DIA ~75 windows @ ratio 0.0005;
1270            // DDA ~20909 @ ratio 0.40.
1271            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/// One template scan in the build-from-template frame schedule (P6e). The Astral
1290/// acquisition mirrors these 1:1 so the trunk's frames align with the template's
1291/// real scan RTs and windows.
1292#[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    /// Present for MS2 (the precursor isolation window; m/z only — no IMS).
1299    pub isolation: Option<IsolationWindow>,
1300    /// Present for MS2 (the template's recorded collision energy).
1301    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        // 3 cycles of [MS1, MS2(420), MS2(480)] over a 9 s gradient at 3 s cycles.
1332        let sched = swath_scheme(3.0, 9.0).dia_frame_schedule();
1333        assert_eq!(sched.len(), 9, "3 cycles x 3 events");
1334        // Contiguous 1-based scan ids.
1335        assert_eq!(
1336            sched.iter().map(|r| r.0).collect::<Vec<_>>(),
1337            (1u32..=9).collect::<Vec<_>>()
1338        );
1339        // RT strictly increasing.
1340        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        // Cycle starts: rows 0,3,6 at t = 0,3,6.
1344        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        // First MS2: center 420, width 40, CE resolved by the linear model (>0).
1350        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        // Force all window CE policies to Unknown.
1360        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        // The legacy provenance is collisional eV (timsTOF assumption).
1383        let legacy = ActivationCondition::legacy_bruker();
1384        assert_eq!(legacy.unit, EnergyUnit::ElectronVolt);
1385
1386        // An NCE value is a distinct unit and must not compare equal to eV.
1387        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        // Legacy dda_selection_scheme: collision_energy = ce_bias + ce_slope*scan.
1394        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        // A per-window (DIA) model has no scan dependence: scan evaluation is
1407        // None (not a silently-wrong value), and CE comes from the window m/z.
1408        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        // Default = full timsTOF capability so existing behaviour is unchanged;
1421        // an Astral-like instrument keeps quad isolation but drops mobility +
1422        // mobility-dependent isotope transmission.
1423        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        // P6c: the named Astral constructor is both-false (isotope mode gated off).
1429        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        // P6c: Astral NCE policy. Per-window normalized CE; no scan dependence.
1438        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        // Resolves a window CE as an NCE-unit condition (NOT eV) — a downstream
1443        // eV-calibrated predictor must be able to reject it on the unit alone.
1444        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        // No IMS: there is no scan-parameterised CE.
1450        assert_eq!(p.collision_energy_for_scan(100), None);
1451        assert!(p.condition_for_scan(100).is_none());
1452
1453        // A rolling (linear) NCE model resolves per window center.
1454        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, // wrong: multi-window on non-tims
1511            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        // valid baseline
1577        assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), frame(win())]).validate().is_ok());
1578        // MS2 first (no leading MS1)
1579        assert!(mk(vec![frame(win()), AcquisitionEvent::Ms1(ms1())]).validate().is_err());
1580        // two MS1 in a cycle
1581        assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), AcquisitionEvent::Ms1(ms1()), frame(win())]).validate().is_err());
1582        // MS1 with no MS2 frame
1583        assert!(mk(vec![AcquisitionEvent::Ms1(ms1())]).validate().is_err());
1584        // window outside mz_range
1585        let mut oob = win();
1586        oob.isolation.center_mz = 2000.0;
1587        assert!(mk(vec![AcquisitionEvent::Ms1(ms1()), frame(oob)]).validate().is_err());
1588        // negative collision energy
1589        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        // bad event duration
1593        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    // Gated: set TIMSIM_BRUKER_DIA_D to a real Bruker DIA-PASEF .d folder.
1604    #[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        // timsTOF windows carry mobility geometry with a known CE.
1620        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        // At least one frame should be mobility-partitioned (>1 window).
1625        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    // Gated: set TIMSIM_SCIEX_WIFF to a real ZenoTOF .wiff (OLE2 method).
1635    // Unconditional: to_bruker_windows must preserve explicit vendor_group_ids
1636    // (incl. non-canonical), allocate non-colliding ids for None frames, and
1637    // reject duplicate explicit ids. (The gated round-trip uses a real .d whose
1638    // ids are already 1..N, so it can't prove non-canonical preservation alone.)
1639    #[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        // preserved non-canonical ids
1680        assert_eq!(ids(&mk(vec![frame(Some(7), 500.0), frame(Some(42), 600.0)])).unwrap(), vec![7, 42]);
1681        // all-None -> sequential 1..N
1682        assert_eq!(ids(&mk(vec![frame(None, 500.0), frame(None, 600.0)])).unwrap(), vec![1, 2]);
1683        // mixed: None allocates a free id (1), avoiding the reserved 7
1684        assert_eq!(ids(&mk(vec![frame(Some(7), 500.0), frame(None, 600.0)])).unwrap(), vec![7, 1]);
1685        // duplicate explicit ids rejected
1686        assert!(ids(&mk(vec![frame(Some(5), 500.0), frame(Some(5), 600.0)])).is_err());
1687    }
1688
1689    // Gated: round-trip a real Bruker DIA .d through the scheme and back to the
1690    // DiaFrameMsMsWindows rows; the regenerated table must match the source.
1691    #[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        // Compare as a sorted multiset of EXACT field tuples, including the
1706        // WindowGroup id (preserved via vendor_group_id) — no normalization, so a
1707        // real id mismatch would fail. Row order in the table is not guaranteed.
1708        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(&regenerated),
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    // Unconditional: DiaFrameMsMsInfo tiling + windows/info group-id consistency.
1744    #[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        // cycle = [MS1, g1, g2]; num_frames=6: 1->skip, 2->g1, 3->g2, 4->skip, 5->g1, 6->g2
1781        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        // The two tables must reference the same set of window-group ids.
1789        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        // Non-ascending explicit ids + a multi-window (mobility-partitioned) frame:
1795        // info must follow CYCLE order (not sorted id) and emit ONE row per frame.
1796        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)]), // 2 mobility windows
1821                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        // cycle len 3; frame2->g7 (first), frame3->g2 — acquisition order, NOT sorted.
1832        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        // windows: the 2-window frame emits 2 rows (group 7), the other 1 (group 2).
1840        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    // Gated: regenerate DiaFrameMsMsInfo for the .d's full frame count and match.
1846    #[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        // Default: CE unknown (rolling CE isn't in the .wiff method).
1880        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        // Supplying a rolling-CE Linear model gives a resolvable, finite CE.
1893        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        // Thermo: every window is m/z-only with a known CE.
1923        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        // centers should be (weakly) increasing across the first cycle is not
1929        // guaranteed by Astral, but coverage must be a sane m/z span.
1930        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        // Scans are contiguous and ascending; RT is finite and non-decreasing.
1951        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    // A DDA template must be REJECTED — its scan events now decode (variable-length
1976    // support), but it has a unique precursor per MS2 (no DIA tiling), so the
1977    // DIA-suitability check rejects it. Gate on a real DDA .raw (e.g. Orbitrap Fusion):
1978    // `TIMSIM_DDA_TEMPLATE=<dda .raw>`.
1979    #[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    // A variable-length-event DIA template (Orbitrap Fusion-class) must be ACCEPTED and
1996    // decode real ms-level + isolation windows. Gate: `TIMSIM_VARLEN_DIA_TEMPLATE=<fusion dia .raw>`.
1997    #[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}