Skip to main content

mscore/timstof/
quadrupole.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::f64;
3use std::f64::consts::E;
4use itertools::izip;
5use crate::data::spectrum::MzSpectrum;
6use crate::simulation::annotation::{MzSpectrumAnnotated, TimsFrameAnnotated};
7use crate::timstof::frame::TimsFrame;
8
9/// Sigmoid step function for quadrupole selection simulation
10///
11/// Arguments:
12///
13/// * `x` - mz values
14/// * `up_start` - start of the step
15/// * `up_end` - end of the step
16/// * `k` - steepness of the step
17///
18/// Returns:
19///
20/// * `Vec<f64>` - transmission probability for each mz value
21///
22/// # Examples
23///
24/// ```
25/// use mscore::timstof::quadrupole::smooth_step;
26///
27/// let mz = vec![100.0, 200.0, 300.0];
28/// let transmission = smooth_step(&mz, 150.0, 250.0, 0.5).iter().map(
29/// |&x| (x * 100.0).round() / 100.0).collect::<Vec<f64>>();
30/// assert_eq!(transmission, vec![0.0, 0.5, 1.0]);
31/// ```
32pub fn smooth_step(x: &Vec<f64>, up_start: f64, up_end: f64, k: f64) -> Vec<f64> {
33    let m = (up_start + up_end) / 2.0;
34    x.iter().map(|&xi| 1.0 / (1.0 + E.powf(-k * (xi - m)))).collect()
35}
36
37/// Sigmoide step function for quadrupole selection simulation
38///
39/// Arguments:
40///
41/// * `x` - mz values
42/// * `up_start` - start of the step up
43/// * `up_end` - end of the step up
44/// * `down_start` - start of the step down
45/// * `down_end` - end of the step down
46/// * `k` - steepness of the step
47///
48/// Returns:
49///
50/// * `Vec<f64>` - transmission probability for each mz value
51///
52/// # Examples
53///
54/// ```
55/// use mscore::timstof::quadrupole::smooth_step_up_down;
56///
57/// let mz = vec![100.0, 200.0, 300.0];
58/// let transmission = smooth_step_up_down(&mz, 150.0, 200.0, 250.0, 300.0, 0.5).iter().map(
59/// |&x| (x * 100.0).round() / 100.0).collect::<Vec<f64>>();
60/// assert_eq!(transmission, vec![0.0, 1.0, 0.0]);
61/// ```
62pub fn smooth_step_up_down(x: &Vec<f64>, up_start: f64, up_end: f64, down_start: f64, down_end: f64, k: f64) -> Vec<f64> {
63    let step_up = smooth_step(x, up_start, up_end, k);
64    let step_down = smooth_step(x, down_start, down_end, k);
65    step_up.iter().zip(step_down.iter()).map(|(&u, &d)| u - d).collect()
66}
67
68/// Ion transmission function for quadrupole selection simulation
69///
70/// Arguments:
71///
72/// * `midpoint` - center of the step
73/// * `window_length` - length of the step
74/// * `k` - steepness of the step
75///
76/// Returns:
77///
78/// * `impl Fn(Vec<f64>) -> Vec<f64>` - ion transmission function
79///
80/// # Examples
81///
82/// ```
83/// use mscore::timstof::quadrupole::ion_transition_function_midpoint;
84///
85/// let ion_transmission = ion_transition_function_midpoint(150.0, 50.0, 1.0);
86/// let mz = vec![100.0, 150.0, 160.0];
87/// let transmission = ion_transmission(mz).iter().map(
88/// |&x| (x * 100.0).round() / 100.0).collect::<Vec<f64>>();
89/// assert_eq!(transmission, vec![0.0, 1.0, 1.0]);
90/// ```
91pub fn ion_transition_function_midpoint(midpoint: f64, window_length: f64, k: f64) -> impl Fn(Vec<f64>) -> Vec<f64> {
92    let half_window = window_length / 2.0;
93
94    let up_start = midpoint - half_window - 0.5;
95    let up_end = midpoint - half_window;
96    let down_start = midpoint + half_window;
97    let down_end = midpoint + half_window + 0.5;
98
99    // take a vector of mz values to their transmission probability
100    move |mz: Vec<f64>| -> Vec<f64> {
101        smooth_step_up_down(&mz, up_start, up_end, down_start, down_end, k)
102    }
103}
104
105/// Apply ion transmission function to mz values
106///
107/// Arguments:
108///
109/// * `midpoint` - center of the step
110/// * `window_length` - length of the step
111/// * `k` - steepness of the step
112/// * `mz` - mz values
113///
114/// Returns:
115///
116/// * `Vec<f64>` - transmission probability for each mz value
117///
118/// # Examples
119///
120/// ```
121/// use mscore::timstof::quadrupole::apply_transmission;
122///
123/// let mz = vec![100.0, 150.0, 160.0];
124/// let transmission = apply_transmission(150.0, 50.0, 1.0, mz).iter().map(
125/// |&x| (x * 100.0).round() / 100.0).collect::<Vec<f64>>();
126/// assert_eq!(transmission, vec![0.0, 1.0, 1.0]);
127/// ```
128pub fn apply_transmission(midpoint: f64, window_length: f64, k: f64, mz: Vec<f64>) -> Vec<f64> {
129    ion_transition_function_midpoint(midpoint, window_length, k)(mz)
130}
131
132/// Vendor-neutral precursor transmission for ONE isolation window (P6b).
133///
134/// The quadrupole m/z transfer is the SAME [`apply_transmission`] curve the
135/// scan-indexed Bruker path uses internally — the only vendor difference is HOW
136/// the active window is found: Bruker locates it by `(frame_id, scan_id)` (the
137/// mobility-partitioned PASEF window), while a no-IMS instrument (Orbitrap
138/// Astral) locates it by acquisition event / cycle position + m/z (there is no
139/// scan axis). This type decouples the m/z transfer from that lookup, so the
140/// fragment render can ask "is this precursor transmitted?" without knowing how
141/// the window was selected. It is purely additive — the existing
142/// `IonTransmission` (TimsTransmissionDIA/DDA) path is unchanged.
143#[derive(Clone, Copy, Debug)]
144pub struct WindowTransmission {
145    pub center_mz: f64,
146    pub width_mz: f64,
147    /// Sigmoid steepness of the quadrupole edge (same `k` as the Bruker path).
148    pub k: f64,
149}
150
151impl WindowTransmission {
152    pub fn new(center_mz: f64, width_mz: f64, k: f64) -> Self {
153        WindowTransmission { center_mz, width_mz, k }
154    }
155
156    /// Per-m/z transmission probabilities through this window — identical to the
157    /// curve the scan-indexed path applies once it has found the window.
158    pub fn probabilities(&self, mz: &[f64]) -> Vec<f64> {
159        apply_transmission(self.center_mz, self.width_mz, self.k, mz.to_vec())
160    }
161
162    /// True if ANY of `mz` is transmitted above `min_proba` (default 0.5) — the
163    /// window-based equivalent of `IonTransmission::any_transmitted`.
164    pub fn any_transmitted(&self, mz: &[f64], min_proba: Option<f64>) -> bool {
165        let cutoff = min_proba.unwrap_or(0.5);
166        self.probabilities(mz).iter().any(|&p| p > cutoff)
167    }
168
169    /// Indices of `mz` transmitted above `min_proba` (default 0.5) — the
170    /// window-based equivalent of `IonTransmission::get_transmission_set`.
171    pub fn transmitted_set(&self, mz: &[f64], min_proba: Option<f64>) -> HashSet<usize> {
172        let cutoff = min_proba.unwrap_or(0.5);
173        let p = self.probabilities(mz);
174        mz.iter()
175            .enumerate()
176            .filter(|&(i, _)| p[i] > cutoff)
177            .map(|(i, _)| i)
178            .collect()
179    }
180}
181
182pub trait IonTransmission {
183    fn apply_transmission(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>) -> Vec<f64>;
184
185    /// Transmit a spectrum given a frame id and scan id
186    ///
187    /// Arguments:
188    ///
189    /// * `frame_id` - frame id
190    /// * `scan_id` - scan id
191    /// * `spectrum` - MzSpectrum
192    /// * `min_probability` - minimum probability for transmission
193    ///
194    /// Returns:
195    ///
196    /// * `MzSpectrum` - transmitted spectrum
197    ///
198    fn transmit_spectrum(&self, frame_id: i32, scan_id: i32, spectrum: MzSpectrum, min_probability: Option<f64>) -> MzSpectrum {
199
200        let probability_cutoff = min_probability.unwrap_or(0.5);
201        let transmission_probability = self.apply_transmission(frame_id, scan_id, &spectrum.mz);
202
203        let mut filtered_mz = Vec::new();
204        let mut filtered_intensity = Vec::new();
205
206        // zip mz and intensity with transmission probability and filter out all mz values with transmission probability 0.001
207        for (i, (mz, intensity)) in spectrum.mz.iter().zip(spectrum.intensity.iter()).enumerate() {
208            if transmission_probability[i] > probability_cutoff {
209                filtered_mz.push(*mz);
210                filtered_intensity.push(*intensity* transmission_probability[i]);
211            }
212        }
213
214        MzSpectrum::new(filtered_mz, filtered_intensity)
215    }
216
217    /// Transmit an annotated spectrum given a frame id and scan id
218    ///
219    /// Arguments:
220    ///
221    /// * `frame_id` - frame id
222    /// * `scan_id` - scan id
223    /// * `spectrum` - MzSpectrumAnnotated
224    /// * `min_probability` - minimum probability for transmission
225    ///
226    /// Returns:
227    ///
228    /// * `MzSpectrumAnnotated` - transmitted spectrum
229    ///
230    fn transmit_annotated_spectrum(&self, frame_id: i32, scan_id: i32, spectrum: MzSpectrumAnnotated, min_probability: Option<f64>) -> MzSpectrumAnnotated {
231        let probability_cutoff = min_probability.unwrap_or(0.5);
232        let transmission_probability = self.apply_transmission(frame_id, scan_id, &spectrum.mz);
233
234        let mut filtered_mz = Vec::new();
235        let mut filtered_intensity = Vec::new();
236        let mut filtered_annotation = Vec::new();
237
238        // zip mz and intensity with transmission probability and filter out all mz values with transmission probability 0.5
239        for (i, (mz, intensity, annotation)) in izip!(spectrum.mz.iter(), spectrum.intensity.iter(), spectrum.annotations.iter()).enumerate() {
240            if transmission_probability[i] > probability_cutoff {
241                filtered_mz.push(*mz);
242                filtered_intensity.push(*intensity* transmission_probability[i]);
243                filtered_annotation.push(annotation.clone());
244            }
245        }
246
247        MzSpectrumAnnotated {
248            mz: filtered_mz,
249            intensity: filtered_intensity,
250            annotations: filtered_annotation,
251        }
252    }
253
254    fn transmit_ion(&self, frame_ids: Vec<i32>, scan_ids: Vec<i32>, spec: MzSpectrum, min_proba: Option<f64>) -> Vec<Vec<MzSpectrum>> {
255
256        let mut result: Vec<Vec<MzSpectrum>> = Vec::new();
257
258        for frame_id in frame_ids.iter() {
259            let mut frame_result: Vec<MzSpectrum> = Vec::new();
260            for scan_id in scan_ids.iter() {
261                let transmitted_spectrum = self.transmit_spectrum(*frame_id, *scan_id, spec.clone(), min_proba);
262                frame_result.push(transmitted_spectrum);
263            }
264            result.push(frame_result);
265        }
266        result
267    }
268
269    /// Get all ions in a frame that are transmitted
270    ///
271    /// Arguments:
272    ///
273    /// * `frame_id` - frame id
274    /// * `scan_id` - scan id
275    /// * `mz` - mz values
276    /// * `min_proba` - minimum probability for transmission
277    ///
278    /// Returns:
279    ///
280    /// * `HashSet<usize>` - indices of transmitted mz values
281    ///
282    fn get_transmission_set(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>, min_proba: Option<f64>) -> HashSet<usize> {
283        // go over enumerated mz and push all indices with transmission probability > min_proba to a set
284        let probability_cutoff = min_proba.unwrap_or(0.5);
285        let transmission_probability = self.apply_transmission(frame_id, scan_id, mz);
286        mz.iter().enumerate().filter(|&(i, _)| transmission_probability[i] > probability_cutoff).map(|(i, _)| i).collect()
287    }
288
289    /// Check if all mz values in a given collection are transmitted
290    ///
291    /// Arguments:
292    ///
293    /// * `frame_id` - frame id
294    /// * `scan_id` - scan id
295    /// * `mz` - mz values
296    /// * `min_proba` - minimum probability for transmission
297    ///
298    /// Returns:
299    ///
300    /// * `bool` - true if all mz values are transmitted
301    ///
302    fn all_transmitted(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>, min_proba: Option<f64>) -> bool {
303        let probability_cutoff = min_proba.unwrap_or(0.5);
304        let transmission_probability = self.apply_transmission(frame_id, scan_id, mz);
305        transmission_probability.iter().all(|&p| p > probability_cutoff)
306    }
307
308    /// Check if a single mz value is transmitted
309    ///
310    /// Arguments:
311    ///
312    /// * `frame_id` - frame id
313    /// * `scan_id` - scan id
314    /// * `mz` - mz value
315    /// * `min_proba` - minimum probability for transmission
316    ///
317    /// Returns:
318    ///
319    /// * `bool` - true if mz value is transmitted
320    ///
321    fn is_transmitted(&self, frame_id: i32, scan_id: i32, mz: f64, min_proba: Option<f64>) -> bool {
322        let probability_cutoff = min_proba.unwrap_or(0.5);
323        let transmission_probability = self.apply_transmission(frame_id, scan_id, &vec![mz]);
324        transmission_probability[0] > probability_cutoff
325    }
326
327    /// Check if any mz value is transmitted, can be used to check if one peak of isotopic envelope is transmitted
328    ///
329    /// Arguments:
330    ///
331    /// * `frame_id` - frame id
332    /// * `scan_id` - scan id
333    /// * `mz` - mz values
334    /// * `min_proba` - minimum probability for transmission
335    ///
336    /// Returns:
337    ///
338    /// * `bool` - true if any mz value is transmitted
339    ///
340    fn any_transmitted(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>, min_proba: Option<f64>) -> bool {
341        let probability_cutoff = min_proba.unwrap_or(0.5);
342        let transmission_probability = self.apply_transmission(frame_id, scan_id, mz);
343        transmission_probability.iter().any(|&p| p > probability_cutoff)
344    }
345
346    /// Transmit a frame given a diaPASEF transmission layout
347    fn transmit_tims_frame(&self, frame: &TimsFrame, min_probability: Option<f64>) -> TimsFrame {
348        let spectra = frame.to_tims_spectra();
349        let mut filtered_spectra = Vec::new();
350
351        for mut spectrum in spectra {
352            let filtered_spectrum = self.transmit_spectrum(frame.frame_id, spectrum.scan, spectrum.spectrum.mz_spectrum, min_probability);
353            if filtered_spectrum.mz.len() > 0 {
354                spectrum.spectrum.mz_spectrum = filtered_spectrum;
355                filtered_spectra.push(spectrum);
356            }
357        }
358
359        if  filtered_spectra.len() > 0 {
360            TimsFrame::from_tims_spectra(filtered_spectra)
361        } else {
362            TimsFrame::new(
363                frame.frame_id,
364                frame.ms_type.clone(),
365                0.0,
366                vec![],
367                vec![],
368                vec![],
369                vec![],
370                vec![]
371            )
372        }
373    }
374
375    /// Transmit a frame given a diaPASEF transmission layout with annotations
376    ///
377    /// Arguments:
378    ///
379    /// * `frame` - TimsFrameAnnotated
380    /// * `min_probability` - minimum probability for transmission
381    ///
382    /// Returns:
383    ///
384    /// * `TimsFrameAnnotated` - transmitted frame
385    ///
386    fn transmit_tims_frame_annotated(&self, frame: &TimsFrameAnnotated, min_probability: Option<f64>) -> TimsFrameAnnotated {
387        let spectra = frame.to_tims_spectra_annotated();
388        let mut filtered_spectra = Vec::new();
389
390        for mut spectrum in spectra {
391            let filtered_spectrum = self.transmit_annotated_spectrum(frame.frame_id, spectrum.scan as i32, spectrum.spectrum.clone(), min_probability);
392            if filtered_spectrum.mz.len() > 0 {
393                spectrum.spectrum = filtered_spectrum;
394                filtered_spectra.push(spectrum);
395            }
396        }
397
398        if  filtered_spectra.len() > 0 {
399            TimsFrameAnnotated::from_tims_spectra_annotated(filtered_spectra)
400        } else {
401            TimsFrameAnnotated::new(
402                frame.frame_id,
403                frame.retention_time,
404                frame.ms_type.clone(),
405                vec![],
406                vec![],
407                vec![],
408                vec![],
409                vec![],
410                vec![]
411            )
412        }
413    }
414
415    fn isotopes_transmitted(&self, frame_id: i32, scan_id: i32, mz_mono: f64, isotopic_envelope: &Vec<f64>, min_probability: Option<f64>) -> (f64, Vec<(f64, f64)>) {
416
417        let probability_cutoff = min_probability.unwrap_or(0.5);
418        let transmission_probability = self.apply_transmission(frame_id, scan_id, &isotopic_envelope);
419        let mut result: Vec<(f64, f64)> = Vec::new();
420
421        for (mz, p) in isotopic_envelope.iter().zip(transmission_probability.iter()) {
422            if *p > probability_cutoff {
423                result.push((*mz - mz_mono, *p));
424            }
425        }
426
427        (mz_mono, result)
428    }
429}
430
431#[derive(Clone, Debug)]
432pub struct TimsTransmissionDIA {
433    frame_to_window_group: HashMap<i32, i32>,
434    window_group_settings: HashMap<(i32, i32), (f64, f64)>,
435    k: f64,
436}
437
438impl TimsTransmissionDIA {
439    pub fn new(
440        frame: Vec<i32>,
441        frame_window_group: Vec<i32>,
442        window_group: Vec<i32>,
443        scan_start: Vec<i32>,
444        scan_end: Vec<i32>,
445        isolation_mz: Vec<f64>,
446        isolation_width: Vec<f64>,
447        k: Option<f64>,
448    ) -> Self {
449        // hashmap from frame to window group
450        let frame_to_window_group = frame.iter().zip(frame_window_group.iter()).map(|(&f, &wg)| (f, wg)).collect::<HashMap<i32, i32>>();
451        let mut window_group_settings: HashMap<(i32, i32), (f64, f64)> = HashMap::new();
452
453        for (index, &wg) in window_group.iter().enumerate() {
454            let scan_start = scan_start[index];
455            let scan_end = scan_end[index];
456            let isolation_mz = isolation_mz[index];
457            let isolation_width = isolation_width[index];
458
459            let value = (isolation_mz, isolation_width);
460
461            for scan in scan_start..scan_end + 1 {
462                let key = (wg, scan);
463                window_group_settings.insert(key, value);
464            }
465        }
466
467        Self {
468            frame_to_window_group,
469            window_group_settings,
470            k: k.unwrap_or(15.0),
471        }
472    }
473
474    pub fn frame_to_window_group(&self, frame_id: i32) -> i32 {
475        let window_group = self.frame_to_window_group.get(&frame_id);
476        match window_group {
477            Some(&wg) => wg,
478            None => -1,
479        }
480    }
481
482    pub fn get_setting(&self, window_group: i32, scan_id: i32) -> Option<&(f64, f64)> {
483        let setting = self.window_group_settings.get(&(window_group, scan_id));
484        match setting {
485            Some(s) => Some(s),
486            None => None,
487        }
488    }
489
490    // check if a frame is a precursor frame
491    pub fn is_precursor(&self, frame_id: i32) -> bool {
492        // if frame id is in the hashmap, it is not a precursor frame
493        match self.frame_to_window_group.contains_key(&frame_id) {
494            true => false,
495            false => true,
496        }
497    }
498}
499
500impl IonTransmission for TimsTransmissionDIA {
501    fn apply_transmission(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>) -> Vec<f64> {
502
503        let setting = self.get_setting(self.frame_to_window_group(frame_id), scan_id);
504        let is_precursor = self.is_precursor(frame_id);
505
506        match setting {
507            Some((isolation_mz, isolation_width)) => {
508                apply_transmission(*isolation_mz, *isolation_width, self.k, mz.clone())
509            },
510            None => match is_precursor {
511                true => vec![1.0; mz.len()],
512                false => vec![0.0; mz.len()],
513            }
514        }
515    }
516}
517
518#[derive(Clone, Debug)]
519pub struct PASEFMeta {
520    pub frame: i32,
521    pub scan_start: i32,
522    pub scan_end: i32,
523    pub isolation_mz: f64,
524    pub isolation_width: f64,
525    pub collision_energy: f64,
526    pub precursor: i32,
527}
528
529impl PASEFMeta {
530    pub fn new(frame: i32, scan_start: i32, scan_end: i32, isolation_mz: f64, isolation_width: f64, collision_energy: f64, precursor: i32) -> Self {
531        Self {
532            frame,
533            scan_start,
534            scan_end,
535            isolation_mz,
536            isolation_width,
537            collision_energy,
538            precursor,
539        }
540    }
541}
542
543#[derive(Clone, Debug)]
544pub struct TimsTransmissionDDA {
545    // frame id to corresponding pasef meta data
546    pub pasef_meta: BTreeMap<i32, Vec<PASEFMeta>>,
547    pub k: f64,
548}
549
550impl TimsTransmissionDDA {
551    pub fn new(pasef_meta: Vec<PASEFMeta>, k: Option<f64>) -> Self {
552        let mut pasef_map: BTreeMap<i32, Vec<PASEFMeta>> = BTreeMap::new();
553        for meta in pasef_meta {
554            let entry = pasef_map.entry(meta.frame).or_insert(Vec::new());
555            entry.push(meta);
556        }
557        Self {
558            pasef_meta: pasef_map,
559            k: k.unwrap_or(15.0),
560        }
561    }
562
563    pub fn get_collision_energy(&self, frame_id: i32, scan_id: i32) -> Option<f64> {
564        let frame_meta = self.pasef_meta.get(&frame_id);
565        match frame_meta {
566            Some(meta) => {
567                for m in meta {
568                    if scan_id >= m.scan_start && scan_id <= m.scan_end {
569                        return Some(m.collision_energy);
570                    }
571                }
572                None
573            },
574            None => None,
575        }
576    }
577
578    /// Get all (frame_id, collision_energy) pairs where a specific precursor (ion_id) was selected.
579    /// This uses the explicit precursor selection from pasef_meta rather than m/z matching.
580    pub fn get_selections_for_precursor(&self, precursor_id: i32) -> Vec<(i32, f64)> {
581        let mut selections = Vec::new();
582        for (frame_id, meta_list) in &self.pasef_meta {
583            for meta in meta_list {
584                if meta.precursor == precursor_id {
585                    selections.push((*frame_id, meta.collision_energy));
586                }
587            }
588        }
589        selections
590    }
591}
592
593impl IonTransmission for TimsTransmissionDDA {
594    fn apply_transmission(&self, frame_id: i32, scan_id: i32, mz: &Vec<f64>) -> Vec<f64> {
595
596        // get all selections for a frame, if frame is not in the PASEF metadata, no ions are transmitted
597        let meta = self.pasef_meta.get(&frame_id);
598
599        match meta {
600            Some(meta) => {
601                let mut transmission = vec![0.0; mz.len()];
602
603                for m in meta {
604                    // check if scan id is in the range of the selection
605                    if scan_id >= m.scan_start && scan_id <= m.scan_end {
606                        // apply transmission function to mz values
607                        let transmission_prob = apply_transmission(m.isolation_mz, m.isolation_width, self.k, mz.clone());
608                        // make sure that the transmission probability is not lower than the previous one
609                        for (i, p) in transmission_prob.iter().enumerate() {
610                            transmission[i] = p.max(transmission[i]);
611                        }
612                    }
613                }
614                transmission
615            },
616            // if frame is not in the metadata, no ions are transmitted
617            None => vec![0.0; mz.len()],
618        }
619    }
620}
621#[cfg(test)]
622mod p6b_window_transmission_tests {
623    use super::*;
624
625    // The window-based seam must reproduce the scan-indexed Bruker decision when
626    // both see the same isolation window — proving WindowTransmission is a faithful
627    // vendor-neutral factoring of the quad transfer, not a reimplementation.
628    #[test]
629    fn window_transmission_matches_scan_indexed_dia() {
630        // DIA: frame 2 -> window group 1; group 1 isolates m/z 700 +/- 20 over
631        // scans 0..=50. Default sigmoid k = 15.
632        let dia = TimsTransmissionDIA::new(
633            vec![2],            // frame
634            vec![1],            // frame -> window group
635            vec![1],            // window group
636            vec![0],            // scan_start
637            vec![50],           // scan_end
638            vec![700.0],        // isolation_mz
639            vec![20.0],         // isolation_width
640            None,               // k = 15.0
641        );
642        let win = WindowTransmission::new(700.0, 20.0, 15.0);
643
644        // m/z spanning inside, on the edges, and well outside the 690..710 window.
645        let mz = vec![650.0, 689.0, 691.0, 700.0, 709.0, 711.0, 760.0];
646        let scan = 25; // inside the window's scan range
647
648        // Per-m/z probabilities identical.
649        assert_eq!(dia.apply_transmission(2, scan, &mz), win.probabilities(&mz));
650        // Decisions identical.
651        assert_eq!(
652            dia.any_transmitted(2, scan, &mz, None),
653            win.any_transmitted(&mz, None)
654        );
655        assert_eq!(
656            dia.get_transmission_set(2, scan, &mz, Some(0.5)),
657            win.transmitted_set(&mz, Some(0.5))
658        );
659        // Sanity: the window does transmit the center and block the far peaks.
660        assert!(win.any_transmitted(&[700.0], None));
661        assert!(!win.any_transmitted(&[650.0], None));
662    }
663}